What the hook does
SLICE is a Uniswap v4 hook holding resting instructions of the form fill this amount in N pieces, at most one piece per block. The pieces are executed against the pool's own liquidity, inside the swap of whichever address happens to trade next.
It occupies one hook permission: beforeSwap, with beforeSwapReturnDelta enabled so the slice can settle its own token deltas without a second transaction.
// hook permissions
beforeInitialize false
afterInitialize false
beforeAddLiquidity false
afterAddLiquidity false
beforeRemoveLiquidity false
afterRemoveLiquidity false
beforeSwap true
afterSwap false
beforeDonate false
afterDonate false
// return-delta flags
beforeSwapReturnDelta true
afterSwapReturnDelta false
Because the address of a v4 hook encodes its permissions in the low bits, the deployment address is mined, not chosen. That's worth knowing before you assume a vanity address means anything here.
How one order runs
Four states, and only two of them involve you.
REGISTERED → FILLING → FILLED
↓
CANCELLED
Registration
You call registerOrder. The hook pulls amountIn from your address via transferFrom, so an ERC-20 approval to the hook is required first. The order gets an id, and lastBlock is set to the current block so the first slice cannot execute in the same block as registration.
Carrying
On every swap the pool runs beforeSwap. The hook looks up the head of that pool's order queue and asks two questions: is block.number > order.lastBlock, and does the order have slices left. If both are yes, it executes exactly one slice, updates lastBlock, then returns a delta so the pool settles both the slice and the incoming swap together.
If either answer is no, the hook returns a zero delta and the swap proceeds untouched. The added gas in that case is a storage read and a comparison.
Completion or cancellation
When done == slices, the accumulated output is claimable by the owner. Cancellation returns the unfilled input and the output accumulated so far, in the same call.
Draft external functions
interface ISlice {
/// @notice Register a resting order against a v4 pool.
/// @param poolId The v4 pool id.
/// @param zeroForOne True to sell token0 for token1.
/// @param amountIn Total input, pulled on call. Must be >= slices.
/// @param slices Number of pieces. 2 <= slices <= MAX_SLICES.
/// @return id Order id.
function registerOrder(
bytes32 poolId,
bool zeroForOne,
uint256 amountIn,
uint16 slices
) external returns (uint256 id);
/// @notice Cancel and sweep. Returns unfilled input and accrued output.
function cancelOrder(uint256 id) external returns (uint256 refundIn, uint256 refundOut);
/// @notice Claim output of a completed order.
function claim(uint256 id) external returns (uint256 amountOut);
/// @notice Read an order.
function getOrder(uint256 id) external view returns (Order memory);
/// @notice Order ids currently resting on a pool, in fill order.
function queueOf(bytes32 poolId) external view returns (uint256[] memory);
}
| selector | signature |
|---|---|
| 0x4ce2d438 | registerOrder(bytes32,bool,uint256,uint16) |
| 0x514fcac7 | cancelOrder(uint256) |
| 0xd09ef241 | getOrder(uint256) |
These are keccak-256 of the signatures as written above. If a parameter type changes, the selector changes, and anything encoding by hand — including the site's register panel — breaks silently. That's the main reason this page exists.
Order struct
struct Order {
address owner; // 160
uint48 lastBlock; // 48 last block a slice executed in
uint16 slices; // 16 total pieces
uint16 done; // 16 pieces executed
bool zeroForOne; // 8 ──────── slot 0 (248 bits used)
bytes32 poolId; // slot 1
uint128 amountIn; // 128
uint128 filledIn; // 128 ──────── slot 2
uint128 receivedOut; // 128
uint128 minOutTotal; // 128 ──────── slot 3
}
Four slots. The packing matters more than usual here: the carrying path reads this struct on every swap that touches the pool, including swaps where no slice is due, so a fifth slot is a tax paid by everyone else.
What an indexer needs
event OrderRegistered(
uint256 indexed id,
address indexed owner,
bytes32 indexed poolId,
bool zeroForOne, uint256 amountIn, uint16 slices
);
event SliceFilled(
uint256 indexed id,
address indexed carrier,
uint16 index, uint256 amountIn, uint256 amountOut, uint256 rebate
);
event OrderCancelled(uint256 indexed id, uint256 refundIn, uint256 refundOut);
event OrderClaimed (uint256 indexed id, uint256 amountOut);
carrier is indexed on purpose: it's the only way anyone can prove after the fact how much of the rebate pool went to which addresses, which is the number that decides whether the incentive was calibrated correctly or not.
Limits and constants
| name | draft value | notes |
|---|---|---|
| MIN_SLICES | 2 | One slice is just a swap with extra steps. |
| MAX_SLICES | 256 | Bounded by uint16 anyway; 256 keeps worst-case queue scans sane. |
| MIN_SLICE_NOTIONAL | unset | Needs a floor, or dust slices cost more gas than they move. Depends on the chain. |
| BLOCK_GAP | 1 | Minimum blocks between slices. Fixed at 1 in v0.3. |
| REBATE_BPS | unset | Share of the slice's fee paid to the carrier. The open question. |
| minOutTotal | per order | Slippage bound on the whole order, not per slice. |
You don't have to do anything
There is no integration. If you swap on a pool with SLICE attached and a slice is due, your transaction carries it and you receive the rebate on that slice. No approval to the hook, no extra call, no registration.
What changes for you:
- Your gas goes up by the cost of executing one extra swap on the same pool, in the frames where a slice is due.
- You receive
REBATE_BPSof the fee accrued on that slice's notional. - The price you get is the price after the slice executed, because the slice runs first.
That last point is the honest one. Carrying a slice moves the pool slightly against you before your own swap prices. The rebate has to be worth more than that movement, or carrying is a tax dressed as a reward — which is exactly what makes REBATE_BPS the hardest number in the design.
Where the tokens sit
Input is held by the hook from registration. Output accrues to the hook and is credited to the order, claimable by the owner. The hook holds no protocol balance of its own; every token in it belongs to an open or unclaimed order.
slice notional = amountIn / slices // integer division
last slice = amountIn - filledIn // absorbs the remainder
carrier rebate = sliceFee * REBATE_BPS / 10_000
owner receives = receivedOut // net of the rebate
The remainder from integer division goes into the final slice rather than being distributed, so the order always fills exactly and never leaves dust behind.
What breaks, and what happens then
| condition | behaviour |
|---|---|
| No swaps on the pool | Order stalls indefinitely. Funds remain withdrawable via cancelOrder. This is the design's real cost and there is no fallback keeper. |
| Slice would breach minOutTotal | Slice reverts inside the hook, caught, order marked stalled. The carrier's own swap still goes through. |
| Pool liquidity removed | Slices stop filling. Cancellation still returns input and accrued output. |
| Owner cancels mid-fill | Unfilled input and accrued output both returned in one call. No partial-claim state. |
| Two orders, same pool | FIFO. Head order fills first. See the note in 02. |
A hook that reverts takes the swap down with it, so every path the carrying logic can take has to either succeed or fail closed without touching the incoming swap. That constraint is doing more work in this design than anything else on the page.
Known and unresolved
Slice visibility
A resting order is public state. Anyone can read that a slice is due, in which direction, and for how much. On a chain with a public mempool that is a per-slice sandwich target. The per-slice prize is small by construction — that's the point of slicing — but it is not zero, and a searcher can sit on every slice of a large order. Unresolved.
Griefing the queue
Registering a dust order to sit at the head of a pool's queue would delay everyone behind it. MIN_SLICE_NOTIONAL is the intended defence and it is currently unset, which means the defence does not exist yet.
Reentrancy surface
The carrying path calls back into the pool manager from inside beforeSwap. Everything runs under v4's lock, but this is the part that gets read line by line in an audit, and it should be.
Not audited
No audit, no testnet deployment, no bug bounty. Do not approve tokens to any address claiming to be this hook.
What moved, and why
| version | change |
|---|---|
| v0.3 | Slippage bound moved from per-slice to per-order. A per-slice bound was stallable by anyone. Cheapest fix to the nastiest griefing vector found so far. |
| v0.2 | slices narrowed from uint256 to uint16 to fit slot 0. Changed the selector, which broke the front end for a day. |
| v0.2 | Dropped the per-slice keeper reward paid from the order. Replaced with a share of the slice's own fee, so an order never subsidises its own execution out of principal. |
| v0.1 | First sketch. Parallel fills across concurrent orders, since removed — unbounded gas on the carrying swap. |
| slice | One piece of a registered order. amountIn / slices, with the remainder in the last. |
| carrier | The address whose swap executed a slice. Receives the rebate. Did not opt in. |
| resting order | An order registered and not yet filled or cancelled. Public state. |
| arb recovery | How much of a slice's price impact arbitrage undoes before the next slice. The model's key assumption; not a measured value. |
| stalled | An order that cannot fill — no traffic, no liquidity, or a breached bound. Always cancellable. |