Your Custom-Accounting Hook Is Invisible (Here's How to Fix It)
By Twade
If your hook uses beforeSwapReturnDelta or afterSwapReturnDelta, the standard Uniswap V4 events are either missing or lying - and every indexer, dashboard, and LP tool in the ecosystem is reading those events. The fix is four small events, and this post shows where each one goes.
You built a hook with a custom pricing curve. It works. Swaps execute at exactly the price your maths says they should, your settlement balances, your tests are green. You ship it.
And it is completely invisible to every dashboard in the ecosystem.
No volume shows up for your pool on the analytics sites. The TVL numbers are wrong. LP risk tools can't see what your hook is doing. Aggregators skip you. Nobody is against you: the data they all rely on isn't being emitted, and the data that is being emitted is lying to them.
This is the trap waiting for any hook that uses beforeSwapReturnDelta or afterSwapReturnDelta. The good news is the fix is small and well-defined: a handful of standard events, proposed by the Uniswap Foundation and being adopted into the OpenZeppelin hooks library. This post is about what they are, why you need them, and - with real Solidity - exactly where to emit them.
If you're hazy on what the return-delta hooks do, read Every Hook in V4 first - this piece assumes you know what beforeSwapReturnDelta is and how BeforeSwapDelta differs from BalanceDelta. We'll also skip address construction and permission mining entirely here; that's all covered in the hooks article. This one is about events.
Why custom accounting breaks analytics
When a swap runs through a vanilla V4 pool, the PoolManager emits a Swap event. When liquidity changes, it emits ModifyLiquidity. Every indexer in the ecosystem - the analytics dashboards, the aggregators, the LP tools - is built on top of those two events. They are the canonical record of what happened.
The problem: a return-delta hook can bypass the core swap logic. When your beforeSwap returns a BeforeSwapDelta that consumes the full input amount, the PoolManager's internal _swap exits early - the AMM curve never runs. That means one of two things happens to the native events:
- They don't fire at all. If your hook fully handled the swap, the core swap logic that would have emitted
Swapwas skipped. - They fire with wrong numbers. If your hook altered the amounts, the
Swapevent that does fire reflects the core swap, not what your hook did to the user.
Same story for custom curves that hold their own liquidity: the ModifyLiquidity events the indexers expect never appear, because your liquidity isn't living in the PoolManager where those events come from.
The result is a hook that works perfectly on-chain and is misrepresented everywhere off-chain.
Insight #1
Indexers read the PoolManager's events, not your hook's intentions. The moment your hook bypasses or modifies the core swap, those events stop being a faithful record. If you don't emit your own, your hook's real activity is invisible - or worse, actively wrong - to the entire off-chain ecosystem.

The fix: four standard events
The proposal is a small set of hook-specific events that re-establish the off-chain record your custom accounting broke. Drop them into your hook as an interface and implement it:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/// @notice Standard hook events proposed by the Uniswap Foundation, so that
/// hooks doing custom accounting stay legible to indexers and analytics.
interface IHookEvents {
/// @notice Emitted for a swap your hook handled or modified.
/// @dev amount0/amount1 are signed from the swapper's perspective.
event HookSwap(
bytes32 indexed id, // v4 pool id
address indexed sender, // the router that initiated the swap
int128 amount0,
int128 amount1,
uint128 hookLPfeeAmount0,
uint128 hookLPfeeAmount1
);
/// @notice Emitted when your hook charges a fee on a swap or liquidity change.
event HookFee(
bytes32 indexed id,
address indexed sender,
uint128 feeAmount0,
uint128 feeAmount1
);
/// @notice Emitted when your hook changes liquidity outside the PoolManager's
/// own ModifyLiquidity flow (e.g. a custom curve holding its own reserves).
event HookModifyLiquidity(
bytes32 indexed id,
address indexed sender,
int128 amount0,
int128 amount1
);
/// @notice Emitted when your hook rebates value back to the user (the inverse of a fee).
event HookBonus(
bytes32 indexed id,
uint128 amount0,
uint128 amount1
);
}A few things to note before we wire them in:
The id is the V4 PoolId as a bytes32 - you get it from key.toId() and unwrap it. The sender is the router that initiated the swap (the address the PoolManager passed to your hook), which lets indexers attribute volume to integrations. The amount0/amount1 fields on HookSwap and HookModifyLiquidity are signed (int128) and expressed from the swapper's perspective - positive means the user received that token, negative means they paid it. The fee and bonus amounts are unsigned (uint128) because direction is implied by which event you're emitting.
You don't emit all four. You emit the ones relevant to what your hook does. The rest of this post is four worked examples - one per scenario - showing exactly where the emit goes.
Scenario 1: Custom curve → HookSwap (+ HookModifyLiquidity)
The headline use case for beforeSwapReturnDelta is replacing the AMM with your own pricing curve - constant-sum, StableSwap, or anything else. Because you're consuming the swap inside beforeSwap, the core curve never runs, so the native Swap event is either absent or wrong. You emit HookSwap to set the record straight.

Here's a stripped-down constant-sum hook (1:1 pricing) showing where the event lands. The custody and settlement details are simplified so the emission is easy to see - the sign handling for BeforeSwapDelta follows the specified/unspecified convention covered in the hooks article:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {BaseHook} from "v4-hooks-public/src/base/BaseHook.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {PoolId} from "v4-core/types/PoolId.sol";
import {BalanceDelta} from "v4-core/types/BalanceDelta.sol";
import {BeforeSwapDelta, toBeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol";
import {SwapParams} from "v4-core/types/PoolOperation.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
contract ConstantSumHook is BaseHook, IHookEvents {
constructor(IPoolManager _manager) BaseHook(_manager) {}
function _beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
// 1:1 constant-sum curve: one unit in, one unit out.
uint256 specifiedAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
// ... custody + settlement of the two tokens omitted for clarity ...
// Fully consume the specified amount and supply the unspecified side 1:1.
BeforeSwapDelta delta = toBeforeSwapDelta(
int128(-params.amountSpecified), // specified delta: consumed
int128(int256(specifiedAmount)) // unspecified delta: supplied
);
// The native Swap event won't reflect this trade, so emit our own.
// Signed from the swapper's perspective: they pay one token, receive the other.
int128 signedIn = int128(int256(specifiedAmount));
(int128 amount0, int128 amount1) = params.zeroForOne
? (-signedIn, signedIn) // paid token0, received token1
: (signedIn, -signedIn); // received token0, paid token1
emit HookSwap(
PoolId.unwrap(key.toId()),
sender,
amount0,
amount1,
0, // hookLPfeeAmount0 - none in this example
0 // hookLPfeeAmount1
);
return (this.beforeSwap.selector, delta, 0);
}
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
Hooks.Permissions memory p; // all false by default
p.beforeSwap = true;
p.beforeSwapReturnDelta = true; // bits 7 and 3 - see the hooks article
return p;
}
}The key line is the emit HookSwap(...). Everything around it is the curve doing its job; that one statement is what keeps your pool legible to the outside world.
If your custom curve also manages its own liquidity - LPs depositing into the hook rather than the PoolManager - emit HookModifyLiquidity at the point you take or release those tokens, since the native ModifyLiquidity event won't fire for liquidity that never touched the singleton:
function _addReserves(PoolKey calldata key, address provider, uint128 amount0, uint128 amount1)
internal
{
// ... pull tokens from the provider into the hook's own reserves ...
// Positive amounts: liquidity was added to the hook.
emit HookModifyLiquidity(
PoolId.unwrap(key.toId()),
provider,
int128(amount0),
int128(amount1)
);
}Scenario 2: Hook fee → HookFee
If your hook charges a fee on swaps - for a service it provides, or to penalise something like JIT liquidity - that fee is invisible in the native events. Emit HookFee so the fee shows up in analytics and LPs can see the true cost of trading through your pool.

Here's an afterSwap that takes a 10 bps fee on the output token and emits the event. This uses afterSwapReturnDelta, returning a positive hookDeltaUnspecified so the user receives correspondingly less:
import {Currency} from "v4-core/types/Currency.sol";
// import {CurrencySettler} from "..."; // for .take()
function _afterSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
BalanceDelta delta,
bytes calldata
) internal override returns (bytes4, int128) {
// Work out which currency is the "unspecified" (output) side for this swap.
bool specifiedIsToken0 = (params.amountSpecified < 0) == params.zeroForOne;
Currency feeCurrency = specifiedIsToken0 ? key.currency1 : key.currency0;
int128 outputAmount = specifiedIsToken0 ? delta.amount1() : delta.amount0();
// 10 bps fee on the absolute output amount.
uint128 absOutput = uint128(outputAmount < 0 ? -outputAmount : outputAmount);
uint128 feeAmount = (absOutput * 10) / 10_000;
// Pull the fee out of the PoolManager into the hook (settles the accounting).
feeCurrency.take(poolManager, address(this), feeAmount, false);
// Emit the standard fee event, with the amount on the correct side.
(uint128 feeAmount0, uint128 feeAmount1) = specifiedIsToken0
? (uint128(0), feeAmount)
: (feeAmount, uint128(0));
emit HookFee(PoolId.unwrap(key.toId()), sender, feeAmount0, feeAmount1);
// Positive hookDeltaUnspecified => user receives less by feeAmount.
return (this.afterSwap.selector, int128(feeAmount));
}Permissions for this one are afterSwap + afterSwapReturnDelta (bits 6 and 2). The HookFee event is what makes your fee a first-class, indexable number rather than an invisible haircut on the user's output.
Scenario 3: Hook bonus → HookBonus
A bonus is a fee in reverse: instead of taking value from the swapper, your hook gives value - a rebate to incentivise routing through your pool. You achieve it with a negative hookDeltaUnspecified (the user receives more), and emit HookBonus so the incentive is visible.

function _afterSwap(
address,
PoolKey calldata key,
SwapParams calldata params,
BalanceDelta delta,
bytes calldata
) internal override returns (bytes4, int128) {
bool specifiedIsToken0 = (params.amountSpecified < 0) == params.zeroForOne;
Currency bonusCurrency = specifiedIsToken0 ? key.currency1 : key.currency0;
int128 outputAmount = specifiedIsToken0 ? delta.amount1() : delta.amount0();
// 5 bps rebate, funded from the hook's own reserves.
uint128 absOutput = uint128(outputAmount < 0 ? -outputAmount : outputAmount);
uint128 bonusAmount = (absOutput * 5) / 10_000;
// Hand the bonus tokens to the PoolManager so the user can take them.
bonusCurrency.settle(poolManager, address(this), bonusAmount, false);
(uint128 bonus0, uint128 bonus1) = specifiedIsToken0
? (uint128(0), bonusAmount)
: (bonusAmount, uint128(0));
emit HookBonus(PoolId.unwrap(key.toId()), bonus0, bonus1);
// Negative hookDeltaUnspecified => user receives more by bonusAmount.
return (this.afterSwap.selector, -int128(bonusAmount));
}Note that HookBonus has no sender field - the standard treats the rebate as a property of the pool, not of any particular router.
Scenario 4: Async swaps → HookSwap at fulfillment
Async swaps defer execution: rather than filling a swap immediately, the hook takes the input now and settles later, often in a batch. This is used for MEV protection (controlling swap ordering) and is built on the same beforeSwap + beforeSwapReturnDelta flags as custom curves.

The one rule that's specific to async: emit HookSwap at the moment of batch fulfillment, not when the input is first taken. Emitting per-deposit would double-count or misattribute volume, since the actual execution happens later and possibly in aggregate.
/// Called when the deferred batch is actually executed.
function _fulfillBatch(PoolKey calldata key, Order[] memory orders) internal {
for (uint256 i = 0; i < orders.length; i++) {
// ... execute each order against whatever liquidity source ...
emit HookSwap(
PoolId.unwrap(key.toId()),
orders[i].router,
orders[i].amount0, // signed, swapper's perspective
orders[i].amount1,
0,
0
);
}
}OpenZeppelin maintains a BaseAsyncSwap template in the uniswap-hooks library that's a good starting point - and a good place to see the event wiring in a fuller implementation.
Insight #2
These scenarios overlap. A single hook can be a custom curve and charge a fee and hand out a bonus. When it does, emit the events for every flow it touches - HookSwap for the trade, HookFee for the fee, HookBonus for the rebate. The events describe behaviours, not mutually exclusive hook "types."
What the indexers do with this
The other side of the contract is what makes emitting these events pay off.
An indexer building a complete picture of V4 does two things. First, it watches the PoolManager's Initialize event at pool creation - that event carries the hook address, so the indexer can tell a hooked pool from a vanilla one (the latter has the zero address) and build a registry of every initialized hook. Second, for those hooked pools, it parses both the native events and your standard hook events from the logs and trace data.
That second step only works if you emitted the events. When you do, your hook plugs straight into the same pipelines that track volume, TVL, and fees for the rest of the protocol. The Foundation is building open-source tooling around exactly this standard - adopt the events and you get that integration close to free, rather than having to build and maintain a bespoke indexer for your own hook.
This is also why the standard matters at the metric level. Definitions like hooked volume versus vanilla volume, or singleton TVL versus external TVL (liquidity that lives inside hook contracts rather than the PoolManager), all depend on hooks honestly reporting what they did. A custom-curve hook holding its own reserves is external TVL that no native event will ever surface - HookModifyLiquidity is the only way it shows up.
Why bother adopting this
Standards are a coordination game. Nobody forces you to adopt them; the value compounds as more people do - the same way ERC-20 or EIP-1559 only became useful once they were everywhere.
For a hook developer specifically, the trade is good. You add a few emit statements at points your hook already passes through. In return: your pool shows up correctly in analytics, LPs can assess your hook's real risk-and-reward, aggregators and front-ends can integrate you without bespoke work, and you get to lean on the Foundation's open-source indexing tooling instead of rolling your own. The standard is also being baked into the OpenZeppelin uniswap-hooks library, so the default path for new hooks is increasingly to emit these out of the box.
The cost is four event definitions and a handful of emit calls. The downside of skipping it is being invisible. That's an easy call.
Recap
- Return-delta hooks break the native events. Bypass or modify the core swap and the
PoolManager'sSwap/ModifyLiquidityevents go missing or wrong - and every indexer reads those. - Four standard events fix it:
HookSwap,HookFee,HookModifyLiquidity,HookBonus. Emit the ones relevant to your hook's behaviour. - Emit at the right moment.
HookSwapwhere you handle the trade (at batch fulfillment for async);HookFee/HookBonuswhere you take or give value;HookModifyLiquiditywhere liquidity moves outside the singleton. - The events describe behaviours, not types - overlap freely and emit all that apply.
What to read next
- Every Hook in V4 (and the four that secretly run the show) - the prerequisite for this piece. Covers the return-delta hooks,
BeforeSwapDelta, the swap configurations, and the address/permission mechanics we skipped here. - The Uniswap Foundation's original standard - Establishing Hook Data Standards for Uniswap v4. The source for this post, with the full ABI in its appendix and the metric definitions in more depth.
- OpenZeppelin's uniswap-hooks library - github.com/OpenZeppelin/uniswap-hooks.
BaseHook,BaseAsyncSwap, and the home the standard events are being adopted into. - Custom Accounting in the V4 docs - docs.uniswap.org/contracts/v4/guides/custom-accounting. The reference for the delta mechanics underpinning all of the above.
If you adopt the events and hit a rough edge, or you think the standard's missing a case, reach out on X and let me know.
