Zap
One token in, one position out: the pipeline, the two slippage bounds, dust and the 25 bps fee.
A zap is one token in and one Uniswap position out, in one transaction. KerfZapV3.zapMint takes an amount of either pool token or of ETH, skims ZAP_FEE_BPS (25) to the FeeRouter, works out how much of the rest the chosen range does not want, sells exactly that much through the pool itself, mints the position to you and refunds whatever the mint could not use - all or nothing. The one fact not to miss: the swap amount is chosen by a model that fills at the current price and ignores price impact, so the contract gives you two separate bounds - maxSlippageBps on the swap's output and a mint tolerance on the ratio - and neither is a promise about the exact position you get. Between calls the contract holds nothing: no balance, no allowance, no owner.
What it does
Providing liquidity to a concentrated range normally means holding both tokens in the ratio that range needs at the current price, and that ratio changes with every tick. The zap does the arithmetic and the swap for you. KerfZapV3 does it on Uniswap v3 through the NonfungiblePositionManager and SwapRouter02; KerfZapV4 does it on v4 through the singleton PoolManager and the v4 PositionManager, hooks and hookData included. Both are pure pass-throughs: every address they call is immutable and set at construction, and there is no admin function of any kind.
There are three entry points on v3 and two on v4:
| Function | Contract | What it does |
|---|---|---|
zapMint(ZapMintParams) | V3, V4 | A new position, minted to recipient. Returns tokenId, liquidity and, on v3, the amount0/amount1 the mint consumed. |
zapIncrease(ZapIncreaseParams) | V3 only | The same maths pointed at a position you already own or are approved on. The pool and the ticks are read from the position, so you cannot aim a zap at a range you do not own. |
previewZap(params) | V3, V4 | A view that returns the exact swapAmount the zap will use and the optimistic post-swap holdings, modelled at the current price with no price impact. |
The v4 contract has no zapIncrease. Growing a v4 position is a PositionManager action the app does not wrap.
The pipeline
zapMint is one nonReentrant function that runs the steps below in order and reverts on the first that fails. Nothing is written until the mint, so a revert costs gas and nothing else.
- 01Check the parameters
block.timestamp > deadlineisDeadline;amountIn == 0isZeroAmount;tickLower >= tickUpperisBadRange. The pool is resolved withfactory.getPool(token0, token1, fee)and a zero address isNoPool. The order oftoken0andtoken1in the params does not matter; the pool decides which is which. - 02Pull or wrap the inputETH (
tokenIn == address(0)) must arrive asmsg.value == amountInand is wrapped withweth.deposit; an ERC-20 comes bysafeTransferFromand must arrive withmsg.value == 0. The resolved input has to be one of the pool's two tokens, otherwiseBadTokenIn. - 03Skim the fee
feePaid = amountIn × ZAP_FEE_BPS / 10 000is transferred to theFeeRouter, then reported withtake(tokenIn, feePaid, msg.sender)so that a bound referrer is credited. The rest is what the zap now holds on one side of the pool:have0orhave1, the other being zero. - 04Decide how much to swapInside
ZapExec.balanceAndMint,ZapExec.balancereadsslot0and asksZapMath.optimalSwapAmountFrom(have0, have1, sqrtPriceX96, tickLower, tickUpper, fee)which side is in excess and by how much. A zero answer skips the swap entirely. - 05Swap on the same poolThe excess is sold with
router.exactInputSingleon the same(token0, token1, fee)pool,sqrtPriceLimitX96 = 0, andamountOutMinimum = quoteAtSpot(swapAmount) − maxSlippageBps. The allowance to the router is set to the exact amount and cleared straight after. - 06Mint to the recipient
npm.mintwith the post-swap holdings asamount0Desired/amount1Desired,amount0Min/amount1MinatmintToleranceBpsbelow them,recipientas the NFT owner and the caller'sdeadline. The two allowances to the position manager are set to the exact amounts and cleared straight after. - 07Check minLiquidityIf the mint produced less than
minLiquidity,Slippage. This is the bound the app actually computes: the previewed liquidity less the slippage you chose. - 08Refund the dust
_refundreads the contract's balance of both tokens and sends every wei tomsg.sender; a zap paid in ETH gets its WETH side unwrapped first, and a caller that rejects the ETH getsNativeTransferFailed. Reading balances rather than tracking deltas is what makes the holds-nothing invariant true even when the position manager rounds in an unexpected direction. - 09Emit
ZapMint(recipient, tokenId, liquidity, amount0, amount1, feePaid). The amounts are what the mint consumed, not what you sent.
zapIncrease is the same path with three differences: the route comes from npm.positions(tokenId) instead of the params, the caller must be the position's owner, its approved address or an operator (NotOwner otherwise - increaseLiquidity is permissionless on the position manager, so this check is the only thing stopping a stranger from spending your approval), and the mint is an increaseLiquidity that emits ZapIncrease(tokenId, liquidity, amount0, amount1, feePaid).
How much to swap
ZapMath answers one question: given holdings have0 and have1, which side has too much for the range [tickLower, tickUpper] at the current price, and how much of it must be sold so that the two legs support the same liquidity. Two cases are immediate. If the range sits entirely above the price (sqrtPriceX96 ≤ sqrtA) the position takes only token0, so every unit of token1 is sold; entirely below (sqrtPriceX96 ≥ sqrtB) it takes only token1, so every unit of token0 is sold. In range, both legs are needed. The liquidity the token0 leg supports falls as token0 is sold and the liquidity the token1 leg supports rises as the proceeds arrive, so the liquidity a mint would actually produce - the minimum of the two - peaks where they cross. The side whose leg currently binds less is the side there is too much of, and a binary search over [0, have] finds the crossing in ITERATIONS (32) halvings, which resolves the amount to better than one part in four billion.
The search models the fill at a constant price: quoteAtSpot(amountIn) = amountIn × (1 − fee) × price, with the pool fee taken off the input and no movement of the price. That is deliberately wrong, because a real swap moves the price, and the amount is still exact for the model it was chosen under. The realised output is always a little below the quote, which is what the router bound is for, and the small ratio error it leaves behind is what dust is.
previewZap runs the same maths as a view: swapAmount is exactly what zapMint will swap in the same block, and amount0/amount1 are the optimistic side of reality. The app does not call it; @kerf/core's zapMath.ts is a bit-identical port pinned to the same fixtures (contracts/fixtures/zap.json), and providePreview in lib/trade/provide.ts runs it locally to show the split, the dust and the minLiquidity before you sign.
The two bounds and why there are two
ZapExec.Route carries two numbers that look alike and measure different things.
| Bound | Applied to | Measures | Set by |
|---|---|---|---|
maxSlippageBps | the router's amountOutMinimum | value lost to the swap: how far the realised output fell below the constant-price quote, through price impact, a stale spot or a sandwich | your ZapMintParams.maxSlippageBps |
mintToleranceBps | the mint's amount0Min/amount1Min | how far the post-swap holdings sit from the ratio the range wants, which drifts with the price the swap itself moved and gets worse the narrower the range | equal to maxSlippageBps in both zaps; MINT_RATIO_TOLERANCE_BPS (500) in the vaults |
Driving both from one number would mean a narrow range either rejects honest mints or accepts dishonest swaps. The zaps keep them equal, which is the behaviour they always had, and accept the consequence: a large zap into a narrow range sitting right at the edge of the price moves it past maxSlippageBps, and the position manager's own minimum-amount check rejects the mint. The invariant handler measures this - roughly a quarter of its random zaps revert for exactly that reason, and the suite treats it as the design working. If you want a narrow range on a thin pool, pick a wider slippage or a smaller size, not a looser contract. ZapExec.floorBps(amount, bps) is the helper behind both bounds; bps ≥ 10 000 means "no bound at all", which is how a caller opts out without a second parameter.
The minLiquidity check is a third, coarser bound that sits above both: it says what the whole operation must produce, in liquidity units, and it is the one a user can reason about, because the app computes it from the preview.
Parameters
| Parameter | Range | Default | Meaning |
|---|---|---|---|
tokenIn | token0, token1 or address(0) | - | The token supplied. address(0) is native ETH, wrapped to WETH on the way in and unwrapped on the way out. |
amountIn | > 0 wei of tokenIn | - | Before the fee. Must equal msg.value when paying in ETH. |
token0 | either pool token | - | Order does not matter; the pool decides which is token0. |
token1 | the other pool token | - | With token0 and fee, resolves the pool through the factory. |
fee | 500 · 3000 · 10000 … hundredths of a bip | - | The pool fee tier. Also the fee the swap pays. |
tickLower | < tickUpper ticks | - | Lower tick of the position. Must sit on the pool spacing or the position manager reverts. |
tickUpper | > tickLower ticks | - | Upper tick of the position. |
minLiquidity | ≥ 0 liquidity | preview × (1 − slippage) | Revert with Slippage if the mint produces less. The app sets it from providePreview. |
maxSlippageBps | 0 - 10 000 bps | 100 (DEFAULT_SLIPPAGE_BPS.zap) | Bound on the swap output and, at the same figure, on the minted amounts. 10 000 disables both. |
deadline | unix seconds | now + 600 in the terminal | Latest block timestamp the call may execute at; also passed to the position manager. |
recipient | any address | - | Owner of the new position NFT. Dust is refunded to msg.sender, not to recipient. |
ZapIncreaseParams is the subset a position already fixes: tokenId, tokenIn, amountIn, minLiquidity, maxSlippageBps, deadline. There is no recipient because the position already has an owner, and no pool or ticks because they are read from npm.positions(tokenId).
ZapMintV4Params replaces token0/token1/fee with the whole PoolKey (currency0, currency1, fee, tickSpacing, hooks) and adds hookData, which is passed to the pool's hooks on both the swap and the mint. tokenIn == address(0) is only legal when one side of the key is the native currency; the v4 zap does not wrap.
Shapes
The contract takes two ticks and checks only that they are ordered. Every preset lives in @kerf/core's shapes.ts, and the terminal turns a shape into ticks before it builds the call:
| Shape | Half-width | SHAPE_PCT |
|---|---|---|
SPOT | ±5 % of the current price | 0.05 |
CURVE | ±25 % | 0.25 |
WIDE | ±50 % | 0.5 |
CUSTOM | lowerPct in (0, 1], upperPct above 0 | what you drag the handles to |
rangeForPct converts each edge to a tick with ln(1 ± pct) / ln 1.0001 and snaps outward to the pool's spacing - floor on the lower tick, ceil on the upper - so the band you asked for is always fully covered. A lowerPct of 1 or more means "down to a price of zero" and resolves to the minimum usable tick. Both ticks are clamped so that the range keeps at least one spacing of width and never leaves the usable tick range. Because the arithmetic is logarithmic, ±5 % is not symmetric in ticks: roughly 488 up and 513 down.
For tokenised stocks the band is widened by AFTER_HOURS_MULT (2) whenever the New York session is closed. marketHoursAdjustedRange doubles both half-widths when the pool's non-numeraire token is a stock token and isNyseOpen(now) is false - outside 09:30 to 16:00 ET on a weekday, on the 13:00 early closes, on the listed holidays for 2026 and 2027 - and reports widened: true so the panel can say so. The reason is that a stock token keeps trading on chain against a reference price that stops moving when the exchange shuts, then gaps at the open; a SPOT range sized for trading hours would be left behind by the first print. The doubling is a floor on your judgement, not a ceiling: CUSTOM still does what you tell it.
Dust
The swap can never land exactly on the ratio the range wants, so there is always a remainder. Three things put it there: the fill happens at a price the model did not predict, the search itself leaves up to amountIn / 2^32 of residual, and the position manager rounds the amounts it consumes down. Everything left in the contract after the mint is refunded in the same transaction, in whichever token it is sitting in - the zap does not swap it back and charge you a second spread for the privilege. providePreview shows the expected leftover as a fraction of the input (dustFraction), valued in the input token at the pool price, so the number on the panel is the number the refund will roughly be.
Fees and slippage
The zap takes 25 bps of the input, in the input token, and nothing on the way out. The FeeRouter splits 20 % of it to your referrer if you bound a code; the whole table is in Fees & referrals. Uniswap's own pool fee is paid on the swap leg as on any swap, so a 0.30 % pool costs you 0.30 % of the part that was swapped, which is typically about half the input.
Slippage defaults come from @kerf/core's slippage.ts and are all user-overridable:
| Operation | DEFAULT_SLIPPAGE_BPS | Where it is used |
|---|---|---|
zap | 100 | maxSlippageBps and the minLiquidity haircut; presets of 50, 100 and 200 in the Provide panel |
mint | 50 | a plain mint or increase without a swap |
modify | 30 | decreasing or collecting from an existing position |
marketFloor | 500 | the signer's server-side floor on a perp market order, see Perps via Lighter |
Above MAX_SLIPPAGE_WITHOUT_ACK_BPS (500) the terminal refuses to build the transaction until you acknowledge it. The deadline the terminal sends is 600 seconds ahead, and minLiquidity is minOut(preview.liquidity, slippageBps): the liquidity the local preview predicts, reduced by the same percentage.
The v4 zap
KerfZapV4.zapMint has the same product surface over a different plumbing. v4 has one PoolManager for every pool, swaps happen inside an unlock callback, and the PositionManager pulls ERC-20s through Permit2 rather than through a direct allowance.
- Intake. The same checks, except that ETH is not wrapped: a native input is only legal when a side of the key is
address(0), and its fee goes tofeeRouter.takeNative{value: fee}(msg.sender). The pool fee the model uses is read fromslot0when the key marks the pool as dynamic-fee, and fromkey.feeotherwise. - The swap.
_rebalanceHoldingscomputesswapAmountandminOutand callspoolManager.unlockwith them encoded. The pool manager calls backunlockCallback, which refuses any caller but the pool manager (NotPoolManager), swaps withamountSpecified = −amountIn(exact input) at the widest price limit, revertsSlippageif the output delta is negative or belowminOut, thensyncs,settles the input (asvaluefor native, by transfer for an ERC-20) andtakes the output back to the contract. - The mint.
_mintcomputes the liquidity the holdings support withLiquidityAmounts.getLiquidityForAmounts- zero isSlippage- and the amounts that liquidity implies. v4'sMINT_POSITIONtakes a liquidity figure and maximum amounts, so the mint tolerance runs the other way from v3:amount0Max/amount1Maxare the implied amounts plusmaxSlippageBps, plus one wei for the mint's round-up, clamped to what the contract actually holds. The batch isMINT_POSITIONthenSETTLE_PAIR, withSWEEPappended whencurrency0is native so the unspent ETH comes back; the whole native balance rides along asmsg.value.tokenIdispositionManager.nextTokenId()read just before the call. - After.
liquidity < minLiquidityisSlippage;ZapMintV4(recipient, tokenId, liquidity, amount0, amount1, feePaid)reports the amounts as the difference between what the contract held before the mint and what is left; both leftovers go back tomsg.sender, native ETH bycall.
approveOnce(token) grants the two standing approvals the batch relies on: an ERC-20 allowance from the zap to Permit2 and a Permit2 allowance from the zap to the position manager, both maximal. It is permissionless and idempotent, and anyone may call it for any token. Standing approvals would be a liability in a contract that held a balance; here they are safe precisely because it never does, and invariant_zapV4HoldsNothing is the machine-checked version of that claim. The v3 zap needs no such thing: its allowances are set per call and cleared, and invariant_zapKeepsNoStandingApprovals checks that too.
Errors
| Error | When |
|---|---|
Deadline | block.timestamp > deadline. |
ZeroAmount | amountIn == 0; or ETH was chosen and msg.value != amountIn. |
BadRange | tickLower >= tickUpper. A tick off the spacing is not caught here; the position manager rejects it. |
NoPool | v3 only: factory.getPool(token0, token1, fee) returned zero. On v4 an uninitialised key fails inside Uniswap. |
BadTokenIn | tokenIn is neither pool token; ETH was attached to an ERC-20 zap; or ETH reached receive from anyone but WETH (v3) or the pool and position managers (v4). |
Slippage | the mint produced less than minLiquidity. On v4 also: the swap returned less than minOut, or the holdings support zero liquidity. |
NotOwner | v3 zapIncrease by an address that is neither the owner, the approved address nor an operator of the position. |
NativeTransferFailed | the caller rejected the ETH refund. |
NotPoolManager | v4 only: unlockCallback from anyone but the pool manager. |
Two reverts are not Kerf's. The router's Too little received is the swap bound biting, and the position manager's Price slippage check is the mint bound biting; the app shows both as slippage. ZapMath also declares InvalidRange and InvalidFee (a fee of 100 % or more), but zapMint checks the range first and a v3 pool cannot carry such a fee, so neither is reachable through the zap.
Events
| Event | Fields |
|---|---|
ZapMint (V3) | recipient (indexed), tokenId (indexed), liquidity, amount0, amount1, feePaid |
ZapIncrease (V3) | tokenId (indexed), liquidity, amount0, amount1, feePaid |
ZapMintV4 (V4) | recipient (indexed), tokenId (indexed), liquidity, amount0, amount1, feePaid |
amount0/amount1 are what the mint consumed and feePaid is the 25 bps in the input token, so amountIn − feePaid − swapped + received − consumed is the dust. The indexer does not subscribe to the zap's events at all: the position a zap mints reaches the portfolio through the position manager's own IncreaseLiquidity and Transfer events, which it does index, so a zapped position and a hand-minted one look the same on /portfolio.
What it does not do
- It does not route. The swap is a single hop on the pool you are minting into, with no price limit; a better price on another pool or tier is not found.
- It does not support exact output. You say how much goes in; the position is what comes out.
- It does not pick the range. The presets, the after-hours widening and the snap to spacing happen in the terminal; the contract sees two ticks.
- It does not hold anything, so there is nothing to withdraw, pause or upgrade, and no admin who could.
- It does not swap the dust back, and it does not send dust to
recipient- the refund goes to whoever paid. - It does not guard against a manipulated spot. Your
maxSlippageBpsis measured against the price the block has; the terminal'sslot0check is the defence, not the contract.
contracts/src/KerfZapV3.solzapMint, zapIncrease, previewZap, _intake, _refundcontracts/src/KerfZapV4.solzapMint, approveOnce, unlockCallback, _mint with the PositionManager actionscontracts/src/libraries/ZapExec.solbalance, balanceAndMint, balanceAndIncrease, floorBps - the two boundscontracts/src/libraries/ZapMath.soloptimalSwapAmountFrom, the 32-step search, quoteAtSpotpackages/core/src/zapMath.tsthe bit-identical port and zapPreviewpackages/core/src/shapes.tsSHAPE_PCT, rangeForPct, marketHoursAdjustedRangepackages/core/src/slippage.tsDEFAULT_SLIPPAGE_BPS, MAX_SLIPPAGE_WITHOUT_ACK_BPS, minOutcontracts/test/KerfZapV3.t.solevery revert, the fee, the refund, previewZap against the real swapcontracts/test/KerfZapV4.t.solthe native pair, Permit2, the swap bound bitingcontracts/test/invariants/ZapHoldsNothing.t.solinvariant_zapHoldsNothing, invariant_zapKeepsNoStandingApprovalscontracts/test/invariants/ZapV4HoldsNothing.t.solinvariant_zapV4HoldsNothingapps/web/lib/trade/provide.tsprovidePreview - the split, the dust and minLiquidity the panel shows