Skip to content

feat: integrate Camelot V3 on Arbitrum One - #1449

Open
dvansari65 wants to merge 25 commits into
propeller-heads:mainfrom
dvansari65:feat/camelot-v3-arbitrum
Open

dvansari65 wants to merge 25 commits into
propeller-heads:mainfrom
dvansari65:feat/camelot-v3-arbitrum

Conversation

@dvansari65

Copy link
Copy Markdown
Contributor

Adds Camelot V3 on Arbitrum One as a VM integration: vm:camelot_v3, component type camelot_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-pool DataStorageOperator from 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)

  • Pools are discovered from the factory's Pool event on non-reverted factory call frames.
  • The DataStorageOperator appears in no event. createPool deploys it with CREATE right 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.
  • Each component links the pool, its operator and the factory (read for vaultAddress when a community fee is paid). Code and storage of all three are emitted from the factory's creation block.
  • Balances come from both sides of ERC20 Transfer events on pool tokens.
  • Incentive events are exposed as the active_incentive state attribute. A pool with a non-zero incentive calls an unindexed virtual pool and is filtered out in simulation. The factory's farmingAddress is currently zero.
  • ABIs are the verified factory and pool ABIs from Blockscout.

Simulation (protocols/adapter-integration/evm/src/camelot-v3)

  • CamelotV3SwapAdapter pays swaps in algebraSwapCallback, authenticated against factory.poolByPair, and quotes by reverting inside that callback with a tagged payload.
  • Marginal prices come from the pool's sqrt price and the fee it will charge next; for a zero amount, a 1 wei quote reads the fee the first swap of the block would set.
  • Limits walk the tick table read-only through tickTable and ticks, 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.
  • CamelotV3TickMath ports getSqrtRatioAtTick, the token delta functions and nextTickInTheSameRow from the verified pool source.
  • Runtime registered in vm/constants.rs; filter in filters.rs and EXCHANGES_REQUIRING_FILTER.

Execution

  • Camelot pools expose the same swap(address,bool,int256,uint160,bytes) as Uniswap V3 and settle through algebraSwapCallback, which the router's selector-agnostic fallback() delivers to the executor. vm:camelot_v3 therefore reuses the deployed UniswapV3Executor on Arbitrum, as ramses_v3 does.
  • CamelotV3SwapEncoder emits the executor's 64-byte payload with the fee slot zeroed; the executor never reads it.
  • Config, gas estimator (PROTOCOLS_CALLBACK), test harness mapping and the executor address table are updated.

Verification

  • Substreams: builds to wasm; 9 unit tests (parameter parsing and pool discovery on a synthetic createPool trace); 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.
  • Adapter: 18 Foundry fork tests at Arbitrum block 504411371, including the shared runPoolBehaviourTest on two pools.
  • Execution: encoder unit test, calldata integration test, and a TychoRouterV3 fork test (WETH to USDC via Camelot at block 504411773) checking the exact output and the community fee split.
  • Workspace clippy with -D warnings, fmt, cargo doc, no-default-features check.

Not run here: the protocols/testing range tests in integration_test.tycho.yaml (need Postgres, an Arbitrum archive RPC and a Substreams token). The harness previously had no Arbitrum Substreams endpoint; this adds it.

@github-project-automation github-project-automation Bot moved this to Todo in Tycho Sep 15, 2026
@dvansari65
dvansari65 marked this pull request as draft September 15, 2026 17:10
@dvansari65
dvansari65 force-pushed the feat/camelot-v3-arbitrum branch from a7aee2f to 2dfb480 Compare September 16, 2026 14:17
@dvansari65
dvansari65 force-pushed the feat/camelot-v3-arbitrum branch from 2dfb480 to 0a90f54 Compare September 17, 2026 15:31
@dvansari65
dvansari65 force-pushed the feat/camelot-v3-arbitrum branch from 0a90f54 to ee5a8b4 Compare September 17, 2026 16:03
…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 die-herdplatte left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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's computeSwapStep: same fee on input, same rounding, same rule for the remainder. The Uniswap V3 swap loop carries over, with feeZto/feeOtz in place of one fee.
  • Indexing. Ticks, liquidity, price and the current fee all come from pool events (Mint, Burn, Swap, Fee), like ethereum-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 t the pool recomputes its fee from the operator's timepoints: the last one and the one before it, the oldest one, and the pair that DataStorage.binarySearch finds around t − 1 day, interpolated. aerodrome_slipstreams already does this end to end. It emits its oracle ring as observations/{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 in apply_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's FeeConfiguration event) and volumePerLiquidityInBlock. 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 before t − 1 day is unreachable and can be deleted with ChangeType::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-pool index → timestamp store.

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.

────────────────────────────────────────────────────

Comment thread protocols/adapter-integration/evm/src/camelot-v3/CamelotV3SwapAdapter.sol Outdated
Comment thread protocols/adapter-integration/evm/src/camelot-v3/CamelotV3SwapAdapter.sol Outdated
Comment thread crates/tycho-simulation/src/evm/protocol/filters.rs Outdated
Comment thread protocols/adapter-integration/evm/src/camelot-v3/CamelotV3SwapAdapter.sol Outdated
Comment thread crates/tycho-execution/contracts/test/protocols/CamelotV3.t.sol
Comment thread protocols/substreams/arbitrum-camelot-v3/Cargo.toml Outdated
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.
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

…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.
@dvansari65

Copy link
Copy Markdown
Contributor Author

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.

This branch is waiting to be deployed

1 waiting deployment
integration-tests — 569a750d Waiting Oct 19, 2026 by dvansari65 via EVM tests (requires RPC) #2654
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants