v4quote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. v4quote/__init__.py +1 -0
  2. v4quote/amm/__init__.py +1 -0
  3. v4quote/amm/algebra_integral.py +91 -0
  4. v4quote/amm/erc4626.py +173 -0
  5. v4quote/amm/liquidity_book/__init__.py +40 -0
  6. v4quote/amm/liquidity_book/hooks_allowlist.py +237 -0
  7. v4quote/amm/liquidity_book/math.py +179 -0
  8. v4quote/amm/liquidity_book/pair.py +473 -0
  9. v4quote/amm/metric_omm.py +412 -0
  10. v4quote/amm/native.py +384 -0
  11. v4quote/amm/serialization.py +543 -0
  12. v4quote/amm/uniswap_v2.py +93 -0
  13. v4quote/amm/uniswap_v4.py +282 -0
  14. v4quote/amm/v3/__init__.py +23 -0
  15. v4quote/amm/v3/liquidity_math.py +14 -0
  16. v4quote/amm/v3/pool.py +259 -0
  17. v4quote/amm/v3/pool_address.py +62 -0
  18. v4quote/amm/v3/sqrt_price_math.py +101 -0
  19. v4quote/amm/v3/swap_math.py +151 -0
  20. v4quote/amm/v3/tick_bitmap.py +99 -0
  21. v4quote/amm/v3/tick_math.py +92 -0
  22. v4quote/amm/v4_rd_hooks.py +126 -0
  23. v4quote/chain/__init__.py +1 -0
  24. v4quote/chain/bloom.py +97 -0
  25. v4quote/chain/erc20.py +71 -0
  26. v4quote/chain/loaders.py +540 -0
  27. v4quote/chain/logs.py +647 -0
  28. v4quote/chain/metric.py +161 -0
  29. v4quote/chain/multicall.py +160 -0
  30. v4quote/chain/quoters.py +227 -0
  31. v4quote/chain/rpc.py +302 -0
  32. v4quote/display.py +23 -0
  33. v4quote/errors.py +65 -0
  34. v4quote/math/__init__.py +52 -0
  35. v4quote/math/bit_math.py +13 -0
  36. v4quote/math/full_math.py +37 -0
  37. v4quote/math/uint.py +57 -0
  38. v4quote/routing.py +756 -0
  39. v4quote/routing_pools.py +195 -0
  40. v4quote-0.1.0.dist-info/METADATA +250 -0
  41. v4quote-0.1.0.dist-info/RECORD +43 -0
  42. v4quote-0.1.0.dist-info/WHEEL +4 -0
  43. v4quote-0.1.0.dist-info/licenses/LICENSE +339 -0
v4quote/__init__.py ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ """Exact-integer AMM swap math. Pure functions over explicit pool state."""
@@ -0,0 +1,91 @@
1
+ """Algebra Integral (v1.x) concentrated-liquidity pools: the Uniswap v3 swap math with Algebra's tick walk.
2
+
3
+ What is the same as Uniswap v3 (so ``v4quote.amm.v3.pool`` quotes these pools; no second copy of the math):
4
+ * the swap step (``PriceMovementMath.movePriceTowardsTarget`` = ``SwapMath.computeSwapStep``: fee taken from the
5
+ input as ``mulDiv(amount, 1e6 - fee, 1e6)``, the same rounding of the price and the token deltas), TickMath,
6
+ the crossing (``liquidity += liquidityDelta`` upwards, ``-=`` downwards; tick = nextTick - 1 after crossing down);
7
+ * the swap ABI ``swap(recipient, zeroToOne, amountRequired, limitSqrtPrice, data)`` (selector 0x128acb08) and the
8
+ callback semantics (``algebraSwapCallback(int256, int256, bytes)``);
9
+ * the events ``Swap`` / ``Mint`` / ``Burn`` (same topics as Uniswap v3: post price, liquidity, tick).
10
+
11
+ What differs (and is modelled here):
12
+ * the tick walk: Algebra jumps from one initialised tick to the next (a linked list, ``prevTickGlobal`` /
13
+ ``nextTickGlobal`` / ``ticks(t).nextTick``), it never stops at a 256-tick bitmap word boundary like v3. Every
14
+ step rounds on its own, so the number of steps matters at the wei level: ``AlgebraTickSnapshot`` answers
15
+ ``next_initialized_tick_within_one_word`` with the next initialised tick itself (across words);
16
+ * the bitmap ``tickTable(int16)`` holds UNcompressed ticks (word = tick >> 8, bit = tick & 0xFF, whatever the
17
+ tickSpacing): the snapshot always has ``tick_spacing = 1`` (also the state's ``tick_spacing``, the real spacing
18
+ only matters for minting); ``ticks(int24)`` returns (liquidityTotal uint256, liquidityDelta int128, prevTick,
19
+ nextTick, outerFeeGrowth0, outerFeeGrowth1);
20
+ * the fee: ``globalState().lastFee`` is NOT necessarily the fee of the next swap. With the plugin's DYNAMIC_FEE
21
+ flag (pluginConfig bit 7) the plugin's ``beforeSwap`` returns an override fee (adaptive fee from the volatility
22
+ oracle) and ``fee()`` of the pool returns ``plugin.getCurrentFee()``; the applied fee is logged per swap by
23
+ ``SwapFee(address indexed sender, uint24 overrideFee, uint24 pluginFee)`` right BEFORE the ``Swap`` of the same
24
+ call (Integral v1.2). The state's ``fee`` is the fee the next swap pays: ``fee()`` at load, then the logged
25
+ ``overrideFee`` (``swap_fee_from_log``) and ``fee()`` re-reads.
26
+
27
+ Checked on Robinhood Chain (block 100117724): pool 0x49ffa9b5 WETH/USDG of factory 0xf03875b5, plugin
28
+ AlgebraUpgradeablePlugin (Sourcify), adaptive fee alphas 0 -> fee = baseFee 100; exact-input round trips simulated on
29
+ chain (``eth_simulateV1``) equal ``v3.pool.quote_exact_input`` on a state of ``load_algebra_integral_pool`` to the wei
30
+ (fixture ``tests/fixtures/algebra_integral_robinhood.json``).
31
+
32
+ Not covered: a plugin fee > 0 (``swap_fee_from_log`` returns None: its effect on the step is not verified on chain,
33
+ every logged swap of the pool had pluginFee 0; the book's model check then quarantines the pool), an override fee
34
+ of 0 (the pool then charges ``lastFee``), ``swapWithPaymentInAdvance``, a plugin that changes amounts.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass
39
+
40
+ from v4quote.amm.v3.tick_bitmap import TickBitmapSnapshot, position
41
+ from v4quote.amm.v3.tick_math import MAX_TICK, MIN_TICK
42
+ from v4quote.math.bit_math import least_significant_bit, most_significant_bit
43
+ from v4quote.math.uint import MAX_UINT256
44
+
45
+ MAX_FEE = 1_000_000 # pips; Algebra rejects an override fee >= 1e6
46
+ DYNAMIC_FEE_FLAG = 128 # Plugins.DYNAMIC_FEE (globalState().pluginConfig)
47
+ _MIN_WORD, _MAX_WORD = MIN_TICK >> 8, MAX_TICK >> 8
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AlgebraTickSnapshot(TickBitmapSnapshot):
52
+ """Initialised ticks of an Algebra Integral pool (``tick_spacing`` must be 1: uncompressed ticks).
53
+
54
+ ``next_initialized_tick_within_one_word`` returns the next initialised tick in any word (Algebra's linked-list
55
+ walk); beyond the last one MIN_TICK / MAX_TICK (not initialised, as v3's loop clamps). A word outside
56
+ ``word_range`` on the way raises ``InsufficientState`` (never a guess). A subclass: the native v3 kernel (word
57
+ steps) never gets a handle for it (``v4quote.amm.native`` checks the exact type), the Python loop serves it.
58
+ """
59
+
60
+ def __post_init__(self) -> None:
61
+ if self.tick_spacing != 1:
62
+ raise ValueError("AlgebraTickSnapshot holds uncompressed ticks: tick_spacing must be 1")
63
+
64
+ def next_initialized_tick_within_one_word(self, tick: int, lte: bool) -> tuple[int, bool]:
65
+ if lte:
66
+ wp, bp = position(tick)
67
+ masked = self.word(wp) & ((1 << (bp + 1)) - 1)
68
+ while not masked:
69
+ wp -= 1
70
+ if wp < _MIN_WORD:
71
+ return MIN_TICK, False
72
+ masked = self.word(wp)
73
+ return (wp << 8) + most_significant_bit(masked), True
74
+ t = tick + 1
75
+ wp, bp = position(t)
76
+ masked = self.word(wp) & (~((1 << bp) - 1) & MAX_UINT256)
77
+ while not masked:
78
+ wp += 1
79
+ if wp > _MAX_WORD:
80
+ return MAX_TICK, False
81
+ masked = self.word(wp)
82
+ return (wp << 8) + least_significant_bit(masked), True
83
+
84
+
85
+ def swap_fee_from_log(override_fee: int, plugin_fee: int) -> int | None:
86
+ """Fee (pips) the swap step charged, from ``SwapFee(sender, overrideFee, pluginFee)``; None when core cannot
87
+ apply it: overrideFee 0 (the pool fell back to ``lastFee``, not in this log), >= 1e6 (no fee), pluginFee != 0
88
+ (not modelled)."""
89
+ if plugin_fee != 0 or not 0 < override_fee < MAX_FEE:
90
+ return None
91
+ return override_fee
v4quote/amm/erc4626.py ADDED
@@ -0,0 +1,173 @@
1
+ """ERC-4626 tokenized vault as a two-token "pool" (asset <-> shares), exact integer math of OpenZeppelin v5 ``ERC4626``.
2
+
3
+ Model (OpenZeppelin Contracts v5.0, ``token/ERC20/extensions/ERC4626.sol``), with ``A = totalAssets()``,
4
+ ``S = totalSupply()`` and ``o = _decimalsOffset()``::
5
+
6
+ previewDeposit(a) = convertToShares(a, Floor) = mulDiv(a, S + 10**o, A + 1, Floor)
7
+ previewRedeem(s) = convertToAssets(s, Floor) = mulDiv(s, A + 1, S + 10**o, Floor)
8
+ previewMint(s) = mulDiv(s, A + 1, S + 10**o, Ceil)
9
+ previewWithdraw(a)= mulDiv(a, S + 10**o, A + 1, Ceil)
10
+
11
+ ``deposit(a)`` pulls ``a`` of the asset and mints ``previewDeposit(a)`` shares; ``redeem(s)`` burns ``s`` shares and pays
12
+ ``previewRedeem(s)`` of the asset. No fee. A vault that overrides the conversion (fees, other rounding, a non-default
13
+ ``totalAssets`` that is not the asset balance) is NOT this model: ``v4quote.chain.loaders.load_erc4626`` checks
14
+ ``previewDeposit`` / ``previewRedeem`` on chain against these formulas at load time and refuses a vault that differs.
15
+
16
+ Gnosis sDAI (``SavingsXDai`` 0xaf20...3701): OZ v5 ERC4626 over WXDAI,
17
+ ``totalAssets = WXDAI.balanceOf(vault)``, ``o = 0``, not a proxy; the formulas matched ``previewDeposit`` /
18
+ ``previewRedeem`` 125/125 times on 25 historical blocks. The price moves only through direct asset transfers into the
19
+ vault (``BridgeInterestReceiver.claim()``, every block since 2026-09-28).
20
+
21
+ Pool view for ``v4quote.routing``: tokens ``(asset, vault)`` (the vault address IS the share token);
22
+ coin index 0 -> 1 = ``deposit`` (asset in, shares out), 1 -> 0 = ``redeem`` (shares in, asset out).
23
+
24
+ Reverts (``MathRevert``): a result above 2**256 - 1 (OZ v5.0 ``Math.mulDiv``: ``MathOverflowedMulDiv``), a mint that
25
+ would overflow ``totalSupply`` or an asset balance overflow (checked arithmetic), a redeem of more shares than exist
26
+ (``ERC4626ExceededMaxRedeem``: ``maxRedeem(owner) = balanceOf(owner) <= totalSupply``). ``maxDeposit`` is OZ's default
27
+ (``type(uint256).max``); a vault with another ``maxDeposit`` is outside the model (the loader checks it).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass, replace
33
+
34
+ from v4quote.errors import MathRevert
35
+ from v4quote.math.uint import MAX_UINT256
36
+
37
+ MAX_DECIMALS_OFFSET = 18 # OZ allows uint8, 10**o overflows uint256 for o >= 78; real vaults use 0 .. a few
38
+ ERR_MULDIV = "MathOverflowedMulDiv"
39
+ ERR_MAX_REDEEM = "ERC4626ExceededMaxRedeem"
40
+ ERR_SUPPLY_OVERFLOW = "panic: arithmetic overflow (totalSupply)"
41
+ ERR_ASSETS_OVERFLOW = "panic: arithmetic overflow (asset balance)"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ERC4626VaultState:
46
+ """Snapshot of an OZ v5 ERC-4626 vault: ``address`` is the vault (= share token), ``asset`` the underlying.
47
+
48
+ Not ``slots``: the native backend caches its handle by a weak reference to the (frozen) state object."""
49
+
50
+ address: str
51
+ asset: str
52
+ total_assets: int
53
+ total_supply: int
54
+ decimals_offset: int = 0
55
+ block_number: int | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ for name in ("total_assets", "total_supply"):
59
+ v = getattr(self, name)
60
+ if type(v) is not int or not 0 <= v <= MAX_UINT256:
61
+ raise ValueError(f"{name} must be an int in [0, 2**256)")
62
+ if type(self.decimals_offset) is not int or not 0 <= self.decimals_offset <= MAX_DECIMALS_OFFSET:
63
+ raise ValueError(f"decimals_offset must be an int in 0..{MAX_DECIMALS_OFFSET}")
64
+ if self.address.lower() == self.asset.lower():
65
+ raise ValueError("vault and asset must differ")
66
+
67
+ @property
68
+ def coins(self) -> tuple[str, str]:
69
+ """``(asset, vault)``: index 0 -> 1 is deposit, 1 -> 0 is redeem."""
70
+ return (self.asset, self.address)
71
+
72
+ @property
73
+ def virtual_shares(self) -> int:
74
+ return 10 ** self.decimals_offset
75
+
76
+
77
+ def _mul_div(x: int, y: int, d: int, ceil: bool) -> int:
78
+ q, r = divmod(x * y, d)
79
+ if ceil and r:
80
+ q += 1
81
+ if q > MAX_UINT256:
82
+ raise MathRevert(ERR_MULDIV)
83
+ return q
84
+
85
+
86
+ def convert_to_shares(st: ERC4626VaultState, assets: int, *, ceil: bool = False) -> int:
87
+ return _mul_div(assets, st.total_supply + st.virtual_shares, st.total_assets + 1, ceil)
88
+
89
+
90
+ def convert_to_assets(st: ERC4626VaultState, shares: int, *, ceil: bool = False) -> int:
91
+ return _mul_div(shares, st.total_assets + 1, st.total_supply + st.virtual_shares, ceil)
92
+
93
+
94
+ def preview_deposit(st: ERC4626VaultState, assets: int) -> int:
95
+ """Shares minted by ``deposit(assets)`` (floor)."""
96
+ _check_amount(assets)
97
+ return convert_to_shares(st, assets)
98
+
99
+
100
+ def preview_redeem(st: ERC4626VaultState, shares: int) -> int:
101
+ """Assets paid by ``redeem(shares)`` (floor)."""
102
+ _check_amount(shares)
103
+ return convert_to_assets(st, shares)
104
+
105
+
106
+ def preview_mint(st: ERC4626VaultState, shares: int) -> int:
107
+ """Assets pulled by ``mint(shares)`` (ceil)."""
108
+ _check_amount(shares)
109
+ return convert_to_assets(st, shares, ceil=True)
110
+
111
+
112
+ def preview_withdraw(st: ERC4626VaultState, assets: int) -> int:
113
+ """Shares burnt by ``withdraw(assets)`` (ceil)."""
114
+ _check_amount(assets)
115
+ return convert_to_shares(st, assets, ceil=True)
116
+
117
+
118
+ def _check_amount(x: int) -> None:
119
+ if type(x) is not int or not 0 <= x <= MAX_UINT256:
120
+ raise ValueError("amount must be an int in [0, 2**256)")
121
+
122
+
123
+ def deposit_out(st: ERC4626VaultState, assets: int) -> int:
124
+ """``deposit(assets)`` as executed: the shares minted, with the reverts of the transfer-in and the mint."""
125
+ shares = preview_deposit(st, assets)
126
+ if st.total_assets + assets > MAX_UINT256:
127
+ raise MathRevert(ERR_ASSETS_OVERFLOW)
128
+ if st.total_supply + shares > MAX_UINT256:
129
+ raise MathRevert(ERR_SUPPLY_OVERFLOW)
130
+ return shares
131
+
132
+
133
+ def redeem_out(st: ERC4626VaultState, shares: int) -> int:
134
+ """``redeem(shares)`` as executed by an owner holding ``shares``: the assets paid (``shares <= totalSupply``)."""
135
+ _check_amount(shares)
136
+ if shares > st.total_supply:
137
+ raise MathRevert(ERR_MAX_REDEEM)
138
+ return convert_to_assets(st, shares) # <= A whenever shares <= S (floor of s (A+1) / (S + 10**o))
139
+
140
+
141
+ def deposit(st: ERC4626VaultState, assets: int) -> tuple[int, ERC4626VaultState]:
142
+ """Shares and the post-state (A + assets, S + shares)."""
143
+ shares = deposit_out(st, assets)
144
+ return shares, replace(st, total_assets=st.total_assets + assets, total_supply=st.total_supply + shares)
145
+
146
+
147
+ def redeem(st: ERC4626VaultState, shares: int) -> tuple[int, ERC4626VaultState]:
148
+ """Assets and the post-state (A - assets, S - shares)."""
149
+ assets = redeem_out(st, shares)
150
+ return assets, replace(st, total_assets=st.total_assets - assets, total_supply=st.total_supply - shares)
151
+
152
+
153
+ def quote(st: ERC4626VaultState, i: int, j: int, amount: int) -> int:
154
+ """Exact-input quote in pool-coin terms: ``(0, 1)`` = deposit, ``(1, 0)`` = redeem."""
155
+ if (i, j) == (0, 1):
156
+ return deposit_out(st, amount)
157
+ if (i, j) == (1, 0):
158
+ return redeem_out(st, amount)
159
+ raise ValueError(f"bad coin indices ({i}, {j}) for an ERC-4626 vault (0 = asset, 1 = shares)")
160
+
161
+
162
+ def apply_flows(st: ERC4626VaultState, d_assets: int, d_supply: int, block_number: int | None = None) -> ERC4626VaultState:
163
+ """The state after net asset-balance / supply changes (e.g. summed from simulated ``Transfer`` logs: asset
164
+ transfers to / from the vault, share mints / burns). Raises ValueError when a result leaves [0, 2**256)."""
165
+ return replace(st, total_assets=st.total_assets + d_assets, total_supply=st.total_supply + d_supply,
166
+ block_number=st.block_number if block_number is None else block_number)
167
+
168
+
169
+ __all__ = [
170
+ "ERC4626VaultState", "ERR_ASSETS_OVERFLOW", "ERR_MAX_REDEEM", "ERR_MULDIV", "ERR_SUPPLY_OVERFLOW",
171
+ "MAX_DECIMALS_OFFSET", "apply_flows", "convert_to_assets", "convert_to_shares", "deposit", "deposit_out",
172
+ "preview_deposit", "preview_mint", "preview_redeem", "preview_withdraw", "quote", "redeem", "redeem_out",
173
+ ]
@@ -0,0 +1,40 @@
1
+ """LFJ Liquidity Book v2.1 / v2.2 (and exact forks such as Pharaoh DLMM)."""
2
+
3
+ from v4quote.amm.liquidity_book.math import FeeParameters, get_price_from_id
4
+ from v4quote.amm.liquidity_book.hooks_allowlist import (
5
+ DEFAULT_HOOKS_ALLOWLIST,
6
+ HookApproval,
7
+ HooksAllowlist,
8
+ REPLAY_PENDING,
9
+ validate_allowlist,
10
+ )
11
+ from v4quote.amm.liquidity_book.pair import (
12
+ ALLOWED_HOOK_FLAGS,
13
+ BinSwap,
14
+ LBPairState,
15
+ execution_permitted,
16
+ LBSwapResult,
17
+ get_amounts,
18
+ get_swap_in,
19
+ get_swap_out,
20
+ swap,
21
+ )
22
+
23
+ __all__ = [
24
+ "ALLOWED_HOOK_FLAGS",
25
+ "DEFAULT_HOOKS_ALLOWLIST",
26
+ "BinSwap",
27
+ "HookApproval",
28
+ "HooksAllowlist",
29
+ "REPLAY_PENDING",
30
+ "execution_permitted",
31
+ "validate_allowlist",
32
+ "FeeParameters",
33
+ "LBPairState",
34
+ "LBSwapResult",
35
+ "get_amounts",
36
+ "get_price_from_id",
37
+ "get_swap_in",
38
+ "get_swap_out",
39
+ "swap",
40
+ ]
@@ -0,0 +1,237 @@
1
+ """Allowlist of LB v2.2 hooks contracts whose pools v4quote may simulate.
2
+
3
+ An entry pins the hook by (chain, address), binds it to the one pair it was
4
+ verified with (``pair``), and pins the on-chain codehash and, for proxies, the
5
+ implementation address and implementation codehash. Any change on chain (upgrade,
6
+ beacon switch, redeploy, the hook reused by another pair) makes the pool
7
+ ``UnsupportedPool`` again. For LFJ rewarder clones the pool is also rejected while an
8
+ extra rewarder is set or the hook is unlinked (``pair.LBPairState.unsupported_reasons``).
9
+
10
+ ``execution_allowed`` is a policy flag for callers that send transactions: simulating a pool is
11
+ permitted by being in the list, sending transactions through it is not, unless this flag is True. All entries
12
+ are False.
13
+
14
+ Evidence: a replay of historical ``swap()`` calls of each pair (hooks executed) against the model,
15
+ ``tests/onchain/test_lb_swap_replay.py``, plus the codehash / pair links re-checked by
16
+ ``tests/onchain/test_address_links.py``. Entries whose on-chain swap replay is still pending are listed in
17
+ ``REPLAY_PENDING``: their pools are simulated, but results through them should be treated as unverified.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import Literal, Mapping, Optional
25
+
26
+ Verification = Literal["source-verified", "empirical"]
27
+ ProxyKind = Literal["lfj-immutable-clone", "eip1967-beacon", "eip1967"]
28
+
29
+ _HASH_RE = re.compile(r"^0x[0-9a-fA-F]{64}$")
30
+ _ADDR_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
31
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
32
+ KNOWN_CHAINS = ("monad", "avalanche")
33
+ EVIDENCE = "tests/onchain/test_lb_swap_replay.py" # where the entries are verified against the chain
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HookApproval:
38
+ """Allowlist entry for one hooks contract (hex strings compared case-insensitively).
39
+
40
+ Validation (raises ValueError): ``pair`` is a mandatory address (the LBPair whose
41
+ ``getLBHooksParameters()`` points at this hook); codehashes are
42
+ 32-byte hex; ``verification`` is "source-verified" or "empirical"; for a proxy
43
+ (``proxy_kind`` set) both ``implementation`` and ``implementation_codehash`` are
44
+ mandatory, for a non-proxy both must be absent.
45
+ """
46
+
47
+ codehash: str
48
+ implementation: Optional[str] = None
49
+ implementation_codehash: Optional[str] = None
50
+ proxy_kind: Optional[ProxyKind] = None
51
+ verification: Verification = "empirical"
52
+ execution_allowed: bool = False
53
+ pinned_block: Optional[int] = None
54
+ added_date: Optional[str] = None
55
+ evidence: Optional[str] = None
56
+ pair: Optional[str] = None # mandatory (validated below); last only because the fields above are positional
57
+
58
+ def __post_init__(self) -> None:
59
+ if not (isinstance(self.pair, str) and _ADDR_RE.match(self.pair)):
60
+ raise ValueError(f"allowlist entry requires the pair address, got {self.pair!r}")
61
+ if not _HASH_RE.match(self.codehash or ""):
62
+ raise ValueError(f"invalid codehash {self.codehash!r}")
63
+ if self.verification not in ("source-verified", "empirical"):
64
+ raise ValueError(f"invalid verification {self.verification!r}")
65
+ if not isinstance(self.execution_allowed, bool):
66
+ raise ValueError("execution_allowed must be bool")
67
+ if self.proxy_kind is not None:
68
+ if self.proxy_kind not in ("lfj-immutable-clone", "eip1967-beacon", "eip1967"):
69
+ raise ValueError(f"invalid proxy_kind {self.proxy_kind!r}")
70
+ if not (self.implementation and _ADDR_RE.match(self.implementation)):
71
+ raise ValueError("proxy entry requires a valid implementation address")
72
+ if not (self.implementation_codehash and _HASH_RE.match(self.implementation_codehash)):
73
+ raise ValueError("proxy entry requires implementation_codehash")
74
+ elif self.implementation is not None or self.implementation_codehash is not None:
75
+ raise ValueError("implementation fields set but proxy_kind is None")
76
+ if self.added_date is not None and not _DATE_RE.match(self.added_date):
77
+ raise ValueError(f"invalid added_date {self.added_date!r}")
78
+
79
+
80
+ HooksAllowlist = Mapping[tuple[str, str], HookApproval] # key: (chain, hook address)
81
+
82
+
83
+ def validate_allowlist(allowlist: HooksAllowlist) -> None:
84
+ pairs: set[tuple[str, str]] = set()
85
+ for (chain, hook), approval in allowlist.items():
86
+ if chain not in KNOWN_CHAINS:
87
+ raise ValueError(f"unknown chain {chain!r}")
88
+ if not _ADDR_RE.match(hook):
89
+ raise ValueError(f"invalid hook address {hook!r}")
90
+ if not isinstance(approval, HookApproval):
91
+ raise ValueError("allowlist values must be HookApproval")
92
+ key = (chain, str(approval.pair).lower())
93
+ if key in pairs:
94
+ raise ValueError(f"pair {approval.pair} on {chain} has more than one allowlisted hook")
95
+ pairs.add(key)
96
+
97
+
98
+ # Codehashes re-read on-chain when the entries were added (2026-09-21):
99
+ # Avalanche block 95854756/95854758, Monad blocks 106830370/106830372.
100
+ # AUSD pair hooks added 2026-09-23 at Avalanche block 95951189.
101
+ # USDt/USDC and sAVAX/WAVAX pair hooks added 2026-09-29 at Avalanche block 96389826.
102
+ # ``pair``: the pair each hook was verified and replayed with (tests/fixtures/pools.yaml / route_pools.yaml);
103
+ # re-checked on chain by tests/onchain/test_address_links.py::test_default_hooks_allowlist_matches_chain
104
+ # (pair.getLBHooksParameters() points at the hook).
105
+ _LFJ_HOOK_IMPL = "0x2235CDFe1172aA5eD7cEC6E1713f93eeaBFbc295"
106
+
107
+ DEFAULT_HOOKS_ALLOWLIST: HooksAllowlist = {
108
+ # LFJ WAVAX/USDC bs10 (0x864d4e5e...16ea). Implementation LBHooksSimpleRewarder verified on routescan.
109
+ ("avalanche", "0xBA83987a28b1C7514576e15D58c134b8F0E00Fa9"): HookApproval(
110
+ pair="0x864d4e5Ee7318e97483DB7EB0912E09F161516EA",
111
+ codehash="0x419f4e8dcd4dd32a0220cb95d64b152d3331fc5a8fa7b3d30ac6d7ac256a17b7",
112
+ implementation=_LFJ_HOOK_IMPL,
113
+ implementation_codehash="0x0e088a5b6645a261bcdcb2efad8789e25f5b423a129dbd4188869a8361e73ba1",
114
+ proxy_kind="lfj-immutable-clone",
115
+ verification="source-verified",
116
+ execution_allowed=False,
117
+ pinned_block=95854756,
118
+ added_date="2026-09-21",
119
+ evidence=EVIDENCE,
120
+ ),
121
+ # LFJ WMON/USDC bs10 (0x5e60bc3f...fe22). Monad implementation bytecode differs from the verified
122
+ # Avalanche one -> empirical (swap replay) only.
123
+ ("monad", "0xd0B3Ff65058609743877637e689B30abdb97364a"): HookApproval(
124
+ pair="0x5E60BC3F7a7303BC4dfE4dc2220bdC90bc04fE22",
125
+ codehash="0x908dd8930d84a8f5bae05ee104f1dbae9c34d3f08640c27b2cb1cc279b875769",
126
+ implementation=_LFJ_HOOK_IMPL,
127
+ implementation_codehash="0xef6f52a0e9e2cf26dd81f5456c6071f58dafbb06017c411f9f02b7a93a452ea1",
128
+ proxy_kind="lfj-immutable-clone",
129
+ verification="empirical",
130
+ execution_allowed=False,
131
+ pinned_block=106830370,
132
+ added_date="2026-09-21",
133
+ evidence=EVIDENCE,
134
+ ),
135
+ # LFJ WMON/USDC bs5 (0x5AFD3EC8...7620).
136
+ ("monad", "0x8b0D325fEa6F13BB6D62F825137ABd4cF6457367"): HookApproval(
137
+ pair="0x5AFD3EC861f6104af26e8755aBcc1f876de77620",
138
+ codehash="0xcd9425f235e9de88175cbd19c472d9a3c5ff6d73ed0c4db2169fa77adda3a180",
139
+ implementation=_LFJ_HOOK_IMPL,
140
+ implementation_codehash="0xef6f52a0e9e2cf26dd81f5456c6071f58dafbb06017c411f9f02b7a93a452ea1",
141
+ proxy_kind="lfj-immutable-clone",
142
+ verification="empirical",
143
+ execution_allowed=False,
144
+ pinned_block=106830372,
145
+ added_date="2026-09-21",
146
+ evidence=EVIDENCE,
147
+ ),
148
+ # Pharaoh DLMM WAVAX/USDC bs5 (0x8aC5707f...3379). BeaconProxy; beacon 0xd28467eD...548C.
149
+ ("avalanche", "0x138ce0008F7feBdcFfdf27C0aD156584eCa7D5B6"): HookApproval(
150
+ pair="0x8aC5707f8D4BDe1d771d34c7AfD81c3922b73379",
151
+ codehash="0xa57cb656b9262cee1544dfe70bbd760543cd40bff89a2a1c06f29f888d1ed741",
152
+ implementation="0xC997575204290FF7106aB8b2BCFa7e7dEA43D783",
153
+ implementation_codehash="0x67524de60f5a73e3f9ec5d78fea749d2133b323ef1fa340bf151866b0258fb51",
154
+ proxy_kind="eip1967-beacon",
155
+ verification="empirical",
156
+ execution_allowed=False,
157
+ pinned_block=95854758,
158
+ added_date="2026-09-21",
159
+ evidence=EVIDENCE,
160
+ ),
161
+ # LFJ AUSD/USDC bs1 (0x8573f981...9cee). Same implementation codehash as the WAVAX/USDC hook above;
162
+ # clone bytecode identical to it except the embedded pair address. Allowlisted for simulation 2026-09-27.
163
+ ("avalanche", "0xE104964852BE626EE27762712E4DE521066859C9"): HookApproval(
164
+ pair="0x8573F98175D816d520248B5fACF40D309B1c9ceE",
165
+ codehash="0xdaf577bdcbb29359151d7d9f5d0f0e3bf7bf2aa7cc5a7ff09aea9efe49c29dbf",
166
+ implementation=_LFJ_HOOK_IMPL,
167
+ implementation_codehash="0x0e088a5b6645a261bcdcb2efad8789e25f5b423a129dbd4188869a8361e73ba1",
168
+ proxy_kind="lfj-immutable-clone",
169
+ verification="source-verified",
170
+ execution_allowed=False,
171
+ pinned_block=95951189,
172
+ added_date="2026-09-23",
173
+ evidence=EVIDENCE,
174
+ ),
175
+ # LFJ AUSD/USDt bs1 (0xcec37728...631a). Same as above. Allowlisted for simulation 2026-09-27.
176
+ ("avalanche", "0x72ed0b6AcB5c585873b3F644F99FC167c7601256"): HookApproval(
177
+ pair="0xcEC377285AbF370FDf872625D2742252656d631a",
178
+ codehash="0x1131a0dabd90112bc1cbbd9cdc68110d54374178e8572eaa2dde5fdd27f79006",
179
+ implementation=_LFJ_HOOK_IMPL,
180
+ implementation_codehash="0x0e088a5b6645a261bcdcb2efad8789e25f5b423a129dbd4188869a8361e73ba1",
181
+ proxy_kind="lfj-immutable-clone",
182
+ verification="source-verified",
183
+ execution_allowed=False,
184
+ pinned_block=95951189,
185
+ added_date="2026-09-23",
186
+ evidence=EVIDENCE,
187
+ ),
188
+ # LFJ USDt/USDC bs1 (0x2823299a...ef5d). Clone of the same verified LBHooksSimpleRewarder as 0xBA83...0Fa9;
189
+ # clone bytecode identical except the embedded pair address. Approved for simulation 2026-09-29.
190
+ ("avalanche", "0x90014c5E78Bd8a3E2bbBaA8e5a0Aee2C1D2131CD"): HookApproval(
191
+ pair="0x2823299af89285fF1a1abF58DB37cE57006FEf5D",
192
+ codehash="0x5fd2e48584442dd82aaa6ef7be5ab8a9fa699c9c456a560545800042892b483a",
193
+ implementation=_LFJ_HOOK_IMPL,
194
+ implementation_codehash="0x0e088a5b6645a261bcdcb2efad8789e25f5b423a129dbd4188869a8361e73ba1",
195
+ proxy_kind="lfj-immutable-clone",
196
+ verification="source-verified",
197
+ execution_allowed=False,
198
+ pinned_block=96389826,
199
+ added_date="2026-09-29",
200
+ evidence=EVIDENCE,
201
+ ),
202
+ # LFJ sAVAX/WAVAX bs5 (0x883ea72c...c5c0). Same as above. Approved for simulation 2026-09-29.
203
+ ("avalanche", "0xdAe382F3542dd8a6b42F0F1F71B3f7227a4a3622"): HookApproval(
204
+ pair="0x883eA72c2A46F7AcB3820855344C43666c6cc5c0",
205
+ codehash="0x68d59a1ef8d61856d4a2c036b4603529a848af31cc7efabdae1d2c7fba8dd928",
206
+ implementation=_LFJ_HOOK_IMPL,
207
+ implementation_codehash="0x0e088a5b6645a261bcdcb2efad8789e25f5b423a129dbd4188869a8361e73ba1",
208
+ proxy_kind="lfj-immutable-clone",
209
+ verification="source-verified",
210
+ execution_allowed=False,
211
+ pinned_block=96389826,
212
+ added_date="2026-09-29",
213
+ evidence=EVIDENCE,
214
+ ),
215
+ # Pharaoh DLMM USDt/USDC bs1 (0x16888ee5...255c). BeaconProxy of the same beacon 0xd28467eD...548C and the same
216
+ # DLMMRewarder implementation as 0x138c...D5B6 (identical proxy codehash); pair = same unverified implementation
217
+ # 0xF41253C1...d710 as 0x8aC5...3379. Rewards were active when added (2026-09-29).
218
+ ("avalanche", "0x32E68C4b8Edc6d1F16E99EbA826B292FDC18daDC"): HookApproval(
219
+ pair="0x16888EE58E706C253b821EAA637ae9b2cFeA255C",
220
+ codehash="0xa57cb656b9262cee1544dfe70bbd760543cd40bff89a2a1c06f29f888d1ed741",
221
+ implementation="0xC997575204290FF7106aB8b2BCFa7e7dEA43D783",
222
+ implementation_codehash="0x67524de60f5a73e3f9ec5d78fea749d2133b323ef1fa340bf151866b0258fb51",
223
+ proxy_kind="eip1967-beacon",
224
+ verification="empirical",
225
+ execution_allowed=False,
226
+ pinned_block=96389826,
227
+ added_date="2026-09-29",
228
+ evidence=EVIDENCE,
229
+ ),
230
+ }
231
+ validate_allowlist(DEFAULT_HOOKS_ALLOWLIST)
232
+
233
+ # Pairs (chain, pair address lower case) whose hooks were allowlisted WITHOUT an on-chain swap replay of their own
234
+ # (criterion: >= 24 replayed swaps per pair, 0 wei mismatches). Consumers should treat results through them as
235
+ # unverified. Remove a pair here once its replay passed.
236
+ # 2026-09-29: the replay of all three 2026-09-29 pairs passed (24 of 24 swaps each, 0 mismatches).
237
+ REPLAY_PENDING: frozenset[tuple[str, str]] = frozenset()