Skip to content

feat: BopAMM (Bebop PMM) integration — indexing, simulation, execution#1095

Open
tvinagre wants to merge 11 commits into
mainfrom
tl/bopamm-integration
Open

feat: BopAMM (Bebop PMM) integration — indexing, simulation, execution#1095
tvinagre wants to merge 11 commits into
mainfrom
tl/bopamm-integration

Conversation

@tvinagre

Copy link
Copy Markdown
Collaborator

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 per asset/USDC book. Books discovered from module asset-config storage writes (no events). Component id is the 32-byte settlement ‖ assetId packing (hex-decodable to the adapter's bytes32 poolId). Emits balance_owner, pause state, and override_block_timestamp (the committed quote ts, consumed by tycho-simulation to pass the registry's exact-timestamp StaleUpdate() gate). initialized_accounts bootstraps the registry, which predates the test range.

Simulation adapterBopAMMAdapter under protocols/adapter-integration, wrapping the BopAmmV2 settlement. Sell-side (exact-input only), ConstantPrice + HardLimits; getLimits bisects quote(). pricing/usdc are 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.

ExecutionBopAMMExecutor (ProtocolWillDebit, settlement as approval target, output to recipient) + BopAMMSwapEncoder (vm:bopamm, packed token pair). The router enforces minAmountOut; 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 0x3Cff5E7eBecb676c3Cb602D0ef2d46710b88854E and regenerated calldata.txt.

Verification

  • Substreams: unit tests; the protocol-testing harness passes test_book_discovery 1/1 end-to-end (component discovery + tracked-contract indexing).
  • Adapter: 14/14 fork tests at the pinned block.
  • Execution: 9 executor + 1 TychoRouter integration test pass; FermiSwap router and the TychoRouter single-swap suite (19 tests) re-run green after the executor-array change.
  • cargo clippy -D warnings and rustfmt clean on tycho-execution.

Simulation remains skipped in the integration yaml until the override_block_timestamp support is exercisable on main; the harness logs the expected StaleUpdate warning there.

Supersedes #1081, #1082, #1087.

🤖 Generated with Claude Code

tvinagre and others added 10 commits June 17, 2026 14:57
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>
@tvinagre tvinagre force-pushed the tl/bopamm-integration branch from c22085e to 32a8ddd Compare June 17, 2026 22:11
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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Short-circuit possible since asset IDs are sequential?
If so, I think the iteration should be unbounded.

Comment on lines +23 to +25
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can also be computed lazily

components_store: &StoreGetProto<ProtocolComponent>,
transaction_changes: &mut HashMap<u64, TransactionChangesBuilder>,
) {
let books = enumerate_books(components_store);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here

components_store: &StoreGetProto<ProtocolComponent>,
transaction_changes: &mut HashMap<u64, TransactionChangesBuilder>,
) {
let books = enumerate_books(components_store);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And here

}

/// @inheritdoc ISwapAdapter
function getPoolIds(uint256 offset, uint256 limit)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +28 to +30
let Some(Transfer { from, to, value }) = Transfer::match_and_decode(log.log) else {
continue;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +42 to +49
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(),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +42 to +55
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);
})
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to upsert the token balance for the maker? Also, do we need to call balanceOf(maker) during simulation?

Comment on lines +41 to +54
/// @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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to expose these two functions in the adapter?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

3 participants