mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
* feat(lee): store deployed programs as Account-shaped state, keyed by AccountId Program-as-Account migration, first slice: V03State.programs becomes HashMap<AccountId, Account> instead of HashMap<ProgramId, Program>, with the elf held directly in Account.data. The map key is derived from ProgramId via a new 1:1 From<ProgramId> for AccountId conversion (both types are exactly 32 bytes) rather than a hash, since ProgramId is already content-derived from the elf. Account.program_owner stays ProgramId-typed everywhere - this only changes how deployed programs are stored and looked up host-side, not the dispatch/authorization model any guest program logic depends on. Dispatch resolves a ChainedCall's program_id by converting to AccountId, fetching the Account, and reconstructing a Program via new_unchecked for execution. DATA_MAX_LENGTH is raised from 100 KiB to 700 KiB to fit real program elfs (observed 375 KB-631 KB) directly in Account.data; noted in its docstring as a rough placeholder pending real transaction/block-size budget analysis. * fix(lee): store deployed programs as Account-shaped state, correct SeenShard cap Corrects lee/state_machine internals for the Program-as-Account migration and fixes SeenShard::MAX_DELIVERIES, which was still calibrated for the old 100 KiB DATA_MAX_LENGTH instead of the current 700 KiB cap. Rebuilds program artifacts and the sequencer test fixture to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * address PR #720 review nits - Use FIXME instead of TODO for the temporary ProgramId->AccountId conversion, per review convention for patches guaranteed to be fixed later. - Derive cross_zone_inbox's MAX_DELIVERIES from DATA_MAX_LENGTH instead of a hand-recomputed literal, so it stays in sync automatically the next time the cap changes. * feat(lee): migrate Account.program_owner from ProgramId to AccountId Account.program_owner is now AccountId-typed instead of ProgramId, via a new bijective From<ProgramId> for AccountId / From<AccountId> for ProgramId conversion pair (pure byte reinterpretation, not a hash - both types are exactly 32 bytes). Adds DEFAULT_PROGRAM_OWNER as the AccountId-typed counterpart to DEFAULT_PROGRAM_ID, used at every program_owner comparison/claim site instead of an inline AccountId::default(). Touches every call site across lee_core, lee (including the guest-side privacy-preserving circuit), all 16 deployed guest programs, wallet/wallet-ffi, indexer_ffi/indexer_service/ indexer_service_protocol, sequencer_core, testnet_initial_state, system_accounts, cross_zone, storage, cycle_bench, and integration_tests - mostly mechanical .into() conversions, plus two simplifications: wallet's manual base58 encode/decode of program_owner was dead code once it's AccountId (which already has Display/FromStr), and the FFI crates' program_owner field now reuses the existing generic FfiBytes32 wrapper instead of the now-unused FfiProgramId one. Rebuilds every guest ELF artifact and the prebuilt sequencer test fixture via just build-artifacts, since execute_and_prove runs against the checked-in precompiled privacy_preserving_circuit.bin, which isn't rebuilt automatically by cargo test/check. * chore(lee): rebuild artifacts after rebase, drop unused base58 dep Rebases marvin/program-as-account-2 onto the updated marvin/program-as-account (SeenShard cap fix), regenerating program and circuit artifacts plus the sequencer test fixture to match. Also removes lez/wallet's now-unused base58 dependency, dead since AccountId gained its own Display/FromStr base58 encoding. * docs(lee): trim DEFAULT_PROGRAM_OWNER and From<AccountId> for ProgramId docs * test(lee): add known-answer tests for ProgramId/AccountId conversion, rebuild artifacts * fix(lee): apply program_owner AccountId migration to code added after rebase dev grew new program_owner call sites (sequencer_stake genesis/config handling, committee_discovery, a new selective_pda_delegator test program, and related tests) after this branch's ProgramId->AccountId migration commit was originally written, so they predated the .into() sweep and didn't conflict during the rebase - they just still assumed the old ProgramId-typed field. Converts all of them, fixes a stray unseparated hex literal clippy caught along the way, and rebuilds artifacts against the fixed source. * chore(lee): regenerate test fixture after rebasing onto dev --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
393 lines
15 KiB
Rust
393 lines
15 KiB
Rust
//! End-to-end demo of the sequencer self-join flow.
|
|
|
|
#![expect(
|
|
clippy::tests_outside_test_module,
|
|
reason = "Integration tests live at crate root and don't care about these lints"
|
|
)]
|
|
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context as _, Result};
|
|
use integration_tests::{account_balance, get_account, new_account};
|
|
use lee::{AccountId, PrivateKey, PublicKey, program::Program};
|
|
use log::info;
|
|
use logos_blockchain_core::mantle::ops::channel::Ed25519PublicKey;
|
|
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
|
use logos_blockchain_zone_sdk::{
|
|
CommonHttpClient,
|
|
adapter::{Node as _, NodeHttpClient},
|
|
};
|
|
use sequencer_core::config::GenesisAction;
|
|
use sequencer_service_rpc::RpcClient as _;
|
|
use test_fixtures::{
|
|
MultiZoneTestContextBuilder, TestContext, ZoneTestContextBuilder,
|
|
config::{
|
|
MultiNodeTestContextConfig, SequencerPartialConfig, UrlProtocol, addr_to_url,
|
|
bedrock_channel_id,
|
|
},
|
|
setup::{SequencerSetup, sequencer_client},
|
|
};
|
|
use tokio::test;
|
|
use wallet::AccountIdentity;
|
|
|
|
/// Comfortably above `system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE`.
|
|
const FUNDING_BALANCE: u128 = 2 * system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE;
|
|
|
|
/// Bedrock signing key of the sequencer that stakes its way in.
|
|
const JOINER_SIGNING_KEY: [u8; 32] = [0x42; 32];
|
|
|
|
/// Short block cadence for the demo.
|
|
fn fast_blocks() -> SequencerPartialConfig {
|
|
SequencerPartialConfig {
|
|
block_create_timeout: Duration::from_secs(2),
|
|
..SequencerPartialConfig::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
async fn stake_transaction_joins_the_bedrock_committee() -> Result<()> {
|
|
let demo_sequencer_key = Ed25519Key::from_bytes(&JOINER_SIGNING_KEY).public_key();
|
|
let demo_stake_key = sequencer_stake_core::SequencerKey::new(demo_sequencer_key.to_bytes())
|
|
.expect("a Bedrock key is a valid Ed25519 public key");
|
|
|
|
let funding_private_key = PrivateKey::new_os_random();
|
|
let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_private_key));
|
|
|
|
let mut ctx = MultiZoneTestContextBuilder::default()
|
|
.with_zone(
|
|
ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default())
|
|
.with_sequencer_partial_config(fast_blocks())
|
|
.with_genesis(vec![GenesisAction::SupplyAccount {
|
|
account_id: funding_id,
|
|
balance: FUNDING_BALANCE,
|
|
}]),
|
|
)
|
|
.build()
|
|
.await
|
|
.context("Failed to build test context")?;
|
|
|
|
// Import the funding key directly; it's not one of the wallet's default accounts.
|
|
ctx.wallet_mut()
|
|
.storage_mut()
|
|
.key_chain_mut()
|
|
.add_imported_public_account(funding_private_key);
|
|
|
|
// Claim the genesis supply out of its vault.
|
|
let owner_vault_id = vault_core::compute_vault_account_id(programs::vault().id(), funding_id);
|
|
let claim_instruction_data = Program::serialize_instruction(vault_core::Instruction::Claim {
|
|
amount: FUNDING_BALANCE,
|
|
})
|
|
.context("Failed to serialize vault Claim instruction")?;
|
|
ctx.wallet()
|
|
.send_pub_tx(
|
|
vec![
|
|
AccountIdentity::Public(funding_id),
|
|
AccountIdentity::PublicNoSign(owner_vault_id),
|
|
],
|
|
claim_instruction_data,
|
|
programs::vault().id(),
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
anyhow::anyhow!(
|
|
"Failed to claim the demo funding account from its genesis vault: {err:?}"
|
|
)
|
|
})?;
|
|
info!("Waiting for the vault-claim transaction's block to land");
|
|
poll_until("vault claim to land", 30, || async {
|
|
Ok(account_balance(&ctx, funding_id).await? == FUNDING_BALANCE)
|
|
})
|
|
.await?;
|
|
info!("Funded demo account {funding_id} with {FUNDING_BALANCE} native balance");
|
|
|
|
let ownership_id = new_account(&mut ctx, false, None)
|
|
.await
|
|
.context("Failed to create a fresh stake ownership account")?;
|
|
info!("Fresh stake ownership account: {ownership_id}");
|
|
|
|
let mover_instruction_data =
|
|
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
|
|
amount: FUNDING_BALANCE,
|
|
})
|
|
.context("Failed to serialize mover instruction")?;
|
|
let stake_instruction_data =
|
|
Program::serialize_instruction(sequencer_stake_core::Instruction::Stake {
|
|
sequencer_key: demo_stake_key,
|
|
amount: FUNDING_BALANCE,
|
|
mover_program_id: programs::authenticated_transfer().id(),
|
|
mover_instruction_data,
|
|
})
|
|
.context("Failed to serialize Stake instruction")?;
|
|
|
|
info!(
|
|
"Submitting Stake transaction for sequencer key {}",
|
|
hex::encode(demo_sequencer_key.to_bytes())
|
|
);
|
|
let config_id = system_accounts::sequencer_stake_config_account_id();
|
|
ctx.wallet()
|
|
.send_pub_tx(
|
|
vec![
|
|
AccountIdentity::Public(funding_id),
|
|
AccountIdentity::Public(ownership_id),
|
|
AccountIdentity::PublicNoSign(config_id),
|
|
],
|
|
stake_instruction_data,
|
|
programs::sequencer_stake().id(),
|
|
)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("Failed to submit Stake transaction: {err:?}"))?;
|
|
|
|
info!("Waiting for the Stake transaction's block to land");
|
|
poll_until("stake to take ownership", 30, || async {
|
|
Ok(get_account(&ctx, ownership_id).await?.program_owner
|
|
== programs::sequencer_stake().id().into())
|
|
})
|
|
.await?;
|
|
|
|
let ownership_account = get_account(&ctx, ownership_id)
|
|
.await
|
|
.context("Failed to read the stake ownership account")?;
|
|
assert_eq!(
|
|
ownership_account.program_owner,
|
|
programs::sequencer_stake().id().into(),
|
|
"ownership account should now be owned by sequencer_stake"
|
|
);
|
|
assert_eq!(
|
|
ownership_account.balance, FUNDING_BALANCE,
|
|
"ownership account should hold the staked balance"
|
|
);
|
|
let record = sequencer_stake_core::StakeRecord::from_bytes(ownership_account.data.as_ref())
|
|
.context("ownership account data did not decode as a StakeRecord")?;
|
|
assert_eq!(record.sequencer_key, demo_stake_key);
|
|
info!(
|
|
"Ownership account confirmed: {} staked for sequencer key {}",
|
|
ownership_account.balance,
|
|
hex::encode(record.sequencer_key)
|
|
);
|
|
|
|
let bedrock_url = addr_to_url(UrlProtocol::Http, ctx.bedrock_addr())
|
|
.context("Failed to build the Bedrock node URL")?;
|
|
let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url);
|
|
|
|
// The committee-config update is a separate tx from the block's own
|
|
// publish, so it may land a moment later — poll a few times before failing.
|
|
let mut channel_state = None;
|
|
for _ in 0..10 {
|
|
let state = node
|
|
.channel_state(bedrock_channel_id())
|
|
.await
|
|
.context("Failed to read Bedrock channel state")?
|
|
.context("Bedrock channel does not exist")?;
|
|
if state
|
|
.accredited_keys
|
|
.iter()
|
|
.any(|key: &Ed25519PublicKey| *key == demo_sequencer_key)
|
|
{
|
|
channel_state = Some(state);
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
}
|
|
let channel_state = channel_state.context(
|
|
"demo sequencer key should have been discovered and accredited after the Stake transaction",
|
|
)?;
|
|
info!(
|
|
"Bedrock channel now accredits {} key(s), including the demo sequencer key — self-join complete",
|
|
channel_state.accredited_keys.len()
|
|
);
|
|
|
|
// Only now start a node behind the key, against a channel that already has a chain.
|
|
let (joiner, _joiner_home) = SequencerSetup::new(fast_blocks(), ctx.bedrock_addr())
|
|
.with_channel_id(bedrock_channel_id())
|
|
.with_bedrock_signing_key(JOINER_SIGNING_KEY)
|
|
.joining_existing_channel()
|
|
.setup()
|
|
.await
|
|
.context("Failed to start the joining sequencer")?;
|
|
let joiner_client = sequencer_client(joiner.addr())?;
|
|
|
|
let joined_at = ctx.sequencer_client().get_last_block_id().await?;
|
|
poll_until("the joining sequencer to sync the existing chain", 120, {
|
|
let joiner_client = &joiner_client;
|
|
move || async move { Ok(joiner_client.get_last_block_id().await? >= joined_at) }
|
|
})
|
|
.await?;
|
|
info!("Joining sequencer synced to block {joined_at}");
|
|
|
|
// A tip past `joined_at` under the demo key is a block this node built.
|
|
poll_until("the joining sequencer to build a block on its turn", 180, {
|
|
let node = &node;
|
|
let ctx = &ctx;
|
|
move || async move {
|
|
let Some(state) = node.channel_state(bedrock_channel_id()).await? else {
|
|
return Ok(false);
|
|
};
|
|
let turn = state
|
|
.accredited_keys
|
|
.get(usize::from(state.tip_sequencer))
|
|
.copied();
|
|
Ok(turn == Some(demo_sequencer_key)
|
|
&& ctx.sequencer_client().get_last_block_id().await? > joined_at)
|
|
}
|
|
})
|
|
.await?;
|
|
info!("Joining sequencer produced a block on its round-robin turn");
|
|
|
|
// Both nodes agree, block for block, over everything they share.
|
|
let leader_client = ctx.sequencer_client();
|
|
let common = leader_client
|
|
.get_last_block_id()
|
|
.await?
|
|
.min(joiner_client.get_last_block_id().await?);
|
|
for id in 1..=common {
|
|
let leader_block = leader_client
|
|
.get_block(id)
|
|
.await?
|
|
.with_context(|| format!("Leader is missing block {id}"))?;
|
|
let joiner_block = joiner_client
|
|
.get_block(id)
|
|
.await?
|
|
.with_context(|| format!("Joining sequencer is missing block {id}"))?;
|
|
anyhow::ensure!(
|
|
leader_block.header.hash == joiner_block.header.hash,
|
|
"Chain divergence at block {id}: leader {:?} vs joiner {:?}",
|
|
leader_block.header.hash,
|
|
joiner_block.header.hash
|
|
);
|
|
}
|
|
info!("Leader and joining sequencer agree on all {common} shared blocks");
|
|
|
|
// Exit flow: full UnstakeRequest, wait for the committee removal to land on
|
|
// Bedrock, then check the sequencer's own FinalizeUnstake releases the stake.
|
|
//
|
|
// FinalizeUnstake is unsigned/permissionless, so it can't claim a fresh
|
|
// destination account (that needs the owner's own signature, same as
|
|
// authenticated_transfer's Transfer). Reuse funding_id: already
|
|
// authenticated_transfer-owned, drained to 0 by the Stake above.
|
|
let destination_id = funding_id;
|
|
|
|
let unstake_request_data =
|
|
Program::serialize_instruction(sequencer_stake_core::Instruction::UnstakeRequest {
|
|
amount: FUNDING_BALANCE,
|
|
destination: destination_id,
|
|
})
|
|
.context("Failed to serialize UnstakeRequest instruction")?;
|
|
ctx.wallet()
|
|
.send_pub_tx(
|
|
vec![
|
|
AccountIdentity::Public(ownership_id),
|
|
AccountIdentity::PublicNoSign(config_id),
|
|
],
|
|
unstake_request_data,
|
|
programs::sequencer_stake().id(),
|
|
)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("Failed to submit UnstakeRequest transaction: {err:?}"))?;
|
|
info!("Submitted full UnstakeRequest for the demo sequencer key");
|
|
|
|
// A full drain crosses below the minimum, so discovery removes the key.
|
|
// Wide window: the removal has to wait for this sequencer to regain its
|
|
// round-robin turn (posting_timeframe/posting_timeout reclaim), on top of
|
|
// normal Bedrock confirmation latency.
|
|
let mut removed = false;
|
|
for _ in 0..30 {
|
|
let state = node
|
|
.channel_state(bedrock_channel_id())
|
|
.await
|
|
.context("Failed to read Bedrock channel state")?
|
|
.context("Bedrock channel does not exist")?;
|
|
if !state
|
|
.accredited_keys
|
|
.iter()
|
|
.any(|key: &Ed25519PublicKey| *key == demo_sequencer_key)
|
|
{
|
|
removed = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
}
|
|
anyhow::ensure!(
|
|
removed,
|
|
"demo sequencer key should have been removed after the full UnstakeRequest"
|
|
);
|
|
info!("Demo sequencer key removed from the Bedrock committee");
|
|
|
|
// Once removed, the sequencer injects FinalizeUnstake itself; this test
|
|
// never submits one.
|
|
poll_until(
|
|
"FinalizeUnstake to drain the ownership account",
|
|
90,
|
|
|| async { Ok(get_account(&ctx, ownership_id).await?.balance == 0) },
|
|
)
|
|
.await?;
|
|
|
|
let drained_ownership_account = get_account(&ctx, ownership_id)
|
|
.await
|
|
.context("Failed to read the drained ownership account")?;
|
|
assert_eq!(
|
|
drained_ownership_account.balance, 0,
|
|
"ownership account should be fully drained"
|
|
);
|
|
let drained_record =
|
|
sequencer_stake_core::StakeRecord::from_bytes(drained_ownership_account.data.as_ref())
|
|
.context("drained ownership account data did not decode as a StakeRecord")?;
|
|
assert!(
|
|
drained_record.pending_unstake.is_none(),
|
|
"pending unstake should be cleared"
|
|
);
|
|
|
|
let destination_balance = account_balance(&ctx, destination_id).await?;
|
|
assert_eq!(
|
|
destination_balance, FUNDING_BALANCE,
|
|
"destination should receive the released stake"
|
|
);
|
|
|
|
// Nothing is at stake for this key any more: a fully drained account has
|
|
// its config entry removed outright.
|
|
assert!(
|
|
stake_entry(&ctx, config_id, demo_stake_key)
|
|
.await?
|
|
.is_none(),
|
|
"the config entry should be gone once the stake is fully released"
|
|
);
|
|
info!(
|
|
"FinalizeUnstake auto-included: {FUNDING_BALANCE} released to {destination_id}, nothing left at stake"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The `sequencer_stake` config entry for `sequencer_key`, or `None` if the key
|
|
/// has nothing at stake.
|
|
async fn stake_entry(
|
|
ctx: &TestContext,
|
|
config_id: AccountId,
|
|
sequencer_key: sequencer_stake_core::SequencerKey,
|
|
) -> Result<Option<sequencer_stake_core::SequencerEntry>> {
|
|
let config_account = get_account(ctx, config_id)
|
|
.await
|
|
.context("Failed to read the sequencer_stake config account")?;
|
|
let config =
|
|
sequencer_stake_core::SequencerStakeConfig::from_bytes(config_account.data.as_ref())
|
|
.context("config account data did not decode as a SequencerStakeConfig")?;
|
|
Ok(config.entries.get(&sequencer_key).copied())
|
|
}
|
|
|
|
/// Polls `check` once a second, up to `max_attempts` times, replacing fixed
|
|
/// block-wait sleeps: the accelerated devnet crosses an epoch boundary every
|
|
/// ~100 slots, so every second of wall-clock spent sleeping increases the
|
|
/// chance of straddling one.
|
|
async fn poll_until<F, Fut>(what: &str, max_attempts: u32, mut check: F) -> Result<()>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<bool>>,
|
|
{
|
|
for _ in 0..max_attempts {
|
|
if check().await.unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
anyhow::bail!("timed out waiting for {what}")
|
|
}
|