wp-evm-v4-core 0.1.14

Pure data + quote + plan for v4-family DEXes — no async dependencies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! V4 hook permission decoder — `HookFlag` + `has_permission`.
//!
//! Replaces `uniswap_v4_sdk::utils::hook` (R17-pre Slice D). The V4
//! `IHooks.sol` library encodes the hook permission set in the **low 14
//! bits** of the hook contract's address; bytes `[18..=19]` form the
//! 16-bit permission word and each `HookFlag` variant indexes one bit
//! within that word.
//!
//! ## Bit layout
//!
//! Higher bits (further from LSB) encode earlier in the swap lifecycle:
//!
//! | Bit | Permission                                | Notes                       |
//! |----:|-------------------------------------------|-----------------------------|
//! |  13 | `BEFORE_INITIALIZE`                       | initialize callbacks        |
//! |  12 | `AFTER_INITIALIZE`                       |                             |
//! |  11 | `BEFORE_ADD_LIQUIDITY`                    | liquidity callbacks         |
//! |  10 | `AFTER_ADD_LIQUIDITY`                     |                             |
//! |   9 | `BEFORE_REMOVE_LIQUIDITY`                 |                             |
//! |   8 | `AFTER_REMOVE_LIQUIDITY`                  |                             |
//! |   7 | `BEFORE_SWAP`                             | **swap-permission flag**    |
//! |   6 | `AFTER_SWAP`                              | **swap-permission flag**    |
//! |   5 | `BEFORE_DONATE`                           | donate callbacks            |
//! |   4 | `AFTER_DONATE`                            |                             |
//! |   3 | `BEFORE_SWAP_RETURNS_DELTA`               | **swap-permission flag**    |
//! |   2 | `AFTER_SWAP_RETURNS_DELTA`                | **swap-permission flag**    |
//! |   1 | `AFTER_ADD_LIQUIDITY_RETURNS_DELTA`       |                             |
//! |   0 | `AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA`    |                             |
//!
//! The four bits marked **swap-permission** form the `0xCC` mask
//! (`0b1100_1100`) used by [`has_swap_permissions`] to gate
//! `quote::exact_in`. See V4 periphery's [`Hooks.sol`][1] for the
//! canonical bit layout.
//!
//! ## Why a stricter swap check than the SDK's
//!
//! `uniswap_v4_sdk::utils::hook::has_swap_permissions` only checks
//! `BEFORE_SWAP | AFTER_SWAP` and claims the `_RETURNS_DELTA` flags are
//! "implicitly encapsulated". Per V4 Hooks.sol, the `_RETURNS_DELTA`
//! flags are independent bits that can be set without their base flags;
//! checking all four is intentionally stricter than the SDK so the
//! quote path errs on the side of rejecting hooks it can't reason about.
//!
//! [1]: https://github.com/Uniswap/v4-core/blob/main/src/libraries/Hooks.sol

use alloy_primitives::Address;

/// A single V4 hook permission flag — indexes one bit in the low 16
/// bits of a hook contract's address.
///
/// Discriminants are the bit indices used by V4 `Hooks.sol`. Don't
/// renumber them; on-chain hook addresses encode permissions at these
/// exact positions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum HookFlag {
    /// Hook implements `afterRemoveLiquidityReturnDelta` callback.
    AfterRemoveLiquidityReturnsDelta = 0,
    /// Hook implements `afterAddLiquidityReturnDelta` callback.
    AfterAddLiquidityReturnsDelta = 1,
    /// Hook implements `afterSwapReturnDelta` callback. **Swap-permission flag.**
    AfterSwapReturnsDelta = 2,
    /// Hook implements `beforeSwapReturnDelta` callback. **Swap-permission flag.**
    BeforeSwapReturnsDelta = 3,
    /// Hook implements `afterDonate` callback.
    AfterDonate = 4,
    /// Hook implements `beforeDonate` callback.
    BeforeDonate = 5,
    /// Hook implements `afterSwap` callback. **Swap-permission flag.**
    AfterSwap = 6,
    /// Hook implements `beforeSwap` callback. **Swap-permission flag.**
    BeforeSwap = 7,
    /// Hook implements `afterRemoveLiquidity` callback.
    AfterRemoveLiquidity = 8,
    /// Hook implements `beforeRemoveLiquidity` callback.
    BeforeRemoveLiquidity = 9,
    /// Hook implements `afterAddLiquidity` callback.
    AfterAddLiquidity = 10,
    /// Hook implements `beforeAddLiquidity` callback.
    BeforeAddLiquidity = 11,
    /// Hook implements `afterInitialize` callback.
    AfterInitialize = 12,
    /// Hook implements `beforeInitialize` callback.
    BeforeInitialize = 13,
}

/// Bitmask matching every swap-permission flag (`0b1100_1100 = 0xCC`).
///
/// Set on `BEFORE_SWAP | AFTER_SWAP | BEFORE_SWAP_RETURNS_DELTA |
/// AFTER_SWAP_RETURNS_DELTA`. Crate-private — external consumers go
/// through [`has_swap_permissions`]. Promote to `pub` only when a
/// downstream crate needs to compose its own `0xCC`-style gate.
pub(crate) const SWAP_PERMISSIONS_MASK: u16 = (1 << HookFlag::BeforeSwap as u16)
    | (1 << HookFlag::AfterSwap as u16)
    | (1 << HookFlag::BeforeSwapReturnsDelta as u16)
    | (1 << HookFlag::AfterSwapReturnsDelta as u16);

/// Returns `true` if `hooks`'s low 16 bits set the bit indexed by `flag`.
///
/// Mirrors `uniswap_v4_sdk::utils::hook::has_permission` exactly: reads
/// bytes `[18..=19]` of the address, builds a 16-bit mask, and tests
/// `mask & (1 << flag) != 0`.
#[inline]
#[must_use]
pub const fn has_permission(hooks: Address, flag: HookFlag) -> bool {
    let bytes = hooks.0 .0;
    let mask = ((bytes[18] as u16) << 8) | (bytes[19] as u16);
    let bit = flag as u16;
    mask & (1 << bit) != 0
}

/// Returns `true` if any of the four swap-permission flags are set.
///
/// Stricter than `uniswap_v4_sdk::utils::hook::has_swap_permissions`
/// (which only checks `BEFORE_SWAP | AFTER_SWAP`); see module docs.
/// `Address::ZERO` always returns `false` — that's the canonical
/// hookless sentinel.
#[inline]
#[must_use]
pub const fn has_swap_permissions(hooks: Address) -> bool {
    // Only the low 16 bits encode permissions, so testing the mask
    // alone is sufficient — `Address::ZERO` falls out naturally
    // (every bit zero ⇒ mask = 0 ⇒ AND returns 0). Documented
    // separately for the canonical-hookless intuition.
    let bytes = hooks.0 .0;
    let mask = ((bytes[18] as u16) << 8) | (bytes[19] as u16);
    mask & SWAP_PERMISSIONS_MASK != 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy_primitives::address;

    /// Build a fixture hook address whose low 16 bits encode the OR of
    /// the given flags. Other bytes are zero — irrelevant to the
    /// permission decoder.
    fn hook_address_with(flags: &[HookFlag]) -> Address {
        let mut bytes = [0u8; 20];
        let mut mask: u16 = 0;
        for f in flags {
            mask |= 1 << (*f as u16);
        }
        bytes[18] = (mask >> 8) as u8;
        bytes[19] = mask as u8;
        Address::from(bytes)
    }

    /// Lock the bit-index discriminants of every flag against V4
    /// `Hooks.sol`. Renumbering any of these breaks decoding for
    /// every hook deployed on-chain.
    #[test]
    fn flag_bit_indices_match_v4_hooks_sol() {
        assert_eq!(HookFlag::AfterRemoveLiquidityReturnsDelta as u8, 0);
        assert_eq!(HookFlag::AfterAddLiquidityReturnsDelta as u8, 1);
        assert_eq!(HookFlag::AfterSwapReturnsDelta as u8, 2);
        assert_eq!(HookFlag::BeforeSwapReturnsDelta as u8, 3);
        assert_eq!(HookFlag::AfterDonate as u8, 4);
        assert_eq!(HookFlag::BeforeDonate as u8, 5);
        assert_eq!(HookFlag::AfterSwap as u8, 6);
        assert_eq!(HookFlag::BeforeSwap as u8, 7);
        assert_eq!(HookFlag::AfterRemoveLiquidity as u8, 8);
        assert_eq!(HookFlag::BeforeRemoveLiquidity as u8, 9);
        assert_eq!(HookFlag::AfterAddLiquidity as u8, 10);
        assert_eq!(HookFlag::BeforeAddLiquidity as u8, 11);
        assert_eq!(HookFlag::AfterInitialize as u8, 12);
        assert_eq!(HookFlag::BeforeInitialize as u8, 13);
    }

    /// `SWAP_PERMISSIONS_MASK` must equal Hooks.sol's `0xCC` low-byte
    /// pattern — bits 7, 6, 3, 2.
    #[test]
    fn swap_permissions_mask_is_0xcc() {
        assert_eq!(SWAP_PERMISSIONS_MASK, 0x00CC);
        assert_eq!(SWAP_PERMISSIONS_MASK, 0b1100_1100);
    }

    /// Each flag, set in isolation, decodes back to itself and to no
    /// other flag.
    #[test]
    fn each_flag_decodes_in_isolation() {
        let all = [
            HookFlag::AfterRemoveLiquidityReturnsDelta,
            HookFlag::AfterAddLiquidityReturnsDelta,
            HookFlag::AfterSwapReturnsDelta,
            HookFlag::BeforeSwapReturnsDelta,
            HookFlag::AfterDonate,
            HookFlag::BeforeDonate,
            HookFlag::AfterSwap,
            HookFlag::BeforeSwap,
            HookFlag::AfterRemoveLiquidity,
            HookFlag::BeforeRemoveLiquidity,
            HookFlag::AfterAddLiquidity,
            HookFlag::BeforeAddLiquidity,
            HookFlag::AfterInitialize,
            HookFlag::BeforeInitialize,
        ];
        for set_flag in all {
            let addr = hook_address_with(&[set_flag]);
            for probe in all {
                let expected = probe == set_flag;
                assert_eq!(
                    has_permission(addr, probe),
                    expected,
                    "addr={addr:?} set={set_flag:?} probe={probe:?}",
                );
            }
        }
    }

    /// `Address::ZERO` is the canonical hookless sentinel; nothing is
    /// permitted, including swap permissions.
    #[test]
    fn zero_address_has_no_permissions() {
        assert!(!has_swap_permissions(Address::ZERO));
        for f in [
            HookFlag::BeforeSwap,
            HookFlag::AfterSwap,
            HookFlag::BeforeSwapReturnsDelta,
            HookFlag::AfterSwapReturnsDelta,
            HookFlag::BeforeInitialize,
            HookFlag::AfterRemoveLiquidityReturnsDelta,
        ] {
            assert!(!has_permission(Address::ZERO, f));
        }
    }

    /// All-ones address has every flag set.
    #[test]
    fn all_ones_address_has_every_permission() {
        let addr = Address::from([0xFFu8; 20]);
        let all = [
            HookFlag::AfterRemoveLiquidityReturnsDelta,
            HookFlag::AfterAddLiquidityReturnsDelta,
            HookFlag::AfterSwapReturnsDelta,
            HookFlag::BeforeSwapReturnsDelta,
            HookFlag::AfterDonate,
            HookFlag::BeforeDonate,
            HookFlag::AfterSwap,
            HookFlag::BeforeSwap,
            HookFlag::AfterRemoveLiquidity,
            HookFlag::BeforeRemoveLiquidity,
            HookFlag::AfterAddLiquidity,
            HookFlag::BeforeAddLiquidity,
            HookFlag::AfterInitialize,
            HookFlag::BeforeInitialize,
        ];
        for f in all {
            assert!(has_permission(addr, f));
        }
        assert!(has_swap_permissions(addr));
    }

    /// `has_swap_permissions` triggers on each of the four swap flags
    /// independently — including the two `_RETURNS_DELTA` variants the
    /// SDK helper missed.
    #[test]
    fn swap_permissions_trips_on_each_swap_flag_independently() {
        for flag in [
            HookFlag::BeforeSwap,
            HookFlag::AfterSwap,
            HookFlag::BeforeSwapReturnsDelta,
            HookFlag::AfterSwapReturnsDelta,
        ] {
            let addr = hook_address_with(&[flag]);
            assert!(has_swap_permissions(addr), "swap flag {flag:?} must trip the gate");
        }
    }

    /// Non-swap flags don't trip the swap gate even when set.
    #[test]
    fn swap_permissions_ignores_non_swap_flags() {
        for flag in [
            HookFlag::BeforeInitialize,
            HookFlag::AfterInitialize,
            HookFlag::BeforeAddLiquidity,
            HookFlag::AfterAddLiquidity,
            HookFlag::BeforeRemoveLiquidity,
            HookFlag::AfterRemoveLiquidity,
            HookFlag::BeforeDonate,
            HookFlag::AfterDonate,
            HookFlag::AfterAddLiquidityReturnsDelta,
            HookFlag::AfterRemoveLiquidityReturnsDelta,
        ] {
            let addr = hook_address_with(&[flag]);
            assert!(!has_swap_permissions(addr), "non-swap flag {flag:?} must not trip the gate");
        }
    }

    /// Bytes 0..=17 of an address are ignored by the permission decoder
    /// — only the low 16 bits (bytes 18..=19) matter. Use noisy upper
    /// bytes plus byte 18 = 0x01, byte 19 = 0x00 to set bit 8
    /// (`AfterRemoveLiquidity`) and only that.
    #[test]
    fn high_address_bits_dont_affect_permissions() {
        let addr = address!("dead00112233445566778899aabbccddeeff0100");
        assert!(!has_swap_permissions(addr), "no swap flags in low 16 bits");
        assert!(has_permission(addr, HookFlag::AfterRemoveLiquidity), "bit 8 must decode");
        assert!(!has_permission(addr, HookFlag::BeforeSwap));
        assert!(!has_permission(addr, HookFlag::BeforeInitialize));
    }

    /// Lock the absolute byte offset (bytes 18..=19, *not* 16..=17 or
    /// 19 alone) using literal addresses written by hand — independent
    /// of the `hook_address_with` fixture builder. A coordinated
    /// off-by-two mutation in both the decoder and the fixture would
    /// slip past `each_flag_decodes_in_isolation`; this test wouldn't.
    #[test]
    fn permission_byte_offset_is_18_and_19() {
        // byte 19 = 0x80 ⇒ bit 7 ⇒ BeforeSwap
        let before_swap = address!("0000000000000000000000000000000000000080");
        assert!(has_permission(before_swap, HookFlag::BeforeSwap));
        assert!(has_swap_permissions(before_swap));
        assert!(!has_permission(before_swap, HookFlag::AfterSwap));

        // byte 18 = 0x20 ⇒ bit 13 ⇒ BeforeInitialize
        let before_init = address!("0000000000000000000000000000000000002000");
        assert!(has_permission(before_init, HookFlag::BeforeInitialize));
        assert!(!has_swap_permissions(before_init));

        // byte 19 = 0x01 ⇒ bit 0 ⇒ AfterRemoveLiquidityReturnsDelta
        let arm_lrd = address!("0000000000000000000000000000000000000001");
        assert!(has_permission(arm_lrd, HookFlag::AfterRemoveLiquidityReturnsDelta));
        assert!(!has_swap_permissions(arm_lrd));

        // byte 17 should be ignored — set it without triggering anything
        let off_by_one = address!("0000000000000000000000000000000000800000");
        assert!(!has_swap_permissions(off_by_one), "byte 17 must not affect decoding");
        for f in [
            HookFlag::BeforeSwap,
            HookFlag::AfterSwap,
            HookFlag::BeforeSwapReturnsDelta,
            HookFlag::AfterSwapReturnsDelta,
            HookFlag::BeforeInitialize,
        ] {
            assert!(!has_permission(off_by_one, f), "byte 17 leaked into flag {f:?}");
        }
    }

    // R17-pre Slice D included a `swap_flag_parity_with_sdk` test
    // that cross-checked each swap flag against
    // `uniswap_v4_sdk::utils::hook::has_permission`. Slice F dropped
    // the SDK dep; the test was removed because its regression power
    // is fully covered by `flag_bit_indices_match_v4_hooks_sol`
    // (locks each discriminant individually) +
    // `each_flag_decodes_in_isolation` (verifies the bit-twiddle on
    // every flag) + `permission_byte_offset_is_18_and_19` (locks the
    // absolute byte offset).

    /// Real V4 mainnet hook addresses pulled from `eth_getLogs` of
    /// V4 PoolManager (`0x000000000004444c5dc75cB358380D2e3dE08A90`)
    /// `Initialize` events between mainnet blocks 24,996,790 and
    /// 24,997,290 (capture date 2026-05-01). Each was the `hooks`
    /// field of an actual V4 pool. Closes the "synthetic-vs-real
    /// cross-check" sub-task that was the last open follow-up from
    /// the R17-pre retro.
    ///
    /// Pinning real on-chain addresses (rather than synthesizing
    /// `hook_address_with(...)` fixtures) catches a class of decoder
    /// regressions that synthetic tests can't: reading the wrong
    /// byte offset (we already have a guard test for offsets 18-19,
    /// but a real address that *happens* to set bits in adjacent
    /// bytes is the real-world stress case). All three addresses
    /// here naturally have non-flag bits set elsewhere in the low
    /// 16 bits — the decoder must ignore those.
    ///
    /// ## Discovery recipe (for fixture rotation)
    ///
    /// To re-mine fresh fixtures (e.g. after a V4 periphery upgrade
    /// changes the Hooks.sol bit layout, or to refresh staleness
    /// flagged in the R17-pre retro's "fixture rotation" follow-up):
    ///
    /// ```text
    /// RPC=https://eth.drpc.org
    /// PM=0x000000000004444c5dc75cb358380d2e3de08a90
    /// INIT=0xdd466e674ea557f56295e2d0218a125ea4b4f0f6f3307b95f85e6110838d6438
    /// curl -s -X POST -H 'Content-Type: application/json' \
    ///   --data "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"params\":[{
    ///     \"fromBlock\":\"<recent>\",\"toBlock\":\"<+500>\",
    ///     \"address\":\"$PM\",\"topics\":[\"$INIT\"]}],\"id\":1}" "$RPC"
    /// ```
    ///
    /// Each Initialize log's `data` field has the layout
    /// `fee(uint24,32B) | tickSpacing(int24,32B) | hooks(address,32B) |
    /// sqrtPriceX96(uint160,32B) | tick(int24,32B)`, so `hooks` is the
    /// 20-byte tail of `data[64..96]`. Filter for non-zero hooks; the
    /// low 16 bits are `(hooks[18] << 8) | hooks[19]` and must AND
    /// with `SWAP_PERMISSIONS_MASK = 0xCC` to be considered for this
    /// fixture set.
    #[test]
    fn real_v4_mainnet_hooks_decode_correctly() {
        // Hook with mask 0xc0cc — all four swap flags set, plus
        // upper bits 14/15 set (V4 ValidateHooks ignores those;
        // bit 13 is BeforeInitialize, this addr does NOT have it).
        // Recovered from V4 PM Initialize event in tx
        // 0xd9a4e3cc1404fc85ecfaab0b897bf1140f32f1faaf6d00b98638b401b224a958
        // at block 24_996_849.
        let hook_a = address!("0d62529346ac2c61f5c0582210d01214687bc0cc");
        assert!(has_swap_permissions(hook_a), "hook_a must have swap permissions");
        assert!(has_permission(hook_a, HookFlag::BeforeSwap));
        assert!(has_permission(hook_a, HookFlag::AfterSwap));
        assert!(has_permission(hook_a, HookFlag::BeforeSwapReturnsDelta));
        assert!(has_permission(hook_a, HookFlag::AfterSwapReturnsDelta));
        // Non-swap flags must be false despite the upper-byte noise.
        assert!(!has_permission(hook_a, HookFlag::BeforeInitialize));
        assert!(!has_permission(hook_a, HookFlag::AfterInitialize));
        assert!(!has_permission(hook_a, HookFlag::BeforeAddLiquidity));

        // Hook with mask 0x00cc — pure 0xCC, swap-only. Recovered
        // from V4 PM Initialize event in tx
        // 0x9b8e9cecf2e635ed1557a25d02e8d7580262683045d6274fb24cad162a941624
        // at block 24_997_171.
        let hook_b = address!("627fa6f76fa96b10bae1b6fba280a3c9264500cc");
        assert!(has_swap_permissions(hook_b));
        assert!(has_permission(hook_b, HookFlag::BeforeSwap));
        assert!(has_permission(hook_b, HookFlag::AfterSwap));
        assert!(has_permission(hook_b, HookFlag::BeforeSwapReturnsDelta));
        assert!(has_permission(hook_b, HookFlag::AfterSwapReturnsDelta));

        // Hook with mask 0x00cc, distinct deployment. Recovered
        // from V4 PM Initialize event in tx
        // 0x6ef4d4d724300d2b627d525a1c79483b2cbf10b90b55b21ad1b8e2b9497e295e
        // at block 24_997_238.
        let hook_c = address!("a2dcd7bf7ff3c014a855bf00799ccf07e6c800cc");
        assert!(has_swap_permissions(hook_c));
        assert!(has_permission(hook_c, HookFlag::AfterSwap));

        // Sanity: confirm one mask is exactly 0x00CC and another has
        // the upper bits, proving the decoder ignores them.
        let bytes_a = hook_a.0 .0;
        let mask_a = ((bytes_a[18] as u16) << 8) | (bytes_a[19] as u16);
        assert_eq!(mask_a, 0xc0cc, "hook_a low-16 mask preserved as captured");
        let bytes_b = hook_b.0 .0;
        let mask_b = ((bytes_b[18] as u16) << 8) | (bytes_b[19] as u16);
        assert_eq!(mask_b, 0x00cc, "hook_b is the canonical 0xCC swap-only mask");
    }
}