mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
test: sequencer registration
This commit is contained in:
Generated
+1
@@ -4799,6 +4799,7 @@ dependencies = [
|
||||
"sequencer_service",
|
||||
"sequencer_service_rpc",
|
||||
"sequencer_stake_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"system_accounts",
|
||||
"tempfile",
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user