Repository navigation
feat: integrate Camelot V3 on Arbitrum One - #1449
dvansari65 wants to merge 25 commits into
Conversation
a7aee2f to
2dfb480
Compare
2dfb480 to
0a90f54
Compare
0a90f54 to
ee5a8b4
Compare
…itrum # Conflicts: # crates/tycho-execution/config/test_executor_addresses.json # crates/tycho-execution/contracts/test/assets/calldata.txt # crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs # protocols/substreams/Cargo.toml # protocols/testing/src/state_registry.rs
Firehose delivers storage keys and values as full 32-byte words, which the exact key comparison against ACTIVE_INCENTIVE_SLOT already relies on. Padding shorter values hid a broken assumption instead of failing; the decoder now rejects any value that is not a word. Verified with `substreams run` on block 101213149: every storage key and value arrived as 32 bytes and `active_incentive` is emitted as a Creation attribute.
die-herdplatte
left a comment
There was a problem hiding this comment.
🤖 Claude Code ──────────────────────────
I'd like to see Camelot V3 integrated natively rather than through the VM. As far as I can see, nothing stands in the way:
- Swap math. The swap step (
PriceMovementMath.movePriceTowardsTarget) is Uniswap V3'scomputeSwapStep: same fee on input, same rounding, same rule for the remainder. The Uniswap V3 swap loop carries over, withfeeZto/feeOtzin place of one fee. - Indexing. Ticks, liquidity, price and the current fee all come from pool events (
Mint,Burn,Swap,Fee), likeethereum-uniswap-v3. The community fee and tick spacing don't affect quotes, and incentives don't either (see incentive-guard-in-adapter). - The adaptive fee is the real work, and it has precedent. At a new timestamp
tthe pool recomputes its fee from the operator's timepoints: the last one and the one before it, the oldest one, and the pair thatDataStorage.binarySearchfinds aroundt − 1 day, interpolated.aerodrome_slipstreamsalready does this end to end. It emits its oracle ring asobservations/{idx}straight from storage changes (protocols/substreams/base-aerodrome-slipstreams/src/storage/pool_storage.rs:164), decodes the ring (crates/tycho-simulation/src/evm/protocol/aerodrome_slipstreams/decoder.rs:178-188), sets the execution timestamp inapply_block(crates/tycho-simulation/src/evm/protocol/aerodrome_slipstreams/state.rs:641), and runs the TWAP search on demand while quoting. Camelot additionally needs the fee configs (the operator'sFeeConfigurationevent) andvolumePerLiquidityInBlock. A swap at a new timestamp must also write its timepoint and fee into the new state. - Pruning the ring. Camelot's ring is a fixed 65,536 entries. It has already wrapped on WETH/USDC and WETH/ARB, yet only 3,835 and 2,571 of those timepoints fall within the last day. Emitting every write would ship the whole ring in each snapshot, about 4 MB raw per busy pool. Timestamps only increase along the ring, and execution time only moves forward. So after a write at
t, everything older than the newest timepoint at or beforet − 1 dayis unreachable and can be deleted withChangeType::Deletion, as Slipstreams already does for ticks (protocols/substreams/base-aerodrome-slipstreams/src/storage/pool_storage.rs:102-108). Choosing which indices to delete needs a small per-poolindex → timestampstore.
walk-cap-exceeds-swap-gas, boundary-pool-stops-stream, incentive-guard-in-adapter and fee-keyed-on-timestamp are about the VM adapter. They show why it doesn't hold up against the engine; there is no need to fix them. Much of this PR carries over: pool discovery, the transfer-based balances, the execution wiring, and the careful verification against the Camelot source (the slot-4 packing, the tick-table port, and recovering the operator from the createPool trace all check out).
The PR description lags the code: active_incentive comes from the storage slot, not from Incentive events; only the factory ABI is vendored; and there are 12 substreams unit tests, not 9.
────────────────────────────────────────────────────
The package emits what a swap reads as state attributes, decoded from the storage words the pool and its DataStorageOperator write: the globalState scalars, liquidity and volumePerLiquidityInBlock, the ticks a Mint or Burn of the pool names (deleted when liquidityTotal reaches zero), the timepoint ring pruned to the entries the adaptive fee can still reach, and both fee configurations. A pool created in a transaction starts from every scalar the decoder requires, overridden by the transaction's own writes. Balances come from ERC20 Transfer events on both sides. The storage-to-attribute fold is pure and unit-tested; the vendored pool ABI carries events only.
CamelotV3State runs the Uniswap V3 swap loop over a TickList with spacing 1 and the fee of the swap's direction. timepoints.rs ports DataStorage and adaptive_fee.rs ports AdaptiveFee.getFee and calculateVolumePerLiquidity. The fee is keyed on the execution timestamp: a quote performs the timepoint write and fee recomputation the first swap at that timestamp performs, otherwise it pays the stored fee. get_limits walks ticks until a swap would spend the gas budget, priced as swap prices it. Gas constants come from gasleft() measurements around pool.swap on an Arbitrum fork. TickList gains set_tick, remove_tick and is_empty because Algebra keeps a zero-net tick initialized. Three real swaps replay wei-exact. The VM runtime, decoder entry and pool filter are removed; the state is registered in the testing crate, the integration-test runner and the quickstart example.
Camelot pools expose Uniswap V3's swap and settle through algebraSwapCallback, which the router's fallback routes to the executor. The encoder is registered under camelot_v3 instead of vm:camelot_v3, with the executor address, the executors.sql row, the gas estimator's callback list and the calldata test renamed to match. The VM adapter, its fork tests and its Foundry profile are removed. The solver docs describe the timestamp-keyed fee.
|
All contributors have signed the CLA ✍️ ✅ |
…itrum # Conflicts: # crates/tycho-execution/contracts/test/assets/calldata.txt # crates/tycho-execution/src/encoding/evm/swap_encoder/swap_encoder_registry.rs # crates/tycho-execution/substreams/executors.sql
… 0.8.2 The merge with upstream/main left the arbitrum-camelot-v3 entry pointing at the 0.8.1 package that the merged lock file no longer lists; cargo resolves the package's "0.8.1" requirement to 0.8.2 like every other registry user.
|
I have read the PropellerHeads CLA and I hereby sign the CLA |
The oracle ring requires `timepoint_index` to name its newest timepoint and, unless that is the ring's first write, to be preceded by the timepoint `timepoint_index - 1`. The constructor, the snapshot decoder and every write check this instead of falling back to the current tick when that timepoint is missing. Attribute values must be non-empty, and the fee configuration decoder shares the attribute narrowing, so bytes above the packed struct must be zero. Unit tests pin the sigmoid series on both branches, the fee between its saturations, the volume branch above 192 bits and the ring averages with values from an integer model of the deployed contract.
`dump_fixture.py` turns one Arbitrum transaction into a replay fixture from an archive RPC with the standard library only; the three existing fixtures are its output. Five new fixtures cover a swap crossing an initialized tick, two swaps at a timestamp the ring already holds, which pay the stored fees, and two idle pools whose fee is recomputed after more than a day. The spot-price test sells a hundredth of the captured swap in each direction instead of one whole token, which is not a small amount on every pool.
`active_registry_matches_runtime_address_configs` requires each chain's executors in `deployment_registry.json` to equal `executor_addresses.json`.
…itrum Adapts the Camelot V3 swap loop and limits walk to the tick list that now returns the next tick's info instead of a flag and to the lazy `PoolTick`, and the encoder test to the `origin` field of `EncodingContext`.
A wrapped ring overwrites its oldest slot, which the pruning has usually deleted already. That write is now a creation, and an update only while the slot is still kept, so a revert of it finds no missing prior value. Storage writes are grouped by contract and parsed only for tracked pools and operators, so another contract's write never fails the block. The manifest drops the unused `vm.proto` import, and the creation-only integration test documents the first-write ring it covers.
`query_pool_swap` reaches a target spot price through the closed form Slipstreams uses, falling back to the generic search only when the band is missed. Output and post-swap state come from an exact-input swap of the input found, which is what executes. A trade limit is refused, as for Uniswap V3: the generic search returned zero on every pool with full-range positions.
Adds Camelot V3 on Arbitrum One as a VM integration:
vm:camelot_v3, component typecamelot_v3_pool.Camelot V3 is an Algebra V1.9 deployment (factory
0x1a3c9B1d2F0529D97f2afC5136Cc23e58f1FD35B, created at block 101163738). Its fee is adaptive and differs per direction, computed by a per-poolDataStorageOperatorfrom stored price history; swaps write oracle timepoints; the tick spacing is mutable. The integration runs the pool as deployed rather than porting that logic.Indexing (
protocols/substreams/arbitrum-camelot-v3)Poolevent on non-reverted factory call frames.DataStorageOperatorappears in no event.createPooldeploys it withCREATEright before the pool deployer deploys the pool, so it is recovered from the call trace as the single contract the factory call creates; any other shape is an error.vaultAddresswhen a community fee is paid). Code and storage of all three are emitted from the factory's creation block.Transferevents on pool tokens.Incentiveevents are exposed as theactive_incentivestate attribute. A pool with a non-zero incentive calls an unindexed virtual pool and is filtered out in simulation. The factory'sfarmingAddressis currently zero.Simulation (
protocols/adapter-integration/evm/src/camelot-v3)CamelotV3SwapAdapterpays swaps inalgebraSwapCallback, authenticated againstfactory.poolByPair, and quotes by reverting inside that callback with a tagged payload.tickTableandticks, mirroring the native Uniswap V3 implementation, capped at 400 steps. Quoting through the pool costs 8M gas and more on liquid pools because every crossed tick writes storage, above the 8M the engine grants a call. The walk measured 3.3M to 6.0M gas on the WETH/USDC and WETH/ARB pools.CamelotV3TickMathportsgetSqrtRatioAtTick, the token delta functions andnextTickInTheSameRowfrom the verified pool source.vm/constants.rs; filter infilters.rsandEXCHANGES_REQUIRING_FILTER.Execution
swap(address,bool,int256,uint160,bytes)as Uniswap V3 and settle throughalgebraSwapCallback, which the router's selector-agnosticfallback()delivers to the executor.vm:camelot_v3therefore reuses the deployedUniswapV3Executoron Arbitrum, asramses_v3does.CamelotV3SwapEncoderemits the executor's 64-byte payload with the fee slot zeroed; the executor never reads it.PROTOCOLS_CALLBACK), test harness mapping and the executor address table are updated.Verification
createPooltrace); clippy and fmt clean. Streamed block 101213149 through the package on StreamingFast: the emitted component, balances and storage match the chain, including the tick and bitmap slots of the first mint.runPoolBehaviourTeston two pools.-D warnings, fmt,cargo doc, no-default-features check.Not run here: the
protocols/testingrange tests inintegration_test.tycho.yaml(need Postgres, an Arbitrum archive RPC and a Substreams token). The harness previously had no Arbitrum Substreams endpoint; this adds it.