test: sequencer registration

This commit is contained in:
Roman
2026-08-24 11:31:29 +08:00
parent 7dac077487
commit b9fca2f256
16 changed files with 1414 additions and 4 deletions
Generated
+1
View File
@@ -4799,6 +4799,7 @@ dependencies = [
"sequencer_service",
"sequencer_service_rpc",
"sequencer_stake_core",
"serde",
"serde_json",
"system_accounts",
"tempfile",
+1
View File
@@ -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
@@ -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 <count> 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
+14
View File
@@ -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<wallet::AccountIdentity>,
instruction_data: lee_core::program::InstructionData,
program_id: lee_core::program::ProgramId,
) -> Result<HashType, StepError> {
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,
+2
View File
@@ -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.
@@ -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<AccountId>,
ownership_id: Option<AccountId>,
second_ownership_id: Option<AccountId>,
off_curve_bytes: Option<[u8; 32]>,
snapshot: Option<AccountsSnapshot>,
last_submission: Option<SubmissionRecord>,
}
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<AccountId, StepError> {
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<AccountId, StepError> {
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<AccountId, StepError> {
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<u128, StepError> {
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::<u128>().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<InstructionData, StepError> {
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<InstructionData, StepError> {
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<InstructionData, StepError> {
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<InstructionData, StepError> {
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<bool, StepError> {
let words = raw_stake_instruction(key_bytes, amount)?;
Ok(risc0_zkvm::serde::from_slice::<sequencer_stake_core::Instruction, u32>(&words).is_err())
}
@@ -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<SequencerPartialConfig>,
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 {
@@ -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,
@@ -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";
@@ -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<AccountIdentity> {
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
}
@@ -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<SequencerEntry, StepError> {
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(())
}
@@ -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<Account, StepError> {
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<SequencerStakeConfig, StepError> {
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<Option<SequencerEntry>, 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<AccountId, StepError> {
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<u64, StepError> {
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<AccountsSnapshot, StepError> {
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<AccountIdentity>,
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
}
@@ -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;
@@ -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(())
}
+27
View File
@@ -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<LezScenarioContext>,
/// Scenario-owned view of the multi-sequencer registry.
pub sequencer_registry: Option<LezSequencerRegistryScenarioContext>,
/// Node-level (L3) stake lifecycle scenario state.
pub stake: Option<StakeScenario>,
/// 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(),
@@ -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<Result<HashType, String>>,
},
SendProgramTransaction {
accounts: Vec<AccountIdentity>,
instruction_data: InstructionData,
program_id: ProgramId,
response: oneshot::Sender<Result<HashType, String>>,
},
WalletPassword {
response: oneshot::Sender<Result<String, String>>,
},
@@ -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<AccountIdentity>,
instruction_data: InstructionData,
program_id: ProgramId,
) -> Result<HashType, DynError> {
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,