feat(cross-zone): seed bridge-lock holdings into indexer genesis for consistency

This commit is contained in:
moudyellaz 2026-06-24 11:24:28 +02:00
parent ff7610804a
commit c9c999e0de
5 changed files with 60 additions and 13 deletions

View File

@ -13,6 +13,7 @@ logos-blockchain-zone-sdk.workspace = true
lee.workspace = true
lee_core.workspace = true
cross_zone_inbox_core = { workspace = true, features = ["host"] }
bridge_lock_core = { workspace = true, features = ["host"] }
ping_core.workspace = true
storage.workspace = true
testnet_initial_state.workspace = true

View File

@ -22,11 +22,12 @@ pub struct IndexerStore {
impl IndexerStore {
/// Starting database at the start of new chain.
/// Creates files if necessary.
pub fn open_db(location: &Path, genesis_seed: Option<(AccountId, Account)>) -> Result<Self> {
pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result<Self> {
let mut initial_state = testnet_initial_state::initial_state();
// Seed any zone-specific genesis accounts (e.g. the cross-zone inbox
// config) so the indexer's replayed state matches the sequencer's.
if let Some((account_id, account)) = genesis_seed {
// Seed any zone-specific genesis accounts (the cross-zone inbox config and
// bridge-lock holdings) so the indexer's replayed state matches the
// sequencer's; none are produced by a transaction.
for (account_id, account) in genesis_seed {
initial_state.insert_genesis_account(account_id, account);
}
let dbio = RocksDBIO::open_or_create(location, &initial_state)?;
@ -220,18 +221,37 @@ mod tests {
fn correct_startup() {
let home = tempdir().unwrap();
let storage = IndexerStore::open_db(home.as_ref(), None).unwrap();
let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let final_id = storage.get_last_block_id().unwrap();
assert_eq!(final_id, None);
}
#[tokio::test]
async fn seeds_bridge_lock_holding_into_genesis_state() {
// The holding is force-inserted, not produced by a transaction, so the
// indexer must seed it to match the sequencer. Use the same builder both
// sides use, so this also guards against the two drifting.
let home = tempdir().unwrap();
let holder = AccountId::new([5; 32]);
let (id, account) = bridge_lock_core::build_holding_account(holder, 42);
let storage = IndexerStore::open_db(home.as_ref(), vec![(id, account)]).unwrap();
let seeded = storage.account_current_state(&holder).await.unwrap();
assert_eq!(
bridge_lock_core::read_balance(&seeded.data.into_inner()),
42,
"indexer genesis must hold the seeded bridge-lock balance"
);
}
#[tokio::test]
async fn state_transition() {
let home = tempdir().unwrap();
let storage = IndexerStore::open_db(home.as_ref(), None).unwrap();
let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let initial_accounts = initial_pub_accounts_private_keys();
let from = initial_accounts[0].account_id;
@ -283,7 +303,7 @@ mod tests {
async fn account_state_at_block() {
let home = tempdir().unwrap();
let storage = IndexerStore::open_db(home.as_ref(), None).unwrap();
let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap();
let mut prev_hash = None;

View File

@ -9,6 +9,7 @@ use anyhow::{Context as _, Result};
use common::config::BasicAuth;
use cross_zone_inbox_core::CrossZoneConfig;
use humantime_serde;
use lee::AccountId;
pub use logos_blockchain_core::mantle::ops::channel::ChannelId;
use serde::{Deserialize, Serialize};
use url::Url;
@ -31,6 +32,19 @@ pub struct IndexerConfig {
/// Cross-zone configuration. `None` disables the indexer's cross-zone handling.
#[serde(default)]
pub cross_zone: Option<CrossZoneConfig>,
/// Bridge-lock holdings to seed into genesis, mirroring the sequencer's
/// `SupplyBridgeLockHolding` actions. They are not produced by any
/// transaction, so the indexer must seed them to match the sequencer's state.
#[serde(default)]
pub bridge_lock_holdings: Vec<BridgeLockHolding>,
}
/// A genesis-funded bridge-lock holder balance, configured identically on the
/// sequencer (via `SupplyBridgeLockHolding`) and the indexer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgeLockHolding {
pub holder: AccountId,
pub amount: u128,
}
impl IndexerConfig {

View File

@ -37,12 +37,23 @@ impl IndexerCore {
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node);
// Seed the inbox config so the indexer can replay cross-zone dispatch
// transactions, matching the account the sequencer seeds at genesis.
let inbox_config_seed = config.cross_zone.as_ref().map(|cross_zone| {
// Genesis accounts the indexer must seed to match the sequencer's state,
// since none are produced by a transaction: the cross-zone inbox config
// and any bridge-lock holdings. Both go through the same builders the
// sequencer uses, so the states are byte-identical.
let mut genesis_seed = Vec::new();
if let Some(cross_zone) = config.cross_zone.as_ref() {
let self_zone: [u8; 32] = *config.channel_id.as_ref();
cross_zone_inbox_core::build_inbox_config_account(self_zone, cross_zone)
});
genesis_seed.push(cross_zone_inbox_core::build_inbox_config_account(
self_zone, cross_zone,
));
}
for holding in &config.bridge_lock_holdings {
genesis_seed.push(bridge_lock_core::build_holding_account(
holding.holder,
holding.amount,
));
}
// Option B verifier: re-derives each cross-zone dispatch from the peer's
// finalized blocks. `None` when cross-zone messaging is disabled.
@ -51,7 +62,7 @@ impl IndexerCore {
Ok(Self {
zone_indexer: Arc::new(zone_indexer),
config,
store: IndexerStore::open_db(&home, inbox_config_seed)?,
store: IndexerStore::open_db(&home, genesis_seed)?,
verifier,
})
}

View File

@ -182,6 +182,7 @@ pub fn indexer_config(
},
channel_id,
cross_zone,
bridge_lock_holdings: Vec::new(),
})
}