Skip to content

feat: integrate BaiBai CurveBook v3 on Base - #1438

Open
mteam88 wants to merge 21 commits into
propeller-heads:mainfrom
mteam88:feat/baibai
Open

mteam88 wants to merge 21 commits into
propeller-heads:mainfrom
mteam88:feat/baibai

Conversation

@mteam88

@mteam88 mteam88 commented Sep 12, 2026 •

Copy link
Copy Markdown

Add indexing, native exact-input simulation and swap execution for BaiBai CurveBook v3 on Base. The indexer discovers base-token pairs from successful curve publications on the configured deployment, retaining pre-listing fee, storage and custody history. Pair IDs combine the entrypoint and base token; all pairs use the deployment's quote token.

Custody is tracked once per token and propagated to every affected pair, together with shared claim reservations, TTL and taker-wide fees. A new base uses one block-pinned balance bootstrap that accounts for earlier deposits and same-block transfers. Normal updates and quotes require no RPC calls. Each pair is simulated independently, like other venues whose makers share inventory across pairs; the README notes that a route through several BaiBai pairs can overestimate a shared balance, with a revert as the worst case since the router enforces minAmountOut. balance_owner identifies the custodian.

Fees follow the deployed contract: executing taker's pair override, then taker-wide override, then zero. Explicit zero overrides remain meaningful. DecoderContext::caller supports custom execution callers and defaults to the Tycho Base router. Integer rounding, gross curve consumption, net custody payouts and normal router slippage protection are preserved.

Addressed the remaining review items: removed the redundant fees_indexed marker, local simulator pause handling and unused testing dependency. Dependency upgrades after the validated block still emit Tycho's standard pause attribute, including for later-discovered pairs. Generic client removal of paused components is an upstream follow-up; this PR does not duplicate that logic. The separate historical-retention fix also remains upstream-owned and is applied only in the isolated replay checkout.

The CurveBook proxy was upgraded at Base block 51,696,183. The new implementation adds a PairListed(base, quote) event, emitted on a base's first curve publication and once for WETH during the upgrade; immutables (entrypoint, quote token, curve unit), storage layout and curve/fill/quote logic are unchanged. After checking the verified source and deployed bytecode, the validated block is now 51,696,183. Pair discovery still uses the first CurveUpdated, because WETH's history predates PairListed; the adapter ignores the new event.

Validation:

  • Rebased onto main at b5826a2. BaiBai is now listed in RuntimeBytecodeFixtures.sol, pinned at Base block 51,696,183 because the executor constructor reads quoteToken() from the entrypoint, which postdates the shared Base fork block. Upstream's builder reproduces the committed Baibai.runtime.json byte for byte. Upstream's new EncodingContext::origin field is set in the BaiBai encoder tests, and the test executor address was refreshed for the new executor deployment order. Canonical Rust CI unit command: 1,782 passed. Root and Substreams Clippy, formatting, WASM build, 11 native, 8 Substreams and encoder/calldata-generator tests, and all six BaiBai fork tests passed.
  • Eleven BaiBai native tests; eight Substreams tests covering discovery, pre-listing fees/claims, shared balance fan-out, bootstrap reconciliation, WETH events, transaction boundaries, upgrade pause attributes and the validated-block boundary.
  • Earlier rebase: canonical Rust CI unit command 1,310 passed. Scoped root and Substreams Clippy, WASM build, formatting, encoder/calldata-generator tests and six BaiBai fork tests passed.
  • Fresh authenticated replay from deployment through Base block 51,696,283, past the CurveBook upgrade and PairListed log at 51,696,183, passed: nine runner checks, the full integration harness and six exact simulation/execution matches (three sizes in each direction). An independent database comparison matched all 32 storage words and both custody balances, checked complete fee-event history (no overrides in this range), confirmed the removed marker is absent and found no pause attribute. Nonzero overrides and fee changes are covered by synthetic tests. The replay database dump checksum was verified and disposable services were stopped.
  • Historical replay covers the deployed WETH/USDC pair; additional-pair behavior is covered by synthetic multi-pair tests. Earlier deployed-bytecode quote comparisons and fork executor results remain preserved; no Solidity source changed in this review update.

The package is still unreleased at 0.1.0. The changed module graph requires indexing from the manifest's initial block. A production executor deployment and indexing registration are still required; no production executor address is assigned here.

@github-project-automation github-project-automation Bot moved this to Todo in Tycho Sep 12, 2026
@mteam88
mteam88 deployed to integration-tests September 12, 2026 21:50 — with GitHub Actions Active
@mteam88
mteam88 deployed to integration-tests September 12, 2026 21:50 — with GitHub Actions Active
@mteam88
mteam88 deployed to integration-tests September 12, 2026 21:50 — with GitHub Actions Active
@mteam88
mteam88 deployed to integration-tests September 12, 2026 21:50 — with GitHub Actions Active
@mteam88
mteam88 deployed to integration-tests September 12, 2026 21:50 — with GitHub Actions Active
@mteam88

mteam88 commented Sep 21, 2026

Copy link
Copy Markdown
Author

@zach030 rebased onto current main and resolved the conflicts. Updated the generated BaiBai test calldata for the changed upstream executor setup; all six fork tests, native/encoder/Substreams tests and scoped Clippy pass. Could you take another look?

@zach030 zach030 left a comment

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.

Thanks! Looks good! Just left a few comments.

Comment thread protocols/substreams/base-baibai/src/config.rs Outdated
Comment thread crates/tycho-simulation/src/evm/protocol/baibai/decoder.rs Outdated
Comment thread crates/tycho-simulation/src/evm/protocol/baibai/decoder.rs Outdated
Comment thread protocols/testing/Cargo.toml Outdated
@mteam88

mteam88 commented Sep 23, 2026

Copy link
Copy Markdown
Author

Heads-up: the BaiBai CurveBook was upgraded on Base at block 51696183 (tx 0xc9d0e733da27ab34e42e499e704b881b39fda83a4cbe1e4545d2bb41e666ef5b). The new implementation, 0xAd6beCedB2c4b898CA9d4dE248Dc7207fe4d1C80, is source-verified.

What changed: the book now emits an explicit pair-creation event:

event PairListed(address indexed base, address indexed quote);

A base's first successful SHAPE emits it. The upgrade itself backfilled it once for WETH/USDC through a one-shot announcePairs reinitializer. Nothing else changed: CurveUpdated, Fill, quote/swap math, and existing storage are the same. The diff is the event, a single emit in _applyShape, and the reinitializer (spire-labs/mm-platform#89).

Effect on this PR: 51696183 is past VALIDATED_BLOCK (51,191,196), so as written the adapter pauses every BaiBai component from that block on. My next push will:

  • raise VALIDATED_BLOCK to 51,696,183, after reviewing the new implementation's wiring and storage layout
  • keep pair discovery on the first CurveUpdated. WETH's history starts before the new event existed, so PairListed alone can't cover the replay from initialBlock. Consumers who want a single creation-event filter can use PairListed from 51696183 onward.
  • rerun the replay and the fork/native/Substreams tests across the upgrade block

@zach030 zach030 left a comment

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.

Thanks for the update! This round, I mainly focused on performance and extensibility, so I left a few suggestions for improvement.

Comment on lines +126 to +131
if let Some(base) = delta.key.strip_prefix("pair:") {
store.append(delta.ordinal, "pairs", base.to_string());
} else if let Some(fee) = delta.key.strip_prefix("fee:") {
let (base, taker) = fee.split_once(':').unwrap();
store.append(delta.ordinal, format!("fees:{base}"), taker.to_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.

How many pairs and fee entries do you expect on Base? Since Substreams limits store values to 8 KB, we can’t append to the store indefinitely.
I think fees:{base} is the main concern, since you may need to whitelist a large number of taker addresses.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We expect very few: one pair (WETH-USDC) today and single digits later, with fee overrides only for a handful of routers. To remove the ceiling anyway, taker lists are now sharded by the taker's first byte (fees:{base}:{xx}), which gives roughly 50k takers per base before any shard nears 8 KB. pairs stays a single list (~190 bases), which is well above what we plan to list against one quote.

Comment on lines +74 to +79
if log.address == config.custodian.as_slice() &&
(topic == settled.as_slice() || topic == executed.as_slice())
{
let token = U256::from_be_slice(&log.topics[2]);
claim_slots.insert(B256::from(mapping(token, claims)));
}

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.

Are Settled and Executed the only events that update claims[token]? Is there any other way it could change that we haven’t covered?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes. That mapping (totalClaimable[token]) is written only in settleWithdraw (including the zero-amount cancel) and executeWithdraw. They emit WithdrawSettled and WithdrawExecuted respectively, both with token as topic 2. Nothing else writes it, and an implementation change would emit Upgraded, which pauses the component.

Comment on lines +21 to +25
const TAKER_FEE_SET: B256 =
b256!("1f50e1aaaff835659bf08a8d3473edefb7f61e28a27572c62fa8daa40da9e268");
const TAKER_FEE_CLEARED: B256 =
b256!("4e36b92da5e2a98be73ea9f9bd228101bb4b4e83629908148cef524aaad69b94");
const UPGRADED: B256 = b256!("bc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b");

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.

Can we define all event signatures as constants here? There are other topics in state_changes that are also generated with keccak256.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. All event topics are now b256! constants.

Comment on lines +147 to +150
let bases = addresses(keys, u64::MAX, "pairs");
let previous: BTreeSet<_> = addresses(keys, 0, "pairs")
.into_iter()
.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.

We can use the store_state deltas to get the updated tokens, so we don’t need to read it twice and filter them here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. map_balances now takes store_state deltas and bootstraps the bases whose pair: key was created in this block, so the get_first/get_last double read is gone.

Comment on lines +33 to +43
fn addresses(read: ReadStore<'_>, ordinal: u64, key: &str) -> Vec<Address> {
read(ordinal, key).map_or_else(Vec::new, |value| {
// Written only by store_keys; append's wire encoding is semicolon-delimited.
String::from_utf8(value)
.unwrap()
.split(';')
.filter(|v| !v.is_empty())
.map(|v| v.parse().unwrap())
.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.

The addresses function is used in several places and behaves differently for different ordinals. Can we simplify this, or would it make sense to wrap it in a helper function?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Simplified. addresses is now a plain parser for a store_keys value, and callers read the store themselves (get_last("pairs") in the handlers, get_at for fee shards), so there's no ordinal-dependent behaviour left.

Comment on lines +347 to +350
for &base in &bases {
if state(tx.end_ordinal, &format!("pair:{base:x}")).is_none() {
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.

The pair:{base:x} store only changes once when the pair is created, right? IMO, we can add an early filter so we don’t have to scan every base.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. listings() collects the pair: creation ordinals from the deltas once per block. A base is skipped until its listing ordinal and treated as created within that tx, so the per-base, per-tx store read is gone.

attrs
}

fn protocol_changes(

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.

Can you try simplifying this function? It has high cyclomatic complexity and some redundant reads and loops, so I think there’s room to simplify it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Simplified. Each tx now takes its prefix of the (ordinal-ordered) deltas instead of filtering the full lists per tx, pause/creation come straight from the deltas, and balance changes use the delta's new_value. The balance store is only read on creation.

@zach030 zach030 left a comment

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.

Ran the range test locally end to end, all tests work fine! Left a few suggestions.

Comment on lines +40 to +51
pub fn sync_custody(&mut self, from: &Self) {
for (i, token) in self.tokens.iter().enumerate() {
if let Some(j) = from
.tokens
.iter()
.position(|other| other == token)
{
self.balances[i] = from.balances[j];
self.words[30 + i] = from.words[30 + j];
}
}
}

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.

Can we remove sync_custody? Tycho doesn't model cross-pool coupling anyway (RFQ makers also share inventory across pairs), and the worst case is a revert since the router enforces minAmountOut. I'd remove it along with the test, and just note the limitation in the README.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed in ec83f4a: sync_custody and its test are gone, along with the static custodian attribute that only existed to group pools for it (balance_owner still identifies the custodian). The README now notes that pairs sharing the custodian are simulated independently, so the worst case is a revert enforced by the router's minAmountOut.

Comment on lines +277 to +288
for fee_base in [base, Address::ZERO] {
for taker in (0..=u8::MAX).flat_map(|shard| {
addresses(keys(ordinal, &fees_key(&format!("{fee_base:x}"), &format!("{shard:02x}"))))
}) {
let value = state(ordinal, &format!("fee:{fee_base:x}:{taker:x}")).unwrap();
attrs.push(attribute(
&format!("{}_fee_{taker:x}", if fee_base.is_zero() { "taker" } else { "pair" }),
value,
true,
));
}
}

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.

Passing the shard as taker only works because fees_key slices &taker[..2], which is a bit confusing. Suggest a fee_shard_key(base, shard: u8) + plain loops. Also, could you add a second taker with a different first byte to the tests to cover cross-shard reads?

for fee_base in [base, Address::ZERO] {
    let base_hex = format!("{fee_base:x}");
    let kind = if fee_base.is_zero() { "taker" } else { "pair" };
    for shard in 0..=u8::MAX {
        for taker in addresses(keys(ordinal, &fee_shard_key(&base_hex, shard))) {
            let value = state(ordinal, &format!("fee:{base_hex}:{taker:x}"))
                .expect("store_keys only lists takers with a fee entry");
            attrs.push(attribute(&format!("{kind}_fee_{taker:x}"), value, true));
        }
    }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 983415a: added fee_shard_key(base, shard: u8), used by both store_keys (shard = the taker's first byte) and the snapshot reader, which now uses your plain-loop shape. Stored keys are unchanged. The discovery test adds a second taker (0xa7…) with pair and taker-wide fees in a different shard, and fails if the reader skips that shard.

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

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

@mteam88

mteam88 commented Oct 7, 2026

Copy link
Copy Markdown
Author

I have read the PropellerHeads CLA and I hereby sign the CLA

@mteam88

mteam88 commented Oct 16, 2026

Copy link
Copy Markdown
Author

@zach030 rebased onto current main (96feed8), so the branch is conflict-free again. Upstream changes it needed:

  • BaiBai is listed in RuntimeBytecodeFixtures.sol, on its own Base fork block (51,696,183) because the executor constructor reads quoteToken() from the entrypoint. The builder reproduces the committed Baibai.runtime.json byte for byte.
  • The BaiBai encoder test sets the new EncodingContext::origin.
  • The test executor address and generated calldata are refreshed for the new executor deployment order.

Your Oct 5 suggestions went in with the previous push (fee_shard_key, plain-loop reads, sync_custody removed with the README note). Locally: canonical unit command 1,782 passed, Clippy/fmt clean, and all six BaiBai fork tests pass.

The fork CI runs are waiting on maintainer approval (integration-tests environment). Could you approve them and take another look? Happy to turn around anything else quickly.

Tycho simulates components independently, so sync_custody and the static
custodian attribute that grouped pools for it are removed. The README notes
that routes through several pairs may overestimate shared custody; the
router's minAmountOut bounds the outcome to a revert.
Replace fees_key's taker-prefix slicing with fee_shard_key(base, shard),
used by both store_keys and snapshot reads. Stored keys are unchanged.
Cover snapshot reads across two shards.
Upstream renamed the contracts' SPDX license; scripts/check-licensing.sh
rejects the remaining BUSL-1.1 headers.
@mteam88

mteam88 commented Oct 19, 2026

Copy link
Copy Markdown
Author

Rebased again onto main (b5826a2); only conflict was the per-chain executor map in protocols/testing/src/execution.rs (kept both BaiBai and the new Robinhood Infinity entry). Locally: 1,782 unit tests, Clippy/fmt, Substreams, native, encoder and all six BaiBai fork tests pass. @zach030 the CI runs still need maintainer approval; anything else needed before approving?

This branch is waiting to be deployed

1 waiting deployment
integration-tests — 545905ac Waiting Oct 19, 2026 by mteam88 via Forge build, fmt, test (crates/tycho-execution/contracts) #3216
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants