From b9fca2f25641d6870071e0158a14bd5be6a5bf68 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 24 Aug 2026 11:31:29 +0800 Subject: [PATCH] test: sequencer registration --- Cargo.lock | 1 + integration_tests/Cargo.toml | 1 + .../features/sequencer_registration.feature | 157 +++++++++ integration_tests/src/cucumber/context.rs | 14 + integration_tests/src/cucumber/mod.rs | 2 + .../src/cucumber/stake_scenario.rs | 305 ++++++++++++++++++ .../src/cucumber/steps/environment/helpers.rs | 20 +- .../src/cucumber/steps/environment/setup.rs | 29 +- integration_tests/src/cucumber/steps/mod.rs | 1 + .../src/cucumber/steps/stake/actions.rs | 200 ++++++++++++ .../src/cucumber/steps/stake/assertions.rs | 233 +++++++++++++ .../src/cucumber/steps/stake/helpers.rs | 225 +++++++++++++ .../src/cucumber/steps/stake/mod.rs | 8 + .../src/cucumber/steps/stake/setup.rs | 153 +++++++++ integration_tests/src/cucumber/world.rs | 27 ++ .../src/testing_framework/apps/wallet.rs | 42 +++ 16 files changed, 1414 insertions(+), 4 deletions(-) create mode 100644 integration_tests/cucumber_tests/features/sequencer_registration.feature create mode 100644 integration_tests/src/cucumber/stake_scenario.rs create mode 100644 integration_tests/src/cucumber/steps/stake/actions.rs create mode 100644 integration_tests/src/cucumber/steps/stake/assertions.rs create mode 100644 integration_tests/src/cucumber/steps/stake/helpers.rs create mode 100644 integration_tests/src/cucumber/steps/stake/mod.rs create mode 100644 integration_tests/src/cucumber/steps/stake/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 30cbd2a02..2dcddd459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4799,6 +4799,7 @@ dependencies = [ "sequencer_service", "sequencer_service_rpc", "sequencer_stake_core", + "serde", "serde_json", "system_accounts", "tempfile", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 56aab0f18..0c3888a18 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -17,6 +17,7 @@ sequencer_core = { workspace = true, features = ["default", "testnet"] } wallet.workspace = true common.workspace = true key_protocol.workspace = true +serde.workspace = true serde_json.workspace = true vault_core.workspace = true faucet_core.workspace = true diff --git a/integration_tests/cucumber_tests/features/sequencer_registration.feature b/integration_tests/cucumber_tests/features/sequencer_registration.feature new file mode 100644 index 000000000..448aae34a --- /dev/null +++ b/integration_tests/cucumber_tests/features/sequencer_registration.feature @@ -0,0 +1,157 @@ +Feature: Sequencer registration — a first Stake turns balance into stake + + # Node-level (L3) port of "Flow A — Registration: balance becomes stake" + # from sequencer_self_join_phase1_cucumber_test_cases.md. Every scenario + # runs against a deployed LEZ stack: transactions are signed and submitted + # through the scenario wallet, executed by the real sequencer, and every + # assertion reads state back through the sequencer's RPC API. The @P-NN + # tags are the case ids shared with the test plan. + # + # Rejection semantics at node level: an invalid transaction is admitted to + # the mempool and only fails during block building, where the builder drops + # it from the block without surfacing the in-program rejection reason + # through any API. Rejection scenarios therefore assert non-inclusion plus + # unchanged accounts; the expected in-program reason is kept as a comment + # on each scenario and stays pinned by sequencer_core's unit tests. + # + # Flow A cases not ported: + # - P-15, P-16 need a bad-mover guest + # - P-17, P-19 need a chained-caller guest + # - P-21 needs a second mover program fitting Stake's two-account slot + # - G-01..G-03 exercise genesis builders private to sequencer_core, where + # G-01 and G-02 are already covered + + Background: + Given a LEZ stack with fast blocks and configured public accounts + And the sequencer_stake config account is at the default minimum stake + And a sequencer key with no config entry + And a default-owned, unclaimed ownership account for the sequencer key + And a funding account holding "ten times the minimum stake" + + @stake_registration_ci @P-01 @P0 @L3 + # Mirrors the registration leg of tests/sequencer_stake_demo.rs, + # additionally asserting the config entry and both balance deltas. + Scenario: Happy-path registration through authenticated_transfer + When a Stake of "twice the minimum stake" is submitted + Then the stake transaction is accepted + And the config entry tracks the staked amount with no pending unstake + And the config entry points at the ownership account + And the ownership account is claimed by sequencer_stake backing the sequencer key with no pending unstake + And the ownership account balance increased by the staked amount + And the funding account balance decreased by the staked amount + + @stake_registration_ci @P-02 @P0 @L3 + # In-program reason: "an initial stake must already meet the minimum". + Scenario: Registration one below the minimum is rejected + When a Stake of "one below the minimum stake" is submitted + Then the stake transaction is not included in a block + And the ownership account is not claimed + And the config has no entry for the sequencer key + And the stake accounts are unchanged + + @stake_registration_ci @P-03 @P0 @L3 + # The boundary is ≥ and genesis relies on it. + Scenario: Registration at exactly the minimum is accepted + When a Stake of "the minimum stake" is submitted + Then the stake transaction is accepted + And the config entry tracks the staked amount with no pending unstake + + @stake_registration_ci @P-04 @P0 @L3 + # In-program reason: "must sign for the ownership account". + Scenario: Registration without the ownership account's signature is rejected + When a Stake of "twice the minimum stake" is submitted without the ownership account's signature + Then the stake transaction is not included in a block + And the stake accounts are unchanged + + @stake_registration_ci @P-13 @P1 @L3 + # In-program reason: "not a sequencer_stake ownership account". The plan + # names the token program as the foreign owner; at node level the only + # foreign owner reachable through deployed programs is + # authenticated_transfer, which claims a default-owned recipient on a + # signed transfer. The guest's owner check rejects both the same way. + Scenario: Ownership account owned by another program is rejected + Given the ownership account is already claimed by the authenticated_transfer program + When a Stake of "twice the minimum stake" is submitted + Then the stake transaction is not included in a block + And the config has no entry for the sequencer key + And the stake accounts are unchanged + + @stake_registration_ci @P-14 @P0 @L3 + # In-program reason: "not the sequencer_stake config account". Mirrors + # lez/sequencer/core/src/tests.rs::an_ownership_account_cannot_stand_in_for_the_config_account + # for the Stake path: the stand-in is owned by sequencer_stake too, so only + # the id check can reject it. + Scenario: An ownership account cannot stand in for the config account + Given a second sequencer key staked through its own ownership account + When a Stake of "twice the minimum stake" is submitted with the second ownership account standing in for the config account + Then the stake transaction is not included in a block + And the ownership account is not claimed + And the stake accounts are unchanged + + @stake_registration_ci @P-25 @P0 @L3 + # In-program reason: "Sender has insufficient balance" — the mover call + # itself fails, so the whole transaction is rejected atomically. The most + # common real-world rejection on the stake-in walk. + Scenario: Funding account holds less than the amount + Given a funding account holding "one below the minimum stake" + When a Stake of "the minimum stake" is submitted + Then the stake transaction is not included in a block + And the ownership account is not claimed + And the config has no entry for the sequencer key + And the stake accounts are unchanged + + @stake_registration_ci @P-18 @P0 @L3 + # In-program reason: "ConfirmStake can only be invoked as a self-chained + # call". The ownership balance already matches the expected post-balance, + # so the caller check is the only assert that can reject it. + Scenario: ConfirmStake submitted top-level is rejected + When a ConfirmStake matching the current ownership balance is submitted as a top-level transaction + Then the stake transaction is not included in a block + And the stake accounts are unchanged + + @stake_registration_ci @P-20 @P2 @L3 + # In-program reason: "Stake requires a funding account, an ownership + # account, and the config account". + Scenario Outline: Wrong pre-state account count is rejected + When a Stake of "the minimum stake" is submitted with pre-state accounts + Then the stake transaction is not included in a block + And the stake accounts are unchanged + + Examples: + | count | + | 2 | + | 4 | + + @stake_registration_ci @P-23 @P1 @L3 + # ⚠️ Diverges further from the plan than the earlier L1 port did. The plan + # expects acceptance with expected_balance_after = donation + amount; at L1 + # the runtime instead rejected the Stake because the donation had made the + # unclaimed ownership account non-default (validate_execution rule 6). At + # node level even the donated pre-state is unreachable: claiming a + # default-owned recipient needs the recipient's signature, so a plain + # transfer at an unclaimed account is itself dropped + # (ClaimedUnauthorizedAccount) and the account stays fresh. This scenario + # pins that behaviour and shows registration is unaffected afterwards. + # Revisit with the plan's §12 decisions. + Scenario: A donation cannot reach an unclaimed ownership account before the first Stake + When a donation of 25 to the unclaimed ownership account is submitted + Then the donation transaction is not included in a block + And the ownership account is not claimed + And the stake accounts are unchanged + When a Stake of "twice the minimum stake" is submitted + Then the stake transaction is accepted + And the ownership account balance increased by the staked amount + + @stake_registration_ci @P-24 @P1 @L3 + # The borsh half mirrors sequencer_stake core's + # a_non_curve_point_is_not_a_sequencer_key; the serde/instruction half is + # the 🆕 path of the plan: an off-curve Stake never reaches the handler + # (the host-side instruction decode panics before the guest runs). + Scenario: SequencerKey accepts only Ed25519 curve points + Given 32 bytes that are not an Ed25519 curve point + Then the bytes are not decodable as a SequencerKey + And a StakeRecord carrying the bytes fails to decode + And an Instruction carrying the bytes fails to deserialize + When a Stake carrying the off-curve key bytes is submitted + Then the stake transaction is not included in a block + And the stake accounts are unchanged diff --git a/integration_tests/src/cucumber/context.rs b/integration_tests/src/cucumber/context.rs index e8af8c136..746624e1b 100644 --- a/integration_tests/src/cucumber/context.rs +++ b/integration_tests/src/cucumber/context.rs @@ -241,6 +241,20 @@ impl LezScenarioContext { .map_err(StepError::query_failed_boxed) } + /// Signs and submits a public transaction against an arbitrary program + /// through the scenario wallet. + pub async fn send_program_transaction( + &self, + accounts: Vec, + instruction_data: lee_core::program::InstructionData, + program_id: lee_core::program::ProgramId, + ) -> Result { + self.wallet() + .send_program_transaction(accounts, instruction_data, program_id) + .await + .map_err(StepError::query_failed_boxed) + } + /// Executes an authenticated public transfer using wallet-resolved labels. pub async fn public_transfer_by_labels( &self, diff --git a/integration_tests/src/cucumber/mod.rs b/integration_tests/src/cucumber/mod.rs index 01d08c878..c1e120ad9 100644 --- a/integration_tests/src/cucumber/mod.rs +++ b/integration_tests/src/cucumber/mod.rs @@ -3,6 +3,8 @@ pub mod context; /// Cucumber runner configuration and filesystem helpers. pub mod default; mod error; +/// Node-level (L3) scenario state for the stake lifecycle scenarios. +pub mod stake_scenario; /// Cucumber step implementations. pub mod steps; /// Per-scenario Cucumber world and lifecycle management. diff --git a/integration_tests/src/cucumber/stake_scenario.rs b/integration_tests/src/cucumber/stake_scenario.rs new file mode 100644 index 000000000..42bfe8e1b --- /dev/null +++ b/integration_tests/src/cucumber/stake_scenario.rs @@ -0,0 +1,305 @@ +//! Node-level (L3) scenario state backing the sequencer registration Cucumber +//! scenarios. +//! +//! The scenarios run against a deployed LEZ stack: transactions are signed and +//! submitted through the scenario wallet and every assertion reads state back +//! through the sequencer's RPC API. This module owns only the per-scenario +//! bookkeeping — the cast of account ids, the amount vocabulary, instruction +//! builders and the record of the last submission. Chain access lives in the +//! step helpers. +//! +//! A submission handed to the node is admitted to the mempool first and only +//! executed during block building; a rejected transaction is dropped from the +//! block without any error surfacing through the RPC API. Rejection scenarios +//! therefore assert non-inclusion plus unchanged accounts instead of the +//! in-program rejection message. + +use common::HashType; +use lee::{Account, AccountId, program::Program}; +use lee_core::program::InstructionData; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use sequencer_stake_core::SequencerKey; + +use crate::cucumber::error::StepError; + +/// Deterministic Bedrock signing seeds: each scenario runs against a fresh +/// chain, so fixed seeds cannot collide across scenarios. +const SEQUENCER_KEY_SEED: u8 = 0x51; +const SECOND_SEQUENCER_KEY_SEED: u8 = 0x52; + +/// Padding account used to inflate `Stake`'s pre-state list (case P-20). +pub const EXTRA_ACCOUNT_ID: [u8; 32] = [0xEE; 32]; + +/// The last transaction handed to the sequencer, kept for the +/// inclusion/non-inclusion assertions. +pub struct SubmissionRecord { + /// Transaction hash returned by the mempool admission. + pub hash: HashType, + /// Amount the submission attempted to move. + pub amount: u128, + /// Sequencer tip observed immediately before submission. + pub submitted_at_block: u64, +} + +/// Pre-submission snapshot of every account the submission can touch, so +/// balance deltas and atomicity can be asserted against exact pre-states. +pub struct AccountsSnapshot { + accounts: Vec<(AccountId, Account)>, +} + +impl AccountsSnapshot { + /// Creates a snapshot from `(account id, pre-state)` pairs. + #[must_use] + pub const fn new(accounts: Vec<(AccountId, Account)>) -> Self { + Self { accounts } + } + + /// Returns the snapshotted accounts. + #[must_use] + pub fn accounts(&self) -> &[(AccountId, Account)] { + &self.accounts + } + + /// Returns the snapshotted state of one account, or a typed error if the + /// account was not part of the snapshot. + pub fn account(&self, account_id: AccountId) -> Result<&Account, StepError> { + self.accounts + .iter() + .find_map(|(id, account)| (*id == account_id).then_some(account)) + .ok_or_else(|| StepError::LogicalError { + message: format!("account {account_id} is not part of the pre-state snapshot"), + }) + } +} + +/// Per-scenario cast of a registration scenario: one funding account, one +/// ownership account, one sequencer key, plus the observations recorded by +/// earlier steps. +pub struct StakeScenario { + minimum_stake: u128, + sequencer_key: SequencerKey, + second_sequencer_key: SequencerKey, + funding_id: Option, + ownership_id: Option, + second_ownership_id: Option, + off_curve_bytes: Option<[u8; 32]>, + snapshot: Option, + last_submission: Option, +} + +impl StakeScenario { + /// Creates the scenario cast against the deployed chain's configured + /// minimum stake. + #[must_use] + pub fn new(minimum_stake: u128) -> Self { + Self { + minimum_stake, + sequencer_key: sequencer_key_from_seed(SEQUENCER_KEY_SEED), + second_sequencer_key: sequencer_key_from_seed(SECOND_SEQUENCER_KEY_SEED), + funding_id: None, + ownership_id: None, + second_ownership_id: None, + off_curve_bytes: None, + snapshot: None, + last_submission: None, + } + } + + /// Returns the chain's configured minimum sequencer stake. + #[must_use] + pub const fn minimum_stake(&self) -> u128 { + self.minimum_stake + } + + /// Returns the scenario's sequencer key. + #[must_use] + pub const fn sequencer_key(&self) -> SequencerKey { + self.sequencer_key + } + + /// Returns the sequencer key staked by the second-registration setup step. + #[must_use] + pub const fn second_sequencer_key(&self) -> SequencerKey { + self.second_sequencer_key + } + + /// Stores the funding account created by a setup step, replacing any + /// earlier funding account. + pub const fn set_funding_id(&mut self, account_id: AccountId) { + self.funding_id = Some(account_id); + } + + /// Returns the funding account id, or a typed error before setup. + pub fn funding_id(&self) -> Result { + self.funding_id.ok_or(StepError::MissingObservation { + field: "funding account", + }) + } + + /// Stores the ownership account created by a setup step. + pub const fn set_ownership_id(&mut self, account_id: AccountId) { + self.ownership_id = Some(account_id); + } + + /// Returns the ownership account id, or a typed error before setup. + pub fn ownership_id(&self) -> Result { + self.ownership_id.ok_or(StepError::MissingObservation { + field: "ownership account", + }) + } + + /// Stores the ownership account of the second staked key. + pub const fn set_second_ownership_id(&mut self, account_id: AccountId) { + self.second_ownership_id = Some(account_id); + } + + /// Returns the ownership account of the second staked key, or a typed + /// error before that setup step ran. + pub fn second_ownership_id(&self) -> Result { + self.second_ownership_id + .ok_or(StepError::MissingObservation { + field: "second staked sequencer key", + }) + } + + /// Stores the off-curve byte string used by the `SequencerKey` decoding + /// scenario. + pub const fn set_off_curve_bytes(&mut self, bytes: [u8; 32]) { + self.off_curve_bytes = Some(bytes); + } + + /// Returns the stored off-curve bytes, or a typed error before setup. + pub fn off_curve_bytes(&self) -> Result<[u8; 32], StepError> { + self.off_curve_bytes.ok_or(StepError::MissingObservation { + field: "off-curve key bytes", + }) + } + + /// Stores the pre-submission account snapshot. + pub fn set_snapshot(&mut self, snapshot: AccountsSnapshot) { + self.snapshot = Some(snapshot); + } + + /// Returns the pre-submission snapshot, or a typed error before any + /// submission. + pub fn snapshot(&self) -> Result<&AccountsSnapshot, StepError> { + self.snapshot.as_ref().ok_or(StepError::MissingObservation { + field: "pre-submission snapshot", + }) + } + + /// Records the last transaction handed to the sequencer. + pub const fn record_submission(&mut self, record: SubmissionRecord) { + self.last_submission = Some(record); + } + + /// Returns the last submission, or a typed error before any transaction + /// was submitted. + pub fn last_submission(&self) -> Result<&SubmissionRecord, StepError> { + self.last_submission + .as_ref() + .ok_or(StepError::MissingObservation { + field: "stake submission", + }) + } + + /// Resolves a Gherkin stake-amount expression against the configured + /// minimum. Plain integers are accepted as a fallback. + pub fn amount(&self, expression: &str) -> Result { + let minimum = self.minimum_stake; + let amount = match expression.trim().to_lowercase().as_str() { + "the minimum stake" => Some(minimum), + "one below the minimum stake" => minimum.checked_sub(1), + "twice the minimum stake" => minimum.checked_mul(2), + "ten times the minimum stake" => minimum.checked_mul(10), + other => other.parse::().ok(), + }; + amount.ok_or_else(|| StepError::InvalidArgument { + message: format!("unsupported stake amount expression '{expression}'"), + }) + } +} + +/// Serde mirror of `sequencer_stake_core::Instruction::Stake` with the +/// `SequencerKey` field widened to raw bytes, so an off-curve key can be +/// serialized into otherwise well-formed instruction data (case P-24). The +/// variant index and field order match the real instruction. +#[derive(serde::Serialize)] +enum RawStakeInstruction { + Stake { + sequencer_key: [u8; 32], + amount: u128, + mover_program_id: lee_core::program::ProgramId, + mover_instruction_data: InstructionData, + }, +} + +/// Derives the Bedrock-style sequencer key for a fixed seed byte. +fn sequencer_key_from_seed(seed: u8) -> SequencerKey { + let bytes = Ed25519Key::from_bytes(&[seed; 32]).public_key().to_bytes(); + SequencerKey::new(bytes).expect("a Bedrock public key is a valid Ed25519 public key") +} + +/// Serialized `authenticated_transfer::Transfer` moving `amount`. +pub fn transfer_instruction(amount: u128) -> Result { + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { amount }) + .map_err(|error| StepError::LogicalError { + message: format!("failed to serialize the mover instruction: {error}"), + }) +} + +/// Serialized `sequencer_stake::Stake` for `sequencer_key` through the +/// `authenticated_transfer` mover. +pub fn stake_instruction( + sequencer_key: SequencerKey, + amount: u128, +) -> Result { + Program::serialize_instruction(sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data: transfer_instruction(amount)?, + }) + .map_err(|error| StepError::LogicalError { + message: format!("failed to serialize the Stake instruction: {error}"), + }) +} + +/// Serialized `sequencer_stake::ConfirmStake` expecting +/// `expected_balance_after` on the ownership account. +pub fn confirm_stake_instruction(expected_balance_after: u128) -> Result { + Program::serialize_instruction(sequencer_stake_core::Instruction::ConfirmStake { + expected_balance_after, + }) + .map_err(|error| StepError::LogicalError { + message: format!("failed to serialize the ConfirmStake instruction: {error}"), + }) +} + +/// Serialized `Stake` carrying `key_bytes` in the `SequencerKey` position +/// (case P-24). +pub fn raw_stake_instruction( + key_bytes: [u8; 32], + amount: u128, +) -> Result { + Program::serialize_instruction(RawStakeInstruction::Stake { + sequencer_key: key_bytes, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data: transfer_instruction(amount)?, + }) + .map_err(|error| StepError::LogicalError { + message: format!("failed to serialize the raw Stake instruction: {error}"), + }) +} + +/// Whether instruction data carrying `key_bytes` in the `SequencerKey` +/// position fails host-side deserialization as a `sequencer_stake` +/// instruction (the serde half of case P-24). +pub fn raw_key_instruction_fails_to_decode( + key_bytes: [u8; 32], + amount: u128, +) -> Result { + let words = raw_stake_instruction(key_bytes, amount)?; + Ok(risc0_zkvm::serde::from_slice::(&words).is_err()) +} diff --git a/integration_tests/src/cucumber/steps/environment/helpers.rs b/integration_tests/src/cucumber/steps/environment/helpers.rs index dcc605650..aec099595 100644 --- a/integration_tests/src/cucumber/steps/environment/helpers.rs +++ b/integration_tests/src/cucumber/steps/environment/helpers.rs @@ -19,6 +19,16 @@ pub(crate) async fn deploy_lez_stack( bedrock: BedrockApp, initialize_private_accounts: bool, step: &Step, +) -> StepResult { + deploy_lez_stack_with_config(world, bedrock, initialize_private_accounts, None, step).await +} + +pub(crate) async fn deploy_lez_stack_with_config( + world: &mut CucumberWorld, + bedrock: BedrockApp, + initialize_private_accounts: bool, + sequencer_config: Option, + step: &Step, ) -> StepResult { if world.lez.is_some() { return Err(StepError::FixtureAlreadyDeployed); @@ -29,10 +39,14 @@ pub(crate) async fn deploy_lez_stack( .clone() .unwrap_or_else(|| "unknown-time".to_owned()); let scenario_base_dir = world.scenario_base_dir.join(entropy); - let app = LezLocalApp::new() + let mut app = LezLocalApp::new() .with_bedrock(bedrock) - .with_scenario_base_dir(scenario_base_dir) - .with_priority_fee(10_000); + .with_scenario_base_dir(scenario_base_dir); + if let Some(sequencer_config) = sequencer_config { + app = app.with_sequencer_config(sequencer_config); + } + // Applied after any config override so the stack-wide fee always wins. + let app = app.with_priority_fee(10_000); let app = if initialize_private_accounts { app } else { diff --git a/integration_tests/src/cucumber/steps/environment/setup.rs b/integration_tests/src/cucumber/steps/environment/setup.rs index 7b051efba..cf8b87463 100644 --- a/integration_tests/src/cucumber/steps/environment/setup.rs +++ b/integration_tests/src/cucumber/steps/environment/setup.rs @@ -1,10 +1,15 @@ +use std::time::Duration; + use cucumber::{gherkin::Step, given}; use super::super::log_step; use crate::{ + config::SequencerPartialConfig, cucumber::{ error::StepResult, - steps::environment::helpers::{deploy_lez_sequencer_registry, deploy_lez_stack}, + steps::environment::helpers::{ + deploy_lez_sequencer_registry, deploy_lez_stack, deploy_lez_stack_with_config, + }, world::CucumberWorld, }, testing_framework::BedrockApp, @@ -23,6 +28,28 @@ async fn deploy_lez_public_stack(world: &mut CucumberWorld, step: &Step) -> Step .await } +#[given("a LEZ stack with fast blocks and configured public accounts")] +async fn deploy_lez_public_stack_with_fast_blocks( + world: &mut CucumberWorld, + step: &Step, +) -> StepResult { + log_step(step); + // Short block cadence keeps inclusion and non-inclusion waits cheap for + // scenarios that submit several transactions, like the stake lifecycle. + let sequencer_config = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(2), + ..SequencerPartialConfig::default() + }; + deploy_lez_stack_with_config( + world, + BedrockApp::nodes_with_blend_core_nodes(1, 0, world.test_context()), + false, + Some(sequencer_config), + step, + ) + .await +} + #[given(expr = "a LEZ multi-sequencer environment with {int} validator and {int} Blend nodes")] async fn deploy_lez_multi_sequencer_environment( world: &mut CucumberWorld, diff --git a/integration_tests/src/cucumber/steps/mod.rs b/integration_tests/src/cucumber/steps/mod.rs index 09840000c..65aaaffe6 100644 --- a/integration_tests/src/cucumber/steps/mod.rs +++ b/integration_tests/src/cucumber/steps/mod.rs @@ -4,6 +4,7 @@ pub mod accounts; pub mod committee; pub mod environment; pub mod indexer; +pub mod stake; pub mod transfers; pub const TARGET: &str = "cucumber_steps"; diff --git a/integration_tests/src/cucumber/steps/stake/actions.rs b/integration_tests/src/cucumber/steps/stake/actions.rs new file mode 100644 index 000000000..3b6a50e6f --- /dev/null +++ b/integration_tests/src/cucumber/steps/stake/actions.rs @@ -0,0 +1,200 @@ +use cucumber::{gherkin::Step, when}; +use lee::AccountId; +use wallet::AccountIdentity; + +use super::{ + super::log_step, + helpers::{get_account, submit_and_record}, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + stake_scenario::{ + EXTRA_ACCOUNT_ID, confirm_stake_instruction, raw_stake_instruction, stake_instruction, + transfer_instruction, + }, + world::CucumberWorld, +}; + +/// The standard `Stake` account list: signing funding and ownership accounts +/// plus the unsigned config account. +fn stake_accounts(funding_id: AccountId, ownership_id: AccountId) -> Vec { + vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::Public(ownership_id), + AccountIdentity::PublicNoSign(system_accounts::sequencer_stake_config_account_id()), + ] +} + +#[when(expr = "a Stake of {string} is submitted")] +async fn submit_stake(world: &mut CucumberWorld, step: &Step, expression: String) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let amount = scenario.amount(&expression)?; + let accounts = stake_accounts(scenario.funding_id()?, scenario.ownership_id()?); + let instruction = stake_instruction(scenario.sequencer_key(), amount)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + amount, + ) + .await +} + +#[when(expr = "a Stake of {string} is submitted without the ownership account's signature")] +async fn submit_stake_unsigned_ownership( + world: &mut CucumberWorld, + step: &Step, + expression: String, +) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let amount = scenario.amount(&expression)?; + let accounts = vec![ + AccountIdentity::Public(scenario.funding_id()?), + AccountIdentity::PublicNoSign(scenario.ownership_id()?), + AccountIdentity::PublicNoSign(system_accounts::sequencer_stake_config_account_id()), + ]; + let instruction = stake_instruction(scenario.sequencer_key(), amount)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + amount, + ) + .await +} + +#[when( + expr = "a Stake of {string} is submitted with the second ownership account standing in for \ + the config account" +)] +async fn submit_stake_with_ownership_as_config( + world: &mut CucumberWorld, + step: &Step, + expression: String, +) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let amount = scenario.amount(&expression)?; + let accounts = vec![ + AccountIdentity::Public(scenario.funding_id()?), + AccountIdentity::Public(scenario.ownership_id()?), + AccountIdentity::PublicNoSign(scenario.second_ownership_id()?), + ]; + let instruction = stake_instruction(scenario.sequencer_key(), amount)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + amount, + ) + .await +} + +#[when(expr = "a Stake of {string} is submitted with {int} pre-state accounts")] +async fn submit_stake_with_account_count( + world: &mut CucumberWorld, + step: &Step, + expression: String, + count: usize, +) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let amount = scenario.amount(&expression)?; + let funding_id = scenario.funding_id()?; + let ownership_id = scenario.ownership_id()?; + let accounts = match count { + 2 => vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::Public(ownership_id), + ], + 4 => vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::Public(ownership_id), + AccountIdentity::PublicNoSign(system_accounts::sequencer_stake_config_account_id()), + AccountIdentity::PublicNoSign(AccountId::new(EXTRA_ACCOUNT_ID)), + ], + other => { + return Err(StepError::InvalidArgument { + message: format!("unsupported pre-state account count {other}"), + }); + } + }; + let instruction = stake_instruction(scenario.sequencer_key(), amount)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + amount, + ) + .await +} + +#[when( + "a ConfirmStake matching the current ownership balance is submitted as a top-level transaction" +)] +async fn submit_confirm_stake_top_level(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let ownership_id = scenario.ownership_id()?; + // The expected balance matches the current one so the caller check is the + // only assert that can reject it. + let balance = get_account(world.lez()?, ownership_id).await?.balance; + let accounts = vec![AccountIdentity::Public(ownership_id)]; + let instruction = confirm_stake_instruction(balance)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + 0, + ) + .await +} + +#[when("a Stake carrying the off-curve key bytes is submitted")] +async fn submit_stake_with_off_curve_key(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let amount = scenario.minimum_stake(); + let accounts = stake_accounts(scenario.funding_id()?, scenario.ownership_id()?); + let instruction = raw_stake_instruction(scenario.off_curve_bytes()?, amount)?; + submit_and_record( + world, + accounts, + instruction, + programs::sequencer_stake().id(), + amount, + ) + .await +} + +#[when(expr = "a donation of {int} to the unclaimed ownership account is submitted")] +async fn submit_donation_to_unclaimed_ownership( + world: &mut CucumberWorld, + step: &Step, + donation: u128, +) -> StepResult { + log_step(step); + let scenario = world.stake()?; + // The recipient deliberately does not sign: a donation is a plain + // transfer someone else pushes at the account. + let accounts = vec![ + AccountIdentity::Public(scenario.funding_id()?), + AccountIdentity::PublicNoSign(scenario.ownership_id()?), + ]; + let instruction = transfer_instruction(donation)?; + submit_and_record( + world, + accounts, + instruction, + programs::authenticated_transfer().id(), + donation, + ) + .await +} diff --git a/integration_tests/src/cucumber/steps/stake/assertions.rs b/integration_tests/src/cucumber/steps/stake/assertions.rs new file mode 100644 index 000000000..cc41d9083 --- /dev/null +++ b/integration_tests/src/cucumber/steps/stake/assertions.rs @@ -0,0 +1,233 @@ +#![expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers use the framework's mutable-world signature" +)] + +use cucumber::{gherkin::Step, then}; +use lee::Account; +use sequencer_stake_core::{SequencerEntry, SequencerKey, StakeRecord}; + +use super::{ + super::log_step, + helpers::{assert_not_included, config_entry, get_account, wait_for_inclusion}, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + stake_scenario::raw_key_instruction_fails_to_decode, + world::CucumberWorld, +}; + +/// Returns the config entry backing the scenario's sequencer key, or an +/// assertion failure if there is none. +async fn required_entry(world: &CucumberWorld) -> Result { + config_entry(world.lez()?, world.stake()?.sequencer_key()) + .await? + .ok_or_else(|| StepError::AssertionFailed { + message: "the config has no entry for the sequencer key".to_owned(), + }) +} + +#[then("the stake transaction is accepted")] +async fn stake_transaction_accepted(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let hash = world.stake()?.last_submission()?.hash; + wait_for_inclusion(world.lez()?, hash).await +} + +#[then("the stake transaction is not included in a block")] +#[then("the donation transaction is not included in a block")] +async fn transaction_not_included(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let submission = world.stake()?.last_submission()?; + assert_not_included(context, submission).await +} + +#[then("the config entry tracks the staked amount with no pending unstake")] +async fn entry_tracks_staked_amount(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let amount = world.stake()?.last_submission()?.amount; + let entry = required_entry(world).await?; + if entry.total_staked != amount || entry.total_pending_unstake != 0 { + return Err(StepError::AssertionFailed { + message: format!( + "the entry tracks {} staked with {} pending unstake, expected {amount} and 0", + entry.total_staked, entry.total_pending_unstake + ), + }); + } + Ok(()) +} + +#[then("the config entry points at the ownership account")] +async fn entry_points_at_ownership_account(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let ownership_id = world.stake()?.ownership_id()?; + let entry = required_entry(world).await?; + if entry.account_id != ownership_id { + return Err(StepError::AssertionFailed { + message: format!( + "the entry points at {:?}, expected the ownership account {ownership_id:?}", + entry.account_id + ), + }); + } + Ok(()) +} + +#[then("the config has no entry for the sequencer key")] +async fn config_has_no_entry(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let sequencer_key = world.stake()?.sequencer_key(); + if config_entry(world.lez()?, sequencer_key).await?.is_some() { + return Err(StepError::AssertionFailed { + message: "the config carries an entry for the sequencer key, expected none".to_owned(), + }); + } + Ok(()) +} + +#[then( + "the ownership account is claimed by sequencer_stake backing the sequencer key with no \ + pending unstake" +)] +async fn ownership_account_is_claimed(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let account = get_account(world.lez()?, scenario.ownership_id()?).await?; + if account.program_owner != programs::sequencer_stake().id().into() { + return Err(StepError::AssertionFailed { + message: "the ownership account is not owned by sequencer_stake".to_owned(), + }); + } + let record = StakeRecord::from_bytes(account.data.as_ref()).ok_or_else(|| { + StepError::AssertionFailed { + message: "the ownership account data does not decode as a StakeRecord".to_owned(), + } + })?; + if record.sequencer_key != scenario.sequencer_key() || record.pending_unstake.is_some() { + return Err(StepError::AssertionFailed { + message: format!( + "the StakeRecord does not carry the sequencer key with no pending unstake: \ + {record:?}" + ), + }); + } + Ok(()) +} + +#[then("the ownership account is not claimed")] +async fn ownership_account_is_not_claimed(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let ownership_id = world.stake()?.ownership_id()?; + let account = get_account(world.lez()?, ownership_id).await?; + if account.program_owner != Account::default().program_owner { + return Err(StepError::AssertionFailed { + message: "the ownership account is claimed, expected it to stay default-owned" + .to_owned(), + }); + } + Ok(()) +} + +#[then("the ownership account balance increased by the staked amount")] +async fn ownership_balance_increased(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let ownership_id = scenario.ownership_id()?; + let balance_before = scenario.snapshot()?.account(ownership_id)?.balance; + let expected = balance_before + .checked_add(scenario.last_submission()?.amount) + .ok_or_else(|| StepError::AssertionFailed { + message: "expected ownership balance overflows".to_owned(), + })?; + let observed = get_account(world.lez()?, ownership_id).await?.balance; + if observed != expected { + return Err(StepError::AssertionFailed { + message: format!("the ownership balance is {observed}, expected {expected}"), + }); + } + Ok(()) +} + +#[then("the funding account balance decreased by the staked amount")] +async fn funding_balance_decreased(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let funding_id = scenario.funding_id()?; + let balance_before = scenario.snapshot()?.account(funding_id)?.balance; + let expected = balance_before + .checked_sub(scenario.last_submission()?.amount) + .ok_or_else(|| StepError::AssertionFailed { + message: "expected funding balance underflows".to_owned(), + })?; + let observed = get_account(world.lez()?, funding_id).await?.balance; + if observed != expected { + return Err(StepError::AssertionFailed { + message: format!("the funding balance is {observed}, expected {expected}"), + }); + } + Ok(()) +} + +#[then("the stake accounts are unchanged")] +async fn stake_accounts_are_unchanged(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let snapshot = world.stake()?.snapshot()?; + for (account_id, before) in snapshot.accounts() { + let after = get_account(context, *account_id).await?; + if after != *before { + return Err(StepError::AssertionFailed { + message: format!( + "account {account_id} differs from its pre-submission snapshot: \ + {before:?} -> {after:?}" + ), + }); + } + } + Ok(()) +} + +#[then("the bytes are not decodable as a SequencerKey")] +fn bytes_are_not_a_sequencer_key(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let key_bytes = world.stake()?.off_curve_bytes()?; + if SequencerKey::new(key_bytes).is_some() { + return Err(StepError::AssertionFailed { + message: "the off-curve bytes decode as a SequencerKey".to_owned(), + }); + } + Ok(()) +} + +#[then("a StakeRecord carrying the bytes fails to decode")] +fn stake_record_with_bytes_fails_to_decode(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let key_bytes = world.stake()?.off_curve_bytes()?; + // 32 key bytes then a `None` discriminant: a `StakeRecord` with no + // pending unstake. + let record_bytes = [&key_bytes[..], &[0_u8][..]].concat(); + if StakeRecord::from_bytes(&record_bytes).is_some() { + return Err(StepError::AssertionFailed { + message: "a StakeRecord carrying the off-curve bytes decodes".to_owned(), + }); + } + Ok(()) +} + +#[then("an Instruction carrying the bytes fails to deserialize")] +fn instruction_with_bytes_fails_to_deserialize( + world: &mut CucumberWorld, + step: &Step, +) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let key_bytes = scenario.off_curve_bytes()?; + if !raw_key_instruction_fails_to_decode(key_bytes, scenario.minimum_stake())? { + return Err(StepError::AssertionFailed { + message: "a Stake instruction carrying the off-curve bytes deserializes".to_owned(), + }); + } + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/stake/helpers.rs b/integration_tests/src/cucumber/steps/stake/helpers.rs new file mode 100644 index 000000000..028b4d362 --- /dev/null +++ b/integration_tests/src/cucumber/steps/stake/helpers.rs @@ -0,0 +1,225 @@ +//! Chain access shared by the stake lifecycle steps: account and config +//! queries, submission bookkeeping and the inclusion/non-inclusion waits. + +use std::time::Duration; + +use common::HashType; +use lee::{Account, AccountId}; +use lee_core::program::{InstructionData, ProgramId}; +use sequencer_service_rpc::RpcClient as _; +use sequencer_stake_core::{SequencerEntry, SequencerKey, SequencerStakeConfig}; +use wallet::AccountIdentity; + +use crate::cucumber::{ + context::LezScenarioContext, + error::{StepError, StepResult}, + stake_scenario::{AccountsSnapshot, SubmissionRecord, stake_instruction}, + world::CucumberWorld, +}; + +/// Cadence of the inclusion and non-inclusion polls. +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Upper bound on every wait; generous because a freshly accredited key with +/// no node behind it slows block production down to the posting-turn reclaim. +const WAIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Blocks past the submission tip that prove a dropped transaction: the +/// builder pulls the whole mempool on every turn, so once two more blocks +/// exist the transaction was tried and dropped rather than still queued. +const NON_INCLUSION_BLOCKS: u64 = 2; + +/// Reads one account from the sequencer; an untouched account comes back with +/// default values. +pub(super) async fn get_account( + context: &LezScenarioContext, + account_id: AccountId, +) -> Result { + context + .sequencer_client() + .get_account(account_id) + .await + .map_err(StepError::query_failed) +} + +/// Reads and decodes the `sequencer_stake` config account. +pub(super) async fn stake_config( + context: &LezScenarioContext, +) -> Result { + let account = get_account(context, system_accounts::sequencer_stake_config_account_id()).await?; + SequencerStakeConfig::from_bytes(account.data.as_ref()).ok_or_else(|| { + StepError::LogicalError { + message: "the config account does not decode as a SequencerStakeConfig".to_owned(), + } + }) +} + +/// Returns the config entry backing `sequencer_key`, if any. +pub(super) async fn config_entry( + context: &LezScenarioContext, + sequencer_key: SequencerKey, +) -> Result, StepError> { + Ok(stake_config(context).await?.entries.get(&sequencer_key).copied()) +} + +/// Returns the first public account configured into the scenario wallet. +pub(super) async fn first_configured_public_account( + context: &LezScenarioContext, +) -> Result { + context + .existing_public_accounts() + .await? + .into_iter() + .next() + .ok_or(StepError::MissingSelectedAccount) +} + +/// Returns the sequencer's current tip. +pub(super) async fn last_block(context: &LezScenarioContext) -> Result { + context + .sequencer_client() + .get_last_block_id() + .await + .map_err(StepError::query_failed) +} + +/// Snapshots the config account plus every scenario account introduced so +/// far, immediately before a submission. +pub(super) async fn scenario_snapshot( + world: &CucumberWorld, +) -> Result { + let scenario = world.stake()?; + let context = world.lez()?; + let mut account_ids = vec![system_accounts::sequencer_stake_config_account_id()]; + account_ids.extend(scenario.funding_id().ok()); + account_ids.extend(scenario.ownership_id().ok()); + account_ids.extend(scenario.second_ownership_id().ok()); + + let mut accounts = Vec::with_capacity(account_ids.len()); + for account_id in account_ids { + accounts.push((account_id, get_account(context, account_id).await?)); + } + Ok(AccountsSnapshot::new(accounts)) +} + +/// Snapshots the touchable accounts, submits one transaction through the +/// scenario wallet and records it for the inclusion/non-inclusion assertions. +pub(super) async fn submit_and_record( + world: &mut CucumberWorld, + accounts: Vec, + instruction_data: InstructionData, + program_id: ProgramId, + amount: u128, +) -> StepResult { + let snapshot = scenario_snapshot(world).await?; + let context = world.lez()?; + let submitted_at_block = last_block(context).await?; + let hash = context + .send_program_transaction(accounts, instruction_data, program_id) + .await?; + + let scenario = world.stake_mut()?; + scenario.set_snapshot(snapshot); + scenario.record_submission(SubmissionRecord { + hash, + amount, + submitted_at_block, + }); + Ok(()) +} + +/// Waits until `hash` appears in a block. +pub(super) async fn wait_for_inclusion( + context: &LezScenarioContext, + hash: HashType, +) -> StepResult { + let poll = async { + loop { + let included = context + .sequencer_client() + .get_transaction(hash) + .await + .map_err(StepError::query_failed)? + .is_some(); + if included { + return Ok(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + match tokio::time::timeout(WAIT_TIMEOUT, poll).await { + Ok(result) => result, + Err(_elapsed) => Err(StepError::Timeout { + message: format!("transaction {hash} was not included within {WAIT_TIMEOUT:?}"), + }), + } +} + +/// Waits until the chain has moved [`NON_INCLUSION_BLOCKS`] past the +/// submission tip and asserts the transaction is in none of them. +pub(super) async fn assert_not_included( + context: &LezScenarioContext, + submission: &SubmissionRecord, +) -> StepResult { + let target = submission + .submitted_at_block + .saturating_add(NON_INCLUSION_BLOCKS); + let poll = async { + loop { + if last_block(context).await? >= target { + return Ok::<(), StepError>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + match tokio::time::timeout(WAIT_TIMEOUT, poll).await { + Ok(result) => result?, + Err(_elapsed) => { + return Err(StepError::Timeout { + message: format!( + "the chain did not reach block {target} within {WAIT_TIMEOUT:?} to prove \ + non-inclusion" + ), + }); + } + } + + let included = context + .sequencer_client() + .get_transaction(submission.hash) + .await + .map_err(StepError::query_failed)?; + if let Some((_transaction, block_id)) = included { + return Err(StepError::AssertionFailed { + message: format!( + "transaction {} was included in block {block_id}, expected it to be dropped", + submission.hash + ), + }); + } + Ok(()) +} + +/// Submits a fully signed, well-formed `Stake` and waits for its inclusion. +/// Used by setup steps whose registrations must succeed; the submission is +/// not recorded as the one under test. +pub(super) async fn submit_accepted_stake( + context: &LezScenarioContext, + funding_id: AccountId, + ownership_id: AccountId, + sequencer_key: SequencerKey, + amount: u128, +) -> StepResult { + let hash = context + .send_program_transaction( + vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::Public(ownership_id), + AccountIdentity::PublicNoSign(system_accounts::sequencer_stake_config_account_id()), + ], + stake_instruction(sequencer_key, amount)?, + programs::sequencer_stake().id(), + ) + .await?; + wait_for_inclusion(context, hash).await +} diff --git a/integration_tests/src/cucumber/steps/stake/mod.rs b/integration_tests/src/cucumber/steps/stake/mod.rs new file mode 100644 index 000000000..85ccb415f --- /dev/null +++ b/integration_tests/src/cucumber/steps/stake/mod.rs @@ -0,0 +1,8 @@ +/// `When` steps submitting stake lifecycle transactions. +pub mod actions; +/// `Then` steps asserting stake lifecycle outcomes. +pub mod assertions; +/// Chain access shared by the stake lifecycle steps. +mod helpers; +/// `Given` steps preparing the node-level stake lifecycle scenario. +pub mod setup; diff --git a/integration_tests/src/cucumber/steps/stake/setup.rs b/integration_tests/src/cucumber/steps/stake/setup.rs new file mode 100644 index 000000000..592674ecd --- /dev/null +++ b/integration_tests/src/cucumber/steps/stake/setup.rs @@ -0,0 +1,153 @@ +#![expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers use the framework's mutable-world signature" +)] + +use cucumber::{gherkin::Step, given}; +use lee::Account; + +use super::{ + super::log_step, + helpers::{ + config_entry, first_configured_public_account, get_account, stake_config, + submit_accepted_stake, + }, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + stake_scenario::StakeScenario, + world::CucumberWorld, +}; + +/// Byte string that is not an Ed25519 curve point, matching the L0 test +/// `a_non_curve_point_is_not_a_sequencer_key`. +const OFF_CURVE_BYTES: [u8; 32] = [2; 32]; + +#[given("the sequencer_stake config account is at the default minimum stake")] +async fn config_account_at_default_minimum(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let minimum = stake_config(world.lez()?).await?.minimum_sequencer_stake; + if minimum != system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE { + return Err(StepError::AssertionFailed { + message: format!( + "config minimum is {minimum}, expected the default {}", + system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE + ), + }); + } + world.set_stake(StakeScenario::new(minimum)) +} + +#[given("a sequencer key with no config entry")] +async fn sequencer_key_has_no_entry(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let sequencer_key = world.stake()?.sequencer_key(); + if config_entry(world.lez()?, sequencer_key).await?.is_some() { + return Err(StepError::AssertionFailed { + message: "the sequencer key already has a config entry".to_owned(), + }); + } + Ok(()) +} + +#[given("a default-owned, unclaimed ownership account for the sequencer key")] +async fn ownership_account_is_unclaimed(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let ownership_id = context.new_public_account().await?; + let account = get_account(context, ownership_id).await?; + if account != Account::default() { + return Err(StepError::AssertionFailed { + message: "the ownership account does not start out fresh and unclaimed".to_owned(), + }); + } + world.stake_mut()?.set_ownership_id(ownership_id); + Ok(()) +} + +#[given(expr = "a funding account holding {string}")] +async fn fund_funding_account( + world: &mut CucumberWorld, + step: &Step, + expression: String, +) -> StepResult { + log_step(step); + let balance = world.stake()?.amount(&expression)?; + let context = world.lez()?; + let funding_id = context.new_public_account().await?; + let supply_id = first_configured_public_account(context).await?; + // Claims the fresh account for authenticated_transfer with exactly + // `balance` on it, so it can act as the Stake mover's sender. + context + .public_transfer_to_new_account(supply_id, funding_id, balance) + .await?; + let funded = get_account(context, funding_id).await?.balance; + if funded != balance { + return Err(StepError::AssertionFailed { + message: format!("the funding account holds {funded}, expected {balance}"), + }); + } + world.stake_mut()?.set_funding_id(funding_id); + Ok(()) +} + +#[given("the ownership account is already claimed by the authenticated_transfer program")] +async fn ownership_account_claimed_by_other_program( + world: &mut CucumberWorld, + step: &Step, +) -> StepResult { + log_step(step); + let ownership_id = world.stake()?.ownership_id()?; + let context = world.lez()?; + let supply_id = first_configured_public_account(context).await?; + // A transfer with the recipient signing claims a default-owned recipient + // for authenticated_transfer — the only foreign owner reachable through + // deployed programs. + context + .public_transfer_to_new_account(supply_id, ownership_id, 1) + .await?; + let owner = get_account(context, ownership_id).await?.program_owner; + if owner != programs::authenticated_transfer().id().into() { + return Err(StepError::AssertionFailed { + message: format!( + "the ownership account is owned by {owner}, expected authenticated_transfer" + ), + }); + } + Ok(()) +} + +#[given("a second sequencer key staked through its own ownership account")] +async fn stake_second_sequencer_key(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let scenario = world.stake()?; + let funding_id = scenario.funding_id()?; + let second_sequencer_key = scenario.second_sequencer_key(); + let amount = scenario.minimum_stake(); + let context = world.lez()?; + let second_ownership_id = context.new_public_account().await?; + submit_accepted_stake( + context, + funding_id, + second_ownership_id, + second_sequencer_key, + amount, + ) + .await?; + let owner = get_account(context, second_ownership_id).await?.program_owner; + if owner != programs::sequencer_stake().id().into() { + return Err(StepError::AssertionFailed { + message: "staking the second sequencer key did not claim its ownership account" + .to_owned(), + }); + } + world.stake_mut()?.set_second_ownership_id(second_ownership_id); + Ok(()) +} + +#[given("32 bytes that are not an Ed25519 curve point")] +fn off_curve_key_bytes(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + world.stake_mut()?.set_off_curve_bytes(OFF_CURVE_BYTES); + Ok(()) +} diff --git a/integration_tests/src/cucumber/world.rs b/integration_tests/src/cucumber/world.rs index 3d74ca2b2..f9cf19792 100644 --- a/integration_tests/src/cucumber/world.rs +++ b/integration_tests/src/cucumber/world.rs @@ -14,6 +14,7 @@ use crate::{ context::{LezScenarioContext, LezSequencerRegistryScenarioContext}, default::CUCUMBER_NODE_CONFIG_OVERRIDE, error::{StepError, StepResult}, + stake_scenario::StakeScenario, }, testing_framework::shutdown_lez_deployment, }; @@ -194,6 +195,8 @@ pub struct CucumberWorld { pub lez: Option, /// Scenario-owned view of the multi-sequencer registry. pub sequencer_registry: Option, + /// Node-level (L3) stake lifecycle scenario state. + pub stake: Option, /// Runtime observations collected by scenario steps. pub environment: EnvironmentState, /// A unique per-scenario context string used to isolate runtime resources. @@ -265,6 +268,28 @@ impl CucumberWorld { .ok_or(StepError::FixtureNotDeployed) } + /// Stores the stake lifecycle scenario state, rejecting duplicate setup. + pub fn set_stake(&mut self, scenario: StakeScenario) -> StepResult { + if self.stake.is_some() { + return Err(StepError::FixtureAlreadyDeployed); + } + + self.stake = Some(scenario); + Ok(()) + } + + /// Returns the stake lifecycle scenario state, or a typed error before + /// setup. + pub fn stake(&self) -> Result<&StakeScenario, StepError> { + self.stake.as_ref().ok_or(StepError::FixtureNotDeployed) + } + + /// Returns the stake lifecycle scenario state mutably, or a typed error + /// before setup. + pub fn stake_mut(&mut self) -> Result<&mut StakeScenario, StepError> { + self.stake.as_mut().ok_or(StepError::FixtureNotDeployed) + } + /// Stop all runtime services and release both scenario and registry-owned /// handles. This is intentionally explicit because artifact cleanup must /// never race a still-running LEZ service. @@ -340,6 +365,7 @@ impl CucumberWorld { "sequencer_registry", &self.sequencer_registry.as_ref().map(|_| "deployed"), ) + .field("stake", &self.stake.as_ref().map(|_| "initialized")) .field("environment", &self.environment) .field( "runtime_teardown_attempted", @@ -411,6 +437,7 @@ impl Default for CucumberWorld { deployment: DeployContext::new(AppHostTopology, NodeClients::default()), lez: None, sequencer_registry: None, + stake: None, environment: EnvironmentState::default(), test_context: None, scenario_base_dir: PathBuf::default(), diff --git a/integration_tests/src/testing_framework/apps/wallet.rs b/integration_tests/src/testing_framework/apps/wallet.rs index ed48e8194..cc4fa783d 100644 --- a/integration_tests/src/testing_framework/apps/wallet.rs +++ b/integration_tests/src/testing_framework/apps/wallet.rs @@ -9,6 +9,7 @@ use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use common::HashType; use lee::{AccountId, PrivateKey, PublicKey}; +use lee_core::program::{InstructionData, ProgramId}; use tempfile::TempDir; use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; use testing_framework_core::scenario::DynError; @@ -96,6 +97,12 @@ enum WalletRequest { amount: u128, response: oneshot::Sender>, }, + SendProgramTransaction { + accounts: Vec, + instruction_data: InstructionData, + program_id: ProgramId, + response: oneshot::Sender>, + }, WalletPassword { response: oneshot::Sender>, }, @@ -373,6 +380,19 @@ impl WalletActor { .map_err(|error| error.to_string()); let _unused = response.send(result); } + WalletRequest::SendProgramTransaction { + accounts, + instruction_data, + program_id, + response, + } => { + let result = components + .wallet + .send_pub_tx(accounts, instruction_data, program_id) + .await + .map_err(|error| format!("{error:?}")); + let _unused = response.send(result); + } WalletRequest::WalletPassword { response } => { let _unused = response.send(Ok(components.password.clone())); } @@ -565,6 +585,28 @@ impl LezRuntime { .await } + /// Signs and submits a public transaction against an arbitrary program. + /// + /// `AccountIdentity::Public` entries are signed with the wallet's key for + /// that account; `AccountIdentity::PublicNoSign` entries are carried as + /// unsigned pre-state accounts. The returned hash only means the + /// sequencer's mempool admitted the transaction; whether it executes is + /// decided during block building. + pub async fn send_program_transaction( + &self, + accounts: Vec, + instruction_data: InstructionData, + program_id: ProgramId, + ) -> Result { + self.request(|response| WalletRequest::SendProgramTransaction { + accounts, + instruction_data, + program_id, + response, + }) + .await + } + /// Executes an authenticated public transfer using wallet-resolved labels. pub async fn public_transfer_by_labels( &self,