Enforcement in the V4 Hook
Flaunch's PositionManager is a Uniswap V4 hook. On every swap it calls the protocol's fee calculator, which — on a spend-gated pool — is SpendGatedSignerFeeCalculator, reached through the dispatcher.
Two entry points matter:
determineSwapFeeruns inbeforeSwapand, for this calculator, does nothing. The gate never alters the fee; it returns the base fee unchanged.trackSwapruns inafterSwapand is where all enforcement happens.
Enforcement lives in afterSwap because that is the only place the swap's real native input is knowable. A gate that checked an intended amount beforehand would be checking a promise.
What trackSwap checks, in order
function trackSwap(
address _sender, // who called PoolManager.swap, forwarded by the hook
PoolKey calldata _poolKey,
SwapParams calldata _params,
BalanceDelta _delta, // what the swap actually moved
bytes calldata _hookData // carries the signed authorization
) public nonReentrant;Caller holds
POSITION_MANAGER. In a dispatcher deployment that role belongs to the dispatcher. Anyone else revertsCallerNotPositionManager.Gate enabled? If not, return immediately — nothing to enforce.
Past
endsAt? Return. The gate has expired on its own and the pool is now ordinary.Per-pool signer zeroed? Return. This is the settler's early off-switch.
Decode and pre-check the authorization —
deadlinenot passed,poolIdmatches.Bind the buyer to the submitter.
Reject a consumed signature.
Recover the signer and check it against the per-pool signer, or the protocol-wide trusted set.
Refuse exact output.
Measure native spend and enforce
maxSpendWei.Accumulate against the wallet cap, emit
SpendRecorded, burn the signature.
Steps 2–4 are the three ways a gated pool stops being gated, and none of them need a transaction from the game server.
Buyer binding
The signed message is public calldata. Without a binding, a front-runner could copy a victim's authorization out of the mempool, receive the tokens, consume the victim's allowance, and burn the signature.
An EOA submitting its own transaction satisfies the binding through tx.origin. That alone would exclude smart-contract wallets and account abstraction entirely — under a 7702 relay or a 4337 bundler, tx.origin is the relayer, not the user.
So the binding is also satisfied by the msgSender() an approved router reports. Approval matters: any other contract could simply lie about its original caller. A front-runner replaying the message through an approved router is still refused, because the router honestly reports the front-runner.
Measuring spend
The primary measure is the swap's BalanceDelta — a negative native-side amount is what the swapper paid. The specified input is a backstop, and the larger of the two is enforced, so hook-side fills that adjust the visible delta can only ever make enforcement stricter.
Why exact output is refused
The delta afterSwap receives is already net of any beforeSwapDelta, and Flaunch's InternalSwapPool fills buys out of accrued memecoin inventory through exactly that mechanism. Native taken for such a fill never appears in the delta, and the amountSpecified backstop only covers exact input.
An exact-output buy that inventory could cover outright would therefore measure as zero spend. Zero passes any maxSpendWei and never reaches walletSpentWei, so neither cap could see it — and the buy would be repeatable with every fresh authorization.
Refusing exact output is the safe direction. The gate exists to bound native input, and every buy path a gated round actually uses is exact-input native.
The revert matrix
TokenNotFlaunched
A buy before the window opens. Raised by the PositionManager from the launch's flaunchAt, so it binds the game server exactly as hard as it binds a player.
CallerNotPositionManager
Something other than the PositionManager (or the dispatcher acting for it) tried to report a swap.
DeadlineExpired
The authorization is past its deadline.
InvalidPoolKey
The authorization was signed for a different pool. On the retired first-generation Robinhood calculator, also what a non-ETH-paired pool hit; the current stack measures spend in the pool's paired token.
BuyerNotSubmitter
Someone other than the signed buyer submitted it, via an unapproved router or a mismatched msgSender().
SignatureAlreadyUsed
The digest has been consumed.
InvalidSigner
Recovered signer is not the pool's signer, nor in the trusted set. Very often a domain mismatch rather than a wrong key — see the signer page.
ExactOutputNotSupported
amountSpecified > 0. Unmeasurable, so refused.
SpendCapExceeded
The swap's real native input exceeded the signed maxSpendWei.
WalletCapExceeded
Cumulative spend for this wallet on this pool passed walletCapWei.
UnrecognisedGateParams
Launch params were neither the tagged 192-byte form nor the 64-byte short form.
SettlerRequired
An enforcing gate named a signer but no settler — nobody could ever lift it.
GateExpiryRequired / GateExpiryTooDistant
An enforcing gate had no endsAt, or one beyond MAX_GATE_DURATION.
GateAlreadyConfigured
A second attempt to write gate params for a pool.
Recording spend
The write and the event are unconditional, including when the amount is zero. A zero is a sell — the swapper receives native — which legitimately consumes an authorization without spending anything. Emitting it anyway keeps the on-chain record complete for off-chain reconciliation; guarding on a non-zero amount meant a consumed signature could leave no trace at all.
Two endings
A gated pool becomes an ordinary pool in one of two ways, and the difference matters:
The settler zeroes the signer. Immediate, and requires a transaction from a live key. This is how a round opens trading early.
endsAtpasses. Automatic, on chain, requiring nothing from anyone. This is the guarantee that survives the game server vanishing mid-round.
Design for the second and treat the first as an optimisation. A gate whose only exit needs the gatekeeper to come back is not a gate.
Last updated
Was this helpful?