feat: BopAMM (Bebop PMM) integration — indexing, simulation, execution#1095
feat: BopAMM (Bebop PMM) integration — indexing, simulation, execution#1095tvinagre wants to merge 11 commits into
Conversation
Index Bebop's BopAMM PMM as a VM protocol. Books (asset/USDC) are discovered from the pricing module's asset-config storage writes, since the contracts emit no config events. Tracks the full storage of the settlement, pricing module, and registry contracts; the maker wallet's token balances as per-book TVL; the per-book committed quote timestamp and block decoded from updateState/batch calldata; and pause/unpause state from the settlement. Deployment addresses and storage slots are passed as params so the module can target another deployment without code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the protocol design, on-chain interfaces, storage layout, the indexing model, verification, and remaining work (simulation adapter, quote-override stream, extractor registration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tycho-simulation hex-decodes component ids into the adapter's bytes32
poolId (string_to_bytes32), so the previous 0x{settlement}-{assetId}
format failed before reaching any adapter. Ids are now the 32-byte
settlement (20) || assetId (12, BE) packing.
Replace the committed_ts/committed_block attributes with a single
override_block_timestamp (8-byte BE u64): tycho-simulation pins
block.timestamp from this attribute when simulating, which passes the
registry's exact-timestamp StaleUpdate() gate without a separate
quote-override stream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntegration test The PrioUpdateRegistry was deployed before the test's block range, so within range it only emits storage updates, never an account creation. The indexer crashed with Account NotFound when applying those updates. Add it to initialized_accounts so its account is fetched from RPC and seeded before indexing, matching how other VM integrations bootstrap pre-existing contracts. Verified: the protocol-testing harness now passes test_book_discovery 1/1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sell-side ISwapAdapter for BopAMM (Bebop's on-chain PMM) wrapping the BopAmmV2 settlement contract. Pool ids pack settlement (20 bytes) and assetId (12 bytes), matching the substreams component ids. Limits are found by bisecting quote(), which reverts InsufficientLiquidity above the committed lane size. Buy orders revert NotImplemented: the venue quotes exact-input only. Quotes are gated on block.timestamp equalling the book's committed update timestamp (StaleUpdate() otherwise); fork tests re-stamp the registry lane via vm.store, and simulation pins the timestamp via the override_block_timestamp component attribute. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The simulation engine deploys the adapter in a context where the settlement contract's code is injected at call time, not at construction. The constructor read settlement.pricing()/usdc() eagerly, so deployment reverted there (the protocol-testing harness caught this; the fork tests passed only because setUp forks mainnet where settlement already exists). Resolve both lazily via public view functions instead. Pin the test fork to a quote-commit block (25266710): the venue is operator-driven, so forking latest drifts (a third book was added, quotes go stale) and broke the limit/price/swap assertions. At the pinned block exactly two books exist and the block timestamp matches book 0's committed quote. Verified: adapter fork tests 14/14, and the protocol-testing harness builds and deploys the adapter and passes test_book_discovery 1/1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BopAMM (Bebop's on-chain PMM) settles swaps through the BopAmmV2 contract, which debits tokenIn from the caller via allowance and sends output to the recipient param. The executor is ProtocolWillDebit with the settlement as approval target; protocol data is the packed (tokenIn, tokenOut) pair and the book follows from it. The protocol-level minAmountOut is 0 (the router enforces the user's minimum and the Dispatcher measures output via balance diff) and expiry is block.timestamp: BopAMM quotes are only valid in the block whose timestamp equals the book's committed registry update, so fork tests pin block 25266710 (a quote-commit block) and fund the maker wallet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The adapter measured output via a balanceOf(msg.sender) diff, which reads zero under tycho-simulation because the swap recipient's output-token balance is not modeled, so get_amount_out reverted. BopAMM is a constant-price PMM, so the settled output equals the lane quote; take the amount from settlement.quote() instead. The swap is still executed for fidelity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PrioUpdateRegistry is shared across venues and namespaces lanes by (caller, bookId). extract_committed_quotes ignored the caller, so a foreign protocol's commit that happened to share a bookId either crashed the indexer (update for a non-existent component) or overwrote a book's override_block_timestamp with a value that never reached its lane (StaleUpdate on simulate). Thread the caller through committed_updates and only emit when it is the BopAMM pricing module. Adds test_book_simulation covering the indexing -> simulation path over a range that contains each book's committed quotes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Embed the BopAMMSwapAdapter runtime bytecode and map "bopamm" in get_adapter_file so live consumers (ProtocolStreamBuilder) can build the adapter and price vm:bopamm books, matching what the protocol-testing harness already does from the integration yaml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
c22085e to
32a8ddd
Compare
CI formats Solidity with foundry stable and Rust with nightly rustfmt; apply both formatters to the bopamm executor, adapter, and substreams common module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| } | ||
|
|
||
| /// All currently-known book component ids, by probing the components store. | ||
| pub fn enumerate_books(store: &StoreGetProto<ProtocolComponent>) -> Vec<String> { |
There was a problem hiding this comment.
Short-circuit possible since asset IDs are sequential?
If so, I think the iteration should be unbounded.
| let slot_to_asset: HashMap<Vec<u8>, u64> = (0..MAX_ASSET_ID) | ||
| .map(|i| (asset_config_slot(i, config.asset_config_base_slot), i)) | ||
| .collect(); |
There was a problem hiding this comment.
To avoid this upper bound, keep computations to a minimum, and prevent missing out on books, one could do the following:
- Collect all relevant storage changes first
- If they're non-empty, compute the storage slot for the next asset ID, based on the highest recorded asset ID (needs a store read of some kind)
- If one of the changed slots equals the computed slot, emit the new component and start from the previous step with the next asset ID
This assumes that asset IDs increase by 1.
If delisting should be supported, one would have to add the storage slots of the existing books to the comparison.
| components_store: &StoreGetProto<ProtocolComponent>, | ||
| transaction_changes: &mut HashMap<u64, TransactionChangesBuilder>, | ||
| ) { | ||
| let books = enumerate_books(components_store); |
There was a problem hiding this comment.
This can also be computed lazily
| components_store: &StoreGetProto<ProtocolComponent>, | ||
| transaction_changes: &mut HashMap<u64, TransactionChangesBuilder>, | ||
| ) { | ||
| let books = enumerate_books(components_store); |
| components_store: &StoreGetProto<ProtocolComponent>, | ||
| transaction_changes: &mut HashMap<u64, TransactionChangesBuilder>, | ||
| ) { | ||
| let books = enumerate_books(components_store); |
| } | ||
|
|
||
| /// @inheritdoc ISwapAdapter | ||
| function getPoolIds(uint256 offset, uint256 limit) |
There was a problem hiding this comment.
Similar argument as in the Substreams package, if the asset IDs are assumed to be sequential, this can be changed to support the case where there are more than MAX_ASSET_ID assets and be made more efficient by breaking early.
| let Some(Transfer { from, to, value }) = Transfer::match_and_decode(log.log) else { | ||
| continue; | ||
| }; |
There was a problem hiding this comment.
When I was integrating FermiSwap, WETH was a special case here. Users can wrap/unwrap ETH <> WETH without an erc20 Transfer log in the flow we track, so the indexed WETH balance may drift.
Not sure if that’s an issue here too.
| let delta = if to_maker { value.clone() } else { value.clone().neg() }; | ||
| balance_deltas.push(BalanceDelta { | ||
| ord: log.ordinal(), | ||
| tx: Some(log.receipt.transaction.into()), | ||
| token: token.clone(), | ||
| delta: delta.to_signed_bytes_be(), | ||
| component_id: comp_id.into_bytes(), | ||
| }); |
There was a problem hiding this comment.
If the maker already held tokens before start_block, the balance may be wrong. We may need to snapshot the balance first, then apply deltas.
| aggregate_balances_changes(balance_store, deltas) | ||
| .into_iter() | ||
| .for_each(|(_, (tx, balances))| { | ||
| let builder = transaction_changes | ||
| .entry(tx.index) | ||
| .or_insert_with(|| TransactionChangesBuilder::new(&tx)); | ||
| balances | ||
| .values() | ||
| .for_each(|token_bc_map| { | ||
| token_bc_map.values().for_each(|bc| { | ||
| builder.add_balance_change(bc); | ||
| }) | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Do we need to upsert the token balance for the maker? Also, do we need to call balanceOf(maker) during simulation?
| /// @notice The pricing module, read from the settlement contract. | ||
| /// @dev Resolved lazily rather than cached in the constructor: the | ||
| /// simulation engine deploys this adapter in a context where the | ||
| /// settlement contract's code is injected at call time, not at | ||
| /// construction, so a constructor that called into it would revert. | ||
| function pricing() public view returns (IBopAmmPricing) { | ||
| return IBopAmmPricing(settlement.pricing()); | ||
| } | ||
|
|
||
| /// @notice The USDC (hub) token, read from the settlement contract. | ||
| /// @dev Resolved lazily for the same reason as {pricing}. | ||
| function usdc() public view returns (address) { | ||
| return settlement.usdc(); | ||
| } |
There was a problem hiding this comment.
Do we need to expose these two functions in the adapter?
Full BopAMM (Bebop's on-chain PMM) integration, aggregating the three previously separate PRs (#1081 substreams, #1082 adapter, #1087 execution) into one branch now that they've been reconciled against current main. Each layer is a self-contained commit series; the parts are disjoint except for the execution wiring (see below).
Layers
Substreams (
vm:bopamm) — indexes BopAMM as a VM protocol, one component perasset/USDCbook. Books discovered from module asset-config storage writes (no events). Component id is the 32-bytesettlement ‖ assetIdpacking (hex-decodable to the adapter'sbytes32 poolId). Emitsbalance_owner, pause state, andoverride_block_timestamp(the committed quote ts, consumed by tycho-simulation to pass the registry's exact-timestampStaleUpdate()gate).initialized_accountsbootstraps the registry, which predates the test range.Simulation adapter —
BopAMMAdapterunderprotocols/adapter-integration, wrapping the BopAmmV2 settlement. Sell-side (exact-input only),ConstantPrice+HardLimits;getLimitsbisectsquote().pricing/usdcare resolved lazily (not in the constructor) because the simulation engine deploys the adapter in a context where the settlement code is injected at call time. Fork tests pinned to a quote-commit block.Execution —
BopAMMExecutor(ProtocolWillDebit, settlement as approval target, output to recipient) +BopAMMSwapEncoder(vm:bopamm, packed token pair). The router enforcesminAmountOut; the Dispatcher measures output by balance diff.Reconciliation with FermiSwap (#1034, now on main)
FermiSwap merged while these branches were open and shares the execution wiring. Reconciled here: bopamm is appended as test executor index 22 (FermiSwap holds 21), with its re-derived deterministic test address
0x3Cff5E7eBecb676c3Cb602D0ef2d46710b88854Eand regeneratedcalldata.txt.Verification
protocol-testingharness passestest_book_discovery1/1 end-to-end (component discovery + tracked-contract indexing).cargo clippy -D warningsand rustfmt clean on tycho-execution.Simulation remains skipped in the integration yaml until the
override_block_timestampsupport is exercisable on main; the harness logs the expectedStaleUpdatewarning there.Supersedes #1081, #1082, #1087.
🤖 Generated with Claude Code