mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
Merge branch 'dev' into Pravdyvy/cli-extension
This commit is contained in:
Generated
+207
-207
File diff suppressed because it is too large
Load Diff
+21
-8
@@ -54,6 +54,7 @@ members = [
|
||||
"lez/programs/wrapped_token",
|
||||
"lez/programs/ping_sender",
|
||||
"lez/programs/ping_receiver",
|
||||
"lez/programs/sequencer_stake",
|
||||
"lez/cross_zone",
|
||||
|
||||
"test_programs",
|
||||
@@ -118,6 +119,7 @@ cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" }
|
||||
bridge_lock_core = { path = "lez/programs/bridge_lock/core" }
|
||||
wrapped_token_core = { path = "lez/programs/wrapped_token/core" }
|
||||
ping_core = { path = "lez/programs/ping_core" }
|
||||
sequencer_stake_core = { path = "lez/programs/sequencer_stake/core" }
|
||||
cross_zone = { path = "lez/cross_zone" }
|
||||
build_utils = { path = "build_utils" }
|
||||
test_programs = { path = "test_programs" }
|
||||
@@ -158,12 +160,23 @@ metrics-exporter-prometheus = "0.18.3"
|
||||
lru = "0.16.3"
|
||||
thiserror = "2.0"
|
||||
sha2 = "0.10.8"
|
||||
ed25519-dalek = { version = "2.2.0", default-features = false }
|
||||
hex = "0.4.3"
|
||||
bytemuck = "1.24.0"
|
||||
bytesize = { version = "2.3.1", features = ["serde"] }
|
||||
humantime-serde = "1.1"
|
||||
arc-swap = "1.7"
|
||||
humantime = "2.1"
|
||||
libp2p = { version = "0.56", features = [
|
||||
"ed25519",
|
||||
"gossipsub",
|
||||
"identify",
|
||||
"kad",
|
||||
"macros",
|
||||
"mdns",
|
||||
"quic",
|
||||
"tokio",
|
||||
] }
|
||||
aes-gcm = "0.10.3"
|
||||
toml = "0.9.8"
|
||||
bincode = "1.3.3"
|
||||
@@ -188,14 +201,14 @@ schemars = "1.2"
|
||||
async-stream = "0.3.6"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-codec = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" }
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-codec = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" }
|
||||
|
||||
keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" }
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,7 +41,7 @@ cryptarchia:
|
||||
version: Bedrock
|
||||
parent_block: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
slot: 0
|
||||
block_root: cb5951ac1ffa1aa5d0e585fb54e784bd9c025b28d752324e98b3837f34648692
|
||||
block_root: '379bace32490b92ea47583b9aad4a32b059312a920d45b3b7386cffbc2737e18'
|
||||
proof_of_leadership:
|
||||
proof: '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'
|
||||
entropy_contribution: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
@@ -153,6 +153,8 @@ cryptarchia:
|
||||
pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26'
|
||||
- value: 100000
|
||||
pk: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a'
|
||||
- value: 1000000000000
|
||||
pk: ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717
|
||||
- value: 18446744073709551615
|
||||
pk: c2a6a4a0981d5bdcf8ddeb8d7934fd8c5510efeb1053f613b45871670b6f7b19
|
||||
- opcode: 17
|
||||
@@ -168,7 +170,7 @@ cryptarchia:
|
||||
- /ip4/65.109.51.37/udp/3400/quic-v1
|
||||
provider_id: '59c662860b737f4e2515599adb3434856db8070b373a449ff66955ad3da6b473'
|
||||
zk_id: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a'
|
||||
locked_note_id: '7e449a14172fc90679f6fca7b49a2d58c305ebf7ac42ef20202e533c31115222'
|
||||
locked_note_id: '67e2ec02c536a82f0eb80800fcfb8f2d66c5423d57cd081dfb0f237234f6ac26'
|
||||
ops_proofs:
|
||||
- !ZkSig
|
||||
pi_a: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
|
||||
@@ -32,10 +32,14 @@ wallet-ffi.workspace = true
|
||||
indexer_ffi.workspace = true
|
||||
indexer_service_protocol.workspace = true
|
||||
system_accounts.workspace = true
|
||||
sequencer_stake_core.workspace = true
|
||||
programs.workspace = true
|
||||
test_programs.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
|
||||
logos-blockchain-core.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
logos-blockchain-key-management-system-service.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
|
||||
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
|
||||
//!
|
||||
//! Not production-safe. The inbox allowlist gates the target program, not the
|
||||
//! source emitter, and `extract_emission` recognizes any known emitter, so in a
|
||||
//! zone that allows `wrapped_token` as a target a permissionless `ping_sender`
|
||||
//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe
|
||||
//! needs source verification, where a value-bearing target checks the message
|
||||
//! originated from `bridge_lock`; that is out of scope for the demo.
|
||||
//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no
|
||||
//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a
|
||||
//! source-and-target pair. Nothing forbids writing that route, and the token
|
||||
//! still trusts the table rather than checking its own sources, which is #673.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -163,14 +161,14 @@ fn build_lock_tx(
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let accounts = vec![
|
||||
bridge_lock_core::config_account_id(bridge_lock_id),
|
||||
holder_id,
|
||||
bridge_lock_core::escrow_account_id(bridge_lock_id),
|
||||
outbox_pda(outbox_id, &target_zone, ordinal),
|
||||
outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal),
|
||||
];
|
||||
// One nonce per signature: the holder signs, at its genesis nonce 0.
|
||||
let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock)
|
||||
|
||||
@@ -19,7 +19,10 @@ use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::config::{self, SequencerPartialConfig};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use test_fixtures::{
|
||||
@@ -119,18 +122,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ use integration_tests::{
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use test_fixtures::{
|
||||
@@ -112,18 +115,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,10 @@ use integration_tests::{
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use tokio::test;
|
||||
@@ -190,18 +193,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Gossip end-to-end: two sequencers share one channel with p2p gossip on,
|
||||
//! and B's block production is disabled. A transfer submitted to B's RPC can
|
||||
//! therefore only be included if gossip hands it to A — without gossip it
|
||||
//! would sit in B's mempool forever and the test times out.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use integration_tests::config::{self, SequencerPartialConfig};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use test_fixtures::{
|
||||
MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig,
|
||||
};
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
use tokio::test;
|
||||
|
||||
const PHASE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const TRANSFER_AMOUNT: u128 = 10;
|
||||
|
||||
#[test]
|
||||
async fn gossiped_transaction_reaches_producing_sequencer() -> Result<()> {
|
||||
let bedrock_channel_id = config::bedrock_channel_id();
|
||||
let partial = SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(5),
|
||||
..SequencerPartialConfig::default()
|
||||
};
|
||||
// B never produces: its production timer is longer than the test, so its
|
||||
// local mempool is a dead end and inclusion proves gossip delivery to A.
|
||||
let follower_partial = SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(100_000),
|
||||
..SequencerPartialConfig::default()
|
||||
};
|
||||
|
||||
let ctx = MultiZoneTestContextBuilder::default()
|
||||
.with_zone(
|
||||
ZoneTestContextBuilder::new(MultiNodeTestContextConfig {
|
||||
num_nodes: 2,
|
||||
bedrock_channel: bedrock_channel_id,
|
||||
})
|
||||
.disable_wallet()
|
||||
.disable_indexer()
|
||||
.with_sequencer_partial_config(partial)
|
||||
.with_follower_sequencer_partial_config(follower_partial)
|
||||
.with_gossip()
|
||||
.with_genesis(vec![]),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap();
|
||||
|
||||
let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client);
|
||||
let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client);
|
||||
|
||||
wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?;
|
||||
|
||||
// B follows the chain via L1 even though it never produces.
|
||||
let sync_target = seq_client_a.get_last_block_id().await?;
|
||||
wait_for_height(seq_client_b, sync_target, "B to sync to A's height").await?;
|
||||
|
||||
let accounts = initial_public_user_accounts();
|
||||
let from = accounts[0].account_id;
|
||||
let to = accounts[1].account_id;
|
||||
let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
||||
|
||||
let to_balance_before = seq_client_a.get_account_balance(to).await?;
|
||||
let nonce = seq_client_b.get_accounts_nonces(vec![from]).await?[0];
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
from,
|
||||
nonce.0,
|
||||
to,
|
||||
TRANSFER_AMOUNT,
|
||||
&sign_key,
|
||||
);
|
||||
seq_client_b
|
||||
.send_transaction(tx)
|
||||
.await
|
||||
.context("Failed to submit the transfer to B")?;
|
||||
|
||||
// Only A produces, so the balance changing on A proves the transaction
|
||||
// crossed the gossip mesh from B.
|
||||
wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls the sequencer until its chain height reaches `target`.
|
||||
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
|
||||
log::info!("Waiting for {what:?}, target is {target}");
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_last_block_id().await? >= target {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for {what} (target height {target})"))?
|
||||
}
|
||||
|
||||
/// Polls the sequencer until `account`'s balance reaches `expected`.
|
||||
async fn wait_for_balance(
|
||||
client: &SequencerClient,
|
||||
account: lee::AccountId,
|
||||
expected: u128,
|
||||
) -> Result<()> {
|
||||
log::info!("Waiting for {account} to have {expected} tokens");
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_account_balance(account).await? == expected {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Timed out waiting for the gossiped transfer to be included by A")?
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Two sequencers share one channel: A starts solo as channel admin, live-
|
||||
//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both
|
||||
//! Two sequencers share one channel: both are staked at genesis, so the channel
|
||||
//! is created already accrediting `[A, B]`, B syncs the chain A began, both
|
||||
//! produce on their turns, and A, B and an indexer converge on the same chain.
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -13,7 +13,12 @@ use anyhow::{Context as _, Result, ensure};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
init_logger,
|
||||
};
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
use sequencer_core::{
|
||||
block_publisher::{Ed25519PublicKey, read_channel_state},
|
||||
config::BedrockConfig,
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use test_fixtures::{
|
||||
@@ -25,12 +30,14 @@ use tokio::test;
|
||||
const PHASE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const TRANSFER_AMOUNT: u128 = 10;
|
||||
/// ≈4 turn windows past B's join (5 s blocks, ~20 s turns → ~4 blocks/window).
|
||||
/// ≈4 turn windows, at the `system_accounts` posting timeframe and 5 s blocks.
|
||||
const ROTATION_BLOCKS: u64 = 8;
|
||||
|
||||
#[test]
|
||||
async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
let bedrock_channel_id = config::bedrock_channel_id();
|
||||
init_logger();
|
||||
|
||||
let channel = config::bedrock_channel_id();
|
||||
let partial = SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(5),
|
||||
..SequencerPartialConfig::default()
|
||||
@@ -40,73 +47,70 @@ async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
.with_zone(
|
||||
ZoneTestContextBuilder::new(MultiNodeTestContextConfig {
|
||||
num_nodes: 2,
|
||||
bedrock_channel: bedrock_channel_id,
|
||||
bedrock_channel: channel,
|
||||
})
|
||||
.disable_wallet()
|
||||
.with_sequencer_partial_config(partial)
|
||||
.with_genesis(vec![]),
|
||||
.with_sequencer_partial_config(partial),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap();
|
||||
|
||||
let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client);
|
||||
let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client);
|
||||
.await
|
||||
.context("Failed to build the two-sequencer test context")?;
|
||||
|
||||
let a = ctx
|
||||
.sequencer_client_by_node_ids(channel, 0)
|
||||
.context("Missing sequencer A")?;
|
||||
let b = ctx
|
||||
.sequencer_client_by_node_ids(channel, 1)
|
||||
.context("Missing sequencer B")?;
|
||||
let indexer = ctx.indexer_client();
|
||||
|
||||
wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?;
|
||||
let pub_a = Ed25519Key::from_bytes(&config::SEQUENCER_SIGNING_KEY).public_key();
|
||||
let pub_b = Ed25519Key::from_bytes(&config::sequencer_signing_key_from_seed(1)).public_key();
|
||||
|
||||
log::info!("Passed wait for height A to be at least 2");
|
||||
let bedrock_config = BedrockConfig {
|
||||
channel_id: channel,
|
||||
node_url: config::addr_to_url(config::UrlProtocol::Http, ctx.bedrock_addr())?,
|
||||
funding_key: config::bedrock_funding_key(),
|
||||
auth: None,
|
||||
priority_fee: sequencer_core::config::default_priority_fee(),
|
||||
};
|
||||
|
||||
let height_at_config = seq_client_a.get_last_block_id().await?;
|
||||
wait_for_height(
|
||||
seq_client_a,
|
||||
height_at_config + 1,
|
||||
"A to produce after the roster change",
|
||||
)
|
||||
// Phase 1: both keys accredited from channel creation.
|
||||
let mut want = vec![pub_a.to_bytes(), pub_b.to_bytes()];
|
||||
want.sort_unstable();
|
||||
wait_until("Bedrock to accredit both staked keys", || async {
|
||||
Ok(committee(&bedrock_config).await?.0 == want)
|
||||
})
|
||||
.await?;
|
||||
|
||||
log::info!(
|
||||
"Passed wait for height A to be at least {}",
|
||||
height_at_config + 1
|
||||
);
|
||||
// Phase 2: B follows the chain A began.
|
||||
let join_height = a.get_last_block_id().await?.max(1);
|
||||
wait_for_height(b, join_height, "B to sync to A's height").await?;
|
||||
|
||||
let join_height = seq_client_a.get_last_block_id().await?;
|
||||
wait_for_height(seq_client_b, join_height, "B to sync to A's height at join").await?;
|
||||
|
||||
log::info!("Passed wait for height B to be at least {join_height}");
|
||||
|
||||
// Phase 4: rotation + convergence over ≈4 turn windows.
|
||||
// Phase 3: rotation + convergence; without the turn check, a chain A
|
||||
// produces alone satisfies everything below.
|
||||
let rotation_target = join_height + ROTATION_BLOCKS;
|
||||
wait_for_height(
|
||||
seq_client_a,
|
||||
a,
|
||||
rotation_target,
|
||||
"the chain to advance across turn windows",
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::info!("Passed wait for height A to be at least {rotation_target}");
|
||||
|
||||
wait_for_height(
|
||||
seq_client_b,
|
||||
rotation_target,
|
||||
"B to follow across turn windows",
|
||||
)
|
||||
wait_for_height(b, rotation_target, "B to follow across turn windows").await?;
|
||||
wait_until("the round-robin turn to reach B", || async {
|
||||
Ok(committee(&bedrock_config).await?.1 == Some(pub_b))
|
||||
})
|
||||
.await?;
|
||||
assert_same_chain(seq_client_a, seq_client_b).await?;
|
||||
assert_same_chain(a, b).await?;
|
||||
|
||||
log::info!("Passed wait for height B to be at least {rotation_target}");
|
||||
|
||||
// Phase 5: a tx submitted only to B is included by B and visible on A.
|
||||
// Phase 4: a tx submitted only to B is included by B and visible on A.
|
||||
let accounts = initial_public_user_accounts();
|
||||
let from = accounts[0].account_id;
|
||||
let to = accounts[1].account_id;
|
||||
let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
||||
|
||||
let to_balance_before = seq_client_a.get_account_balance(to).await?;
|
||||
let nonce = seq_client_b.get_accounts_nonces(vec![from]).await?[0];
|
||||
let to_balance_before = a.get_account_balance(to).await?;
|
||||
let nonce = b.get_accounts_nonces(vec![from]).await?[0];
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
from,
|
||||
nonce.0,
|
||||
@@ -114,30 +118,28 @@ async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
TRANSFER_AMOUNT,
|
||||
&sign_key,
|
||||
);
|
||||
seq_client_b
|
||||
.send_transaction(tx)
|
||||
b.send_transaction(tx)
|
||||
.await
|
||||
.context("Failed to submit the transfer to B")?;
|
||||
|
||||
wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?;
|
||||
|
||||
log::info!(
|
||||
"Passed wait for height balance {to} to be {}",
|
||||
to_balance_before + TRANSFER_AMOUNT
|
||||
);
|
||||
|
||||
// Phase 6: the indexer finalizes the same chain, with no stall.
|
||||
wait_for_finalized(indexer, join_height).await?;
|
||||
|
||||
log::info!("Passed indexer to see finalized {join_height}");
|
||||
let expected = to_balance_before + TRANSFER_AMOUNT;
|
||||
wait_until("the cross-sequencer transfer to reach A", || async {
|
||||
Ok(a.get_account_balance(to).await? == expected)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Phase 5: the indexer finalizes the same chain, with no stall.
|
||||
wait_until("the indexer to finalize", || async {
|
||||
Ok(indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= join_height)
|
||||
})
|
||||
.await?;
|
||||
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
|
||||
for id in 1..=finalized {
|
||||
let block_i = indexer
|
||||
.get_block_by_id(id)
|
||||
.await?
|
||||
.with_context(|| format!("Indexer is missing finalized block {id}"))?;
|
||||
let block_a = seq_client_a
|
||||
let block_a = a
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("A is missing block {id}"))?;
|
||||
@@ -156,59 +158,47 @@ async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls `check` until it reports ready, failing with `what` on timeout.
|
||||
async fn wait_until<F, Fut>(what: &str, mut check: F) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<bool>>,
|
||||
{
|
||||
let wait = async {
|
||||
while !check().await? {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for {what}"))?
|
||||
}
|
||||
|
||||
/// Polls the sequencer until its chain height reaches `target`.
|
||||
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
|
||||
log::info!("Waiting for {what:?}, target is {target}");
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_last_block_id().await? >= target {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for {what} (target height {target})"))?
|
||||
wait_until(&format!("{what} (target height {target})"), || async {
|
||||
Ok(client.get_last_block_id().await? >= target)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Polls the sequencer until `account`'s balance reaches `expected`.
|
||||
async fn wait_for_balance(
|
||||
client: &SequencerClient,
|
||||
account: lee::AccountId,
|
||||
expected: u128,
|
||||
) -> Result<()> {
|
||||
log::info!("Waiting for {account} to have {expected} tokens");
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_account_balance(account).await? == expected {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
/// The channel's accredited keys, sorted, plus whose turn the tip was written on.
|
||||
async fn committee(config: &BedrockConfig) -> Result<(Vec<[u8; 32]>, Option<Ed25519PublicKey>)> {
|
||||
let Some(state) = read_channel_state(config).await? else {
|
||||
return Ok((Vec::new(), None));
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Timed out waiting for the cross-sequencer transfer to reach A")?
|
||||
}
|
||||
|
||||
/// Polls the indexer until its finalized height reaches `target`.
|
||||
async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> {
|
||||
log::info!("Waiting for indexer to see target finalized, target is {target}");
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PHASE_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Timed out waiting for the indexer to finalize")?
|
||||
let turn = state
|
||||
.accredited_keys
|
||||
.get(usize::from(state.tip_sequencer))
|
||||
.copied();
|
||||
let mut keys: Vec<_> = state
|
||||
.accredited_keys
|
||||
.iter()
|
||||
.map(Ed25519PublicKey::to_bytes)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
Ok((keys, turn))
|
||||
}
|
||||
|
||||
/// Asserts A and B hold byte-identical block hashes over their common prefix.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! End-to-end demo of the sequencer self-join flow.
|
||||
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "Integration tests live at crate root and don't care about these lints"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use integration_tests::{account_balance, get_account, new_account};
|
||||
use lee::{AccountId, PrivateKey, PublicKey, program::Program};
|
||||
use log::info;
|
||||
use logos_blockchain_core::mantle::ops::channel::Ed25519PublicKey;
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient,
|
||||
adapter::{Node as _, NodeHttpClient},
|
||||
};
|
||||
use sequencer_core::config::GenesisAction;
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use test_fixtures::{
|
||||
MultiZoneTestContextBuilder, TestContext, ZoneTestContextBuilder,
|
||||
config::{
|
||||
MultiNodeTestContextConfig, SequencerPartialConfig, UrlProtocol, addr_to_url,
|
||||
bedrock_channel_id,
|
||||
},
|
||||
setup::{SequencerSetup, sequencer_client},
|
||||
};
|
||||
use tokio::test;
|
||||
use wallet::AccountIdentity;
|
||||
|
||||
/// Comfortably above `system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE`.
|
||||
const FUNDING_BALANCE: u128 = 2 * system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE;
|
||||
|
||||
/// Bedrock signing key of the sequencer that stakes its way in.
|
||||
const JOINER_SIGNING_KEY: [u8; 32] = [0x42; 32];
|
||||
|
||||
/// Short block cadence for the demo.
|
||||
fn fast_blocks() -> SequencerPartialConfig {
|
||||
SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(2),
|
||||
..SequencerPartialConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn stake_transaction_joins_the_bedrock_committee() -> Result<()> {
|
||||
let demo_sequencer_key = Ed25519Key::from_bytes(&JOINER_SIGNING_KEY).public_key();
|
||||
let demo_stake_key = sequencer_stake_core::SequencerKey::new(demo_sequencer_key.to_bytes())
|
||||
.expect("a Bedrock key is a valid Ed25519 public key");
|
||||
|
||||
let funding_private_key = PrivateKey::new_os_random();
|
||||
let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_private_key));
|
||||
|
||||
let mut ctx = MultiZoneTestContextBuilder::default()
|
||||
.with_zone(
|
||||
ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default())
|
||||
.with_sequencer_partial_config(fast_blocks())
|
||||
.with_genesis(vec![GenesisAction::SupplyAccount {
|
||||
account_id: funding_id,
|
||||
balance: FUNDING_BALANCE,
|
||||
}]),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to build test context")?;
|
||||
|
||||
// Import the funding key directly; it's not one of the wallet's default accounts.
|
||||
ctx.wallet_mut()
|
||||
.storage_mut()
|
||||
.key_chain_mut()
|
||||
.add_imported_public_account(funding_private_key);
|
||||
|
||||
// Claim the genesis supply out of its vault.
|
||||
let owner_vault_id = vault_core::compute_vault_account_id(programs::vault().id(), funding_id);
|
||||
let claim_instruction_data = Program::serialize_instruction(vault_core::Instruction::Claim {
|
||||
amount: FUNDING_BALANCE,
|
||||
})
|
||||
.context("Failed to serialize vault Claim instruction")?;
|
||||
ctx.wallet()
|
||||
.send_pub_tx(
|
||||
vec![
|
||||
AccountIdentity::Public(funding_id),
|
||||
AccountIdentity::PublicNoSign(owner_vault_id),
|
||||
],
|
||||
claim_instruction_data,
|
||||
programs::vault().id(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to claim the demo funding account from its genesis vault: {err:?}"
|
||||
)
|
||||
})?;
|
||||
info!("Waiting for the vault-claim transaction's block to land");
|
||||
poll_until("vault claim to land", 30, || async {
|
||||
Ok(account_balance(&ctx, funding_id).await? == FUNDING_BALANCE)
|
||||
})
|
||||
.await?;
|
||||
info!("Funded demo account {funding_id} with {FUNDING_BALANCE} native balance");
|
||||
|
||||
let ownership_id = new_account(&mut ctx, false, None)
|
||||
.await
|
||||
.context("Failed to create a fresh stake ownership account")?;
|
||||
info!("Fresh stake ownership account: {ownership_id}");
|
||||
|
||||
let mover_instruction_data =
|
||||
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
|
||||
amount: FUNDING_BALANCE,
|
||||
})
|
||||
.context("Failed to serialize mover instruction")?;
|
||||
let stake_instruction_data =
|
||||
Program::serialize_instruction(sequencer_stake_core::Instruction::Stake {
|
||||
sequencer_key: demo_stake_key,
|
||||
amount: FUNDING_BALANCE,
|
||||
mover_program_id: programs::authenticated_transfer().id(),
|
||||
mover_instruction_data,
|
||||
})
|
||||
.context("Failed to serialize Stake instruction")?;
|
||||
|
||||
info!(
|
||||
"Submitting Stake transaction for sequencer key {}",
|
||||
hex::encode(demo_sequencer_key.to_bytes())
|
||||
);
|
||||
let config_id = system_accounts::sequencer_stake_config_account_id();
|
||||
ctx.wallet()
|
||||
.send_pub_tx(
|
||||
vec![
|
||||
AccountIdentity::Public(funding_id),
|
||||
AccountIdentity::Public(ownership_id),
|
||||
AccountIdentity::PublicNoSign(config_id),
|
||||
],
|
||||
stake_instruction_data,
|
||||
programs::sequencer_stake().id(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("Failed to submit Stake transaction: {err:?}"))?;
|
||||
|
||||
info!("Waiting for the Stake transaction's block to land");
|
||||
poll_until("stake to take ownership", 30, || async {
|
||||
Ok(
|
||||
get_account(&ctx, ownership_id).await?.program_owner
|
||||
== programs::sequencer_stake().id(),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let ownership_account = get_account(&ctx, ownership_id)
|
||||
.await
|
||||
.context("Failed to read the stake ownership account")?;
|
||||
assert_eq!(
|
||||
ownership_account.program_owner,
|
||||
programs::sequencer_stake().id(),
|
||||
"ownership account should now be owned by sequencer_stake"
|
||||
);
|
||||
assert_eq!(
|
||||
ownership_account.balance, FUNDING_BALANCE,
|
||||
"ownership account should hold the staked balance"
|
||||
);
|
||||
let record = sequencer_stake_core::StakeRecord::from_bytes(ownership_account.data.as_ref())
|
||||
.context("ownership account data did not decode as a StakeRecord")?;
|
||||
assert_eq!(record.sequencer_key, demo_stake_key);
|
||||
info!(
|
||||
"Ownership account confirmed: {} staked for sequencer key {}",
|
||||
ownership_account.balance,
|
||||
hex::encode(record.sequencer_key)
|
||||
);
|
||||
|
||||
let bedrock_url = addr_to_url(UrlProtocol::Http, ctx.bedrock_addr())
|
||||
.context("Failed to build the Bedrock node URL")?;
|
||||
let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url);
|
||||
|
||||
// The committee-config update is a separate tx from the block's own
|
||||
// publish, so it may land a moment later — poll a few times before failing.
|
||||
let mut channel_state = None;
|
||||
for _ in 0..10 {
|
||||
let state = node
|
||||
.channel_state(bedrock_channel_id())
|
||||
.await
|
||||
.context("Failed to read Bedrock channel state")?
|
||||
.context("Bedrock channel does not exist")?;
|
||||
if state
|
||||
.accredited_keys
|
||||
.iter()
|
||||
.any(|key: &Ed25519PublicKey| *key == demo_sequencer_key)
|
||||
{
|
||||
channel_state = Some(state);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
let channel_state = channel_state.context(
|
||||
"demo sequencer key should have been discovered and accredited after the Stake transaction",
|
||||
)?;
|
||||
info!(
|
||||
"Bedrock channel now accredits {} key(s), including the demo sequencer key — self-join complete",
|
||||
channel_state.accredited_keys.len()
|
||||
);
|
||||
|
||||
// Only now start a node behind the key, against a channel that already has a chain.
|
||||
let (joiner, _joiner_home) = SequencerSetup::new(fast_blocks(), ctx.bedrock_addr())
|
||||
.with_channel_id(bedrock_channel_id())
|
||||
.with_bedrock_signing_key(JOINER_SIGNING_KEY)
|
||||
.joining_existing_channel()
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to start the joining sequencer")?;
|
||||
let joiner_client = sequencer_client(joiner.addr())?;
|
||||
|
||||
let joined_at = ctx.sequencer_client().get_last_block_id().await?;
|
||||
poll_until("the joining sequencer to sync the existing chain", 120, {
|
||||
let joiner_client = &joiner_client;
|
||||
move || async move { Ok(joiner_client.get_last_block_id().await? >= joined_at) }
|
||||
})
|
||||
.await?;
|
||||
info!("Joining sequencer synced to block {joined_at}");
|
||||
|
||||
// A tip past `joined_at` under the demo key is a block this node built.
|
||||
poll_until("the joining sequencer to build a block on its turn", 180, {
|
||||
let node = &node;
|
||||
let ctx = &ctx;
|
||||
move || async move {
|
||||
let Some(state) = node.channel_state(bedrock_channel_id()).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let turn = state
|
||||
.accredited_keys
|
||||
.get(usize::from(state.tip_sequencer))
|
||||
.copied();
|
||||
Ok(turn == Some(demo_sequencer_key)
|
||||
&& ctx.sequencer_client().get_last_block_id().await? > joined_at)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
info!("Joining sequencer produced a block on its round-robin turn");
|
||||
|
||||
// Both nodes agree, block for block, over everything they share.
|
||||
let leader_client = ctx.sequencer_client();
|
||||
let common = leader_client
|
||||
.get_last_block_id()
|
||||
.await?
|
||||
.min(joiner_client.get_last_block_id().await?);
|
||||
for id in 1..=common {
|
||||
let leader_block = leader_client
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("Leader is missing block {id}"))?;
|
||||
let joiner_block = joiner_client
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("Joining sequencer is missing block {id}"))?;
|
||||
anyhow::ensure!(
|
||||
leader_block.header.hash == joiner_block.header.hash,
|
||||
"Chain divergence at block {id}: leader {:?} vs joiner {:?}",
|
||||
leader_block.header.hash,
|
||||
joiner_block.header.hash
|
||||
);
|
||||
}
|
||||
info!("Leader and joining sequencer agree on all {common} shared blocks");
|
||||
|
||||
// Exit flow: full UnstakeRequest, wait for the committee removal to land on
|
||||
// Bedrock, then check the sequencer's own FinalizeUnstake releases the stake.
|
||||
//
|
||||
// FinalizeUnstake is unsigned/permissionless, so it can't claim a fresh
|
||||
// destination account (that needs the owner's own signature, same as
|
||||
// authenticated_transfer's Transfer). Reuse funding_id: already
|
||||
// authenticated_transfer-owned, drained to 0 by the Stake above.
|
||||
let destination_id = funding_id;
|
||||
|
||||
let unstake_request_data =
|
||||
Program::serialize_instruction(sequencer_stake_core::Instruction::UnstakeRequest {
|
||||
amount: FUNDING_BALANCE,
|
||||
destination: destination_id,
|
||||
})
|
||||
.context("Failed to serialize UnstakeRequest instruction")?;
|
||||
ctx.wallet()
|
||||
.send_pub_tx(
|
||||
vec![
|
||||
AccountIdentity::Public(ownership_id),
|
||||
AccountIdentity::PublicNoSign(config_id),
|
||||
],
|
||||
unstake_request_data,
|
||||
programs::sequencer_stake().id(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("Failed to submit UnstakeRequest transaction: {err:?}"))?;
|
||||
info!("Submitted full UnstakeRequest for the demo sequencer key");
|
||||
|
||||
// A full drain crosses below the minimum, so discovery removes the key.
|
||||
// Wide window: the removal has to wait for this sequencer to regain its
|
||||
// round-robin turn (posting_timeframe/posting_timeout reclaim), on top of
|
||||
// normal Bedrock confirmation latency.
|
||||
let mut removed = false;
|
||||
for _ in 0..30 {
|
||||
let state = node
|
||||
.channel_state(bedrock_channel_id())
|
||||
.await
|
||||
.context("Failed to read Bedrock channel state")?
|
||||
.context("Bedrock channel does not exist")?;
|
||||
if !state
|
||||
.accredited_keys
|
||||
.iter()
|
||||
.any(|key: &Ed25519PublicKey| *key == demo_sequencer_key)
|
||||
{
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
removed,
|
||||
"demo sequencer key should have been removed after the full UnstakeRequest"
|
||||
);
|
||||
info!("Demo sequencer key removed from the Bedrock committee");
|
||||
|
||||
// Once removed, the sequencer injects FinalizeUnstake itself; this test
|
||||
// never submits one.
|
||||
poll_until(
|
||||
"FinalizeUnstake to drain the ownership account",
|
||||
90,
|
||||
|| async { Ok(get_account(&ctx, ownership_id).await?.balance == 0) },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let drained_ownership_account = get_account(&ctx, ownership_id)
|
||||
.await
|
||||
.context("Failed to read the drained ownership account")?;
|
||||
assert_eq!(
|
||||
drained_ownership_account.balance, 0,
|
||||
"ownership account should be fully drained"
|
||||
);
|
||||
let drained_record =
|
||||
sequencer_stake_core::StakeRecord::from_bytes(drained_ownership_account.data.as_ref())
|
||||
.context("drained ownership account data did not decode as a StakeRecord")?;
|
||||
assert!(
|
||||
drained_record.pending_unstake.is_none(),
|
||||
"pending unstake should be cleared"
|
||||
);
|
||||
|
||||
let destination_balance = account_balance(&ctx, destination_id).await?;
|
||||
assert_eq!(
|
||||
destination_balance, FUNDING_BALANCE,
|
||||
"destination should receive the released stake"
|
||||
);
|
||||
|
||||
// Nothing is at stake for this key any more: a fully drained account has
|
||||
// its config entry removed outright.
|
||||
assert!(
|
||||
stake_entry(&ctx, config_id, demo_stake_key)
|
||||
.await?
|
||||
.is_none(),
|
||||
"the config entry should be gone once the stake is fully released"
|
||||
);
|
||||
info!(
|
||||
"FinalizeUnstake auto-included: {FUNDING_BALANCE} released to {destination_id}, nothing left at stake"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `sequencer_stake` config entry for `sequencer_key`, or `None` if the key
|
||||
/// has nothing at stake.
|
||||
async fn stake_entry(
|
||||
ctx: &TestContext,
|
||||
config_id: AccountId,
|
||||
sequencer_key: sequencer_stake_core::SequencerKey,
|
||||
) -> Result<Option<sequencer_stake_core::SequencerEntry>> {
|
||||
let config_account = get_account(ctx, config_id)
|
||||
.await
|
||||
.context("Failed to read the sequencer_stake config account")?;
|
||||
let config =
|
||||
sequencer_stake_core::SequencerStakeConfig::from_bytes(config_account.data.as_ref())
|
||||
.context("config account data did not decode as a SequencerStakeConfig")?;
|
||||
Ok(config.entries.get(&sequencer_key).copied())
|
||||
}
|
||||
|
||||
/// Polls `check` once a second, up to `max_attempts` times, replacing fixed
|
||||
/// block-wait sleeps: the accelerated devnet crosses an epoch boundary every
|
||||
/// ~100 slots, so every second of wall-clock spent sleeping increases the
|
||||
/// chance of straddling one.
|
||||
async fn poll_until<F, Fut>(what: &str, max_attempts: u32, mut check: F) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
for _ in 0..max_attempts {
|
||||
if check().await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("timed out waiting for {what}")
|
||||
}
|
||||
@@ -5,11 +5,14 @@ use k256::ecdsa::signature::hazmat::PrehashVerifier as _;
|
||||
pub use private_key::PrivateKey;
|
||||
pub use public_key::PublicKey;
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
|
||||
mod private_key;
|
||||
mod public_key;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
#[derive(
|
||||
Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, SerializeDisplay, DeserializeFromStr,
|
||||
)]
|
||||
pub struct Signature {
|
||||
pub value: [u8; 64],
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ impl From<&Block> for BlockMeta {
|
||||
pub struct OwnHasher;
|
||||
|
||||
impl OwnHasher {
|
||||
fn hash(data: &[u8]) -> HashType {
|
||||
#[must_use]
|
||||
pub fn hash(data: &[u8]) -> HashType {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
hasher.update(data);
|
||||
|
||||
@@ -79,4 +79,4 @@
|
||||
37,
|
||||
37
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+115
-31
@@ -9,12 +9,10 @@
|
||||
//! own block-reading, emission-extraction, delivery-building, and trust model; a
|
||||
//! shared trait is best lifted from that first real adapter, not from this one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
|
||||
inbox_seen_shard_account_id,
|
||||
inbox_seen_shard_account_id, inbox_source_marker_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountId, Balance},
|
||||
@@ -64,13 +62,18 @@ pub fn is_sequencer_only_program(program_id: ProgramId) -> bool {
|
||||
#[must_use]
|
||||
pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option<Emission> {
|
||||
if program_id == programs::ping_sender().id() {
|
||||
let ping_core::SenderInstruction::Send {
|
||||
// Not every transaction to an emitter emits: `InitConfig` is one of its
|
||||
// instructions, so a non-`Send` decode is an ordinary non-emitting tx.
|
||||
let Ok(ping_core::SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
}) = risc0_zkvm::serde::from_slice(instruction_data)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
@@ -78,13 +81,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti
|
||||
payload,
|
||||
})
|
||||
} else if program_id == programs::bridge_lock().id() {
|
||||
let bridge_lock_core::Instruction::Lock {
|
||||
let Ok(bridge_lock_core::Instruction::Lock {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
}) = risc0_zkvm::serde::from_slice(instruction_data)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
@@ -103,13 +109,21 @@ fn build_inbox_dispatch_tx(
|
||||
msg: &CrossZoneMessage,
|
||||
target_account_ids: Vec<AccountId>,
|
||||
) -> lee::PublicTransaction {
|
||||
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2));
|
||||
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(3));
|
||||
account_ids.push(inbox_config_account_id(inbox_id));
|
||||
account_ids.push(inbox_seen_shard_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_block_id,
|
||||
));
|
||||
// Declared here rather than derived by the guest, since a guest cannot
|
||||
// conjure an account. Both the watcher and the verifier build it through this
|
||||
// one function, so they cannot disagree about the source a target will see.
|
||||
account_ids.push(inbox_source_marker_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_program_id,
|
||||
));
|
||||
account_ids.extend(target_account_ids);
|
||||
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
@@ -156,33 +170,18 @@ pub fn build_dispatch_from_emission(
|
||||
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
|
||||
}
|
||||
|
||||
/// The inbox config a zone derives from its cross-zone config: the per-peer
|
||||
/// delivery routes plus its own zone id.
|
||||
fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig {
|
||||
let mut allowed_routes = BTreeMap::new();
|
||||
for peer in &cross_zone.peers {
|
||||
allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone());
|
||||
}
|
||||
InboxConfig {
|
||||
self_zone,
|
||||
allowed_routes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The genesis transaction that initializes this zone's inbox config PDA.
|
||||
///
|
||||
/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the
|
||||
/// same account on every node, keeping their state consistent.
|
||||
/// The operator's per-peer routes no longer live here. They are fanned out into
|
||||
/// each target program's own config, so all the inbox keeps is its zone id.
|
||||
/// Replaying this seeds the same account on every node.
|
||||
#[must_use]
|
||||
pub fn build_inbox_init_config_tx(
|
||||
self_zone: ZoneId,
|
||||
cross_zone: &CrossZoneConfig,
|
||||
) -> lee::PublicTransaction {
|
||||
pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
genesis_public_tx(
|
||||
inbox_id,
|
||||
vec![inbox_config_account_id(inbox_id)],
|
||||
Instruction::InitConfig(inbox_config(self_zone, cross_zone)),
|
||||
Instruction::InitConfig(InboxConfig { self_zone }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,19 +201,104 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId,
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the cross-zone inbox as the wrapped-token
|
||||
/// minter, without importing the inbox id into the guest.
|
||||
/// minter and names the peer sources it may mint for, without importing either id
|
||||
/// into the guest.
|
||||
///
|
||||
/// The sources are the operator's own peer routes aimed at this token, moved from
|
||||
/// the inbox's allowlist to the token's own config: the same information, enforced
|
||||
/// by the program that owns the value. A zone with no peers gets an empty list,
|
||||
/// which authorizes nothing, and the config is still seeded so its PDA cannot be
|
||||
/// claimed by a first initializer.
|
||||
#[must_use]
|
||||
pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction {
|
||||
pub fn build_wrapped_token_init_config_tx(
|
||||
cross_zone: Option<&CrossZoneConfig>,
|
||||
) -> lee::PublicTransaction {
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
let sources = cross_zone
|
||||
.map(|cross_zone| {
|
||||
cross_zone
|
||||
.peers
|
||||
.iter()
|
||||
.flat_map(|peer| {
|
||||
peer.allowed_routes
|
||||
.iter()
|
||||
.filter(|route| route.target_program_id == wrapped_token_id)
|
||||
.map(|route| (peer.channel_id, route.src_program_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
genesis_public_tx(
|
||||
wrapped_token_id,
|
||||
vec![wrapped_token_core::config_account_id(wrapped_token_id)],
|
||||
wrapped_token_core::Instruction::InitConfig {
|
||||
wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig {
|
||||
minter: programs::cross_zone_inbox().id(),
|
||||
sources,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the outbox `ping_sender` chains into,
|
||||
/// without importing the outbox id into the guest.
|
||||
#[must_use]
|
||||
pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction {
|
||||
let ping_sender_id = programs::ping_sender().id();
|
||||
genesis_public_tx(
|
||||
ping_sender_id,
|
||||
vec![ping_core::sender_config_account_id(ping_sender_id)],
|
||||
ping_core::SenderInstruction::InitConfig {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the outbox `bridge_lock` chains into and the
|
||||
/// wrapped token it mints, without importing either id into the guest.
|
||||
#[must_use]
|
||||
pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction {
|
||||
let bridge_lock_id = programs::bridge_lock().id();
|
||||
genesis_public_tx(
|
||||
bridge_lock_id,
|
||||
vec![bridge_lock_core::config_account_id(bridge_lock_id)],
|
||||
bridge_lock_core::Instruction::InitConfig {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_program_id: programs::wrapped_token().id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction naming the peer sources `ping_receiver` accepts a
|
||||
/// delivery from, fanned out of the operator's routes exactly as the wrapped
|
||||
/// token's is.
|
||||
#[must_use]
|
||||
pub fn build_ping_receiver_init_config_tx(
|
||||
cross_zone: Option<&CrossZoneConfig>,
|
||||
) -> lee::PublicTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let sources = cross_zone
|
||||
.map(|cross_zone| {
|
||||
cross_zone
|
||||
.peers
|
||||
.iter()
|
||||
.flat_map(|peer| {
|
||||
peer.allowed_routes
|
||||
.iter()
|
||||
.filter(|route| route.target_program_id == receiver_id)
|
||||
.map(|route| (peer.channel_id, route.src_program_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
genesis_public_tx(
|
||||
receiver_id,
|
||||
vec![ping_core::receiver_config_account_id(receiver_id)],
|
||||
ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig {
|
||||
deliverer: programs::cross_zone_inbox().id(),
|
||||
sources,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
|
||||
/// on `program_id` over `account_ids`.
|
||||
fn genesis_public_tx<I: Serialize>(
|
||||
|
||||
@@ -718,7 +718,7 @@ mod tests {
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::ZoneBlock;
|
||||
use ping_core::{SenderInstruction, ping_record_pda};
|
||||
use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -746,10 +746,12 @@ mod tests {
|
||||
fn emission(payload: &[u8]) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_zone: SELF_ZONE,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload: payload.to_vec(),
|
||||
ordinal: 0,
|
||||
};
|
||||
@@ -846,7 +848,10 @@ mod tests {
|
||||
src_program_id: programs::ping_sender().id(),
|
||||
},
|
||||
receiver_id,
|
||||
&[ping_record_pda(receiver_id).into_value()],
|
||||
&[
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload.to_vec(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -84,6 +84,11 @@ name = "wrapped_token"
|
||||
path = "wrapped_token/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "sequencer_stake"
|
||||
path = "sequencer_stake/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[features]
|
||||
# TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved.
|
||||
# default = ["artifacts"]
|
||||
@@ -113,6 +118,7 @@ programs = [
|
||||
"dep:bridge_lock_core",
|
||||
"dep:wrapped_token_core",
|
||||
"dep:ping_core",
|
||||
"dep:sequencer_stake_core",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
@@ -132,6 +138,7 @@ cross_zone_outbox_core = { workspace = true, optional = true }
|
||||
bridge_lock_core = { workspace = true, optional = true }
|
||||
wrapped_token_core = { workspace = true, optional = true }
|
||||
ping_core = { workspace = true, optional = true }
|
||||
sequencer_stake_core = { workspace = true, optional = true }
|
||||
|
||||
amm_program = { path = "amm", optional = true }
|
||||
associated_token_account_program = { path = "associated_token_account", optional = true }
|
||||
|
||||
@@ -10,3 +10,6 @@ workspace = true
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -9,23 +9,41 @@ use lee_core::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/";
|
||||
|
||||
/// Variants are append-only. risc0 serde encodes the variant as a bare leading
|
||||
/// tag word, so inserting one ahead of `Lock` shifts every existing encoding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Lock `amount` of the holder's balance and emit a cross-zone message
|
||||
/// minting the wrapped token on `target_zone`. The emission fields mirror
|
||||
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
|
||||
/// minting the wrapped token on `target_zone`.
|
||||
///
|
||||
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
|
||||
/// `target_program_id` and `target_accounts` are supplied though the guest
|
||||
/// accepts one value for each: `cross_zone::extract_emission` reads them off
|
||||
/// the transaction, decoding every emitter through one shape.
|
||||
///
|
||||
/// `target_zone` is the caller's, so a lock to a zone that will not route it
|
||||
/// escrows and never mints. TODO: bound it source-side.
|
||||
///
|
||||
/// Required accounts (4): config PDA, holder holding (authorized), escrow
|
||||
/// PDA, outbox PDA.
|
||||
Lock {
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
outbox_program_id: ProgramId,
|
||||
ordinal: u32,
|
||||
},
|
||||
/// Pins the outbox program and the mint target, written once into a default
|
||||
/// config PDA at genesis. A re-run naming different programs is refused; an
|
||||
/// identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the config PDA.
|
||||
InitConfig {
|
||||
outbox_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
},
|
||||
}
|
||||
|
||||
/// PDA accumulating all locked balance on this zone.
|
||||
@@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed {
|
||||
PdaSeed::new(ESCROW_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// PDA holding the outbox program id and the mint target, seeded at genesis so
|
||||
/// the guest can pin both without importing their image ids.
|
||||
#[must_use]
|
||||
pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&bridge_lock_id, &config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn config_seed() -> PdaSeed {
|
||||
PdaSeed::new(CONFIG_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// Encodes the pinned outbox and mint target for the config account's data.
|
||||
#[must_use]
|
||||
pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] {
|
||||
let mut bytes = [0_u8; 64];
|
||||
for (word, chunk) in outbox_program_id
|
||||
.iter()
|
||||
.chain(target_program_id.iter())
|
||||
.zip(bytes.chunks_exact_mut(4))
|
||||
{
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the pinned outbox and mint target from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> {
|
||||
if data.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
let mut ids = [0_u32; 16];
|
||||
for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
let (outbox, target) = ids.split_at(8);
|
||||
Some((
|
||||
outbox.try_into().unwrap_or_else(|_| unreachable!()),
|
||||
target.try_into().unwrap_or_else(|_| unreachable!()),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -48,4 +109,31 @@ mod tests {
|
||||
let id: ProgramId = [4; 8];
|
||||
assert_eq!(escrow_account_id(id), escrow_account_id(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_ids_round_trip() {
|
||||
let outbox: ProgramId = [3; 8];
|
||||
let target: ProgramId = [5; 8];
|
||||
assert_eq!(
|
||||
read_config(&config_bytes(outbox, target)),
|
||||
Some((outbox, target))
|
||||
);
|
||||
}
|
||||
|
||||
/// `extract_emission` decodes `Lock` off peer transactions, so its tag word is
|
||||
/// wire format: a variant inserted ahead of it would silently shift every
|
||||
/// existing encoding.
|
||||
#[test]
|
||||
fn lock_is_the_first_variant() {
|
||||
let lock = Instruction::Lock {
|
||||
amount: 1,
|
||||
target_zone: [7; 32],
|
||||
target_program_id: [1; 8],
|
||||
target_accounts: vec![],
|
||||
payload: vec![],
|
||||
ordinal: 0,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed};
|
||||
use bridge_lock_core::{
|
||||
Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed,
|
||||
read_config,
|
||||
};
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
|
||||
read_lee_inputs,
|
||||
},
|
||||
};
|
||||
use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT};
|
||||
|
||||
@@ -22,20 +28,74 @@ fn main() {
|
||||
"bridge_lock is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let Instruction::Lock {
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
match instruction {
|
||||
Instruction::Lock {
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} => lock(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
),
|
||||
Instruction::InitConfig {
|
||||
outbox_program_id,
|
||||
target_program_id,
|
||||
} => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
outbox_program_id,
|
||||
target_program_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the emission fields are passed through verbatim"
|
||||
)]
|
||||
fn lock(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
) {
|
||||
// pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA].
|
||||
let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states)
|
||||
.expect("Lock requires config, holder, escrow, and outbox accounts");
|
||||
|
||||
// Pinned rather than caller-named: chaining elsewhere would debit the escrow
|
||||
// and leave no record of what it was for.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"first account must be the bridge-lock config PDA"
|
||||
);
|
||||
let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an outbox and a mint target");
|
||||
|
||||
// Value conservation: the forwarded payload must mint exactly what is locked.
|
||||
let WrappedInstruction::Mint {
|
||||
recipient,
|
||||
amount: mint_amount,
|
||||
..
|
||||
} = decode_mint(&payload)
|
||||
else {
|
||||
panic!("bridge_lock payload must be a wrapped-token mint");
|
||||
@@ -44,18 +104,27 @@ fn main() {
|
||||
mint_amount, amount,
|
||||
"locked amount must equal the wrapped mint amount"
|
||||
);
|
||||
// Before the debit, not on the destination: nothing releases an escrow, so
|
||||
// an amount the destination will not mint has to fail in the submitter's own
|
||||
// transaction.
|
||||
|
||||
// All before the debit: nothing releases an escrow, so a message the
|
||||
// destination refuses is a burn. `target_zone` is not checkable here, so a
|
||||
// lock aimed at a zone that will not route it still burns.
|
||||
assert_eq!(
|
||||
target_program_id, pinned_target,
|
||||
"bridge_lock only mints through the wrapped token it is pinned to"
|
||||
);
|
||||
assert_eq!(
|
||||
target_accounts,
|
||||
vec![
|
||||
wrapped_token_core::config_account_id(pinned_target).into_value(),
|
||||
wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(),
|
||||
],
|
||||
"target accounts must be the mint's config and the recipient's holding"
|
||||
);
|
||||
assert!(
|
||||
amount <= MAX_MINT_AMOUNT,
|
||||
"locked amount exceeds what the wrapped token will mint"
|
||||
);
|
||||
|
||||
// pre_states: [holder holding (authorized), escrow PDA, outbox PDA].
|
||||
let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Lock requires holder, escrow, and outbox accounts");
|
||||
|
||||
assert!(holder.is_authorized, "holder must authorize the lock");
|
||||
// The holder holding is bridge_lock-owned, so bridge_lock may debit its native
|
||||
// balance directly (state-machine rule 5). This also pins the transfer to a
|
||||
@@ -68,7 +137,7 @@ fn main() {
|
||||
assert_eq!(
|
||||
escrow.account_id,
|
||||
escrow_account_id(self_program_id),
|
||||
"second account must be the escrow PDA"
|
||||
"third account must be the escrow PDA"
|
||||
);
|
||||
|
||||
// Move the real native balance holder -> escrow. bridge_lock owns both accounts,
|
||||
@@ -106,12 +175,15 @@ fn main() {
|
||||
},
|
||||
);
|
||||
|
||||
let config_post = AccountPostState::new(config.account.clone());
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![holder, escrow, outbox.clone()],
|
||||
vec![config, holder, escrow, outbox.clone()],
|
||||
vec![
|
||||
config_post,
|
||||
holder_post,
|
||||
escrow_post,
|
||||
AccountPostState::new(outbox.account),
|
||||
@@ -121,6 +193,58 @@ fn main() {
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the outbox program and the mint target into the config PDA exactly once
|
||||
/// at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
outbox_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
) {
|
||||
// pre_states: [config PDA].
|
||||
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("InitConfig requires the config account");
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"account must be the bridge-lock config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already pin exactly these programs, since
|
||||
// genesis is replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop a later self-owned rewrite.
|
||||
if config.account != Account::default() {
|
||||
assert_eq!(
|
||||
config.account.program_owner, self_program_id,
|
||||
"bridge-lock config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
config_bytes(outbox_program_id, target_program_id).to_vec(),
|
||||
"bridge-lock config already pins a different outbox or mint target"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = config_bytes(outbox_program_id, target_program_id)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("pinned ids fit in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the
|
||||
/// wrapped-token instruction it carries.
|
||||
fn decode_mint(payload: &[u8]) -> WrappedInstruction {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
@@ -13,6 +13,7 @@ const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
|
||||
/// indistinguishable under one domain. Belt and braces, since the image id
|
||||
/// already relocates every PDA in this crate whenever the crate changes.
|
||||
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/";
|
||||
const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
@@ -84,32 +85,20 @@ pub struct CrossZoneMessage {
|
||||
pub l1_inclusion_witness: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Per-peer delivery routes, plus this inbox's own zone id.
|
||||
/// This inbox's own zone id.
|
||||
///
|
||||
/// It no longer decides who may deliver what. Each target program authorizes its
|
||||
/// own sources against the marker the inbox passes, so the only thing the inbox
|
||||
/// still needs to know is which zone it is, to refuse a message addressed to
|
||||
/// itself.
|
||||
#[derive(
|
||||
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
|
||||
)]
|
||||
pub struct InboxConfig {
|
||||
pub self_zone: ZoneId,
|
||||
/// Which deliveries each peer may make. A peer absent from this map may
|
||||
/// deliver nothing.
|
||||
pub allowed_routes: BTreeMap<ZoneId, Vec<CrossZoneRoute>>,
|
||||
}
|
||||
|
||||
impl InboxConfig {
|
||||
/// Whether `src_zone` may deliver from `src_program_id` to
|
||||
/// `target_program_id`. A peer with no routes may deliver nothing.
|
||||
#[must_use]
|
||||
pub fn permits(
|
||||
&self,
|
||||
src_zone: &ZoneId,
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
) -> bool {
|
||||
self.allowed_routes
|
||||
.get(src_zone)
|
||||
.is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id))
|
||||
}
|
||||
|
||||
/// Borsh-encoded form stored in the inbox config account.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
@@ -204,25 +193,6 @@ pub enum Instruction {
|
||||
InitConfig(InboxConfig),
|
||||
}
|
||||
|
||||
/// Whether `routes` authorize a delivery from `src_program_id` to
|
||||
/// `target_program_id`.
|
||||
///
|
||||
/// The one place the rule lives. The inbox guest decides with it and the
|
||||
/// sequencer's watcher drops unroutable messages with it, and those two must
|
||||
/// agree: a watcher stricter than the guest loses messages silently, and one
|
||||
/// looser records deliveries the guest will refuse, which production then feeds
|
||||
/// in and gives up on.
|
||||
#[must_use]
|
||||
pub fn routes_permit(
|
||||
routes: &[CrossZoneRoute],
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
) -> bool {
|
||||
routes.iter().any(|route| {
|
||||
route.src_program_id == src_program_id && route.target_program_id == target_program_id
|
||||
})
|
||||
}
|
||||
|
||||
/// Content-addressed replay key for a delivered message.
|
||||
///
|
||||
/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator.
|
||||
@@ -286,6 +256,48 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// The account naming who sent a delivery, which the inbox passes at position 0
|
||||
/// of the chained call so the target can authenticate its own sources.
|
||||
///
|
||||
/// Nothing writes or claims it, so the state machine's uninitialized-account rule
|
||||
/// skips it for being unchanged rather than for being default: anyone may send it
|
||||
/// balance, and the inbox and the targets all round-trip it untouched.
|
||||
///
|
||||
/// The address is derivable by anyone, so it is not a secret and not a
|
||||
/// capability. What makes it mean something is that a target checks it only after
|
||||
/// pinning its caller to the inbox, and only the inbox can be that caller.
|
||||
#[must_use]
|
||||
pub fn inbox_source_marker_account_id(
|
||||
inbox_id: ProgramId,
|
||||
src_zone: &ZoneId,
|
||||
src_program_id: ProgramId,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(
|
||||
&inbox_id,
|
||||
&inbox_source_marker_seed(src_zone, src_program_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Seed of the source marker, exposed so a target can re-derive the address of
|
||||
/// the one source it accepts and compare.
|
||||
#[must_use]
|
||||
pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 96];
|
||||
bytes[..32].copy_from_slice(&SOURCE_MARKER_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(src_zone);
|
||||
for (word, chunk) in src_program_id.iter().zip(bytes[64..].chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lee_core::account::data::DATA_MAX_LENGTH;
|
||||
@@ -296,63 +308,6 @@ mod tests {
|
||||
[b; 32]
|
||||
}
|
||||
|
||||
fn program(n: u32) -> ProgramId {
|
||||
[n; 8]
|
||||
}
|
||||
|
||||
/// The route is the pair. Two entries that are each reasonable on their own,
|
||||
/// a lock program that may mint and a ping emitter that may reach a
|
||||
/// receiver, must not compose into the lock program's target being
|
||||
/// reachable from the ping emitter: that emitter lets its caller choose the
|
||||
/// target, so it would mint with nothing locked behind it.
|
||||
#[test]
|
||||
fn a_route_authorizes_one_pair_and_does_not_compose() {
|
||||
let lock = program(1);
|
||||
let wrapped_token = program(2);
|
||||
let ping_sender = program(3);
|
||||
let ping_receiver = program(4);
|
||||
|
||||
let mut allowed_routes = BTreeMap::new();
|
||||
allowed_routes.insert(
|
||||
zone(9),
|
||||
vec![
|
||||
CrossZoneRoute {
|
||||
src_program_id: lock,
|
||||
target_program_id: wrapped_token,
|
||||
},
|
||||
CrossZoneRoute {
|
||||
src_program_id: ping_sender,
|
||||
target_program_id: ping_receiver,
|
||||
},
|
||||
],
|
||||
);
|
||||
let config = InboxConfig {
|
||||
self_zone: zone(1),
|
||||
allowed_routes,
|
||||
};
|
||||
|
||||
assert!(config.permits(&zone(9), lock, wrapped_token));
|
||||
assert!(config.permits(&zone(9), ping_sender, ping_receiver));
|
||||
|
||||
assert!(
|
||||
!config.permits(&zone(9), ping_sender, wrapped_token),
|
||||
"an emitter whose caller picks the target must not reach the bridge's target"
|
||||
);
|
||||
assert!(
|
||||
!config.permits(&zone(9), lock, ping_receiver),
|
||||
"a route grants its own target, not every target the peer has"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_with_no_routes_may_deliver_nothing() {
|
||||
let config = InboxConfig {
|
||||
self_zone: zone(1),
|
||||
allowed_routes: BTreeMap::new(),
|
||||
};
|
||||
assert!(!config.permits(&zone(9), program(1), program(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_key_is_stable_and_content_addressed() {
|
||||
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id,
|
||||
inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed,
|
||||
inbox_source_marker_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
@@ -61,10 +62,11 @@ fn dispatch(
|
||||
"l1_inclusion_witness must be None in v1"
|
||||
);
|
||||
|
||||
// pre_states layout: [config, seen_shard, then the target accounts].
|
||||
// pre_states layout: [config, seen_shard, source marker, then the target accounts].
|
||||
let mut accounts = pre_states.into_iter();
|
||||
let config = accounts.next().expect("config account required");
|
||||
let seen = accounts.next().expect("seen shard account required");
|
||||
let marker = accounts.next().expect("source marker account required");
|
||||
let target_accounts: Vec<AccountWithMetadata> = accounts.collect();
|
||||
|
||||
assert_eq!(
|
||||
@@ -77,6 +79,14 @@ fn dispatch(
|
||||
inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id),
|
||||
"Second account must be the seen-shard PDA"
|
||||
);
|
||||
// The one value the chained call carries about where the message came from.
|
||||
// The target re-derives this address from the source it accepts, so binding it
|
||||
// here is what makes a target's own check meaningful.
|
||||
assert_eq!(
|
||||
marker.account_id,
|
||||
inbox_source_marker_account_id(self_program_id, &msg.src_zone, msg.src_program_id),
|
||||
"Third account must be the source marker PDA for this message"
|
||||
);
|
||||
|
||||
let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("inbox config decodes");
|
||||
@@ -85,15 +95,6 @@ fn dispatch(
|
||||
msg.src_zone != cfg.self_zone,
|
||||
"Source zone must not be this zone"
|
||||
);
|
||||
// Checked as a pair. The emitting program is as much a part of the
|
||||
// authorization as the target: an emitter whose caller chooses the target
|
||||
// reaches everything the peer may reach, so a target allowlist on its own
|
||||
// lets any such emitter stand in for every other one.
|
||||
assert!(
|
||||
cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id),
|
||||
"No route from this source program to this target program for this peer"
|
||||
);
|
||||
|
||||
let mut shard =
|
||||
SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes");
|
||||
|
||||
@@ -137,19 +138,23 @@ fn dispatch(
|
||||
.map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!())))
|
||||
.collect();
|
||||
|
||||
// The marker leads, so a target reads its source at a fixed position
|
||||
// without knowing anything about the accounts that follow it.
|
||||
let mut call_pre_states = vec![marker.clone()];
|
||||
call_pre_states.extend(target_accounts.clone());
|
||||
let call = ChainedCall {
|
||||
program_id: msg.target_program_id,
|
||||
pre_states: target_accounts.clone(),
|
||||
pre_states: call_pre_states,
|
||||
instruction_data,
|
||||
pda_seeds: vec![],
|
||||
};
|
||||
(seen_post, vec![call])
|
||||
};
|
||||
|
||||
let mut post_states = vec![unchanged(&config), seen_post];
|
||||
let mut post_states = vec![unchanged(&config), seen_post, unchanged(&marker)];
|
||||
post_states.extend(target_accounts.iter().map(unchanged));
|
||||
|
||||
let mut output_pre_states = vec![config, seen];
|
||||
let mut output_pre_states = vec![config, seen, marker];
|
||||
output_pre_states.extend(target_accounts);
|
||||
|
||||
ProgramOutput::new(
|
||||
@@ -163,8 +168,7 @@ fn dispatch(
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the inbox config (peer + target allowlists) into the config PDA exactly
|
||||
/// once at genesis.
|
||||
/// Writes the inbox config into the config PDA exactly once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
@@ -181,9 +185,8 @@ fn init_config(
|
||||
"account must be the inbox config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned config must already hold exactly these allowlists (the
|
||||
// genesis block is replayed onto seeded state during multi-sequencer
|
||||
// reconstruction), otherwise reject a post-genesis attempt to change them.
|
||||
// init; an already-owned config must already hold exactly this, since genesis
|
||||
// is replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop the owning program from
|
||||
// rewriting its own config data on a later call.
|
||||
if config_meta.account != Account::default() {
|
||||
@@ -194,7 +197,7 @@ fn init_config(
|
||||
assert_eq!(
|
||||
config_meta.account.data.clone().into_inner(),
|
||||
config.to_bytes(),
|
||||
"inbox config already initialized with different allowlists"
|
||||
"inbox config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ use lee_core::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
|
||||
/// Versions the seed layout: bump on any change to its field list or offsets,
|
||||
/// so slots under an old layout can never be re-derived. Redundant with the
|
||||
/// image id, which relocates every PDA in this crate whenever the crate changes,
|
||||
/// but the two version different things.
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00001/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
@@ -14,6 +18,10 @@ pub type ZoneId = [u8; 32];
|
||||
pub enum Instruction {
|
||||
/// Records an outbound cross-zone message as a write to a self-owned PDA.
|
||||
///
|
||||
/// The slot is written once: a second `Emit` at the same
|
||||
/// `(emitter, target_zone, ordinal)` fails the transaction rather than
|
||||
/// replacing the record.
|
||||
///
|
||||
/// Required accounts (1):
|
||||
/// - Outbox PDA account
|
||||
Emit {
|
||||
@@ -28,12 +36,21 @@ pub enum Instruction {
|
||||
},
|
||||
}
|
||||
|
||||
/// The message as stored in an outbox PDA. The destination zone's watcher reads
|
||||
/// this from the inscribed block; the source coordinates are filled by the
|
||||
/// watcher, not stored here.
|
||||
/// One emitted message, as stored in its outbox PDA.
|
||||
///
|
||||
/// Carries the slot it occupies as well as the message, so a reader holding the
|
||||
/// bytes knows who wrote them and where without inverting the address, which is
|
||||
/// a hash. The source zone and block coordinates are filled by the destination's
|
||||
/// watcher and are not stored here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct OutboxRecord {
|
||||
/// The program that called `Emit`, which is the immediate chained caller.
|
||||
/// Cross-zone discovery names the top-level program instead, so joining a
|
||||
/// record against a delivery is only sound while every emitter refuses to be
|
||||
/// called by another program.
|
||||
pub emitter: ProgramId,
|
||||
pub target_zone: ZoneId,
|
||||
pub ordinal: u32,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
@@ -52,22 +69,34 @@ impl OutboxRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding one emitted message, keyed by destination zone and a per-zone
|
||||
/// ordinal.
|
||||
/// PDA holding one emitted message, keyed by the emitting program, the
|
||||
/// destination zone, and a per-emitter per-zone ordinal.
|
||||
///
|
||||
/// `emitter` is the program that called `Emit`, which the guest takes from
|
||||
/// `caller_program_id` rather than from the instruction. Without it in the
|
||||
/// address two programs share a slot and one overwrites the other.
|
||||
#[must_use]
|
||||
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
|
||||
pub fn outbox_pda(
|
||||
outbox_id: ProgramId,
|
||||
emitter: ProgramId,
|
||||
target_zone: &ZoneId,
|
||||
ordinal: u32,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(emitter, target_zone, ordinal))
|
||||
}
|
||||
|
||||
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
pub fn outbox_pda_seed(emitter: ProgramId, target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 68];
|
||||
let mut bytes = [0_u8; 100];
|
||||
bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(target_zone);
|
||||
bytes[64..].copy_from_slice(&ordinal.to_le_bytes());
|
||||
for (word, chunk) in emitter.iter().zip(bytes[32..64].chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes[64..96].copy_from_slice(target_zone);
|
||||
bytes[96..].copy_from_slice(&ordinal.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
@@ -80,14 +109,55 @@ pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OUTBOX: ProgramId = [3; 8];
|
||||
const EMITTER: ProgramId = [4; 8];
|
||||
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_zone_and_ordinal() {
|
||||
let id: ProgramId = [3; 8];
|
||||
let zone_a = [1; 32];
|
||||
let zone_b = [2; 32];
|
||||
|
||||
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
|
||||
assert_eq!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0)
|
||||
);
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 1)
|
||||
);
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_b, 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Two programs emitting to the same zone and ordinal must not share a slot,
|
||||
/// or the second silently overwrites the first.
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_emitter() {
|
||||
let zone = [1; 32];
|
||||
let other: ProgramId = [5; 8];
|
||||
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone, 0),
|
||||
outbox_pda(OUTBOX, other, &zone, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_record_round_trips() {
|
||||
let record = OutboxRecord {
|
||||
emitter: EMITTER,
|
||||
target_zone: [1; 32],
|
||||
ordinal: 7,
|
||||
target_program_id: [6; 8],
|
||||
target_accounts: vec![[9; 32]],
|
||||
payload: b"payload".to_vec(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
OutboxRecord::from_bytes(&record.to_bytes()).expect("record decodes"),
|
||||
record
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed};
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
|
||||
@@ -15,10 +15,14 @@ fn main() {
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"Outbox is only callable through a chain call from a user program"
|
||||
);
|
||||
// The emitter, and the only identity here the state machine verifies: it
|
||||
// checks a guest's claimed caller against the real one. Note this is the
|
||||
// immediate chained caller, not the top-level program that cross-zone
|
||||
// discovery names; the two coincide only while every emitter refuses to be
|
||||
// called by another program, which both do today.
|
||||
let Some(emitter) = caller_program_id else {
|
||||
panic!("Outbox is only callable through a chain call from a user program");
|
||||
};
|
||||
|
||||
let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction {
|
||||
Instruction::Emit {
|
||||
@@ -41,13 +45,32 @@ fn main() {
|
||||
|
||||
assert_eq!(
|
||||
outbox.account_id,
|
||||
outbox_pda(self_program_id, &target_zone, ordinal),
|
||||
"Account must be the outbox PDA for (target_zone, ordinal)"
|
||||
outbox_pda(self_program_id, emitter, &target_zone, ordinal),
|
||||
"Account must be the outbox PDA for (emitter, target_zone, ordinal)"
|
||||
);
|
||||
|
||||
// A slot holds one message for ever. Identity first, so a wrong account that
|
||||
// happens to be free is reported as the wrong account rather than as a used
|
||||
// slot.
|
||||
//
|
||||
// This is the same predicate the state machine already requires of a first
|
||||
// write, so guest and host agree by construction rather than by coincidence.
|
||||
//
|
||||
// It also means a slot can be denied to its intended writer: the ordinal is
|
||||
// caller-chosen in a namespace every user of an emitter shares, and an
|
||||
// emission needs no signature, so anyone can occupy one. A client must pick
|
||||
// an ordinal the chain does not already hold rather than counting from zero.
|
||||
assert_eq!(
|
||||
outbox.account,
|
||||
Account::default(),
|
||||
"Outbox slot already written: one Emit per (emitter, target_zone, ordinal)"
|
||||
);
|
||||
|
||||
let mut post_account = outbox.account.clone();
|
||||
post_account.data = OutboxRecord {
|
||||
emitter,
|
||||
target_zone,
|
||||
ordinal,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
@@ -56,9 +79,10 @@ fn main() {
|
||||
.try_into()
|
||||
.expect("OutboxRecord fits in account data");
|
||||
|
||||
let post = AccountPostState::new_claimed_if_default(
|
||||
// Unconditional, since the pre-state is provably default by the assert above.
|
||||
let post = AccountPostState::new_claimed(
|
||||
post_account,
|
||||
Claim::Pda(outbox_pda_seed(&target_zone, ordinal)),
|
||||
Claim::Pda(outbox_pda_seed(emitter, &target_zone, ordinal)),
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
|
||||
@@ -8,5 +8,9 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
borsh.workspace = true
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
@@ -5,24 +6,79 @@ use lee_core::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
|
||||
const SENDER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingSenderCfg/0000000/";
|
||||
const RECEIVER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingReceiverCfg/00000/";
|
||||
/// Raw 32-byte zone (channel) id, matching the inbox's.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
|
||||
/// Instruction to `ping_receiver`.
|
||||
///
|
||||
/// Variants are append-only, for the same reason `SenderInstruction`'s are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReceiverInstruction {
|
||||
/// Record the payload, delivered by the inbox on behalf of a peer source
|
||||
/// this receiver authorizes.
|
||||
///
|
||||
/// Required accounts (3): the source marker, the receiver config PDA, then
|
||||
/// the record PDA.
|
||||
Record { payload: Vec<u8> },
|
||||
/// Pins the deliverer and the peer sources it may deliver from, written once
|
||||
/// into a default config PDA at genesis. A re-run holding anything different
|
||||
/// is refused; an identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the receiver config PDA.
|
||||
InitConfig(ReceiverConfig),
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
|
||||
/// Who may deliver to this receiver, and which peer sources they may deliver from.
|
||||
///
|
||||
/// `ping_receiver` holds nothing worth stealing, so this is not about value. It is
|
||||
/// about the record meaning something: without it any program on any configured
|
||||
/// peer can overwrite the record, and a delivery proves only that some peer sent
|
||||
/// it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
|
||||
pub struct ReceiverConfig {
|
||||
/// The program allowed to call `Record`: the cross-zone inbox.
|
||||
pub deliverer: ProgramId,
|
||||
/// The `(src_zone, src_program_id)` pairs a delivery may originate from.
|
||||
pub sources: Vec<(ZoneId, ProgramId)>,
|
||||
}
|
||||
|
||||
impl ReceiverConfig {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("receiver config serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`. `Send`'s emission fields are forwarded verbatim
|
||||
/// into `cross_zone_outbox::Instruction::Emit`.
|
||||
///
|
||||
/// Variants are append-only. risc0 serde encodes the variant as a bare leading
|
||||
/// tag word, so inserting one ahead of `Send` shifts every existing encoding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SenderInstruction {
|
||||
/// Emit a cross-zone message through the pinned outbox.
|
||||
///
|
||||
/// Required accounts (2): the sender config PDA, then the outbox PDA.
|
||||
Send {
|
||||
outbox_program_id: ProgramId,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
/// Pins the outbox program, written once into a default config PDA at
|
||||
/// genesis. A re-run naming a different outbox is refused; an identical one
|
||||
/// is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the sender config PDA.
|
||||
InitConfig { outbox_program_id: ProgramId },
|
||||
}
|
||||
|
||||
/// The account a `ping_receiver` records the latest delivered payload into.
|
||||
@@ -36,3 +92,99 @@ pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
|
||||
pub const fn ping_record_seed() -> PdaSeed {
|
||||
PdaSeed::new(PING_RECORD_SEED)
|
||||
}
|
||||
|
||||
/// PDA holding the outbox program id, seeded at genesis so the guest can pin the
|
||||
/// program it chains into without importing the outbox image id.
|
||||
#[must_use]
|
||||
pub fn sender_config_account_id(sender_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&sender_id, &sender_config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn sender_config_seed() -> PdaSeed {
|
||||
PdaSeed::new(SENDER_CONFIG_SEED)
|
||||
}
|
||||
|
||||
/// PDA holding the sources `ping_receiver` accepts a delivery from.
|
||||
#[must_use]
|
||||
pub fn receiver_config_account_id(receiver_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&receiver_id, &receiver_config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn receiver_config_seed() -> PdaSeed {
|
||||
PdaSeed::new(RECEIVER_CONFIG_SEED)
|
||||
}
|
||||
|
||||
/// Encodes the pinned outbox program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn outbox_bytes(outbox_program_id: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in outbox_program_id.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the pinned outbox program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_outbox(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut outbox_program_id = [0_u32; 8];
|
||||
for (word, chunk) in outbox_program_id.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(outbox_program_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `extract_emission` decodes `Send` off peer transactions, so its tag word is
|
||||
/// wire format: a variant inserted ahead of it would silently shift every
|
||||
/// existing encoding.
|
||||
#[test]
|
||||
fn send_is_the_first_variant() {
|
||||
let send = SenderInstruction::Send {
|
||||
target_zone: [7; 32],
|
||||
target_program_id: [1; 8],
|
||||
target_accounts: vec![],
|
||||
payload: vec![],
|
||||
ordinal: 0,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&send).expect("Send serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
/// `Record` is serialized by the source zone into the emission payload and
|
||||
/// decoded by the destination, so its tag word is wire format.
|
||||
#[test]
|
||||
fn record_is_the_first_variant() {
|
||||
let record = ReceiverInstruction::Record { payload: vec![] };
|
||||
let words = risc0_zkvm::serde::to_vec(&record).expect("Record serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_receiver_config_does_not_decode() {
|
||||
assert_eq!(ReceiverConfig::from_bytes(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_config_round_trips() {
|
||||
let config = ReceiverConfig {
|
||||
deliverer: [1; 8],
|
||||
sources: vec![([7; 32], [9; 8])],
|
||||
};
|
||||
assert_eq!(ReceiverConfig::from_bytes(&config.to_bytes()), Some(config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_id_round_trips() {
|
||||
let outbox: ProgramId = [9; 8];
|
||||
assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
cross_zone_inbox_core.workspace = true
|
||||
lee_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use cross_zone_inbox_core::inbox_source_marker_account_id;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use ping_core::{
|
||||
ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed,
|
||||
receiver_config_account_id, receiver_config_seed,
|
||||
};
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
@@ -15,21 +19,61 @@ fn main() {
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<ReceiverInstruction>();
|
||||
|
||||
match instruction {
|
||||
ReceiverInstruction::Record { payload } => record(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
payload,
|
||||
),
|
||||
ReceiverInstruction::InitConfig(config) => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
&config,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
payload: Vec<u8>,
|
||||
) {
|
||||
// pre_states: [source marker, config PDA, record PDA].
|
||||
let [marker, config, record] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Record requires the source marker, config, and record accounts");
|
||||
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
receiver_config_account_id(self_program_id),
|
||||
"Second account must be the receiver config PDA"
|
||||
);
|
||||
let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds a receiver config");
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(cfg.deliverer),
|
||||
"Record is only callable by the authorized deliverer (the cross-zone inbox)"
|
||||
);
|
||||
// Which peer sent it is this program's own business. Without this the record
|
||||
// says only that some program on some configured peer wrote it.
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"ping_receiver is only callable through a chained call"
|
||||
cfg.sources.iter().any(|(src_zone, src_program_id)| {
|
||||
marker.account_id
|
||||
== inbox_source_marker_account_id(cfg.deliverer, src_zone, *src_program_id)
|
||||
}),
|
||||
"Record is only callable for a peer source this receiver authorizes"
|
||||
);
|
||||
|
||||
let payload = match instruction {
|
||||
ReceiverInstruction::Record { payload } => payload,
|
||||
};
|
||||
|
||||
let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("Record requires exactly 1 account");
|
||||
assert_eq!(
|
||||
record.account_id,
|
||||
ping_record_pda(self_program_id),
|
||||
"Account must be the ping record PDA"
|
||||
"Third account must be the ping record PDA"
|
||||
);
|
||||
|
||||
let mut post_account = record.account.clone();
|
||||
@@ -41,8 +85,70 @@ fn main() {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![record],
|
||||
vec![post],
|
||||
vec![marker.clone(), config.clone(), record],
|
||||
vec![
|
||||
AccountPostState::new(marker.account),
|
||||
AccountPostState::new(config.account),
|
||||
post,
|
||||
],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the deliverer and the authorized peer sources into the config PDA
|
||||
/// exactly once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
config_value: &ReceiverConfig,
|
||||
) {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"InitConfig is a top-level genesis transaction"
|
||||
);
|
||||
|
||||
// pre_states: [config PDA].
|
||||
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("InitConfig requires the config account");
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
receiver_config_account_id(self_program_id),
|
||||
"account must be the receiver config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already hold exactly this, since genesis is
|
||||
// replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop a later self-owned rewrite.
|
||||
if config.account != Account::default() {
|
||||
assert_eq!(
|
||||
config.account.program_owner, self_program_id,
|
||||
"receiver config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
config_value.to_bytes(),
|
||||
"receiver config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = config_value
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("receiver config fits in account data");
|
||||
let config_post = AccountPostState::new_claimed_if_default(
|
||||
config_account,
|
||||
Claim::Pda(receiver_config_seed()),
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
|
||||
read_lee_inputs,
|
||||
},
|
||||
};
|
||||
use ping_core::{
|
||||
SenderInstruction, outbox_bytes, read_outbox, sender_config_account_id, sender_config_seed,
|
||||
};
|
||||
use ping_core::SenderInstruction;
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
@@ -21,19 +26,63 @@ fn main() {
|
||||
"ping_sender is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let SenderInstruction::Send {
|
||||
outbox_program_id,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
match instruction {
|
||||
SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} => send(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
),
|
||||
SenderInstruction::InitConfig { outbox_program_id } => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
outbox_program_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// The single account is the outbox PDA the chained call writes into; the
|
||||
// outbox claims it, so ping_sender forwards it unchanged.
|
||||
let [outbox] =
|
||||
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account");
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the emission fields are passed through verbatim"
|
||||
)]
|
||||
fn send(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
) {
|
||||
// pre_states: [config PDA, outbox PDA]. The outbox claims its own slot, so
|
||||
// ping_sender forwards it unchanged.
|
||||
let [config, outbox] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("Send requires the config and outbox accounts");
|
||||
|
||||
// Pinned rather than caller-named: chaining elsewhere would let an emission
|
||||
// skip the real outbox and leave no record of itself.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
sender_config_account_id(self_program_id),
|
||||
"first account must be the ping-sender config PDA"
|
||||
);
|
||||
let outbox_program_id = read_outbox(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an outbox program id");
|
||||
|
||||
let call = ChainedCall::new(
|
||||
outbox_program_id,
|
||||
@@ -47,13 +96,65 @@ fn main() {
|
||||
},
|
||||
);
|
||||
|
||||
let config_post = AccountPostState::new(config.account.clone());
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![outbox.clone()],
|
||||
vec![AccountPostState::new(outbox.account)],
|
||||
vec![config, outbox.clone()],
|
||||
vec![config_post, AccountPostState::new(outbox.account)],
|
||||
)
|
||||
.with_chained_calls(vec![call])
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the outbox program id into the config PDA exactly once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
outbox_program_id: ProgramId,
|
||||
) {
|
||||
// pre_states: [config PDA].
|
||||
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("InitConfig requires the config account");
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
sender_config_account_id(self_program_id),
|
||||
"account must be the ping-sender config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already pin exactly this outbox, since
|
||||
// genesis is replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop a later self-owned rewrite.
|
||||
if config.account != Account::default() {
|
||||
assert_eq!(
|
||||
config.account.program_owner, self_program_id,
|
||||
"ping-sender config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
outbox_bytes(outbox_program_id).to_vec(),
|
||||
"ping-sender config already pins a different outbox"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = outbox_bytes(outbox_program_id)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("outbox id fits in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(sender_config_seed()));
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "sequencer_stake_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
sequencer_stake_core.workspace = true
|
||||
lee_core.workspace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "sequencer_stake_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, default-features = false }
|
||||
borsh.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Core types for the `sequencer_stake` program.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use lee_core::program::PdaSeed;
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{InstructionData, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const INVALID_KEY: &str = "invalid Ed25519 public key";
|
||||
const SEQUENCER_STAKE_CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/MinSequencerStake/0000";
|
||||
|
||||
/// The Bedrock sequencer identity a stake backs. Holds only a valid Ed25519
|
||||
/// public key.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SequencerKey([u8; 32]);
|
||||
|
||||
impl SequencerKey {
|
||||
/// `None` if `bytes` is not a valid Ed25519 public key.
|
||||
#[must_use]
|
||||
pub fn new(bytes: [u8; 32]) -> Option<Self> {
|
||||
ed25519_dalek::VerifyingKey::from_bytes(&bytes)
|
||||
.is_ok()
|
||||
.then_some(Self(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn to_bytes(self) -> [u8; 32] {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for SequencerKey {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SequencerKey {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SequencerKey {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let bytes = <[u8; 32]>::deserialize(deserializer)?;
|
||||
Self::new(bytes).ok_or_else(|| serde::de::Error::custom(INVALID_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
impl borsh::BorshSerialize for SequencerKey {
|
||||
fn serialize<W: borsh::io::Write>(&self, writer: &mut W) -> borsh::io::Result<()> {
|
||||
borsh::BorshSerialize::serialize(&self.0, writer)
|
||||
}
|
||||
}
|
||||
|
||||
impl borsh::BorshDeserialize for SequencerKey {
|
||||
fn deserialize_reader<R: borsh::io::Read>(reader: &mut R) -> borsh::io::Result<Self> {
|
||||
let bytes = <[u8; 32]>::deserialize_reader(reader)?;
|
||||
Self::new(bytes)
|
||||
.ok_or_else(|| borsh::io::Error::new(borsh::io::ErrorKind::InvalidData, INVALID_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Locks `amount` into the ownership account for `sequencer_key`. First
|
||||
/// use claims the account; later calls top up the same account.
|
||||
Stake {
|
||||
sequencer_key: SequencerKey,
|
||||
amount: u128,
|
||||
mover_program_id: ProgramId,
|
||||
mover_instruction_data: InstructionData,
|
||||
},
|
||||
|
||||
/// Self-chained only: verifies the mover deposited `expected_balance_after`.
|
||||
ConfirmStake { expected_balance_after: u128 },
|
||||
|
||||
/// Records a request to release `amount` to `destination`; no balance
|
||||
/// moves yet. Must leave the account at zero or at/above the minimum.
|
||||
UnstakeRequest {
|
||||
amount: u128,
|
||||
destination: AccountId,
|
||||
},
|
||||
|
||||
/// Unsigned, permissionless: releases a pending `UnstakeRequest`.
|
||||
/// Block-inclusion validity is enforced outside this program.
|
||||
FinalizeUnstake,
|
||||
}
|
||||
|
||||
/// Tag written into a claimed ownership account: which key it backs, plus any pending unstake.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)]
|
||||
pub struct StakeRecord {
|
||||
pub sequencer_key: SequencerKey,
|
||||
pub pending_unstake: Option<PendingUnstake>,
|
||||
}
|
||||
|
||||
impl StakeRecord {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("StakeRecord serialization should not fail")
|
||||
}
|
||||
|
||||
/// Returns `None` on malformed input.
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed under the staker's signature at `UnstakeRequest` time — `FinalizeUnstake` needs no
|
||||
/// signature of its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)]
|
||||
pub struct PendingUnstake {
|
||||
pub amount: u128,
|
||||
pub destination: AccountId,
|
||||
}
|
||||
|
||||
/// The single program-owned config account: minimum stake plus per-key standing, kept current
|
||||
/// incrementally.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)]
|
||||
pub struct SequencerStakeConfig {
|
||||
pub minimum_sequencer_stake: u128,
|
||||
pub entries: BTreeMap<SequencerKey, SequencerEntry>,
|
||||
}
|
||||
|
||||
impl SequencerStakeConfig {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("SequencerStakeConfig serialization should not fail")
|
||||
}
|
||||
|
||||
/// Returns `None` on malformed input.
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// One key's standing. `account_id` makes the ownership account findable — a plain account's id
|
||||
/// can't be recomputed from the key.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)]
|
||||
pub struct SequencerEntry {
|
||||
pub account_id: AccountId,
|
||||
pub total_staked: u128,
|
||||
pub total_pending_unstake: u128,
|
||||
}
|
||||
|
||||
impl SequencerEntry {
|
||||
/// Stake still backing this key once every pending release has been
|
||||
/// finalized. Candidacy and every release check measure this, never the
|
||||
/// ownership account's balance: only balance decreases require owning an
|
||||
/// account, so anyone can credit one and push its balance above
|
||||
/// `total_staked`.
|
||||
#[must_use]
|
||||
pub const fn net_stake(&self) -> u128 {
|
||||
self.total_staked.saturating_sub(self.total_pending_unstake)
|
||||
}
|
||||
|
||||
/// Whether releasing `amount` is a legal `UnstakeRequest` against this
|
||||
/// entry: covered by the stake tracked here, and leaving the key either
|
||||
/// fully exited or still at or above `minimum`.
|
||||
#[must_use]
|
||||
pub const fn allows_unstake_request(&self, amount: u128, minimum: u128) -> bool {
|
||||
match self.net_stake().checked_sub(amount) {
|
||||
None => false,
|
||||
Some(remaining) => remaining == 0 || remaining >= minimum,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed of the PDA holding the [`SequencerStakeConfig`].
|
||||
#[must_use]
|
||||
pub const fn sequencer_stake_config_seed() -> PdaSeed {
|
||||
PdaSeed::new(SEQUENCER_STAKE_CONFIG_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sequencer_stake_config_account_id(program_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&program_id, &sequencer_stake_config_seed())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PROGRAM_ID: ProgramId = [9; 8];
|
||||
|
||||
fn test_destination() -> AccountId {
|
||||
AccountId::new([3; 32])
|
||||
}
|
||||
|
||||
/// A distinct valid key per `seed`.
|
||||
fn test_key(seed: u8) -> SequencerKey {
|
||||
let bytes = ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
SequencerKey::new(bytes).expect("a derived public key is a curve point")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_curve_point_is_not_a_sequencer_key() {
|
||||
let off_curve = [2_u8; 32];
|
||||
assert!(SequencerKey::new(off_curve).is_none());
|
||||
|
||||
// 32 key bytes then a `None` discriminant: a `StakeRecord` with no
|
||||
// pending unstake.
|
||||
let record = [&off_curve[..], &[0_u8][..]].concat();
|
||||
assert_eq!(StakeRecord::from_bytes(&record), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_record_roundtrip() {
|
||||
let record = StakeRecord {
|
||||
sequencer_key: test_key(7),
|
||||
pending_unstake: None,
|
||||
};
|
||||
let bytes = record.to_bytes();
|
||||
assert_eq!(StakeRecord::from_bytes(&bytes), Some(record));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_record_with_pending_unstake_roundtrip() {
|
||||
let record = StakeRecord {
|
||||
sequencer_key: test_key(7),
|
||||
pending_unstake: Some(PendingUnstake {
|
||||
amount: 42,
|
||||
destination: test_destination(),
|
||||
}),
|
||||
};
|
||||
let bytes = record.to_bytes();
|
||||
assert_eq!(StakeRecord::from_bytes(&bytes), Some(record));
|
||||
}
|
||||
|
||||
fn test_config() -> SequencerStakeConfig {
|
||||
let mut entries = BTreeMap::new();
|
||||
entries.insert(
|
||||
test_key(1),
|
||||
SequencerEntry {
|
||||
account_id: test_destination(),
|
||||
total_staked: 1_000_000,
|
||||
total_pending_unstake: 0,
|
||||
},
|
||||
);
|
||||
SequencerStakeConfig {
|
||||
minimum_sequencer_stake: 1_000_000,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequencer_stake_config_does_not_decode_as_stake_record() {
|
||||
let bytes = test_config().to_bytes();
|
||||
assert_eq!(StakeRecord::from_bytes(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_record_does_not_decode_as_sequencer_stake_config() {
|
||||
// Secondary to the config account's id check, which is what actually
|
||||
// keeps an ownership account from being passed as the config.
|
||||
for pending_unstake in [
|
||||
None,
|
||||
Some(PendingUnstake {
|
||||
amount: 0,
|
||||
destination: AccountId::new([0; 32]),
|
||||
}),
|
||||
] {
|
||||
let bytes = StakeRecord {
|
||||
sequencer_key: test_key(0),
|
||||
pending_unstake,
|
||||
}
|
||||
.to_bytes();
|
||||
assert_eq!(SequencerStakeConfig::from_bytes(&bytes), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequencer_stake_config_roundtrip() {
|
||||
let config = test_config();
|
||||
let bytes = config.to_bytes();
|
||||
assert_eq!(SequencerStakeConfig::from_bytes(&bytes), Some(config));
|
||||
}
|
||||
|
||||
fn entry(total_staked: u128, total_pending_unstake: u128) -> SequencerEntry {
|
||||
SequencerEntry {
|
||||
account_id: test_destination(),
|
||||
total_staked,
|
||||
total_pending_unstake,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_stake_discounts_what_is_already_pending() {
|
||||
assert_eq!(entry(1_000, 0).net_stake(), 1_000);
|
||||
assert_eq!(entry(1_000, 400).net_stake(), 600);
|
||||
assert_eq!(entry(1_000, 1_000).net_stake(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstake_request_may_fully_exit_or_stay_at_the_minimum() {
|
||||
let minimum = 1_000;
|
||||
let entry = entry(3_000, 0);
|
||||
|
||||
assert!(entry.allows_unstake_request(3_000, minimum), "full exit");
|
||||
assert!(
|
||||
entry.allows_unstake_request(2_000, minimum),
|
||||
"leaves exactly the minimum"
|
||||
);
|
||||
assert!(
|
||||
entry.allows_unstake_request(0, minimum),
|
||||
"no-op leaves everything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstake_request_may_not_leave_a_nonzero_balance_below_the_minimum() {
|
||||
let minimum = 1_000;
|
||||
assert!(!entry(3_000, 0).allows_unstake_request(2_500, minimum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstake_request_may_not_exceed_the_tracked_stake() {
|
||||
// A donation can push the account's balance above `total_staked`; a
|
||||
// request sized off that balance is rejected here.
|
||||
let minimum = 1_000;
|
||||
let donated_balance = 3_001;
|
||||
let entry = entry(3_000, 0);
|
||||
|
||||
assert!(!entry.allows_unstake_request(donated_balance, minimum));
|
||||
assert!(entry.allows_unstake_request(entry.total_staked, minimum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstake_request_is_measured_against_stake_not_already_pending() {
|
||||
let minimum = 1_000;
|
||||
let entry = entry(3_000, 2_000);
|
||||
|
||||
assert!(
|
||||
!entry.allows_unstake_request(3_000, minimum),
|
||||
"2000 is already spoken for"
|
||||
);
|
||||
assert!(
|
||||
entry.allows_unstake_request(1_000, minimum),
|
||||
"exits what is left"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequencer_stake_config_account_id_is_deterministic() {
|
||||
assert_eq!(
|
||||
sequencer_stake_config_account_id(PROGRAM_ID),
|
||||
sequencer_stake_config_account_id(PROGRAM_ID)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
use std::collections::btree_map::Entry;
|
||||
|
||||
use lee_core::{
|
||||
account::{AccountId, AccountWithMetadata},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, Claim, DEFAULT_PROGRAM_ID, InstructionData, ProgramId,
|
||||
ProgramInput, ProgramOutput, read_lee_inputs,
|
||||
},
|
||||
};
|
||||
use sequencer_stake_core::{
|
||||
Instruction, PendingUnstake, SequencerEntry, SequencerKey, SequencerStakeConfig, StakeRecord,
|
||||
sequencer_stake_config_account_id,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
let (post_states, chained_calls) = match instruction {
|
||||
Instruction::Stake {
|
||||
sequencer_key,
|
||||
amount,
|
||||
mover_program_id,
|
||||
mover_instruction_data,
|
||||
} => {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"Stake is only invoked as a top-level user transaction"
|
||||
);
|
||||
stake(
|
||||
self_program_id,
|
||||
pre_states.clone(),
|
||||
sequencer_key,
|
||||
amount,
|
||||
mover_program_id,
|
||||
mover_instruction_data,
|
||||
)
|
||||
}
|
||||
Instruction::ConfirmStake {
|
||||
expected_balance_after,
|
||||
} => {
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(self_program_id),
|
||||
"ConfirmStake can only be invoked as a self-chained call"
|
||||
);
|
||||
let post = confirm_stake(pre_states.clone(), expected_balance_after);
|
||||
(post, Vec::new())
|
||||
}
|
||||
Instruction::UnstakeRequest {
|
||||
amount,
|
||||
destination,
|
||||
} => {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"UnstakeRequest is only invoked as a top-level user transaction"
|
||||
);
|
||||
let post = unstake_request(self_program_id, pre_states.clone(), amount, destination);
|
||||
(post, Vec::new())
|
||||
}
|
||||
Instruction::FinalizeUnstake => {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"FinalizeUnstake is only invoked as a top-level user transaction"
|
||||
);
|
||||
let post = finalize_unstake(self_program_id, pre_states.clone());
|
||||
(post, Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
pre_states,
|
||||
post_states,
|
||||
)
|
||||
.with_chained_calls(chained_calls)
|
||||
.write();
|
||||
}
|
||||
|
||||
fn decode_config(
|
||||
config_account: &AccountWithMetadata,
|
||||
self_program_id: ProgramId,
|
||||
) -> SequencerStakeConfig {
|
||||
// By id, not just by owner: every ownership account is owned by this
|
||||
// program too, and its data is caller-influenced.
|
||||
assert_eq!(
|
||||
config_account.account_id,
|
||||
sequencer_stake_config_account_id(self_program_id),
|
||||
"not the sequencer_stake config account"
|
||||
);
|
||||
assert_eq!(
|
||||
config_account.account.program_owner, self_program_id,
|
||||
"config account is not owned by sequencer_stake"
|
||||
);
|
||||
SequencerStakeConfig::from_bytes(config_account.account.data.as_ref())
|
||||
.expect("config account data should decode as SequencerStakeConfig")
|
||||
}
|
||||
|
||||
fn stake(
|
||||
self_program_id: ProgramId,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
sequencer_key: SequencerKey,
|
||||
amount: u128,
|
||||
mover_program_id: ProgramId,
|
||||
mover_instruction_data: InstructionData,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let [funding_account, ownership_account, config_account] =
|
||||
<[AccountWithMetadata; 3]>::try_from(pre_states).expect(
|
||||
"Stake requires a funding account, an ownership account, and the config account",
|
||||
);
|
||||
|
||||
assert!(
|
||||
ownership_account.is_authorized,
|
||||
"must sign for the ownership account"
|
||||
);
|
||||
|
||||
let mut config = decode_config(&config_account, self_program_id);
|
||||
let minimum_sequencer_stake = config.minimum_sequencer_stake;
|
||||
|
||||
let balance_before = ownership_account.account.balance;
|
||||
let expected_balance_after = balance_before
|
||||
.checked_add(amount)
|
||||
.expect("stake amount overflow");
|
||||
|
||||
// An ownership account stays claimed after a full exit, so what a call is
|
||||
// doing follows from the config entry, not from the account's owner.
|
||||
let is_claimed = ownership_account.account.program_owner != DEFAULT_PROGRAM_ID;
|
||||
if is_claimed {
|
||||
assert_eq!(
|
||||
ownership_account.account.program_owner, self_program_id,
|
||||
"not a sequencer_stake ownership account"
|
||||
);
|
||||
let record = StakeRecord::from_bytes(ownership_account.account.data.as_ref())
|
||||
.expect("claimed ownership account should decode as StakeRecord");
|
||||
assert_eq!(
|
||||
record.sequencer_key, sequencer_key,
|
||||
"ownership account backs a different sequencer key"
|
||||
);
|
||||
assert!(
|
||||
record.pending_unstake.is_none(),
|
||||
"cannot top up while an unstake request is pending"
|
||||
);
|
||||
}
|
||||
|
||||
match config.entries.entry(sequencer_key) {
|
||||
Entry::Occupied(mut occupied) => {
|
||||
// top up: same already-claimed account only
|
||||
assert!(
|
||||
is_claimed,
|
||||
"this sequencer key already has an ownership account"
|
||||
);
|
||||
let entry = occupied.get_mut();
|
||||
assert_eq!(
|
||||
entry.account_id, ownership_account.account_id,
|
||||
"config entry points at a different ownership account"
|
||||
);
|
||||
entry.total_staked = entry
|
||||
.total_staked
|
||||
.checked_add(amount)
|
||||
.expect("total staked overflow");
|
||||
}
|
||||
Entry::Vacant(vacant) => {
|
||||
// first stake for this key, or a new one after a full exit
|
||||
assert!(
|
||||
amount >= minimum_sequencer_stake,
|
||||
"an initial stake must already meet the minimum"
|
||||
);
|
||||
vacant.insert(SequencerEntry {
|
||||
account_id: ownership_account.account_id,
|
||||
total_staked: amount,
|
||||
total_pending_unstake: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// pass-through: propagates authorization into the nested mover call
|
||||
let funding_account_post = AccountPostState::new(funding_account.account.clone());
|
||||
|
||||
// claim is a no-op on a top-up (already owned)
|
||||
let mut ownership_account_data = ownership_account.account.clone();
|
||||
ownership_account_data.data = StakeRecord {
|
||||
sequencer_key,
|
||||
pending_unstake: None,
|
||||
}
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("StakeRecord should fit in account data");
|
||||
let ownership_account_post =
|
||||
AccountPostState::new_claimed_if_default(ownership_account_data.clone(), Claim::Authorized);
|
||||
|
||||
let mut config_account_new = config_account.account;
|
||||
config_account_new.data = config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("SequencerStakeConfig should fit in account data");
|
||||
let config_account_post = AccountPostState::new(config_account_new);
|
||||
|
||||
// chained-call pre-states reflect state as of when each call runs
|
||||
let mut ownership_account_claimed = ownership_account;
|
||||
ownership_account_claimed.account = ownership_account_data;
|
||||
ownership_account_claimed.account.program_owner = self_program_id;
|
||||
|
||||
let mover_call = ChainedCall {
|
||||
program_id: mover_program_id,
|
||||
pre_states: vec![funding_account, ownership_account_claimed.clone()],
|
||||
instruction_data: mover_instruction_data,
|
||||
pda_seeds: Vec::new(),
|
||||
};
|
||||
|
||||
// expected balance after the mover call
|
||||
let mut ownership_account_after_mover = ownership_account_claimed;
|
||||
ownership_account_after_mover.account.balance = expected_balance_after;
|
||||
|
||||
let confirm_call = ChainedCall::new(
|
||||
self_program_id,
|
||||
vec![ownership_account_after_mover],
|
||||
&Instruction::ConfirmStake {
|
||||
expected_balance_after,
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
vec![
|
||||
funding_account_post,
|
||||
ownership_account_post,
|
||||
config_account_post,
|
||||
],
|
||||
vec![mover_call, confirm_call],
|
||||
)
|
||||
}
|
||||
|
||||
fn confirm_stake(
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
expected_balance_after: u128,
|
||||
) -> Vec<AccountPostState> {
|
||||
let [ownership_account] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("ConfirmStake requires exactly the ownership account");
|
||||
|
||||
assert_eq!(
|
||||
ownership_account.account.balance, expected_balance_after,
|
||||
"mover call did not deposit the expected amount into the ownership account"
|
||||
);
|
||||
|
||||
vec![AccountPostState::new(ownership_account.account)]
|
||||
}
|
||||
|
||||
fn unstake_request(
|
||||
self_program_id: ProgramId,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
amount: u128,
|
||||
destination: AccountId,
|
||||
) -> Vec<AccountPostState> {
|
||||
let [ownership_account, config_account] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("UnstakeRequest requires the ownership account and the config account");
|
||||
|
||||
assert!(
|
||||
ownership_account.is_authorized,
|
||||
"must sign for the ownership account"
|
||||
);
|
||||
assert_eq!(
|
||||
ownership_account.account.program_owner, self_program_id,
|
||||
"not a sequencer_stake ownership account"
|
||||
);
|
||||
|
||||
let mut record = StakeRecord::from_bytes(ownership_account.account.data.as_ref())
|
||||
.expect("ownership account should decode as StakeRecord");
|
||||
assert!(
|
||||
record.pending_unstake.is_none(),
|
||||
"an unstake request is already pending"
|
||||
);
|
||||
|
||||
let mut config = decode_config(&config_account, self_program_id);
|
||||
let minimum_sequencer_stake = config.minimum_sequencer_stake;
|
||||
let entry = config
|
||||
.entries
|
||||
.get_mut(&record.sequencer_key)
|
||||
.expect("staked key must already have a config entry");
|
||||
assert_eq!(
|
||||
entry.account_id, ownership_account.account_id,
|
||||
"config entry points at a different ownership account"
|
||||
);
|
||||
|
||||
// Sized against the tracked stake, never the account balance: anyone can
|
||||
// credit a program-owned account, so balance can exceed `total_staked`.
|
||||
// Covers both "not more than is staked" and "zero or at least the minimum".
|
||||
assert!(
|
||||
entry.allows_unstake_request(amount, minimum_sequencer_stake),
|
||||
"unstake request must be covered by the staked total and leave the key at zero or at/above the minimum"
|
||||
);
|
||||
|
||||
record.pending_unstake = Some(PendingUnstake {
|
||||
amount,
|
||||
destination,
|
||||
});
|
||||
entry.total_pending_unstake = entry
|
||||
.total_pending_unstake
|
||||
.checked_add(amount)
|
||||
.expect("total pending unstake overflow");
|
||||
|
||||
// only data changes here; transfer happens in FinalizeUnstake
|
||||
let mut ownership_account_new = ownership_account.account;
|
||||
ownership_account_new.data = record
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("StakeRecord should fit in account data");
|
||||
|
||||
let mut config_account_new = config_account.account;
|
||||
config_account_new.data = config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("SequencerStakeConfig should fit in account data");
|
||||
|
||||
vec![
|
||||
AccountPostState::new(ownership_account_new),
|
||||
AccountPostState::new(config_account_new),
|
||||
]
|
||||
}
|
||||
|
||||
fn finalize_unstake(
|
||||
self_program_id: ProgramId,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
) -> Vec<AccountPostState> {
|
||||
let [ownership_account, destination_account, config_account] =
|
||||
<[AccountWithMetadata; 3]>::try_from(pre_states).expect(
|
||||
"FinalizeUnstake requires the ownership account, a destination account, and the config account",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ownership_account.account.program_owner, self_program_id,
|
||||
"not a sequencer_stake ownership account"
|
||||
);
|
||||
|
||||
let mut record = StakeRecord::from_bytes(ownership_account.account.data.as_ref())
|
||||
.expect("ownership account should decode as StakeRecord");
|
||||
let pending = record
|
||||
.pending_unstake
|
||||
.take()
|
||||
.expect("no unstake request pending on this account");
|
||||
assert_eq!(
|
||||
destination_account.account_id, pending.destination,
|
||||
"destination does not match the recorded unstake request"
|
||||
);
|
||||
|
||||
// no signature check: already authorized back in UnstakeRequest
|
||||
let mut ownership_account_new = ownership_account.account.clone();
|
||||
ownership_account_new.balance = ownership_account_new
|
||||
.balance
|
||||
.checked_sub(pending.amount)
|
||||
.expect("insufficient staked balance");
|
||||
ownership_account_new.data = record
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("StakeRecord should fit in account data");
|
||||
|
||||
let mut destination_new = destination_account.account;
|
||||
destination_new.balance = destination_new
|
||||
.balance
|
||||
.checked_add(pending.amount)
|
||||
.expect("finalize unstake amount overflow");
|
||||
|
||||
let mut config = decode_config(&config_account, self_program_id);
|
||||
let entry = config
|
||||
.entries
|
||||
.get_mut(&record.sequencer_key)
|
||||
.expect("staked key must already have a config entry");
|
||||
assert_eq!(
|
||||
entry.account_id, ownership_account.account_id,
|
||||
"config entry points at a different ownership account"
|
||||
);
|
||||
entry.total_staked = entry
|
||||
.total_staked
|
||||
.checked_sub(pending.amount)
|
||||
.expect("total staked underflow");
|
||||
entry.total_pending_unstake = entry
|
||||
.total_pending_unstake
|
||||
.checked_sub(pending.amount)
|
||||
.expect("total pending unstake underflow");
|
||||
// Full drain is defined on the tracked stake, not the balance.
|
||||
if entry.total_staked == 0 {
|
||||
config.entries.remove(&record.sequencer_key);
|
||||
}
|
||||
|
||||
let mut config_account_new = config_account.account;
|
||||
config_account_new.data = config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("SequencerStakeConfig should fit in account data");
|
||||
|
||||
vec![
|
||||
AccountPostState::new(ownership_account_new),
|
||||
AccountPostState::new(destination_new),
|
||||
AccountPostState::new(config_account_new),
|
||||
]
|
||||
}
|
||||
+12
-2
@@ -14,8 +14,8 @@ mod inner {
|
||||
BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF,
|
||||
CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID,
|
||||
PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF,
|
||||
PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF,
|
||||
VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID,
|
||||
PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID,
|
||||
TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID,
|
||||
};
|
||||
use lee::program::Program;
|
||||
|
||||
@@ -126,6 +126,12 @@ mod inner {
|
||||
Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn sequencer_stake() -> Program {
|
||||
Program::new_unchecked(SEQUENCER_STAKE_ID, Cow::Borrowed(SEQUENCER_STAKE_ELF))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -138,6 +144,7 @@ mod inner {
|
||||
let faucet_program = faucet();
|
||||
let bridge_program = bridge();
|
||||
let pinata_program = pinata();
|
||||
let sequencer_stake_program = sequencer_stake();
|
||||
|
||||
assert_eq!(auth_transfer_program.id(), AUTHENTICATED_TRANSFER_ID);
|
||||
assert_eq!(auth_transfer_program.elf(), AUTHENTICATED_TRANSFER_ELF);
|
||||
@@ -151,6 +158,8 @@ mod inner {
|
||||
assert_eq!(bridge_program.elf(), BRIDGE_ELF);
|
||||
assert_eq!(pinata_program.id(), PINATA_ID);
|
||||
assert_eq!(pinata_program.elf(), PINATA_ELF);
|
||||
assert_eq!(sequencer_stake_program.id(), SEQUENCER_STAKE_ID);
|
||||
assert_eq!(sequencer_stake_program.elf(), SEQUENCER_STAKE_ELF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -172,6 +181,7 @@ mod inner {
|
||||
(PING_RECEIVER_ELF, PING_RECEIVER_ID),
|
||||
(BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID),
|
||||
(WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID),
|
||||
(SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID),
|
||||
];
|
||||
for (elf, expected_id) in cases {
|
||||
let program = Program::new((*elf).into()).unwrap();
|
||||
|
||||
@@ -8,5 +8,6 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
cross_zone_inbox_core.workspace = true
|
||||
lee_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
borsh.workspace = true
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
|
||||
//! this by reading the authorized minter from a genesis-seeded config account.
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
@@ -22,21 +23,49 @@ pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF;
|
||||
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
|
||||
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
|
||||
/// Raw 32-byte zone (channel) id, matching the inbox's.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
|
||||
/// the cross-zone inbox.
|
||||
/// the cross-zone inbox, and only for a peer source this token authorizes.
|
||||
///
|
||||
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
|
||||
/// holding PDA.
|
||||
/// Required accounts (3): the source marker, the wrapped-token config PDA,
|
||||
/// then the recipient's holding PDA.
|
||||
Mint { recipient: [u8; 32], amount: u128 },
|
||||
/// Pins `minter` (the cross-zone inbox) as the authorized minter, written once
|
||||
/// into a default config PDA at genesis. The guest refuses a non-default
|
||||
/// pre-state, so it cannot be re-run to hijack the minter.
|
||||
/// Pins the minter and the peer sources it may mint for, written once into a
|
||||
/// default config PDA at genesis. A re-run holding anything different is
|
||||
/// refused; an identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the wrapped-token config PDA.
|
||||
InitConfig { minter: ProgramId },
|
||||
InitConfig(WrappedTokenConfig),
|
||||
}
|
||||
|
||||
/// Who may mint, and which peer sources they may mint for.
|
||||
///
|
||||
/// The source list is what makes this token authorize its own inbound value
|
||||
/// rather than trusting a central route table to have done it. Borsh because the
|
||||
/// list is variable length.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
|
||||
pub struct WrappedTokenConfig {
|
||||
/// The program allowed to call `Mint`: the cross-zone inbox.
|
||||
pub minter: ProgramId,
|
||||
/// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on
|
||||
/// a zone with no peers, which authorizes nothing.
|
||||
pub sources: Vec<(ZoneId, ProgramId)>,
|
||||
}
|
||||
|
||||
impl WrappedTokenConfig {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("wrapped-token config serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
|
||||
@@ -71,29 +100,6 @@ pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// Encodes the authorized minter program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the authorized minter program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut minter = [0_u32; 8];
|
||||
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(minter)
|
||||
}
|
||||
|
||||
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
@@ -113,9 +119,34 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minter_round_trips() {
|
||||
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
|
||||
fn config_round_trips() {
|
||||
let config = WrappedTokenConfig {
|
||||
minter: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])],
|
||||
};
|
||||
assert_eq!(
|
||||
WrappedTokenConfig::from_bytes(&config.to_bytes()),
|
||||
Some(config)
|
||||
);
|
||||
}
|
||||
|
||||
/// An unclaimed config reads as empty, which must not decode to a config that
|
||||
/// authorizes anything.
|
||||
#[test]
|
||||
fn an_empty_config_does_not_decode() {
|
||||
assert_eq!(WrappedTokenConfig::from_bytes(&[]), None);
|
||||
}
|
||||
|
||||
/// The peer's `bridge_lock` serializes `Mint` into the emission payload, so
|
||||
/// its tag word is wire format.
|
||||
#[test]
|
||||
fn mint_is_the_first_variant() {
|
||||
let mint = Instruction::Mint {
|
||||
recipient: [3; 32],
|
||||
amount: 1,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("Mint serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use cross_zone_inbox_core::inbox_source_marker_account_id;
|
||||
use lee_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use wrapped_token_core::{
|
||||
Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed,
|
||||
holding_account_id, holding_seed, minter_bytes, read_balance, read_minter,
|
||||
Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id,
|
||||
config_seed, holding_account_id, holding_seed, read_balance,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -27,12 +28,12 @@ fn main() {
|
||||
recipient,
|
||||
amount,
|
||||
),
|
||||
Instruction::InitConfig { minter } => init_config(
|
||||
Instruction::InitConfig(config) => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
minter,
|
||||
&config,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -45,29 +46,40 @@ fn mint(
|
||||
recipient: [u8; 32],
|
||||
amount: u128,
|
||||
) {
|
||||
// pre_states: [config PDA, recipient holding PDA].
|
||||
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("Mint requires the config and recipient holding accounts");
|
||||
// pre_states: [source marker, config PDA, recipient holding PDA].
|
||||
let [marker, config, holding] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Mint requires the source marker, config, and recipient holding accounts");
|
||||
|
||||
// The config PDA is genesis-seeded with the authorized minter (the cross-zone
|
||||
// inbox). Pin the caller to it, since the guest cannot import the inbox id.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"first account must be the wrapped-token config PDA"
|
||||
"second account must be the wrapped-token config PDA"
|
||||
);
|
||||
let minter = read_minter(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an authorized minter id");
|
||||
let cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds a wrapped-token config");
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(minter),
|
||||
Some(cfg.minter),
|
||||
"Mint is only callable by the authorized minter (the cross-zone inbox)"
|
||||
);
|
||||
// The inbox vouches only that the message arrived; which peer sent it is this
|
||||
// token's own business, and unbacked value is what gets minted if it takes
|
||||
// anyone's word for it. The marker's address is the source, so re-deriving it
|
||||
// from an authorized pair is the whole check.
|
||||
assert!(
|
||||
cfg.sources.iter().any(|(src_zone, src_program_id)| {
|
||||
marker.account_id
|
||||
== inbox_source_marker_account_id(cfg.minter, src_zone, *src_program_id)
|
||||
}),
|
||||
"Mint is only callable for a peer source this token authorizes"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
holding.account_id,
|
||||
holding_account_id(self_program_id, &recipient),
|
||||
"second account must be the recipient holding PDA"
|
||||
"third account must be the recipient holding PDA"
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -93,19 +105,24 @@ fn mint(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config, holding],
|
||||
vec![config_post, holding_post],
|
||||
vec![marker.clone(), config, holding],
|
||||
vec![
|
||||
AccountPostState::new(marker.account),
|
||||
config_post,
|
||||
holding_post,
|
||||
],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the authorized minter into the config PDA exactly once at genesis.
|
||||
/// Writes the minter and the authorized peer sources into the config PDA exactly
|
||||
/// once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: lee_core::program::ProgramId,
|
||||
caller_program_id: Option<lee_core::program::ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
minter: lee_core::program::ProgramId,
|
||||
config_value: &WrappedTokenConfig,
|
||||
) {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
@@ -133,16 +150,16 @@ fn init_config(
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
minter_bytes(minter).to_vec(),
|
||||
"wrapped-token config already initialized with a different minter"
|
||||
config_value.to_bytes(),
|
||||
"wrapped-token config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = minter_bytes(minter)
|
||||
.to_vec()
|
||||
config_account.data = config_value
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("minter id fits in account data");
|
||||
.expect("wrapped-token config fits in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ impl<BP: BlockPublisherTrait> ExecutorActor<BP> {
|
||||
background_tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to the sequencer's mempool, for feeding externally-received
|
||||
/// (e.g. gossiped) transactions in.
|
||||
#[must_use]
|
||||
pub fn mempool_handle(&self) -> MemPoolHandle<(TransactionOrigin, LeeTransaction)> {
|
||||
self.mempool_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<BP: BlockPublisherTrait + Send + 'static> Actor for ExecutorActor<BP> {
|
||||
@@ -136,10 +143,10 @@ impl<BP: BlockPublisherTrait + Send + 'static> Message<ProduceBlock> for Executo
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Our turn: collecting transactions from mempool, creating block");
|
||||
info!("Our turn: producing a block and any committee update");
|
||||
let id = self
|
||||
.sequencer
|
||||
.produce_new_block()
|
||||
.run_production_turn()
|
||||
.await
|
||||
.map_err(Error::BlockProductionFailed)?;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ fn sequencer_config() -> (SequencerConfig, tempfile::TempDir) {
|
||||
genesis: Vec::new(),
|
||||
cross_zone: None,
|
||||
metrics_address: None,
|
||||
gossip: None,
|
||||
};
|
||||
|
||||
(config, home)
|
||||
|
||||
@@ -4,7 +4,7 @@ use bytesize::ByteSize;
|
||||
use jsonrpsee::server::ServerHandle;
|
||||
use kameo::{Actor, actor::ActorRef, mailbox::Signal};
|
||||
use log::info;
|
||||
use sequencer_core::block_publisher::BlockPublisherTrait;
|
||||
use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher};
|
||||
use sequencer_service_rpc::RpcServer as _;
|
||||
use tokio::select;
|
||||
|
||||
@@ -24,6 +24,7 @@ impl RpcServerActor {
|
||||
executor_ref: ActorRef<sequencer_executor_actor::ExecutorActor<BP>>,
|
||||
listen_addr: SocketAddr,
|
||||
max_block_size: ByteSize,
|
||||
gossip_tx_publisher: Option<GossipTxPublisher>,
|
||||
) -> Result<Self> {
|
||||
let server = jsonrpsee::server::ServerBuilder::with_config(
|
||||
jsonrpsee::server::ServerConfigBuilder::new()
|
||||
@@ -43,7 +44,7 @@ impl RpcServerActor {
|
||||
|
||||
info!("Starting RPC Server on {addr}");
|
||||
|
||||
let service = service::Service::new(executor_ref, max_block_size);
|
||||
let service = service::Service::new(executor_ref, max_block_size, gossip_tx_publisher);
|
||||
let server_handle = server.start(service.into_rpc());
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -8,7 +8,7 @@ use jsonrpsee::{
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use log::{error, warn};
|
||||
use sequencer_core::block_publisher::BlockPublisherTrait;
|
||||
use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher};
|
||||
use sequencer_service_protocol::{
|
||||
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest,
|
||||
CrossZoneDeadLetter, CrossZoneDeadLetterReport, HashType, MembershipProof, Nonce, ProgramId,
|
||||
@@ -17,18 +17,21 @@ use sequencer_service_protocol::{
|
||||
pub struct Service<BP: BlockPublisherTrait + Send + 'static> {
|
||||
executor_ref: ActorRef<sequencer_executor_actor::ExecutorActor<BP>>,
|
||||
max_block_size: ByteSize,
|
||||
gossip_tx_publisher: Option<GossipTxPublisher>,
|
||||
}
|
||||
|
||||
impl<BP: BlockPublisherTrait + Send + 'static> Service<BP> {
|
||||
pub fn new(
|
||||
executor_ref: ActorRef<sequencer_executor_actor::ExecutorActor<BP>>,
|
||||
max_block_size: ByteSize,
|
||||
gossip_tx_publisher: Option<GossipTxPublisher>,
|
||||
) -> Self {
|
||||
sequencer_rpc_server_actor_metrics::init();
|
||||
|
||||
Self {
|
||||
executor_ref,
|
||||
max_block_size,
|
||||
gossip_tx_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +100,12 @@ impl<BP: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
error!("Transaction failed before reaching mempool: {err:#?}");
|
||||
})?;
|
||||
|
||||
// Publish to the gossip mesh before the local mempool admission so a
|
||||
// full mempool doesn't delay propagation.
|
||||
if let Some(publisher) = &self.gossip_tx_publisher {
|
||||
publisher.publish(authenticated_tx.clone());
|
||||
}
|
||||
|
||||
self.executor_ref
|
||||
.ask(sequencer_executor_actor::protocol::Transaction {
|
||||
transaction: authenticated_tx,
|
||||
|
||||
@@ -18,12 +18,14 @@ mempool.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
faucet_core.workspace = true
|
||||
authenticated_transfer_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
programs.workspace = true
|
||||
system_accounts.workspace = true
|
||||
cross_zone.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
sequencer_stake_core.workspace = true
|
||||
|
||||
logos-blockchain-key-management-system-service.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
@@ -37,6 +39,7 @@ chrono.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tokio-util.workspace = true
|
||||
tokio-retry.workspace = true
|
||||
rand.workspace = true
|
||||
borsh.workspace = true
|
||||
bytesize.workspace = true
|
||||
@@ -46,12 +49,15 @@ num-bigint.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
libp2p.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
testnet = []
|
||||
# Generate mock external clients implementations for testing
|
||||
mock = []
|
||||
# Enable mDNS-based local peer discovery for gossip.
|
||||
mdns = []
|
||||
|
||||
[dev-dependencies]
|
||||
futures.workspace = true
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::names;
|
||||
pub enum TransactionOrigin {
|
||||
User,
|
||||
Sequencer,
|
||||
Gossip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, strum::IntoStaticStr, strum::EnumIter)]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::Duration;
|
||||
use anyhow::{Context as _, Result, anyhow, ensure};
|
||||
use common::block::Block;
|
||||
use futures::Stream;
|
||||
use log::warn;
|
||||
use log::{info, warn};
|
||||
pub use logos_blockchain_core::mantle::{
|
||||
ledger::NoteId,
|
||||
ops::channel::{Ed25519PublicKey, MsgId},
|
||||
@@ -11,24 +11,24 @@ pub use logos_blockchain_core::mantle::{
|
||||
use logos_blockchain_core::{
|
||||
mantle::{
|
||||
SignedMantleTx,
|
||||
channel::{SlotTimeframe, SlotTimeout},
|
||||
channel::{ChannelState, SlotTimeframe, SlotTimeout},
|
||||
gas::GasCost,
|
||||
ops::{
|
||||
Op, OpProof,
|
||||
channel::{
|
||||
ChannelId,
|
||||
config::{ChannelConfigOp, Keys},
|
||||
inscribe::Inscription,
|
||||
inscribe::{Inscription, InscriptionOp},
|
||||
},
|
||||
},
|
||||
traits::Hashable as _,
|
||||
transactions::{MantleTxBuilder, OpsProofs},
|
||||
transactions::{MantleTxBuilder, OpsProofs, states::Unverified},
|
||||
},
|
||||
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
|
||||
};
|
||||
use logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundRequestBody;
|
||||
pub use logos_blockchain_key_management_system_service::keys::{
|
||||
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey,
|
||||
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, ZkPublicKey,
|
||||
};
|
||||
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
@@ -103,12 +103,28 @@ enum Command {
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
resp: oneshot::Sender<Result<PublishOutcome>>,
|
||||
},
|
||||
/// Submit a committee `ChannelConfigOp` as its own, independent Mantle tx
|
||||
/// — not bundled with any block publish.
|
||||
SubmitChannelConfig {
|
||||
new_keys: Keys,
|
||||
resp: oneshot::Sender<Result<()>>,
|
||||
},
|
||||
/// Hand zone-sdk a pre-built tx to track and post, keyed by the channel tip
|
||||
/// it leaves behind.
|
||||
SubmitSignedTx {
|
||||
tx: Box<SignedMantleTx<Unverified>>,
|
||||
msg_id: MsgId,
|
||||
resp: oneshot::Sender<Result<PublishOutcome>>,
|
||||
},
|
||||
}
|
||||
|
||||
type CommandSender = mpsc::Sender<Command>;
|
||||
|
||||
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||
pub trait BlockPublisherTrait: Sized {
|
||||
#[expect(
|
||||
async_fn_in_trait,
|
||||
reason = "Only the methods reached from the executor actor need an explicitly Send future"
|
||||
)]
|
||||
pub trait BlockPublisherTrait: Sized + Sync {
|
||||
async fn new(
|
||||
config: &BedrockConfig,
|
||||
bedrock_signing_key: Ed25519Key,
|
||||
@@ -117,6 +133,10 @@ pub trait BlockPublisherTrait: Sized {
|
||||
on_follow: OnFollowSink,
|
||||
) -> Result<Self>;
|
||||
|
||||
/// Whether the channel already exists, checked before anything else is
|
||||
/// set up (no instance, no store, no genesis yet).
|
||||
async fn channel_exists(config: &BedrockConfig) -> Result<bool>;
|
||||
|
||||
/// Publish a block and return what zone-sdk made of it. Zone-sdk drives the
|
||||
/// actual submission and retries internally.
|
||||
///
|
||||
@@ -128,6 +148,28 @@ pub trait BlockPublisherTrait: Sized {
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
) -> impl Future<Output = Result<PublishOutcome>> + Send + 'blk;
|
||||
|
||||
/// Create the channel and write `block` into it in one Mantle tx. Only valid
|
||||
/// while the channel does not exist, and `keys[0]` must be this sequencer's
|
||||
/// own key, since creation hands the first turn to index 0.
|
||||
async fn publish_genesis_creating_channel(
|
||||
&self,
|
||||
block: &Block,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
) -> Result<PublishOutcome>;
|
||||
|
||||
/// Live (adopted, possibly not yet finalized) accredited-key snapshot for
|
||||
/// this channel, read directly from the connected Bedrock node.
|
||||
fn accredited_keys(&self) -> impl Future<Output = Result<Vec<Ed25519PublicKey>>> + Send;
|
||||
|
||||
/// Submit a committee `ChannelConfigOp` as its own, independent Mantle
|
||||
/// tx (not bundled with any block publish). `new_keys` is the full
|
||||
/// replacement accredited-keys list; the channel administration
|
||||
/// parameters posted alongside it are the `system_accounts` defaults.
|
||||
fn submit_channel_config(
|
||||
&self,
|
||||
new_keys: Vec<Ed25519PublicKey>,
|
||||
) -> impl Future<Output = Result<()>> + Send;
|
||||
|
||||
fn channel_id(&self) -> ChannelId;
|
||||
|
||||
/// Whether this sequencer is currently authorized to write to the channel.
|
||||
@@ -148,7 +190,7 @@ pub trait BlockPublisherTrait: Sized {
|
||||
|
||||
/// Current channel frontier slot on the connected chain, or `None` if the
|
||||
/// channel does not exist there. Drives the startup frontier check.
|
||||
async fn channel_tip_slot(&self) -> Result<Option<Slot>>;
|
||||
fn channel_tip_slot(&self) -> impl Future<Output = Result<Option<Slot>>> + Send;
|
||||
|
||||
/// Finalized channel messages from `after_slot` (exclusive) up to LIB, used
|
||||
/// for the startup consistency check and reconstruction. Pass `None` to read
|
||||
@@ -173,9 +215,33 @@ pub struct ZoneSdkPublisher {
|
||||
// path wait until it has actually stopped.
|
||||
drive_task: TaskGroup,
|
||||
indexer: ZoneIndexer<NodeHttpClient>,
|
||||
bedrock_signing_key: Ed25519Key,
|
||||
funding_key: ZkPublicKey,
|
||||
priority_fee: u64,
|
||||
}
|
||||
|
||||
impl ZoneSdkPublisher {
|
||||
/// Runs one [`Command`] on the drive task and waits for its reply.
|
||||
async fn dispatch<T>(
|
||||
&self,
|
||||
command: impl FnOnce(oneshot::Sender<Result<T>>) -> Command,
|
||||
) -> Result<T> {
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(command(resp_tx))
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
||||
resp_rx
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task dropped the response"))?
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
async fn channel_exists(config: &BedrockConfig) -> Result<bool> {
|
||||
Ok(read_channel_state(config).await?.is_some())
|
||||
}
|
||||
|
||||
async fn new(
|
||||
config: &BedrockConfig,
|
||||
bedrock_signing_key: Ed25519Key,
|
||||
@@ -188,17 +254,16 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
|
||||
let zone_sdk_config = ZoneSdkSequencerConfig {
|
||||
resubmit_interval,
|
||||
funding: Some(FundingConfig {
|
||||
..ZoneSdkSequencerConfig::new(FundingConfig {
|
||||
funding_pk: config.funding_key,
|
||||
max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX),
|
||||
priority_fee: config.priority_fee,
|
||||
}),
|
||||
..ZoneSdkSequencerConfig::default()
|
||||
})
|
||||
};
|
||||
|
||||
let mut sequencer = ZoneSequencer::init_with_config(
|
||||
config.channel_id,
|
||||
bedrock_signing_key,
|
||||
bedrock_signing_key.clone(),
|
||||
node.clone(),
|
||||
zone_sdk_config,
|
||||
initial_checkpoint,
|
||||
@@ -262,6 +327,56 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
}
|
||||
let _dontcare = resp_tx.send(msg_result);
|
||||
}
|
||||
Command::SubmitChannelConfig {
|
||||
new_keys,
|
||||
resp: resp_tx,
|
||||
} => {
|
||||
// zone-sdk funds from the node wallet, signs,
|
||||
// and enqueues this as its own independent
|
||||
// Mantle tx onto the drive loop's in-flight
|
||||
// pool — no manual bundling with any block
|
||||
// inscription.
|
||||
let result = sequencer
|
||||
.handle()
|
||||
.channel_config(
|
||||
new_keys,
|
||||
SlotTimeframe::from(
|
||||
system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME,
|
||||
),
|
||||
SlotTimeout::from(
|
||||
system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT,
|
||||
),
|
||||
system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD,
|
||||
system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.context("Failed to submit channel-config update");
|
||||
|
||||
match &result {
|
||||
Ok(()) => info!("Submitted committee channel-config update"),
|
||||
Err(err) => {
|
||||
warn!("Channel-config update submission failed: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let _dontcare = resp_tx.send(result);
|
||||
}
|
||||
Command::SubmitSignedTx { tx, msg_id, resp: resp_tx } => {
|
||||
let submitted = sequencer
|
||||
.handle()
|
||||
.submit_signed_tx(*tx, msg_id)
|
||||
.context("Failed to submit pre-built channel transaction");
|
||||
let msg_result = submitted.map(|(result, checkpoint)| PublishOutcome {
|
||||
this_msg: result.tx.inscription().this_msg,
|
||||
checkpoint,
|
||||
released_notes: released_notes(&result.tx),
|
||||
});
|
||||
if let Err(e) = &msg_result {
|
||||
warn!("zone-sdk rejected the pre-built transaction: {e:?}");
|
||||
}
|
||||
let _dontcare = resp_tx.send(msg_result);
|
||||
}
|
||||
},
|
||||
event = sequencer.next_event() => {
|
||||
match event {
|
||||
@@ -347,6 +462,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
turn_rx,
|
||||
driver_cancellation,
|
||||
drive_task: TaskGroup::new(vec![drive_task]),
|
||||
bedrock_signing_key,
|
||||
funding_key: config.funding_key,
|
||||
priority_fee: config.priority_fee,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -360,19 +478,110 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
.try_into()
|
||||
.context("Block data exceeds maximum allowed size")?;
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(Command::Publish {
|
||||
inscription: data_bounded,
|
||||
withdrawals,
|
||||
resp: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
||||
self.dispatch(|resp| Command::Publish {
|
||||
inscription: data_bounded,
|
||||
withdrawals,
|
||||
resp,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
resp_rx
|
||||
async fn publish_genesis_creating_channel(
|
||||
&self,
|
||||
block: &Block,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
) -> Result<PublishOutcome> {
|
||||
let own_key = self.bedrock_signing_key.public_key();
|
||||
ensure!(
|
||||
keys.first() == Some(&own_key),
|
||||
"Creating the channel requires our own key first; creation gives the turn to index 0"
|
||||
);
|
||||
let key_count = keys.len();
|
||||
let keys =
|
||||
Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
|
||||
|
||||
let config_op = ChannelConfigOp {
|
||||
channel: self.channel_id,
|
||||
keys,
|
||||
posting_timeframe: SlotTimeframe::from(
|
||||
system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME,
|
||||
),
|
||||
posting_timeout: SlotTimeout::from(system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT),
|
||||
configuration_threshold: system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD,
|
||||
transfer_threshold: system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD,
|
||||
};
|
||||
|
||||
let data = borsh::to_vec(block).context("Failed to serialize genesis block")?;
|
||||
let inscription: Inscription = data
|
||||
.try_into()
|
||||
.context("Genesis block exceeds maximum allowed size")?;
|
||||
// The config op runs first and becomes the tip, so it is the parent.
|
||||
let inscribe_op = InscriptionOp {
|
||||
channel_id: self.channel_id,
|
||||
inscription,
|
||||
parent: config_op.id(),
|
||||
signer: own_key,
|
||||
};
|
||||
let msg_id = inscribe_op.id();
|
||||
|
||||
let funded = fund_ops(
|
||||
&self.node,
|
||||
self.funding_key,
|
||||
self.priority_fee,
|
||||
[
|
||||
Op::ChannelConfig(config_op),
|
||||
Op::ChannelInscribe(inscribe_op),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mantle_tx = funded.funded_tx;
|
||||
|
||||
let signature = self
|
||||
.bedrock_signing_key
|
||||
.sign_payload(mantle_tx.hash().as_signing_bytes().as_ref());
|
||||
// Creation skips the channel-config signature check, but the proof must
|
||||
// still be well formed; index 0 is our own key.
|
||||
let config_proof =
|
||||
ChannelMultiSigProof::try_new(IndexedSignature::new(0, signature).into())
|
||||
.map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?;
|
||||
|
||||
let mut ops_proofs: OpsProofs = OpProof::ChannelMultiSigProof(config_proof).into();
|
||||
ops_proofs
|
||||
.try_push(OpProof::Ed25519Sig(signature))
|
||||
.map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?;
|
||||
if let Some(transfer_proof) = funded.transfer_proof {
|
||||
ops_proofs
|
||||
.try_push(transfer_proof)
|
||||
.map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?;
|
||||
}
|
||||
|
||||
info!("Creating the channel with {key_count} accredited key(s), genesis block bundled");
|
||||
|
||||
let tx = Box::new(SignedMantleTx::new(mantle_tx, ops_proofs));
|
||||
self.dispatch(|resp| Command::SubmitSignedTx { tx, msg_id, resp })
|
||||
.await
|
||||
}
|
||||
|
||||
async fn accredited_keys(&self) -> Result<Vec<Ed25519PublicKey>> {
|
||||
Ok(self
|
||||
.node
|
||||
.channel_state(self.channel_id)
|
||||
.await
|
||||
.context("Failed to read channel state")?
|
||||
.map(|state| state.accredited_keys.to_vec())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn submit_channel_config(&self, new_keys: Vec<Ed25519PublicKey>) -> Result<()> {
|
||||
ensure!(
|
||||
!new_keys.is_empty(),
|
||||
"Refusing to submit a committee update with no accredited keys"
|
||||
);
|
||||
let new_keys =
|
||||
Keys::try_from(new_keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
|
||||
|
||||
self.dispatch(|resp| Command::SubmitChannelConfig { new_keys, resp })
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task dropped the publish response"))?
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
@@ -446,6 +655,41 @@ const fn channel_update_inscription(orphan: &ChannelUpdateTx) -> Option<&Inscrip
|
||||
}
|
||||
}
|
||||
|
||||
/// Funds `ops` from the node's wallet, which appends a fee transfer (paid from
|
||||
/// `funding_key`, change back to it) and returns its proof.
|
||||
async fn fund_ops(
|
||||
node: &NodeHttpClient,
|
||||
funding_key: ZkPublicKey,
|
||||
priority_fee: u64,
|
||||
ops: impl IntoIterator<Item = Op>,
|
||||
) -> Result<logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundResponseBody> {
|
||||
let tx_builder = MantleTxBuilder::new()
|
||||
.extend_ops(ops)
|
||||
.map_err(|err| anyhow!("Too many ops in channel transaction: {err:?}"))?;
|
||||
node.fund_tx(WalletFundRequestBody {
|
||||
tip: None,
|
||||
tx_builder,
|
||||
change_public_key: funding_key,
|
||||
funding_public_keys: vec![funding_key],
|
||||
max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX),
|
||||
priority_fee,
|
||||
})
|
||||
.await
|
||||
.context("Failed to fund channel transaction")
|
||||
}
|
||||
|
||||
/// Reads the channel's committee state from the bedrock node, without a running
|
||||
/// sequencer. `None` means the channel does not exist yet.
|
||||
pub async fn read_channel_state(config: &BedrockConfig) -> Result<Option<ChannelState>> {
|
||||
let node = NodeHttpClient::new(
|
||||
CommonHttpClient::new(config.auth.clone().map(Into::into)),
|
||||
config.node_url.clone(),
|
||||
);
|
||||
node.channel_state(config.channel_id)
|
||||
.await
|
||||
.context("Failed to read channel state")
|
||||
}
|
||||
|
||||
/// Signs a `ChannelConfig` op (accredited keys + rotation params) with
|
||||
/// `signing_key`, funds it from `config.funding_key` via the node's wallet,
|
||||
/// and posts it straight to the bedrock node.
|
||||
@@ -495,22 +739,13 @@ pub async fn post_channel_config(
|
||||
config.node_url.clone(),
|
||||
);
|
||||
|
||||
// Fund the op from the node's wallet: the node appends a fee transfer
|
||||
// (paid from `funding_key`, change back to it) and returns its proof.
|
||||
let tx_builder = MantleTxBuilder::new()
|
||||
.extend_ops([Op::ChannelConfig(config_op)])
|
||||
.map_err(|err| anyhow!("Too many ops in channel config transaction: {err:?}"))?;
|
||||
let funded = node
|
||||
.fund_tx(WalletFundRequestBody {
|
||||
tip: None,
|
||||
tx_builder,
|
||||
change_public_key: config.funding_key,
|
||||
funding_public_keys: vec![config.funding_key],
|
||||
max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX),
|
||||
priority_fee: FundingConfig::DEFAULT_PRIORITY_FEE,
|
||||
})
|
||||
.await
|
||||
.context("Failed to fund channel config transaction")?;
|
||||
let funded = fund_ops(
|
||||
&node,
|
||||
config.funding_key,
|
||||
config.priority_fee,
|
||||
[Op::ChannelConfig(config_op)],
|
||||
)
|
||||
.await?;
|
||||
let mantle_tx = funded.funded_tx;
|
||||
|
||||
// Sign the funded tx: the appended fee transfer changes the hash.
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Discovery process for the `sequencer_stake` committee.
|
||||
|
||||
use log::warn;
|
||||
use sequencer_stake_core::{PendingUnstake, SequencerKey, SequencerStakeConfig, StakeRecord};
|
||||
|
||||
/// The accredited-keys list LEZ state says the channel should have, or `None`
|
||||
/// if it already matches the live Bedrock committee.
|
||||
///
|
||||
/// Level-triggered: re-fires on every block where the two disagree, not just
|
||||
/// the block a key crossed the minimum in, so a submission that never lands
|
||||
/// on Bedrock gets retried instead of being asked for once and forgotten.
|
||||
///
|
||||
/// Doesn't cover channel administration params like `posting_timeframe` —
|
||||
/// those are fixed constants supplied separately when building the
|
||||
/// `ChannelConfigOp`.
|
||||
#[must_use]
|
||||
pub fn committee_update(
|
||||
state: &lee::V03State,
|
||||
live_accredited_keys: &[SequencerKey],
|
||||
) -> Option<Vec<SequencerKey>> {
|
||||
let config = read_config(state)?;
|
||||
|
||||
// Sorted by key bytes so the list is deterministic across calls: a
|
||||
// `ChannelConfigOp`'s `keys` field must reproduce the same order every
|
||||
// time given the same state, since Bedrock's accredited-key index is
|
||||
// positional.
|
||||
let mut desired: Vec<SequencerKey> = config
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.net_stake() >= config.minimum_sequencer_stake)
|
||||
.map(|(key, _)| *key)
|
||||
.collect();
|
||||
desired.sort_unstable();
|
||||
|
||||
if desired.is_empty() {
|
||||
warn!(
|
||||
"No staked sequencer key meets the minimum; leaving the live committee untouched \
|
||||
since a channel cannot have zero accredited keys"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut live = live_accredited_keys.to_vec();
|
||||
live.sort_unstable();
|
||||
|
||||
(desired != live).then_some(desired)
|
||||
}
|
||||
|
||||
/// Ownership-account id + pending-release details for every entry with a
|
||||
/// pending unstake — candidates *worth attempting*, not necessarily valid yet.
|
||||
///
|
||||
/// Whether one is actually includable in a block is a separate check,
|
||||
/// [`finalize_unstake_is_valid`], applied uniformly to every `FinalizeUnstake`
|
||||
/// a block builder considers, regardless of whether it came from here (the
|
||||
/// sequencer's own proactive construction) or from the mempool (anyone else
|
||||
/// submitting it directly, per spec).
|
||||
#[must_use]
|
||||
pub fn finalize_unstake_candidates(state: &lee::V03State) -> Vec<(lee::AccountId, PendingUnstake)> {
|
||||
let Some(config) = read_config(state) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
config
|
||||
.entries
|
||||
.into_values()
|
||||
.filter_map(|entry| {
|
||||
let record = stake_record(state, entry.account_id)?;
|
||||
Some((entry.account_id, record.pending_unstake?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Block-validity rule for a `FinalizeUnstake` on `ownership_id`.
|
||||
///
|
||||
/// A partial release is always valid: `UnstakeRequest` already guarantees it
|
||||
/// leaves the key at or above the minimum, so committee membership is
|
||||
/// unaffected. A full drain — measured against tracked stake, not balance,
|
||||
/// which anyone can inflate — is valid only once the key is no longer
|
||||
/// accredited. An unknown account, no pending request, or no config entry
|
||||
/// also counts as valid; the program itself rejects those cases anyway.
|
||||
///
|
||||
/// TODO: checks live Bedrock membership, not an ordered history walk like the
|
||||
/// spec calls for. Fine for the sequencer building the next block, but a
|
||||
/// follower re-checking an already-adopted block has no independent way to
|
||||
/// verify it this way. Switch once zone-sdk exposes ordered `ChannelConfigOp`
|
||||
/// data to LEZ.
|
||||
#[must_use]
|
||||
pub fn finalize_unstake_is_valid(
|
||||
state: &lee::V03State,
|
||||
ownership_id: lee::AccountId,
|
||||
live_accredited_keys: &[SequencerKey],
|
||||
) -> bool {
|
||||
let Some(record) = stake_record(state, ownership_id) else {
|
||||
return true;
|
||||
};
|
||||
let Some(pending) = record.pending_unstake else {
|
||||
return true;
|
||||
};
|
||||
let Some(entry) =
|
||||
read_config(state).and_then(|config| config.entries.get(&record.sequencer_key).copied())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let fully_drains = entry.total_staked == pending.amount;
|
||||
!fully_drains || !live_accredited_keys.contains(&record.sequencer_key)
|
||||
}
|
||||
|
||||
/// Reads the `sequencer_stake` config account — a single account read, not a
|
||||
/// scan, since every `Stake`/`UnstakeRequest`/`FinalizeUnstake` keeps its
|
||||
/// `entries` map current as it executes. `None` only if the account is absent
|
||||
/// or undecodable, which genesis rules out.
|
||||
fn read_config(state: &lee::V03State) -> Option<SequencerStakeConfig> {
|
||||
let Some(account) =
|
||||
state.get_account_by_id_ref(system_accounts::sequencer_stake_config_account_id())
|
||||
else {
|
||||
warn!("sequencer_stake config account is absent");
|
||||
return None;
|
||||
};
|
||||
let config = SequencerStakeConfig::from_bytes(account.data.as_ref());
|
||||
if config.is_none() {
|
||||
warn!("sequencer_stake config account did not decode as SequencerStakeConfig");
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The `StakeRecord` an ownership account carries: which key it backs, plus
|
||||
/// whatever release is pending against it.
|
||||
fn stake_record(state: &lee::V03State, ownership_id: lee::AccountId) -> Option<StakeRecord> {
|
||||
let account = state.get_account_by_id_ref(ownership_id)?;
|
||||
StakeRecord::from_bytes(account.data.as_ref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn config_is_readable(state: &lee::V03State) -> bool {
|
||||
read_config(state).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lee_core::account::Account;
|
||||
use sequencer_stake_core::SequencerEntry;
|
||||
|
||||
use super::*;
|
||||
|
||||
const MINIMUM: u128 = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE;
|
||||
|
||||
/// One staked key: the config entry plus the ownership account backing it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Staked {
|
||||
key: SequencerKey,
|
||||
account_id: lee::AccountId,
|
||||
/// The config entry's tracked stake.
|
||||
total: u128,
|
||||
pending: Option<PendingUnstake>,
|
||||
/// The ownership account's, which sits above `total_staked` once
|
||||
/// anyone donates to it.
|
||||
balance: u128,
|
||||
}
|
||||
|
||||
impl Staked {
|
||||
fn new(tag: u8, total: u128) -> Self {
|
||||
Self {
|
||||
key: test_key(tag),
|
||||
account_id: lee::AccountId::new([tag.wrapping_add(100); 32]),
|
||||
total,
|
||||
pending: None,
|
||||
balance: total,
|
||||
}
|
||||
}
|
||||
|
||||
fn pending(mut self, amount: u128) -> Self {
|
||||
self.pending = Some(PendingUnstake {
|
||||
amount,
|
||||
destination: lee::AccountId::new([200; 32]),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
fn donated(mut self, amount: u128) -> Self {
|
||||
self.balance = self.balance.saturating_add(amount);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// LEZ state holding the config account plus one ownership account per key.
|
||||
fn state_with(stakes: impl IntoIterator<Item = Staked>) -> lee::V03State {
|
||||
let stakes: Vec<Staked> = stakes.into_iter().collect();
|
||||
|
||||
let ownership_accounts = stakes.iter().map(|staked| {
|
||||
(
|
||||
staked.account_id,
|
||||
Account {
|
||||
program_owner: programs::sequencer_stake().id(),
|
||||
balance: staked.balance,
|
||||
data: StakeRecord {
|
||||
sequencer_key: staked.key,
|
||||
pending_unstake: staked.pending,
|
||||
}
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("stake record fits"),
|
||||
..Account::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let config = Account {
|
||||
program_owner: programs::sequencer_stake().id(),
|
||||
data: SequencerStakeConfig {
|
||||
minimum_sequencer_stake: MINIMUM,
|
||||
entries: stakes
|
||||
.iter()
|
||||
.map(|staked| {
|
||||
(
|
||||
staked.key,
|
||||
SequencerEntry {
|
||||
account_id: staked.account_id,
|
||||
total_staked: staked.total,
|
||||
total_pending_unstake: staked
|
||||
.pending
|
||||
.map_or(0, |pending| pending.amount),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("config fits"),
|
||||
..Account::default()
|
||||
};
|
||||
|
||||
lee::V03State::new()
|
||||
.with_public_accounts(ownership_accounts)
|
||||
.with_public_accounts([(system_accounts::sequencer_stake_config_account_id(), config)])
|
||||
}
|
||||
|
||||
/// A distinct valid key per `tag`.
|
||||
fn test_key(tag: u8) -> SequencerKey {
|
||||
let bytes = crate::block_publisher::Ed25519Key::from_bytes(&[tag; 32])
|
||||
.public_key()
|
||||
.to_bytes();
|
||||
SequencerKey::new(bytes).expect("a derived public key is a curve point")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_below_minimum_is_not_accredited() {
|
||||
let staked = Staked::new(1, MINIMUM - 1);
|
||||
|
||||
assert!(committee_update(&state_with([staked]), &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_above_minimum_but_missing_live_is_added() {
|
||||
let staked = Staked::new(2, MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
committee_update(&state_with([staked]), &[]),
|
||||
Some(vec![staked.key])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_matching_live_committee_is_not_re_submitted() {
|
||||
let staked = Staked::new(3, MINIMUM);
|
||||
|
||||
assert!(committee_update(&state_with([staked]), &[staked.key]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_below_minimum_but_still_live_is_removed() {
|
||||
let exiting = Staked::new(4, MINIMUM).pending(MINIMUM);
|
||||
let staying = Staked::new(6, MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
committee_update(&state_with([exiting, staying]), &[exiting.key, staying.key]),
|
||||
Some(vec![staying.key])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_unstake_discounts_the_stake_backing_a_key() {
|
||||
let discounted = Staked::new(5, 2 * MINIMUM).pending(2 * MINIMUM);
|
||||
let staying = Staked::new(7, MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
committee_update(
|
||||
&state_with([discounted, staying]),
|
||||
&[discounted.key, staying.key]
|
||||
),
|
||||
Some(vec![staying.key])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_committee_is_never_submitted() {
|
||||
let exiting = Staked::new(4, MINIMUM).pending(MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
committee_update(&state_with([exiting]), &[exiting.key]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_keeps_firing_until_live_matches() {
|
||||
// A submission that never landed on Bedrock must be retried, not
|
||||
// asked for once and forgotten.
|
||||
let state = state_with([Staked::new(8, MINIMUM)]);
|
||||
|
||||
assert!(committee_update(&state, &[]).is_some());
|
||||
assert!(committee_update(&state, &[]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accredited_keys_are_deterministically_sorted() {
|
||||
let high = Staked::new(9, MINIMUM);
|
||||
let low = Staked::new(1, MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
committee_update(&state_with([high, low]), &[]),
|
||||
Some(vec![low.key, high.key])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pending_unstake_is_a_candidate_regardless_of_validity() {
|
||||
// Even a not-yet-valid full drain is a candidate — validity is decided
|
||||
// separately, by `finalize_unstake_is_valid`, uniformly for every
|
||||
// FinalizeUnstake a block builder considers.
|
||||
let staked = Staked::new(5, MINIMUM).pending(MINIMUM);
|
||||
|
||||
assert_eq!(
|
||||
finalize_unstake_candidates(&state_with([staked])),
|
||||
vec![(staked.account_id, staked.pending.unwrap())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_with_no_pending_unstake_is_not_a_candidate() {
|
||||
let staked = Staked::new(7, MINIMUM);
|
||||
|
||||
assert!(finalize_unstake_candidates(&state_with([staked])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_release_is_always_valid() {
|
||||
let staked = Staked::new(5, MINIMUM + 10).pending(10);
|
||||
let state = state_with([staked]);
|
||||
|
||||
assert!(finalize_unstake_is_valid(
|
||||
&state,
|
||||
staked.account_id,
|
||||
&[staked.key]
|
||||
));
|
||||
assert!(finalize_unstake_is_valid(&state, staked.account_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_drain_is_valid_only_once_absent_from_the_live_committee() {
|
||||
let staked = Staked::new(6, MINIMUM).pending(MINIMUM);
|
||||
let state = state_with([staked]);
|
||||
|
||||
assert!(!finalize_unstake_is_valid(
|
||||
&state,
|
||||
staked.account_id,
|
||||
&[staked.key]
|
||||
));
|
||||
assert!(finalize_unstake_is_valid(&state, staked.account_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_donated_balance_does_not_make_a_full_drain_look_partial() {
|
||||
// The drain is measured against the tracked stake, so a donation
|
||||
// sitting on the ownership account does not reclassify it as partial.
|
||||
let staked = Staked::new(7, MINIMUM).pending(MINIMUM).donated(1);
|
||||
|
||||
assert!(!finalize_unstake_is_valid(
|
||||
&state_with([staked]),
|
||||
staked.account_id,
|
||||
&[staked.key]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use bytesize::ByteSize;
|
||||
use common::config::BasicAuth;
|
||||
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use humantime_serde;
|
||||
use lee::{AccountId, Balance};
|
||||
use lee::{AccountId, Balance, PublicKey, Signature};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_key_management_system_service::keys::ZkPublicKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -33,12 +33,31 @@ pub enum GenesisAction {
|
||||
holder: AccountId,
|
||||
amount: Balance,
|
||||
},
|
||||
/// Stakes `sequencer_key` at genesis.
|
||||
StakeSequencer {
|
||||
sequencer_key: sequencer_stake_core::SequencerKey,
|
||||
ownership_public_key: PublicKey,
|
||||
stake_signature: Signature,
|
||||
},
|
||||
}
|
||||
|
||||
/// Sequencer p2p gossip configuration. Absent (`None`) disables gossip
|
||||
/// entirely: no sockets, no background tasks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GossipConfig {
|
||||
/// Multiaddr to listen on.
|
||||
#[serde(default = "default_gossip_listen_addr")]
|
||||
pub listen_addr: libp2p::Multiaddr,
|
||||
/// Peer multiaddrs to dial at startup, optionally with `/p2p/<peer_id>`.
|
||||
#[serde(default)]
|
||||
pub bootstrap_peers: Vec<libp2p::Multiaddr>,
|
||||
}
|
||||
|
||||
// TODO: Provide default values
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SequencerConfig {
|
||||
/// Home dir of sequencer storage.
|
||||
/// Home dir of sequencer storage. Holds `bedrock_signing_key`, and
|
||||
/// `sequencer_stake_signing_key` when a solo sequencer creates the channel.
|
||||
pub home: PathBuf,
|
||||
/// Maximum number of user transactions in a block (excludes the mandatory clock transaction).
|
||||
pub max_num_tx_in_block: usize,
|
||||
@@ -67,6 +86,9 @@ pub struct SequencerConfig {
|
||||
/// Address the Prometheus metrics exporter binds to.
|
||||
#[serde(default = "default_metrics_address")]
|
||||
pub metrics_address: Option<SocketAddr>,
|
||||
/// Sequencer p2p gossip configuration. `None` disables gossip.
|
||||
#[serde(default)]
|
||||
pub gossip: Option<GossipConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -109,12 +131,20 @@ const fn default_max_block_size() -> ByteSize {
|
||||
ByteSize::mib(1)
|
||||
}
|
||||
|
||||
fn default_gossip_listen_addr() -> libp2p::Multiaddr {
|
||||
"/ip4/0.0.0.0/udp/0/quic-v1"
|
||||
.parse()
|
||||
.expect("hardcoded default gossip listen addr is a valid multiaddr")
|
||||
}
|
||||
|
||||
#[expect(clippy::unnecessary_wraps, reason = "Required by serde")]
|
||||
const fn default_metrics_address() -> Option<SocketAddr> {
|
||||
Some(SequencerConfig::DEFAULT_METRICS_ADDRESS)
|
||||
}
|
||||
|
||||
/// Extra fee added to every funded Bedrock transaction, covering a gas price
|
||||
/// rise before it is mined.
|
||||
#[must_use]
|
||||
pub const fn default_priority_fee() -> u64 {
|
||||
logos_blockchain_zone_sdk::sequencer::FundingConfig::DEFAULT_PRIORITY_FEE
|
||||
10_000
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use common::{HashType, block::Block, transaction::LeeTransaction};
|
||||
use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission};
|
||||
use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit};
|
||||
use cross_zone::{
|
||||
EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program,
|
||||
};
|
||||
use cross_zone_inbox_core::message_key;
|
||||
use futures::{Stream, StreamExt as _};
|
||||
use lee::{GENESIS_BLOCK_ID, PublicKey};
|
||||
use log::{debug, error, warn};
|
||||
@@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20;
|
||||
struct PeerContext {
|
||||
peer_zone: [u8; 32],
|
||||
self_zone: [u8; 32],
|
||||
allowed_routes: Vec<CrossZoneRoute>,
|
||||
expected_pubkey: Option<PublicKey>,
|
||||
}
|
||||
|
||||
@@ -280,7 +281,6 @@ pub fn spawn_watchers(
|
||||
PeerContext {
|
||||
peer_zone: peer.channel_id,
|
||||
self_zone,
|
||||
allowed_routes: peer.allowed_routes,
|
||||
expected_pubkey,
|
||||
},
|
||||
poll_interval,
|
||||
@@ -556,7 +556,6 @@ fn record_block_deliveries(
|
||||
) -> bool {
|
||||
let peer_zone = peer.peer_zone;
|
||||
let self_zone = peer.self_zone;
|
||||
let allowed_routes = peer.allowed_routes.as_slice();
|
||||
// Collected and written once. The pending list is a single value, so a write
|
||||
// per delivery would rewrite the whole list once per message, which is
|
||||
// quadratic in a peer block that carries many of them, on a task holding the
|
||||
@@ -574,16 +573,15 @@ fn record_block_deliveries(
|
||||
if emission.target_zone != self_zone {
|
||||
continue;
|
||||
}
|
||||
// Mirrors the inbox guest, which is the authority. Dropping here keeps
|
||||
// an unroutable message from becoming a record that production would
|
||||
// feed in and give up on three blocks later.
|
||||
if !routes_permit(
|
||||
allowed_routes,
|
||||
message.program_id,
|
||||
emission.target_program_id,
|
||||
) {
|
||||
// Targets authorize their own sources now, so this is not authorization,
|
||||
// it is hygiene: a delivery the zone will certainly refuse still costs a
|
||||
// pending-list slot and three execution attempts before it is dead
|
||||
// lettered. Kept host-side only, never in `extract_emission` or the
|
||||
// verifier's re-derivation, where a check that depends on this build would
|
||||
// make the two disagree and halt ingestion.
|
||||
if is_sequencer_only_program(emission.target_program_id) {
|
||||
warn!(
|
||||
"Watcher dropping message from peer {}: no route from that source program to that target",
|
||||
"Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
continue;
|
||||
@@ -667,7 +665,7 @@ mod tests {
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::ZoneBlock;
|
||||
use ping_core::{SenderInstruction, ping_record_pda};
|
||||
use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id};
|
||||
use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -680,10 +678,6 @@ mod tests {
|
||||
PeerContext {
|
||||
peer_zone: PEER_ZONE,
|
||||
self_zone: SELF_ZONE,
|
||||
allowed_routes: vec![CrossZoneRoute {
|
||||
src_program_id: programs::ping_sender().id(),
|
||||
target_program_id: programs::ping_receiver().id(),
|
||||
}],
|
||||
expected_pubkey: None,
|
||||
}
|
||||
}
|
||||
@@ -707,10 +701,12 @@ mod tests {
|
||||
fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_zone: SELF_ZONE,
|
||||
target_program_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload: b"hi".to_vec(),
|
||||
ordinal: 0,
|
||||
};
|
||||
@@ -1080,13 +1076,49 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_delivery_with_no_route_is_never_recorded() {
|
||||
// The peer is routed to ping_receiver only. A bridging zone would also
|
||||
// route its lock program to wrapped_token, and `ping_sender` lets its
|
||||
// caller name wrapped_token as the target, so without the pair check
|
||||
// this emission would be recorded and delivered, minting with nothing
|
||||
// locked behind it. The guest rejects it too; dropping here keeps it
|
||||
// from becoming a record production feeds in and gives up on.
|
||||
async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() {
|
||||
// Targets authorize their own sources, so the watcher no longer decides
|
||||
// who may reach what. It still refuses to queue a delivery the zone will
|
||||
// certainly refuse: the inbox is injected by this node alone, so a peer
|
||||
// naming it as a target is junk that would cost a pending slot and three
|
||||
// execution attempts.
|
||||
let (_dir, dbio) = store();
|
||||
let mut cursor = None;
|
||||
let mut tip = None;
|
||||
|
||||
let outcome = consume_peer_stream(
|
||||
stream::iter(vec![peer_block_msg_to(
|
||||
1,
|
||||
0,
|
||||
programs::cross_zone_inbox().id(),
|
||||
)]),
|
||||
&peer_context(),
|
||||
&dbio,
|
||||
&mut cursor,
|
||||
&mut tip,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PassOutcome::Drained,
|
||||
"a message the watcher drops is not a failure"
|
||||
);
|
||||
assert!(
|
||||
recorded_keys(&dbio).is_empty(),
|
||||
"a message aimed at a sequencer-only program must not be recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(),
|
||||
Some(Slot::from(0)),
|
||||
"the slot was fully read, so the floor still advances"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_delivery_to_an_unrelated_target_is_still_recorded() {
|
||||
// The watcher is not the authorization point any more. A target it knows
|
||||
// nothing about is recorded and delivered, and that target decides.
|
||||
let (_dir, dbio) = store();
|
||||
let mut cursor = None;
|
||||
let mut tip = None;
|
||||
@@ -1104,19 +1136,11 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, PassOutcome::Drained);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PassOutcome::Drained,
|
||||
"an unroutable message is not a failure"
|
||||
);
|
||||
assert!(
|
||||
recorded_keys(&dbio).is_empty(),
|
||||
"a message with no route must not be recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(),
|
||||
Some(Slot::from(0)),
|
||||
"the slot was fully read, so the floor still advances"
|
||||
recorded_keys(&dbio).len(),
|
||||
1,
|
||||
"the watcher records it and lets the target refuse it"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Source of the channel's current accredited key set, read from L1. Retained
|
||||
//! for future ChannelConfig-signature work; not currently consumed by the
|
||||
//! gossip mesh.
|
||||
//!
|
||||
//! FIXME: `NodeKeysProvider` will be replaced by an L2 Join/Leave-derived provider in a follow-up.
|
||||
//! The related PR is <https://github.com/logos-blockchain/logos-execution-zone/pull/653>.
|
||||
|
||||
use std::{collections::HashSet, future::Future};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519PublicKey;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient,
|
||||
adapter::{Node as _, NodeHttpClient},
|
||||
};
|
||||
|
||||
use crate::config::BedrockConfig;
|
||||
|
||||
pub trait AccreditedKeysProvider: Send + 'static {
|
||||
/// The channel's current accredited Ed25519 keys.
|
||||
///
|
||||
/// An empty set is valid, meaning that the channel does not exist yet.
|
||||
fn accredited_keys(&self) -> impl Future<Output = Result<HashSet<[u8; 32]>>> + Send;
|
||||
}
|
||||
|
||||
/// Reads accredited keys from the bedrock node's channel state, on its own
|
||||
/// HTTP connection (no coupling to the publisher's drive task).
|
||||
pub struct NodeKeysProvider {
|
||||
node: NodeHttpClient,
|
||||
channel_id: ChannelId,
|
||||
}
|
||||
|
||||
impl NodeKeysProvider {
|
||||
#[must_use]
|
||||
pub fn new(config: &BedrockConfig) -> Self {
|
||||
let node = NodeHttpClient::new(
|
||||
CommonHttpClient::new(config.auth.clone().map(Into::into)),
|
||||
config.node_url.clone(),
|
||||
);
|
||||
Self {
|
||||
node,
|
||||
channel_id: config.channel_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccreditedKeysProvider for NodeKeysProvider {
|
||||
async fn accredited_keys(&self) -> Result<HashSet<[u8; 32]>> {
|
||||
let state = self
|
||||
.node
|
||||
.channel_state(self.channel_id)
|
||||
.await
|
||||
.context("Failed to read channel state for accredited keys")?;
|
||||
|
||||
Ok(state
|
||||
.map(|state| {
|
||||
state
|
||||
.accredited_keys
|
||||
.iter()
|
||||
.map(Ed25519PublicKey::to_bytes)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed key set, for tests.
|
||||
pub struct StaticKeysProvider(pub HashSet<[u8; 32]>);
|
||||
|
||||
impl AccreditedKeysProvider for StaticKeysProvider {
|
||||
async fn accredited_keys(&self) -> Result<HashSet<[u8; 32]>> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_provider_returns_its_set() {
|
||||
let keys = HashSet::from([[1; 32], [2; 32]]);
|
||||
let provider = StaticKeysProvider(keys.clone());
|
||||
assert_eq!(provider.accredited_keys().await.unwrap(), keys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Sequencer p2p gossip: a libp2p swarm that discovers peers via Kademlia,
|
||||
//! Identify, and bootstrap (plus mDNS behind a cargo feature).
|
||||
//!
|
||||
//! p2p is a latency optimization, never a source of truth: gossip being
|
||||
//! down degrades to L1-only behavior, and a gossip failure after startup
|
||||
//! never halts the node.
|
||||
|
||||
pub use libp2p::Multiaddr;
|
||||
pub use network::{GossipNetwork, GossipTxPublisher};
|
||||
|
||||
pub mod accreditation;
|
||||
pub mod network;
|
||||
pub mod seen_cache;
|
||||
pub mod validation;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,666 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use common::transaction::LeeTransaction;
|
||||
use futures::StreamExt as _;
|
||||
#[cfg(feature = "mdns")]
|
||||
use libp2p::mdns;
|
||||
use libp2p::{
|
||||
Multiaddr, PeerId, SwarmBuilder, gossipsub, identify,
|
||||
identity::Keypair,
|
||||
kad,
|
||||
multiaddr::Protocol,
|
||||
swarm::{NetworkBehaviour, Swarm, SwarmEvent},
|
||||
};
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
use mempool::MemPoolHandle;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{TransactionOrigin, config::GossipConfig, gossip::seen_cache::SeenCache};
|
||||
|
||||
/// How long to wait for the first listen address before failing startup.
|
||||
const LISTEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// How often the watchdog warns that the driver is down and the node is L1-only.
|
||||
const DRIVER_OUTAGE_WARN_INTERVAL: Duration = Duration::from_secs(300);
|
||||
/// Recently-seen gossiped transaction hashes kept for dedup.
|
||||
const SEEN_CACHE_CAPACITY: usize = 4096;
|
||||
/// Outbound local-publish channel depth; `try_send` drops on overflow.
|
||||
const TX_PUBLISH_CHANNEL_CAPACITY: usize = 1024;
|
||||
/// Headroom over `max_block_size` for `GossipSub` protobuf framing (signature,
|
||||
/// source, seqno, topic) so a maximum-size transaction still fits the transmit
|
||||
/// limit instead of being dropped at the transport before validation.
|
||||
const GOSSIP_FRAME_MARGIN: u64 = 4096;
|
||||
/// How often to re-dial bootstrap peers while the node has no connected
|
||||
/// peers, so a node that starts before its bootstrap peer still joins.
|
||||
const BOOTSTRAP_RETRY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// Local transactions whose publish failed (e.g. `InsufficientPeers` while
|
||||
/// the mesh is still forming), kept for republish once a peer subscribes.
|
||||
const PENDING_PUBLISH_CAPACITY: usize = 256;
|
||||
|
||||
#[derive(NetworkBehaviour)]
|
||||
struct GossipBehaviour {
|
||||
gossipsub: gossipsub::Behaviour,
|
||||
identify: identify::Behaviour,
|
||||
kademlia: kad::Behaviour<kad::store::MemoryStore>,
|
||||
#[cfg(feature = "mdns")]
|
||||
mdns: mdns::tokio::Behaviour,
|
||||
}
|
||||
|
||||
/// Handle to the running gossip network. Dropping it stops the drive task.
|
||||
pub struct GossipNetwork {
|
||||
connected_rx: watch::Receiver<Vec<[u8; 32]>>,
|
||||
shutdown: CancellationToken,
|
||||
listen_addrs: Vec<Multiaddr>,
|
||||
local_peer_id: PeerId,
|
||||
tx_tx: mpsc::Sender<LeeTransaction>,
|
||||
}
|
||||
|
||||
/// Handle for publishing locally-submitted transactions to the gossip mesh.
|
||||
/// `publish` is non-blocking: a full channel drops the transaction rather
|
||||
/// than back-pressuring the caller.
|
||||
#[derive(Clone)]
|
||||
pub struct GossipTxPublisher(mpsc::Sender<LeeTransaction>);
|
||||
|
||||
impl GossipTxPublisher {
|
||||
pub fn publish(&self, tx: LeeTransaction) {
|
||||
if let Err(err) = self.0.try_send(tx) {
|
||||
log::debug!("Dropping local tx publish: outbound gossip channel full or closed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GossipNetwork {
|
||||
/// Builds the swarm, binds `listen_addr`, seeds Kademlia and dials
|
||||
/// bootstrap peers, and spawns the drive task.
|
||||
pub async fn start(
|
||||
config: GossipConfig,
|
||||
channel_id: [u8; 32],
|
||||
signing_key: Ed25519Key,
|
||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
max_block_size: u64,
|
||||
) -> Result<Self> {
|
||||
// Reuse the node's L1 bedrock signing key as the libp2p identity. The
|
||||
// secret stays in a `Zeroizing` buffer that both `ed25519_from_bytes`
|
||||
// and drop wipe.
|
||||
//
|
||||
// FIXME: get rid of `unsecure` here when we introduce accredited key
|
||||
// handling, and a separete `Gossip node key -> Bedrock signing key` mapping.
|
||||
let mut secret = signing_key.into_unsecured().to_bytes();
|
||||
let keypair = Keypair::ed25519_from_bytes(&mut *secret)
|
||||
.map_err(|err| anyhow!("Invalid bedrock signing key for libp2p identity: {err}"))?;
|
||||
let local_peer_id = keypair.public().to_peer_id();
|
||||
|
||||
let listen_addr = config.listen_addr;
|
||||
let bootstrap = config.bootstrap_peers;
|
||||
|
||||
let message_id_fn = |msg: &gossipsub::Message| {
|
||||
// Undecodable messages still need a message-id, but it must be a
|
||||
// deterministic digest, not the attacker-controlled bytes
|
||||
// themselves and not a process-local hash (`DefaultHasher`):
|
||||
// every peer has to derive the same id from the same data.
|
||||
let id = borsh::from_slice::<LeeTransaction>(&msg.data).map_or_else(
|
||||
|_| common::block::OwnHasher::hash(&msg.data).0.to_vec(),
|
||||
|tx| tx.hash().0.to_vec(),
|
||||
);
|
||||
gossipsub::MessageId::from(id)
|
||||
};
|
||||
// Derived from this node's `max_block_size`, so all nodes on a channel
|
||||
// must agree on it: a node configured smaller would drop a larger frame
|
||||
// its peers send at the codec (an inbound-stream close, not a clean
|
||||
// application-level Reject), i.e. a near-invisible partial partition.
|
||||
// `max_block_size` is already effectively a channel-wide parameter
|
||||
// (block validation depends on it), so this inherits that requirement.
|
||||
let max_transmit_size = usize::try_from(max_block_size.saturating_add(GOSSIP_FRAME_MARGIN))
|
||||
.unwrap_or(usize::MAX);
|
||||
let gossipsub_config = gossipsub::ConfigBuilder::default()
|
||||
.validation_mode(gossipsub::ValidationMode::Strict)
|
||||
.message_id_fn(message_id_fn)
|
||||
.validate_messages()
|
||||
.max_transmit_size(max_transmit_size)
|
||||
.build()
|
||||
.map_err(|err| anyhow!("Failed to build gossipsub config: {err}"))?;
|
||||
|
||||
let gossipsub_behaviour = gossipsub::Behaviour::new(
|
||||
gossipsub::MessageAuthenticity::Signed(keypair.clone()),
|
||||
gossipsub_config,
|
||||
)
|
||||
.map_err(|err| anyhow!("Failed to build gossipsub behaviour: {err}"))?;
|
||||
let identify_behaviour =
|
||||
identify::Behaviour::new(identify::Config::new("/lez/1".to_owned(), keypair.public()));
|
||||
let kademlia_behaviour = {
|
||||
let store = kad::store::MemoryStore::new(local_peer_id);
|
||||
let mut kademlia = kad::Behaviour::new(local_peer_id, store);
|
||||
kademlia.set_mode(Some(kad::Mode::Server));
|
||||
kademlia
|
||||
};
|
||||
#[cfg(feature = "mdns")]
|
||||
let mdns_behaviour = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)
|
||||
.map_err(|err| anyhow!("Failed to build mdns behaviour: {err}"))?;
|
||||
|
||||
let mut swarm = SwarmBuilder::with_existing_identity(keypair)
|
||||
.with_tokio()
|
||||
.with_quic()
|
||||
.with_behaviour(|_key| GossipBehaviour {
|
||||
gossipsub: gossipsub_behaviour,
|
||||
identify: identify_behaviour,
|
||||
kademlia: kademlia_behaviour,
|
||||
#[cfg(feature = "mdns")]
|
||||
mdns: mdns_behaviour,
|
||||
})
|
||||
.expect("behaviour constructor is infallible")
|
||||
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60)))
|
||||
.build();
|
||||
|
||||
// subscribe to topic for the selected channel
|
||||
let topic = Self::get_topic_for_channel(channel_id);
|
||||
swarm
|
||||
.behaviour_mut()
|
||||
.gossipsub
|
||||
.subscribe(&topic)
|
||||
.context("Failed to subscribe to gossip tx topic")?;
|
||||
|
||||
swarm
|
||||
.listen_on(listen_addr)
|
||||
.context("Failed to listen on gossip address")?;
|
||||
|
||||
// Fail fast on bind errors: wait for the first listen address.
|
||||
let listen_addrs = wait_for_listen_addr(&mut swarm).await?;
|
||||
log::info!("Gossip listening on {listen_addrs:?} as {local_peer_id}");
|
||||
|
||||
// Seed Kademlia with bootstrap peers that carry an embedded peer id;
|
||||
// dial the rest directly, since Kademlia can't route to an address
|
||||
// without a known peer id.
|
||||
for addr in &bootstrap {
|
||||
let embedded_peer_id = match addr.iter().last() {
|
||||
Some(Protocol::P2p(peer_id)) => Some(peer_id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(peer_id) = embedded_peer_id {
|
||||
swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.add_address(&peer_id, addr.clone());
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = swarm.dial(addr.clone()) {
|
||||
log::warn!("Failed to dial gossip bootstrap peer {addr}: {err}");
|
||||
}
|
||||
}
|
||||
if let Err(err) = swarm.behaviour_mut().kademlia.bootstrap() {
|
||||
log::debug!("Kademlia bootstrap skipped (no known peers yet): {err}");
|
||||
}
|
||||
|
||||
let (connected_tx, connected_rx) = watch::channel(Vec::new());
|
||||
let shutdown = CancellationToken::new();
|
||||
let (tx_tx, tx_rx) = mpsc::channel::<LeeTransaction>(TX_PUBLISH_CHANNEL_CAPACITY);
|
||||
|
||||
let driver = tokio::spawn(run_drive_task(DriveTask {
|
||||
swarm,
|
||||
connected: HashSet::new(),
|
||||
pubkeys: HashMap::new(),
|
||||
connected_tx,
|
||||
shutdown: shutdown.clone(),
|
||||
topic,
|
||||
mempool: mempool_handle,
|
||||
seen: SeenCache::new(SEEN_CACHE_CAPACITY),
|
||||
max_block_size,
|
||||
tx_rx,
|
||||
bootstrap,
|
||||
pending_publish: VecDeque::new(),
|
||||
}));
|
||||
spawn_driver_watchdog(driver, shutdown.clone());
|
||||
|
||||
Ok(Self {
|
||||
connected_rx,
|
||||
shutdown,
|
||||
listen_addrs,
|
||||
local_peer_id,
|
||||
tx_tx,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_topic_for_channel(channel_id: [u8; 32]) -> gossipsub::IdentTopic {
|
||||
gossipsub::IdentTopic::new(format!("/lez/{}/v1/txs", hex::encode(channel_id)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn listen_addrs(&self) -> Vec<Multiaddr> {
|
||||
self.listen_addrs.clone()
|
||||
}
|
||||
|
||||
/// Listen addresses with the `/p2p/` peer id appended — the form other
|
||||
/// nodes put in `bootstrap_peers`.
|
||||
#[must_use]
|
||||
pub fn bootstrap_addrs(&self) -> Vec<Multiaddr> {
|
||||
self.listen_addrs
|
||||
.iter()
|
||||
.map(|addr| addr.clone().with(Protocol::P2p(self.local_peer_id)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn local_peer_id(&self) -> PeerId {
|
||||
self.local_peer_id
|
||||
}
|
||||
|
||||
/// Handle for publishing locally-submitted transactions to the mesh.
|
||||
#[must_use]
|
||||
pub fn tx_publisher(&self) -> GossipTxPublisher {
|
||||
GossipTxPublisher(self.tx_tx.clone())
|
||||
}
|
||||
|
||||
/// Ed25519 public keys of currently connected peers.
|
||||
#[must_use]
|
||||
pub fn connected_peers(&self) -> Vec<[u8; 32]> {
|
||||
self.connected_rx.borrow().clone()
|
||||
}
|
||||
|
||||
/// Cancelled when a graceful shutdown is requested (the handle is dropped).
|
||||
/// Observers must NOT halt the node on it.
|
||||
#[must_use]
|
||||
pub fn shutdown_token(&self) -> CancellationToken {
|
||||
self.shutdown.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GossipNetwork {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the drive task owns.
|
||||
struct DriveTask {
|
||||
swarm: Swarm<GossipBehaviour>,
|
||||
connected: HashSet<PeerId>,
|
||||
/// Ed25519 public keys of peers seen via Identify, keyed by `PeerId`.
|
||||
pubkeys: HashMap<PeerId, [u8; 32]>,
|
||||
connected_tx: watch::Sender<Vec<[u8; 32]>>,
|
||||
shutdown: CancellationToken,
|
||||
topic: gossipsub::IdentTopic,
|
||||
mempool: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
seen: SeenCache,
|
||||
max_block_size: u64,
|
||||
tx_rx: mpsc::Receiver<LeeTransaction>,
|
||||
/// Configured bootstrap peers, re-dialed while the node is isolated.
|
||||
bootstrap: Vec<Multiaddr>,
|
||||
/// Local transactions whose publish failed, retried when a peer
|
||||
/// subscribes to the topic. Bounded; the oldest is dropped on overflow.
|
||||
pending_publish: VecDeque<LeeTransaction>,
|
||||
}
|
||||
|
||||
impl DriveTask {
|
||||
#[expect(
|
||||
clippy::wildcard_enum_match_arm,
|
||||
reason = "SwarmEvent is non_exhaustive; only connection and behaviour events are handled"
|
||||
)]
|
||||
fn on_swarm_event(&mut self, event: SwarmEvent<GossipBehaviourEvent>) {
|
||||
match event {
|
||||
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
|
||||
self.connected.insert(peer_id);
|
||||
self.update_connected_watch();
|
||||
}
|
||||
SwarmEvent::ConnectionClosed {
|
||||
peer_id,
|
||||
num_established: 0,
|
||||
..
|
||||
} => {
|
||||
self.connected.remove(&peer_id);
|
||||
self.pubkeys.remove(&peer_id);
|
||||
self.update_connected_watch();
|
||||
}
|
||||
SwarmEvent::Behaviour(behaviour_event) => self.on_behaviour_event(behaviour_event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// `GossipBehaviourEvent` is generated by `#[derive(NetworkBehaviour)]`;
|
||||
// clippy does not flag wildcard matches against macro-generated enums,
|
||||
// so no `#[expect(clippy::wildcard_enum_match_arm)]` is needed here.
|
||||
fn on_behaviour_event(&mut self, event: GossipBehaviourEvent) {
|
||||
match event {
|
||||
GossipBehaviourEvent::Gossipsub(gossipsub::Event::Message {
|
||||
propagation_source,
|
||||
message_id,
|
||||
message,
|
||||
}) => {
|
||||
self.on_gossip_message(propagation_source, &message_id, &message.data);
|
||||
}
|
||||
GossipBehaviourEvent::Gossipsub(gossipsub::Event::Subscribed { topic, .. })
|
||||
if topic == self.topic.hash() =>
|
||||
{
|
||||
self.flush_pending_publishes();
|
||||
}
|
||||
GossipBehaviourEvent::Identify(identify::Event::Received { peer_id, info, .. }) => {
|
||||
if let Ok(ed25519_pubkey) = info.public_key.try_into_ed25519() {
|
||||
self.pubkeys.insert(peer_id, ed25519_pubkey.to_bytes());
|
||||
self.update_connected_watch();
|
||||
}
|
||||
for addr in info
|
||||
.listen_addrs
|
||||
.into_iter()
|
||||
.filter(|addr| !is_unspecified(addr))
|
||||
{
|
||||
self.swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mdns")]
|
||||
GossipBehaviourEvent::Mdns(mdns::Event::Discovered(peers)) => {
|
||||
for (peer_id, addr) in peers {
|
||||
if let Err(err) = self.swarm.dial(addr) {
|
||||
log::debug!("Failed to dial mdns-discovered peer {peer_id}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_connected_watch(&self) {
|
||||
let mut peers: Vec<[u8; 32]> = self
|
||||
.connected
|
||||
.iter()
|
||||
.filter_map(|peer_id| self.pubkeys.get(peer_id).copied())
|
||||
.collect();
|
||||
peers.sort_unstable();
|
||||
self.connected_tx.send_if_modified(|current| {
|
||||
if *current == peers {
|
||||
false
|
||||
} else {
|
||||
*current = peers;
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validates an inbound gossiped transaction and reports the mesh
|
||||
/// acceptance decision, admitting it to the mempool on first sight.
|
||||
fn on_gossip_message(
|
||||
&mut self,
|
||||
source: PeerId,
|
||||
message_id: &gossipsub::MessageId,
|
||||
data: &[u8],
|
||||
) {
|
||||
use crate::gossip::validation::{TxEvaluation, evaluate_transaction};
|
||||
|
||||
let acceptance = match evaluate_transaction(data, self.max_block_size) {
|
||||
TxEvaluation::Reject(reason) => {
|
||||
log::debug!("Rejecting gossiped tx from {source}: {reason}");
|
||||
gossipsub::MessageAcceptance::Reject
|
||||
}
|
||||
TxEvaluation::Accept(tx) => {
|
||||
let hash = tx.hash();
|
||||
if self.seen.contains(&hash) {
|
||||
gossipsub::MessageAcceptance::Ignore
|
||||
} else {
|
||||
match self.mempool.try_push((TransactionOrigin::Gossip, tx)) {
|
||||
Ok(()) => {
|
||||
// mark seen only on successful pushes, so that if mempool is full we
|
||||
// can later receive the same tx from gossip and try pushing it again
|
||||
self.seen.insert(hash);
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Mempool full; forwarding tx {hash:?} without admitting");
|
||||
}
|
||||
}
|
||||
|
||||
gossipsub::MessageAcceptance::Accept
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_ = self
|
||||
.swarm
|
||||
.behaviour_mut()
|
||||
.gossipsub
|
||||
.report_message_validation_result(message_id, &source, acceptance);
|
||||
}
|
||||
|
||||
/// Publishes a locally-submitted transaction to the mesh. Marked seen
|
||||
/// only once actually published; a failed publish (e.g.
|
||||
/// `InsufficientPeers` while the mesh is still forming) is queued and
|
||||
/// retried when a peer subscribes to the topic.
|
||||
fn publish_transaction(&mut self, tx: LeeTransaction) {
|
||||
let hash = tx.hash();
|
||||
let bytes = borsh::to_vec(&tx).expect("tx borsh serialization should not fail");
|
||||
match self
|
||||
.swarm
|
||||
.behaviour_mut()
|
||||
.gossipsub
|
||||
.publish(self.topic.clone(), bytes)
|
||||
{
|
||||
// Duplicate means the mesh already carries this message.
|
||||
Ok(_) | Err(gossipsub::PublishError::Duplicate) => {
|
||||
self.seen.insert(hash);
|
||||
}
|
||||
Err(err) => {
|
||||
log::debug!("Queueing local tx publish {hash:?} for retry: {err}");
|
||||
if self.pending_publish.len() >= PENDING_PUBLISH_CAPACITY
|
||||
&& let Some(dropped) = self.pending_publish.pop_front()
|
||||
{
|
||||
log::debug!(
|
||||
"Pending publish queue full; dropping oldest tx {:?}",
|
||||
dropped.hash()
|
||||
);
|
||||
}
|
||||
self.pending_publish.push_back(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries queued local publishes; still-failing ones are re-queued by
|
||||
/// `publish_transaction`.
|
||||
fn flush_pending_publishes(&mut self) {
|
||||
for tx in std::mem::take(&mut self.pending_publish) {
|
||||
self.publish_transaction(tx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-dials bootstrap peers while the node is isolated. The startup
|
||||
/// attempt runs once, so a node that starts before its bootstrap peer
|
||||
/// would otherwise never join the mesh.
|
||||
fn retry_bootstrap(&mut self) {
|
||||
if !self.connected.is_empty() || self.bootstrap.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::debug!(
|
||||
"No connected gossip peers; retrying {} bootstrap peer(s)",
|
||||
self.bootstrap.len()
|
||||
);
|
||||
for addr in self.bootstrap.clone() {
|
||||
if let Err(err) = self.swarm.dial(addr.clone()) {
|
||||
log::debug!("Failed to dial gossip bootstrap peer {addr}: {err}");
|
||||
}
|
||||
}
|
||||
_ = self.swarm.behaviour_mut().kademlia.bootstrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `addr` carries an unspecified (`0.0.0.0` / `::`) IP component.
|
||||
/// Peers behind a default `0.0.0.0` listen address advertise these; feeding
|
||||
/// them to Kademlia would pollute the routing table with unroutable entries.
|
||||
#[expect(
|
||||
clippy::wildcard_enum_match_arm,
|
||||
reason = "Protocol is non_exhaustive; only the IP variants matter here"
|
||||
)]
|
||||
fn is_unspecified(addr: &Multiaddr) -> bool {
|
||||
addr.iter().any(|proto| match proto {
|
||||
Protocol::Ip4(ip) => ip.is_unspecified(),
|
||||
Protocol::Ip6(ip) => ip.is_unspecified(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derives the libp2p `PeerId` an Ed25519 public key produces.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "unused by the mesh until a later gossip task; exercised by the identity test below"
|
||||
)
|
||||
)]
|
||||
pub(crate) fn peer_id_from_ed25519(
|
||||
pubkey: &[u8; 32],
|
||||
) -> Result<PeerId, libp2p::identity::DecodingError> {
|
||||
libp2p::identity::ed25519::PublicKey::try_from_bytes(pubkey)
|
||||
.map(|key| libp2p::identity::PublicKey::from(key).to_peer_id())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::integer_division_remainder_used,
|
||||
reason = "Generated by select! macro, can't be easily rewritten to avoid this lint"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::wildcard_enum_match_arm,
|
||||
reason = "SwarmEvent is non_exhaustive; only startup listener events are handled here"
|
||||
)]
|
||||
async fn wait_for_listen_addr(swarm: &mut Swarm<GossipBehaviour>) -> Result<Vec<Multiaddr>> {
|
||||
let deadline = tokio::time::sleep(LISTEN_TIMEOUT);
|
||||
tokio::pin!(deadline);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = swarm.select_next_some() => match event {
|
||||
SwarmEvent::NewListenAddr { address, .. } => return Ok(vec![address]),
|
||||
SwarmEvent::ListenerError { error, .. } => {
|
||||
anyhow::bail!("Gossip listener error during startup: {error}");
|
||||
}
|
||||
SwarmEvent::ListenerClosed { reason, .. } => {
|
||||
anyhow::bail!("Gossip listener closed during startup: {reason:?}");
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
() = &mut deadline => {
|
||||
anyhow::bail!("Timed out waiting for gossip listen address");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::integer_division_remainder_used,
|
||||
reason = "Generated by select! macro, can't be easily rewritten to avoid this lint"
|
||||
)]
|
||||
async fn run_drive_task(mut task: DriveTask) {
|
||||
// `interval_at`: startup already dialed the bootstrap peers, so the
|
||||
// first tick waits a full interval instead of firing immediately.
|
||||
let mut bootstrap_retry = tokio::time::interval_at(
|
||||
tokio::time::Instant::now()
|
||||
.checked_add(BOOTSTRAP_RETRY_INTERVAL)
|
||||
.expect("bootstrap retry deadline within Instant range"),
|
||||
BOOTSTRAP_RETRY_INTERVAL,
|
||||
);
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = task.shutdown.cancelled() => break,
|
||||
event = task.swarm.select_next_some() => task.on_swarm_event(event),
|
||||
Some(tx) = task.tx_rx.recv() => task.publish_transaction(tx),
|
||||
_ = bootstrap_retry.tick() => task.retry_bootstrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the driver handle so it can detect the task ending. A graceful shutdown
|
||||
/// cancels `shutdown` first, so that path stays silent; a crash leaves it
|
||||
/// uncancelled, and operators are warned the node is running L1-only until the
|
||||
/// handle is dropped.
|
||||
#[expect(
|
||||
clippy::integer_division_remainder_used,
|
||||
reason = "Generated by select! macro, can't be easily rewritten to avoid this lint"
|
||||
)]
|
||||
fn spawn_driver_watchdog(driver: tokio::task::JoinHandle<()>, shutdown: CancellationToken) {
|
||||
tokio::spawn(async move {
|
||||
_ = driver.await;
|
||||
if shutdown.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
log::error!(
|
||||
"Sequencer gossip network is down; continuing L1-only. \
|
||||
Restart the node to restore p2p."
|
||||
);
|
||||
tokio::select! {
|
||||
() = shutdown.cancelled() => return,
|
||||
() = tokio::time::sleep(DRIVER_OUTAGE_WARN_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
|
||||
use super::*;
|
||||
use crate::config::GossipConfig;
|
||||
|
||||
const TEST_MAX_BLOCK_SIZE: u64 = 1 << 20;
|
||||
|
||||
fn test_config() -> GossipConfig {
|
||||
GossipConfig {
|
||||
listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap(),
|
||||
bootstrap_peers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn test_mempool_handle() -> MemPoolHandle<(TransactionOrigin, LeeTransaction)> {
|
||||
mempool::MemPool::new(1000).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libp2p_identity_matches_kms_public_key() {
|
||||
// The PeerId derived from an Ed25519 public key must equal the
|
||||
// PeerId the same secret produces as a libp2p identity.
|
||||
let secret = [9; 32];
|
||||
let kms_pubkey = Ed25519Key::from_bytes(&secret).public_key().to_bytes();
|
||||
let mut secret_for_libp2p = secret;
|
||||
let keypair =
|
||||
libp2p::identity::Keypair::ed25519_from_bytes(&mut secret_for_libp2p).unwrap();
|
||||
assert_eq!(
|
||||
peer_id_from_ed25519(&kms_pubkey).unwrap(),
|
||||
keypair.public().to_peer_id()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_binds_and_reports_listen_addr() {
|
||||
let network = GossipNetwork::start(
|
||||
test_config(),
|
||||
[1; 32],
|
||||
Ed25519Key::from_bytes(&[9; 32]),
|
||||
test_mempool_handle(),
|
||||
TEST_MAX_BLOCK_SIZE,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let addrs = network.listen_addrs();
|
||||
assert!(!addrs.is_empty());
|
||||
assert!(addrs[0].to_string().contains("/udp/"));
|
||||
assert!(network.connected_peers().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_cancels_driver() {
|
||||
let network = GossipNetwork::start(
|
||||
test_config(),
|
||||
[1; 32],
|
||||
Ed25519Key::from_bytes(&[9; 32]),
|
||||
test_mempool_handle(),
|
||||
TEST_MAX_BLOCK_SIZE,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let token = network.shutdown_token();
|
||||
drop(network);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), token.cancelled())
|
||||
.await
|
||||
.expect("driver should stop when the handle is dropped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use common::HashType;
|
||||
|
||||
/// Bounded, FIFO-eviction membership cache over transaction hashes.
|
||||
///
|
||||
/// The mempool is a plain channel with no dedup, so the gossip layer tracks
|
||||
/// recently seen transactions here to avoid re-admitting duplicates that
|
||||
/// arrive from multiple peers or echo back after a local publish.
|
||||
///
|
||||
/// TODO: expose counters to `metrics` later.
|
||||
pub struct SeenCache {
|
||||
capacity: usize,
|
||||
order: VecDeque<HashType>,
|
||||
set: HashSet<HashType>,
|
||||
hits: u64,
|
||||
inserts: u64,
|
||||
evictions: u64,
|
||||
}
|
||||
|
||||
impl SeenCache {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity: capacity.max(1),
|
||||
order: VecDeque::new(),
|
||||
set: HashSet::new(),
|
||||
hits: 0,
|
||||
inserts: 0,
|
||||
evictions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `hash` as seen. Returns `true` if it was newly inserted,
|
||||
/// `false` if already present (a hit). Evicts the oldest entry when full.
|
||||
pub fn insert(&mut self, hash: HashType) -> bool {
|
||||
if self.set.contains(&hash) {
|
||||
self.hits = self.hits.saturating_add(1);
|
||||
return false;
|
||||
}
|
||||
if self.order.len() >= self.capacity
|
||||
&& let Some(oldest) = self.order.pop_front()
|
||||
{
|
||||
self.set.remove(&oldest);
|
||||
self.evictions = self.evictions.saturating_add(1);
|
||||
}
|
||||
self.set.insert(hash);
|
||||
self.order.push_back(hash);
|
||||
self.inserts = self.inserts.saturating_add(1);
|
||||
true
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains(&self, hash: &HashType) -> bool {
|
||||
self.set.contains(hash)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.order.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.order.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn hits(&self) -> u64 {
|
||||
self.hits
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn inserts(&self) -> u64 {
|
||||
self.inserts
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn evictions(&self) -> u64 {
|
||||
self.evictions
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn h(n: u8) -> HashType {
|
||||
HashType([n; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_reports_novelty_and_contains() {
|
||||
let mut cache = SeenCache::new(4);
|
||||
assert!(cache.insert(h(1)));
|
||||
assert!(!cache.insert(h(1)));
|
||||
assert!(cache.contains(&h(1)));
|
||||
assert!(!cache.contains(&h(2)));
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert_eq!(cache.inserts(), 1);
|
||||
assert_eq!(cache.hits(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_past_capacity() {
|
||||
let mut cache = SeenCache::new(2);
|
||||
assert!(cache.insert(h(1)));
|
||||
assert!(cache.insert(h(2)));
|
||||
assert!(cache.insert(h(3))); // evicts h(1)
|
||||
assert!(!cache.contains(&h(1)));
|
||||
assert!(cache.contains(&h(2)));
|
||||
assert!(cache.contains(&h(3)));
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert_eq!(cache.evictions(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::transaction::LeeTransaction;
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
use mempool::MemPool;
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
|
||||
use crate::{TransactionOrigin, config::GossipConfig, gossip::GossipNetwork};
|
||||
|
||||
const CHANNEL: [u8; 32] = [1; 32];
|
||||
const TEST_MAX_BLOCK_SIZE: u64 = 1 << 20;
|
||||
|
||||
fn pubkey(secret: [u8; 32]) -> [u8; 32] {
|
||||
Ed25519Key::from_bytes(&secret).public_key().to_bytes()
|
||||
}
|
||||
|
||||
/// A real, validly-signed transfer, reusing the same helper the RPC-side
|
||||
/// admission tests use.
|
||||
fn valid_transaction() -> LeeTransaction {
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let sign_key1 = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
||||
common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key1)
|
||||
}
|
||||
|
||||
/// Structurally well-formed but with a signature/public-key pair that does
|
||||
/// not match, so it decodes but fails the stateless witness check; used to
|
||||
/// exercise rejection through the real gossip pipeline rather than
|
||||
/// `evaluate_transaction` directly.
|
||||
fn invalidly_signed_transaction() -> LeeTransaction {
|
||||
let LeeTransaction::Public(mut tx) = valid_transaction() else {
|
||||
unreachable!("valid_transaction always builds a Public transaction");
|
||||
};
|
||||
let (signature, _correct_public_key) = tx.witness_set.signatures_and_public_keys()[0].clone();
|
||||
let wrong_public_key =
|
||||
lee::PublicKey::new_from_private_key(&initial_pub_accounts_private_keys()[1].pub_sign_key);
|
||||
tx.witness_set =
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![(signature, wrong_public_key)]);
|
||||
LeeTransaction::Public(tx)
|
||||
}
|
||||
|
||||
async fn start_node(
|
||||
secret: [u8; 32],
|
||||
bootstrap: Vec<libp2p::Multiaddr>,
|
||||
) -> (GossipNetwork, MemPool<(TransactionOrigin, LeeTransaction)>) {
|
||||
let config = GossipConfig {
|
||||
listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap(),
|
||||
bootstrap_peers: bootstrap,
|
||||
};
|
||||
let (mempool, mempool_handle) = MemPool::new(1000);
|
||||
let network = GossipNetwork::start(
|
||||
config,
|
||||
CHANNEL,
|
||||
Ed25519Key::from_bytes(&secret),
|
||||
mempool_handle,
|
||||
TEST_MAX_BLOCK_SIZE,
|
||||
)
|
||||
.await
|
||||
.expect("node should start");
|
||||
(network, mempool)
|
||||
}
|
||||
|
||||
async fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.expect("deadline within Instant range");
|
||||
while Instant::now() < deadline {
|
||||
if condition() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nodes_discover_each_other_via_bootstrap() {
|
||||
let secrets = [[10; 32], [11; 32], [12; 32]];
|
||||
let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await;
|
||||
let a_addr = node_a.listen_addrs()[0].clone();
|
||||
// B bootstraps with a `/p2p/`-suffixed address (the Kademlia-seeded
|
||||
// branch operators configure), C with a plain one (the direct-dial
|
||||
// branch), covering both paths in `GossipNetwork::start`.
|
||||
let a_addr_with_peer_id = a_addr
|
||||
.clone()
|
||||
.with(libp2p::multiaddr::Protocol::P2p(node_a.local_peer_id()));
|
||||
let (node_b, _mempool_b) = start_node(secrets[1], vec![a_addr_with_peer_id]).await;
|
||||
let (node_c, _mempool_c) = start_node(secrets[2], vec![a_addr]).await;
|
||||
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
node_a.connected_peers().contains(&pubkey(secrets[1]))
|
||||
&& node_a.connected_peers().contains(&pubkey(secrets[2]))
|
||||
})
|
||||
.await,
|
||||
"A never connected to both B and C; A sees {:?}",
|
||||
node_a.connected_peers()
|
||||
);
|
||||
drop((node_a, node_b, node_c));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transaction_submitted_to_one_node_reaches_others() {
|
||||
let secrets = [[20; 32], [21; 32], [22; 32]];
|
||||
let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await;
|
||||
let a_addr = node_a.listen_addrs()[0].clone();
|
||||
let (node_b, mut mempool_b) = start_node(secrets[1], vec![a_addr.clone()]).await;
|
||||
let (node_c, mut mempool_c) = start_node(secrets[2], vec![a_addr]).await;
|
||||
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
node_a.connected_peers().contains(&pubkey(secrets[1]))
|
||||
&& node_a.connected_peers().contains(&pubkey(secrets[2]))
|
||||
})
|
||||
.await,
|
||||
"A never connected to both B and C"
|
||||
);
|
||||
|
||||
let tx = valid_transaction();
|
||||
let expected_hash = tx.hash();
|
||||
node_a.tx_publisher().publish(tx.clone());
|
||||
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
mempool_b
|
||||
.pop()
|
||||
.is_some_and(|(_, received)| received.hash() == expected_hash)
|
||||
})
|
||||
.await,
|
||||
"B never received the gossiped transaction"
|
||||
);
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
mempool_c
|
||||
.pop()
|
||||
.is_some_and(|(_, received)| received.hash() == expected_hash)
|
||||
})
|
||||
.await,
|
||||
"C never received the gossiped transaction"
|
||||
);
|
||||
drop((node_a, node_b, node_c));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_transaction_is_not_propagated() {
|
||||
// `TxPublisher::publish` only accepts a `LeeTransaction`, so genuinely
|
||||
// undecodable bytes are not reachable through the public API; instead we
|
||||
// publish a structurally well-formed transaction with an invalid
|
||||
// signature, which still exercises the real gossip pipeline's rejection
|
||||
// path (`evaluate_transaction`'s stateless check, then
|
||||
// `MessageAcceptance::Reject`) end-to-end.
|
||||
let secrets = [[30; 32], [31; 32]];
|
||||
let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await;
|
||||
let a_addr = node_a.listen_addrs()[0].clone();
|
||||
let (node_b, mut mempool_b) = start_node(secrets[1], vec![a_addr]).await;
|
||||
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
node_a.connected_peers().contains(&pubkey(secrets[1]))
|
||||
})
|
||||
.await,
|
||||
"A never connected to B"
|
||||
);
|
||||
|
||||
// Publish a valid transaction first and wait for it to arrive: swarm
|
||||
// connectivity alone does not mean the gossipsub mesh has grafted, and
|
||||
// without proof the link is live the absence assertion below would pass
|
||||
// vacuously.
|
||||
let valid_tx = valid_transaction();
|
||||
let valid_hash = valid_tx.hash();
|
||||
node_a.tx_publisher().publish(valid_tx);
|
||||
assert!(
|
||||
wait_for(Duration::from_secs(30), || {
|
||||
mempool_b
|
||||
.pop()
|
||||
.is_some_and(|(_, received)| received.hash() == valid_hash)
|
||||
})
|
||||
.await,
|
||||
"B never received the valid transaction; gossip link not live"
|
||||
);
|
||||
|
||||
node_a
|
||||
.tx_publisher()
|
||||
.publish(invalidly_signed_transaction());
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
mempool_b.pop().is_none(),
|
||||
"an invalidly-signed transaction must not reach the mempool"
|
||||
);
|
||||
drop((node_a, node_b));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Pure decision function for an inbound gossiped transaction.
|
||||
//!
|
||||
//! The same stateless admission the RPC performs, minus mempool/seen-cache
|
||||
//! side effects (those live in the drive task). Testable without a swarm.
|
||||
|
||||
use common::transaction::LeeTransaction;
|
||||
|
||||
/// Reserve ~200 bytes for block header overhead, mirroring the RPC check.
|
||||
const BLOCK_HEADER_OVERHEAD: u64 = 200;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TxEvaluation {
|
||||
/// Structurally valid and authenticated; forward and admit.
|
||||
Accept(LeeTransaction),
|
||||
/// Malformed / forbidden; do not forward. `GossipSub` peer scoring is not
|
||||
/// configured, so this does not currently penalize the propagating peer.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
/// Decodes and stateless-checks a gossiped transaction the same way the RPC
|
||||
/// admits a submitted one: size check, signature/witness check, then the
|
||||
/// sequencer-only-program guard.
|
||||
#[must_use]
|
||||
pub fn evaluate_transaction(data: &[u8], max_block_size: u64) -> TxEvaluation {
|
||||
let tx_size = u64::try_from(data.len()).unwrap_or(u64::MAX);
|
||||
let max_tx_size = max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD);
|
||||
if tx_size > max_tx_size {
|
||||
return TxEvaluation::Reject(format!("transaction too large: {tx_size} > {max_tx_size}"));
|
||||
}
|
||||
|
||||
let tx: LeeTransaction = match borsh::from_slice(data) {
|
||||
Ok(tx) => tx,
|
||||
Err(err) => return TxEvaluation::Reject(format!("undecodable transaction: {err}")),
|
||||
};
|
||||
|
||||
let authenticated = match tx.transaction_stateless_check() {
|
||||
Ok(tx) => tx,
|
||||
Err(err) => return TxEvaluation::Reject(format!("stateless check failed: {err:?}")),
|
||||
};
|
||||
|
||||
if let LeeTransaction::Public(public_tx) = &authenticated
|
||||
&& crate::is_sequencer_only_program(public_tx.message().program_id)
|
||||
{
|
||||
return TxEvaluation::Reject("sequencer-only program".to_owned());
|
||||
}
|
||||
|
||||
TxEvaluation::Accept(authenticated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn valid_transaction() -> LeeTransaction {
|
||||
let acc1 = initial_public_user_accounts()[0].account_id;
|
||||
let acc2 = initial_public_user_accounts()[1].account_id;
|
||||
let sign_key1 = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
||||
common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_transaction_is_accepted() {
|
||||
let tx = valid_transaction();
|
||||
let bytes = borsh::to_vec(&tx).unwrap();
|
||||
assert!(matches!(
|
||||
evaluate_transaction(&bytes, 1 << 20),
|
||||
TxEvaluation::Accept(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_bytes_are_rejected() {
|
||||
assert!(matches!(
|
||||
evaluate_transaction(&[0xff, 0xff, 0xff], 1 << 20),
|
||||
TxEvaluation::Reject(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_transaction_is_rejected() {
|
||||
let tx = valid_transaction();
|
||||
let bytes = borsh::to_vec(&tx).unwrap();
|
||||
assert!(matches!(
|
||||
evaluate_transaction(&bytes, 1),
|
||||
TxEvaluation::Reject(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
+545
-48
@@ -2,7 +2,7 @@ use std::{
|
||||
collections::VecDeque,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
@@ -21,7 +21,8 @@ use futures::StreamExt as _;
|
||||
use itertools::Itertools as _;
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::GENESIS_BLOCK_ID;
|
||||
use log::{debug, error, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use logos_blockchain_core::mantle::ops::channel::Ed25519PublicKey;
|
||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
use logos_blockchain_zone_sdk::{
|
||||
Slot, ZoneMessage,
|
||||
@@ -42,6 +43,7 @@ use storage::sequencer::{
|
||||
WithdrawalReconciliationKey, ZoneAnchorRecord,
|
||||
},
|
||||
};
|
||||
use tokio_retry::{Retry, strategy::FixedInterval};
|
||||
|
||||
use crate::{
|
||||
block_publisher::{BlockPublisherTrait, MsgId, NoteId, ZoneSdkPublisher},
|
||||
@@ -51,8 +53,10 @@ use crate::{
|
||||
|
||||
pub mod block_publisher;
|
||||
pub mod block_store;
|
||||
pub mod committee_discovery;
|
||||
pub mod config;
|
||||
pub mod cross_zone_watcher;
|
||||
pub mod gossip;
|
||||
|
||||
#[cfg(feature = "mock")]
|
||||
pub mod mock;
|
||||
@@ -74,6 +78,25 @@ const RETIRE_DISPATCH_AFTER_FAILURES: u32 = 3;
|
||||
/// block; nothing is dropped.
|
||||
const MAX_DISPATCHES_PER_BLOCK: usize = 16;
|
||||
|
||||
/// Fixed, public key behind a genesis-only funding account: the faucet can
|
||||
/// only be called top-level, not as `Stake`'s mover, so this account is a
|
||||
/// pass-through that receives faucet funds and then moves them into the real
|
||||
/// stake account. Not a secret: every node derives the same account, and it
|
||||
/// holds nothing once genesis has run.
|
||||
// TODO: replace the faucet pass-through with a real deposit from Bedrock,
|
||||
// once that path exists, instead of a fixed genesis-only key.
|
||||
const GENESIS_STAKE_FUNDING_KEY: [u8; 32] = [9; 32];
|
||||
|
||||
/// A number of Bedrock slots, as opposed to a [`Slot`] position.
|
||||
type SlotCount = u64;
|
||||
|
||||
/// A founding sequencer's key, plus the ownership account attesting to its stake.
|
||||
type FoundingStake = (
|
||||
sequencer_stake_core::SequencerKey,
|
||||
lee::PublicKey,
|
||||
lee::Signature,
|
||||
);
|
||||
|
||||
/// The origin of a transaction.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TransactionOrigin {
|
||||
@@ -81,6 +104,8 @@ pub enum TransactionOrigin {
|
||||
User,
|
||||
/// Transactions generated by the sequencer itself.
|
||||
Sequencer,
|
||||
/// Transactions received via p2p gossip from a peer sequencer.
|
||||
Gossip,
|
||||
}
|
||||
|
||||
impl From<TransactionOrigin> for sequencer_core_metrics::TransactionOrigin {
|
||||
@@ -88,6 +113,7 @@ impl From<TransactionOrigin> for sequencer_core_metrics::TransactionOrigin {
|
||||
match origin {
|
||||
TransactionOrigin::User => Self::User,
|
||||
TransactionOrigin::Sequencer => Self::Sequencer,
|
||||
TransactionOrigin::Gossip => Self::Gossip,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,15 +135,26 @@ pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
|
||||
/// store handle, so leaving them running would keep the `RocksDB` lock held
|
||||
/// and make the home directory unopenable by a restarting sequencer.
|
||||
watchers: TaskGroup,
|
||||
/// Channel tip slot as of the last committee-config submission.
|
||||
last_committee_submission_slot: Option<Slot>,
|
||||
}
|
||||
|
||||
impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
const CHANNEL_PROBE_RETRIES: usize = 29;
|
||||
const CHANNEL_PROBE_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
/// Channel slots between committee-config submissions; a margin over
|
||||
/// observed Bedrock confirmation lag.
|
||||
const COMMITTEE_SUBMISSION_COOLDOWN: SlotCount = 10;
|
||||
|
||||
/// Starts the sequencer using the provided configuration.
|
||||
/// If an existing database is found, the sequencer state is loaded from it and
|
||||
/// assumed to represent the correct latest state consistent with Bedrock-finalized data.
|
||||
/// If no database is found, the sequencer performs a fresh start from genesis,
|
||||
/// initializing its state with the accounts defined in the configuration file.
|
||||
fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) {
|
||||
fn open_or_create_store(
|
||||
config: &SequencerConfig,
|
||||
bootstrap_sequencer_key: Option<sequencer_stake_core::SequencerKey>,
|
||||
) -> (SequencerStore, lee::V03State) {
|
||||
let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap();
|
||||
let db_path = config.db_path();
|
||||
|
||||
@@ -146,7 +183,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
db_path.display()
|
||||
);
|
||||
|
||||
let (genesis_state, genesis_txs) = build_genesis_state(config);
|
||||
let (genesis_state, genesis_txs) = build_genesis_state(config, bootstrap_sequencer_key);
|
||||
|
||||
let hashable_data = HashableBlockData {
|
||||
block_id: GENESIS_BLOCK_ID,
|
||||
@@ -228,7 +265,36 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
hex::encode(bedrock_signing_key.public_key().to_bytes())
|
||||
);
|
||||
|
||||
let (store, state) = Self::open_or_create_store(&config);
|
||||
let own_sequencer_key =
|
||||
sequencer_stake_core::SequencerKey::new(bedrock_signing_key.public_key().to_bytes())
|
||||
.expect("our own Bedrock public key is a valid Ed25519 public key");
|
||||
|
||||
// Only seed our own key into genesis as the bootstrap sequencer if the
|
||||
// channel doesn't exist yet. Otherwise it's someone else's channel and
|
||||
// we join later, the normal self-join way.
|
||||
let channel_probe_retry_strategy =
|
||||
FixedInterval::new(Self::CHANNEL_PROBE_RETRY_DELAY).take(Self::CHANNEL_PROBE_RETRIES);
|
||||
let channel_already_exists = Retry::start(channel_probe_retry_strategy, || async {
|
||||
BP::channel_exists(&config.bedrock_config)
|
||||
.await
|
||||
.inspect_err(|err| warn!("Failed to probe Bedrock channel: {err:#}"))
|
||||
})
|
||||
.await
|
||||
.expect("Failed to probe Bedrock channel");
|
||||
if channel_already_exists {
|
||||
info!("Channel already exists; joining as a non channel creator");
|
||||
} else {
|
||||
info!("Channel does not exist yet; starting it as channel creator");
|
||||
}
|
||||
let bootstrap_sequencer_key = (!channel_already_exists).then_some(own_sequencer_key);
|
||||
|
||||
let (store, state) = Self::open_or_create_store(&config, bootstrap_sequencer_key);
|
||||
|
||||
assert!(
|
||||
committee_discovery::config_is_readable(&state),
|
||||
"sequencer_stake config account is absent or undecodable; this chain's state is not \
|
||||
one this sequencer can operate on"
|
||||
);
|
||||
|
||||
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
|
||||
&config, &store, &state,
|
||||
@@ -307,17 +373,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
"First pending block on fresh start should be the genesis block"
|
||||
);
|
||||
|
||||
// The channel is born holding only its creator's key, so a configured
|
||||
// founding set is applied by the same tx that writes genesis; the
|
||||
// committee is never observable without it.
|
||||
let founding_committee = founding_committee(&config, own_sequencer_key);
|
||||
|
||||
let mut last_checkpoint = None;
|
||||
for block in &pending_blocks {
|
||||
let outcome = block_publisher
|
||||
.publish_block(block, vec![])
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"Failed to publish block {} on fresh start: {err:#}",
|
||||
block.header.block_id
|
||||
)
|
||||
});
|
||||
let publish = match &founding_committee {
|
||||
Some(keys) if block.header.block_id == GENESIS_BLOCK_ID => {
|
||||
block_publisher
|
||||
.publish_genesis_creating_channel(block, keys.clone())
|
||||
.await
|
||||
}
|
||||
_ => block_publisher.publish_block(block, vec![]).await,
|
||||
};
|
||||
let outcome = publish.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"Failed to publish block {} on fresh start: {err:#}",
|
||||
block.header.block_id
|
||||
)
|
||||
});
|
||||
last_checkpoint = Some(outcome.checkpoint);
|
||||
store
|
||||
.raise_published_high_water(block.header.block_id)
|
||||
@@ -341,6 +417,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
sequencer_config: config,
|
||||
block_publisher,
|
||||
watchers,
|
||||
last_committee_submission_slot: None,
|
||||
};
|
||||
|
||||
sequencer_core_metrics::record_chain_height(sequencer_core.chain_height());
|
||||
@@ -585,10 +662,18 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
|
||||
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
||||
let BlockWithMeta { block, withdrawals } = self
|
||||
.build_block_from_mempool()
|
||||
/// Runs everything this sequencer owes its turn: builds a block from
|
||||
/// mempool transactions, publishes it via zone-sdk, and submits any
|
||||
/// committee-config update the new state calls for.
|
||||
pub async fn run_production_turn(&mut self) -> Result<u64> {
|
||||
let live_accredited_keys = self.live_accredited_sequencer_keys().await;
|
||||
|
||||
let BlockWithMeta {
|
||||
block,
|
||||
withdrawals,
|
||||
committee_update,
|
||||
} = self
|
||||
.build_block_from_mempool(live_accredited_keys.as_deref())
|
||||
.context("Failed to build block from mempool transactions")?;
|
||||
|
||||
let block_publisher::PublishOutcome {
|
||||
@@ -607,6 +692,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.raise_published_high_water(block.header.block_id)
|
||||
.context("Failed to persist published high water mark")?;
|
||||
|
||||
// Independent Mantle tx, not bundled with the block above — join/exit
|
||||
// config updates don't need to be.
|
||||
self.submit_committee_update(committee_update).await;
|
||||
|
||||
let withdrawal_reconciliation_keys: Vec<_> = released_notes
|
||||
.iter()
|
||||
.map(withdrawal_reconciliation_key)
|
||||
@@ -622,6 +711,78 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
Ok(block.header.block_id)
|
||||
}
|
||||
|
||||
/// Live committee snapshot for gating `FinalizeUnstake` inclusion and
|
||||
/// committee updates. `None` if it could not be read.
|
||||
async fn live_accredited_sequencer_keys(
|
||||
&self,
|
||||
) -> Option<Vec<sequencer_stake_core::SequencerKey>> {
|
||||
match self.block_publisher.accredited_keys().await {
|
||||
Ok(keys) => Some(
|
||||
keys.iter()
|
||||
.filter_map(|key| {
|
||||
sequencer_stake_core::SequencerKey::new(key.to_bytes()).or_else(|| {
|
||||
warn!(
|
||||
"Ignoring accredited key {}: not a valid Ed25519 public key",
|
||||
hex::encode(key.to_bytes())
|
||||
);
|
||||
None
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to read live committee snapshot; skipping FinalizeUnstake inclusion \
|
||||
and committee updates this round: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the channel has advanced far enough past `last_submission` to
|
||||
/// submit again. A missing tip counts as no advance.
|
||||
fn committee_cooldown_elapsed(last_submission: Option<Slot>, tip: Option<Slot>) -> bool {
|
||||
let Some(last_submission) = last_submission else {
|
||||
return true;
|
||||
};
|
||||
tip.is_some_and(|tip| {
|
||||
tip.into_inner()
|
||||
.saturating_sub(last_submission.into_inner())
|
||||
>= Self::COMMITTEE_SUBMISSION_COOLDOWN
|
||||
})
|
||||
}
|
||||
|
||||
async fn submit_committee_update(
|
||||
&mut self,
|
||||
committee_update: Option<Vec<sequencer_stake_core::SequencerKey>>,
|
||||
) {
|
||||
let Some(new_keys) = committee_update else {
|
||||
return;
|
||||
};
|
||||
let tip_slot = match self.block_publisher.channel_tip_slot().await {
|
||||
Ok(tip_slot) => tip_slot,
|
||||
Err(err) => {
|
||||
warn!("Failed to read channel tip slot; skipping committee update: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !Self::committee_cooldown_elapsed(self.last_committee_submission_slot, tip_slot) {
|
||||
return;
|
||||
}
|
||||
let new_keys = new_keys
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
Ed25519PublicKey::from_bytes(&key.to_bytes())
|
||||
.expect("sequencer key was decoded from a valid Ed25519 public key")
|
||||
})
|
||||
.collect();
|
||||
self.last_committee_submission_slot = tip_slot;
|
||||
if let Err(err) = self.block_publisher.submit_channel_config(new_keys).await {
|
||||
warn!("Failed to submit committee channel-config update: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies our own freshly-published block to the head with the [`MsgId`] the
|
||||
/// publish assigned it, so the head advances and the later adopted
|
||||
/// redelivery dedups, then persists it.
|
||||
@@ -688,13 +849,26 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
) -> bool {
|
||||
let tx_hash = tx.hash();
|
||||
match origin {
|
||||
TransactionOrigin::User => {
|
||||
// Gossiped transactions arrive from untrusted peers, same as
|
||||
// user-submitted ones, so they get the same full state validation.
|
||||
TransactionOrigin::User | TransactionOrigin::Gossip => {
|
||||
let validated_diff = match tx.validate_on_state(state, block_height, timestamp) {
|
||||
Ok(diff) => diff,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
|
||||
);
|
||||
// A gossiped tx the leader already included is
|
||||
// expected to fail here (e.g. on nonce) for every
|
||||
// other node on its turn; that is steady-state noise,
|
||||
// not an error. User-submitted failures still warrant
|
||||
// `error!`.
|
||||
if matches!(origin, TransactionOrigin::Gossip) {
|
||||
debug!(
|
||||
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -736,7 +910,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
true
|
||||
}
|
||||
|
||||
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
|
||||
fn build_block_from_mempool(
|
||||
&mut self,
|
||||
live_accredited_keys: Option<&[sequencer_stake_core::SequencerKey]>,
|
||||
) -> Result<BlockWithMeta> {
|
||||
let now = Instant::now();
|
||||
|
||||
// Decoded outside the chain lock, and read before it is taken: the usual
|
||||
@@ -774,7 +951,13 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
// The delivery records are classified in here rather than after, so the
|
||||
// final state can be read by reference. Cloning it cost a full state
|
||||
// copy on every block of every zone, cross-zone or not.
|
||||
let (prev_block_hash, new_block_height, mut working_state, pending_dispatches) = {
|
||||
let (
|
||||
prev_block_hash,
|
||||
new_block_height,
|
||||
mut working_state,
|
||||
pending_dispatches,
|
||||
finalize_unstake_txs,
|
||||
) = {
|
||||
let chain = self.chain.lock().expect("chain state mutex poisoned");
|
||||
let tip = chain.head_tip();
|
||||
let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| {
|
||||
@@ -803,7 +986,13 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
(prev, height, chain.head_state().clone(), pending)
|
||||
(
|
||||
prev,
|
||||
height,
|
||||
chain.head_state().clone(),
|
||||
pending,
|
||||
build_finalize_unstake_txs(chain.head_state()),
|
||||
)
|
||||
};
|
||||
|
||||
if !settled.is_empty() {
|
||||
@@ -871,6 +1060,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
// it was not submitted by a user.
|
||||
let mut pending_from_store = pending_deposits;
|
||||
pending_from_store.extend(pending_dispatches);
|
||||
pending_from_store.extend(finalize_unstake_txs);
|
||||
while let Some((origin, tx, from_store)) = pending_from_store
|
||||
.pop_front()
|
||||
.map(|tx| (TransactionOrigin::Sequencer, tx, true))
|
||||
@@ -935,6 +1125,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
break;
|
||||
}
|
||||
|
||||
// Block-validity rule: a not-yet-valid FinalizeUnstake is dropped
|
||||
// outright, not applied — whether it arrived via the mempool
|
||||
// (anyone may submit one, per spec) or from this sequencer's own
|
||||
// discovery above. It re-appears on its own once conditions are
|
||||
// met (mempool: whoever wants it finalized resubmits;
|
||||
// discovery-sourced: reconstructed fresh next block), so it
|
||||
// doesn't need requeuing here.
|
||||
if !finalize_unstake_is_includable(&working_state, &tx, live_accredited_keys) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let before_tx_apply = Instant::now();
|
||||
let applied = Self::apply_mempool_transaction(
|
||||
&mut working_state,
|
||||
@@ -996,7 +1197,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
|
||||
sequencer_core_metrics::record_block_creation_time(now.elapsed());
|
||||
|
||||
Ok(BlockWithMeta { block, withdrawals })
|
||||
let committee_update = live_accredited_keys
|
||||
.and_then(|keys| committee_discovery::committee_update(&working_state, keys));
|
||||
|
||||
Ok(BlockWithMeta {
|
||||
block,
|
||||
withdrawals,
|
||||
committee_update,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the current head state under the lock without cloning it, so callers
|
||||
@@ -1215,6 +1423,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
struct BlockWithMeta {
|
||||
block: Block,
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
committee_update: Option<Vec<sequencer_stake_core::SequencerKey>>,
|
||||
}
|
||||
|
||||
/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt
|
||||
@@ -1492,8 +1701,9 @@ fn apply_follow_update(
|
||||
}
|
||||
|
||||
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
|
||||
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
||||
/// by genesis `InitConfig` transactions and reconstructed by replaying them.
|
||||
/// the only accounts seeded outside any transaction. Everything else, including
|
||||
/// the bootstrap sequencer's own stake, is applied as a genesis transaction in
|
||||
/// [`build_genesis_state`] so followers replay it instead of guessing it.
|
||||
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
#[cfg(not(feature = "testnet"))]
|
||||
let base = testnet_initial_state::initial_state();
|
||||
@@ -1513,7 +1723,10 @@ fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
/// genesis transactions. Returns the final state and the list of
|
||||
/// [`LeeTransaction`]s that should be committed to the genesis block so external
|
||||
/// observers can replay them.
|
||||
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
|
||||
fn build_genesis_state(
|
||||
config: &SequencerConfig,
|
||||
bootstrap_sequencer_key: Option<sequencer_stake_core::SequencerKey>,
|
||||
) -> (lee::V03State, Vec<LeeTransaction>) {
|
||||
let mut state = build_initial_state(config);
|
||||
|
||||
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
|
||||
@@ -1523,16 +1736,24 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
);
|
||||
|
||||
// Config txs seed the config accounts by transaction, so every node
|
||||
// reconstructs them by replaying the genesis block. The wrapped-token minter is
|
||||
// initialized on every zone (wrapped_token is a builtin), since its InitConfig
|
||||
// is user-callable and a config PDA left default would be claimable by anyone as
|
||||
// the first initializer (a minter hijack). The inbox allowlist is initialized
|
||||
// only on receiving zones; the inbox is sequencer-only, so its default config
|
||||
// PDA is not user-claimable, merely unused until the zone receives.
|
||||
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx());
|
||||
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
|
||||
// reconstructs them by replaying the genesis block. The wrapped-token minter and
|
||||
// both emitters' pins are initialized on every zone: all three are builtins with
|
||||
// a user-callable InitConfig, so a config PDA left default is claimable by the
|
||||
// first initializer, hijacking the minter or repointing an emitter's outbox. The
|
||||
// inbox allowlist is initialized only on receiving zones; the inbox is
|
||||
// sequencer-only, so its default config PDA is not user-claimable, merely unused
|
||||
// until the zone receives.
|
||||
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx(
|
||||
config.cross_zone.as_ref(),
|
||||
));
|
||||
let ping_sender_config_tx = std::iter::once(cross_zone::build_ping_sender_init_config_tx());
|
||||
let ping_receiver_config_tx = std::iter::once(cross_zone::build_ping_receiver_init_config_tx(
|
||||
config.cross_zone.as_ref(),
|
||||
));
|
||||
let bridge_lock_config_tx = std::iter::once(cross_zone::build_bridge_lock_init_config_tx());
|
||||
let inbox_config_tx = config.cross_zone.as_ref().map(|_| {
|
||||
let self_zone = *config.bedrock_config.channel_id.as_ref();
|
||||
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
|
||||
cross_zone::build_inbox_init_config_tx(self_zone)
|
||||
});
|
||||
let supply_txs = config.genesis.iter().filter_map(|action| match action {
|
||||
GenesisAction::SupplyAccount {
|
||||
@@ -1545,13 +1766,32 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
Some(build_supply_bridge_account_genesis_transaction(*balance))
|
||||
}
|
||||
// Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a
|
||||
// genesis tx.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
// genesis tx. Stakes are built separately below.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } | GenesisAction::StakeSequencer { .. } => {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// The creator falls back to staking itself, signing with the key it owns.
|
||||
let mut staked = founding_stakes(config);
|
||||
if staked.is_empty() {
|
||||
staked.extend(bootstrap_sequencer_key.map(|key| {
|
||||
let key_path = config.home.join("sequencer_stake_signing_key");
|
||||
let owner = load_or_create_stake_signing_key(&key_path)
|
||||
.expect("Failed to load or create the stake signing key");
|
||||
let signature = sign_genesis_stake(0, key, &owner);
|
||||
(key, lee::PublicKey::new_from_private_key(&owner), signature)
|
||||
}));
|
||||
}
|
||||
let bootstrap_stake_txs = build_stake_genesis_transactions(&staked);
|
||||
|
||||
let genesis_txs = wrapped_token_config_tx
|
||||
.chain(ping_sender_config_tx)
|
||||
.chain(ping_receiver_config_tx)
|
||||
.chain(bridge_lock_config_tx)
|
||||
.chain(inbox_config_tx)
|
||||
.chain(supply_txs)
|
||||
.chain(bootstrap_stake_txs)
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
.inspect(|tx| {
|
||||
state
|
||||
@@ -1564,13 +1804,180 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
(state, genesis_txs)
|
||||
}
|
||||
|
||||
fn founding_stakes(config: &SequencerConfig) -> Vec<FoundingStake> {
|
||||
config
|
||||
.genesis
|
||||
.iter()
|
||||
.filter_map(|action| match action {
|
||||
GenesisAction::StakeSequencer {
|
||||
sequencer_key,
|
||||
ownership_public_key,
|
||||
stake_signature,
|
||||
} => Some((
|
||||
*sequencer_key,
|
||||
ownership_public_key.clone(),
|
||||
stake_signature.clone(),
|
||||
)),
|
||||
GenesisAction::SupplyAccount { .. }
|
||||
| GenesisAction::SupplyBridgeAccount { .. }
|
||||
| GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The accredited keys a newly created channel should carry, `own_key` first
|
||||
/// because creation gives the turn to index 0. `None` leaves creation to the
|
||||
/// plain inscription path.
|
||||
fn founding_committee(
|
||||
config: &SequencerConfig,
|
||||
own_key: sequencer_stake_core::SequencerKey,
|
||||
) -> Option<Vec<block_publisher::Ed25519PublicKey>> {
|
||||
let mut keys: Vec<_> = founding_stakes(config)
|
||||
.into_iter()
|
||||
.map(|(key, ..)| key)
|
||||
.collect();
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.retain(|key| *key != own_key);
|
||||
|
||||
Some(
|
||||
std::iter::once(own_key)
|
||||
.chain(keys)
|
||||
.map(|key| {
|
||||
block_publisher::Ed25519PublicKey::from_bytes(&key.to_bytes())
|
||||
.expect("sequencer key was decoded from a valid Ed25519 public key")
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn genesis_stake_funding_account() -> AccountId {
|
||||
let key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY)
|
||||
.expect("GENESIS_STAKE_FUNDING_KEY is a valid private key");
|
||||
AccountId::from(&lee::PublicKey::new_from_private_key(&key))
|
||||
}
|
||||
|
||||
/// The exact `Stake` message the founding sequencer at `index` must sign. Shared
|
||||
/// offchain by the genesis sequencer.
|
||||
fn genesis_stake_message(
|
||||
index: usize,
|
||||
sequencer_key: sequencer_stake_core::SequencerKey,
|
||||
ownership_id: AccountId,
|
||||
) -> Message {
|
||||
let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE;
|
||||
let mover_instruction_data = lee::program::Program::serialize_instruction(
|
||||
authenticated_transfer_core::Instruction::Transfer { amount },
|
||||
)
|
||||
.expect("Failed to serialize genesis mover instruction");
|
||||
// A nonce counts how many times an account has signed. The funding account
|
||||
// signed the faucet tx already, so its count starts at 1 here.
|
||||
let funding_nonce = u128::try_from(index)
|
||||
.expect("founding sequencer count fits in u128")
|
||||
.checked_add(1)
|
||||
.expect("genesis funding nonce overflow");
|
||||
|
||||
Message::try_new(
|
||||
programs::sequencer_stake().id(),
|
||||
vec![
|
||||
genesis_stake_funding_account(),
|
||||
ownership_id,
|
||||
system_accounts::sequencer_stake_config_account_id(),
|
||||
],
|
||||
vec![
|
||||
lee_core::account::Nonce(funding_nonce),
|
||||
lee_core::account::Nonce(0),
|
||||
],
|
||||
sequencer_stake_core::Instruction::Stake {
|
||||
sequencer_key,
|
||||
amount,
|
||||
mover_program_id: programs::authenticated_transfer().id(),
|
||||
mover_instruction_data,
|
||||
},
|
||||
)
|
||||
.expect("Failed to build genesis Stake message")
|
||||
}
|
||||
|
||||
/// Signs the founding sequencer at `index`'s genesis `Stake`, for an operator
|
||||
/// producing their `GenesisAction::StakeSequencer` entry.
|
||||
#[must_use]
|
||||
pub fn sign_genesis_stake(
|
||||
index: usize,
|
||||
sequencer_key: sequencer_stake_core::SequencerKey,
|
||||
ownership_key: &lee::PrivateKey,
|
||||
) -> lee::Signature {
|
||||
let ownership_id = AccountId::from(&lee::PublicKey::new_from_private_key(ownership_key));
|
||||
let message = genesis_stake_message(index, sequencer_key, ownership_id);
|
||||
lee::Signature::new(ownership_key, &message.hash())
|
||||
}
|
||||
|
||||
/// The founding sequencers' `Stake`s, funded via the faucet. Real transactions,
|
||||
/// not raw state, so followers replay them instead of missing them.
|
||||
fn build_stake_genesis_transactions(staked: &[FoundingStake]) -> Vec<PublicTransaction> {
|
||||
if staked.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let funding_key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY).unwrap();
|
||||
let funding_public_key = lee::PublicKey::new_from_private_key(&funding_key);
|
||||
let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE;
|
||||
let total = u128::try_from(staked.len())
|
||||
.ok()
|
||||
.and_then(|count| amount.checked_mul(count))
|
||||
.expect("genesis stake total overflow");
|
||||
|
||||
let fund_message = Message::try_new(
|
||||
programs::faucet().id(),
|
||||
vec![
|
||||
system_accounts::faucet_account_id(),
|
||||
genesis_stake_funding_account(),
|
||||
],
|
||||
vec![lee_core::account::Nonce(0)],
|
||||
faucet_core::Instruction::GenesisTransferDirect { amount: total },
|
||||
)
|
||||
.expect("Failed to build genesis funding message");
|
||||
// The funding account signs even though it is only receiving. It is a brand
|
||||
// new account, so the transfer claims it, and a claim needs that account's
|
||||
// own signature.
|
||||
let fund_witness_set =
|
||||
lee::public_transaction::WitnessSet::for_message(&fund_message, &[&funding_key]);
|
||||
|
||||
let mut txs = vec![PublicTransaction::new(fund_message, fund_witness_set)];
|
||||
|
||||
for (index, (sequencer_key, ownership_public_key, signature)) in staked.iter().enumerate() {
|
||||
let ownership_id = AccountId::from(ownership_public_key);
|
||||
let stake_message = genesis_stake_message(index, *sequencer_key, ownership_id);
|
||||
let stake_witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![
|
||||
(
|
||||
lee::Signature::new(&funding_key, &stake_message.hash()),
|
||||
funding_public_key.clone(),
|
||||
),
|
||||
(signature.clone(), ownership_public_key.clone()),
|
||||
]);
|
||||
|
||||
// Redundant with the signature check every tx gets, but names the entry.
|
||||
assert!(
|
||||
stake_witness_set.is_valid_for(&stake_message),
|
||||
"genesis stake signature does not match founding sequencer {index} ({})",
|
||||
hex::encode(sequencer_key)
|
||||
);
|
||||
|
||||
txs.push(PublicTransaction::new(stake_message, stake_witness_set));
|
||||
}
|
||||
|
||||
txs
|
||||
}
|
||||
|
||||
/// Bridge-lock holder balances configured for this zone's genesis.
|
||||
fn bridge_lock_holdings(
|
||||
genesis: &[GenesisAction],
|
||||
) -> impl Iterator<Item = (lee::AccountId, lee::Balance)> + '_ {
|
||||
genesis.iter().filter_map(|action| match action {
|
||||
GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)),
|
||||
GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None,
|
||||
GenesisAction::SupplyAccount { .. }
|
||||
| GenesisAction::SupplyBridgeAccount { .. }
|
||||
| GenesisAction::StakeSequencer { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1670,6 +2077,84 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
|
||||
)))
|
||||
}
|
||||
|
||||
/// Block-validity gate for a `FinalizeUnstake`, applied uniformly regardless
|
||||
/// of where the transaction came from. Passes through unconditionally for
|
||||
/// anything that isn't a `FinalizeUnstake` call.
|
||||
fn finalize_unstake_is_includable(
|
||||
state: &lee::V03State,
|
||||
tx: &LeeTransaction,
|
||||
live_accredited_keys: Option<&[sequencer_stake_core::SequencerKey]>,
|
||||
) -> bool {
|
||||
let Some(ownership_id) = finalize_unstake_ownership_account(tx) else {
|
||||
return true;
|
||||
};
|
||||
// Without a committee snapshot there is nothing to check a FinalizeUnstake
|
||||
// against, so none is includable this block.
|
||||
live_accredited_keys.is_some_and(|live_accredited_keys| {
|
||||
committee_discovery::finalize_unstake_is_valid(state, ownership_id, live_accredited_keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// The ownership account a `FinalizeUnstake` call targets, or `None` if `tx`
|
||||
/// isn't one.
|
||||
fn finalize_unstake_ownership_account(tx: &LeeTransaction) -> Option<AccountId> {
|
||||
let LeeTransaction::Public(tx) = tx else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let message = tx.message();
|
||||
if message.program_id != programs::sequencer_stake().id() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match risc0_zkvm::serde::from_slice::<sequencer_stake_core::Instruction, u32>(
|
||||
&message.instruction_data,
|
||||
) {
|
||||
Ok(sequencer_stake_core::Instruction::FinalizeUnstake) => {
|
||||
message.account_ids.first().copied()
|
||||
}
|
||||
Ok(_) | Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A `FinalizeUnstake` for every release `state` has pending. Whether each one
|
||||
/// is actually includable is decided later, uniformly, by
|
||||
/// [`finalize_unstake_is_includable`].
|
||||
fn build_finalize_unstake_txs(state: &lee::V03State) -> VecDeque<LeeTransaction> {
|
||||
committee_discovery::finalize_unstake_candidates(state)
|
||||
.into_iter()
|
||||
.filter_map(|(ownership_id, pending)| {
|
||||
build_finalize_unstake_tx(ownership_id, pending)
|
||||
.inspect_err(|err| warn!("Failed to build FinalizeUnstake tx: {err:#}"))
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Unsigned: FinalizeUnstake needs no authorization, per the program.
|
||||
fn build_finalize_unstake_tx(
|
||||
ownership_id: AccountId,
|
||||
pending: sequencer_stake_core::PendingUnstake,
|
||||
) -> Result<LeeTransaction> {
|
||||
let message = Message::try_new(
|
||||
programs::sequencer_stake().id(),
|
||||
vec![
|
||||
ownership_id,
|
||||
pending.destination,
|
||||
system_accounts::sequencer_stake_config_account_id(),
|
||||
],
|
||||
vec![],
|
||||
sequencer_stake_core::Instruction::FinalizeUnstake,
|
||||
)
|
||||
.context("Failed to build FinalizeUnstake message")?;
|
||||
|
||||
let witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![]);
|
||||
Ok(LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
witness_set,
|
||||
)))
|
||||
}
|
||||
|
||||
/// User transactions of an orphaned block to return to the mempool: everything
|
||||
/// except the trailing clock tx, sequencer-generated bridge deposits (replayed
|
||||
/// from their own bedrock events) and sequencer-only cross-zone txs (replayed
|
||||
@@ -1862,16 +2347,14 @@ fn withdrawal_reconciliation_key(note_id: &NoteId) -> WithdrawalReconciliationKe
|
||||
WithdrawalReconciliationKey { released_note_id }
|
||||
}
|
||||
|
||||
/// Load signing key from file or generate a new one if it doesn't exist.
|
||||
pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
|
||||
/// Load key bytes from file or generate a new set if it doesn't exist.
|
||||
fn load_or_create_key_bytes(path: &Path) -> Result<[u8; ED25519_SECRET_KEY_SIZE]> {
|
||||
if path.exists() {
|
||||
let key_bytes = std::fs::read(path)?;
|
||||
|
||||
let key_array: [u8; ED25519_SECRET_KEY_SIZE] = key_bytes
|
||||
key_bytes
|
||||
.try_into()
|
||||
.map_err(|_bytes| anyhow!("Found key with incorrect length"))?;
|
||||
|
||||
Ok(Ed25519Key::from_bytes(&key_array))
|
||||
.map_err(|_bytes| anyhow!("Found key with incorrect length"))
|
||||
} else {
|
||||
let mut key_bytes = [0_u8; ED25519_SECRET_KEY_SIZE];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key_bytes);
|
||||
@@ -1880,10 +2363,24 @@ pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, key_bytes)?;
|
||||
Ok(Ed25519Key::from_bytes(&key_bytes))
|
||||
Ok(key_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load signing key from file or generate a new one if it doesn't exist.
|
||||
pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
|
||||
Ok(Ed25519Key::from_bytes(&load_or_create_key_bytes(path)?))
|
||||
}
|
||||
|
||||
/// Load the key owning this sequencer's genesis stake, or generate one.
|
||||
///
|
||||
/// Only read when a solo sequencer creates the channel: a configured founding
|
||||
/// set carries a signature instead, so the key never reaches the node.
|
||||
pub fn load_or_create_stake_signing_key(path: &Path) -> Result<lee::PrivateKey> {
|
||||
let bytes = load_or_create_key_bytes(path)?;
|
||||
lee::PrivateKey::try_new(bytes).context("stake signing key file holds an invalid private key")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "mock")]
|
||||
mod tests;
|
||||
|
||||
@@ -7,7 +7,7 @@ use logos_blockchain_core::{
|
||||
header::HeaderId,
|
||||
mantle::{
|
||||
ledger::{NoteId, Utxo},
|
||||
ops::channel::{ChannelId, MsgId},
|
||||
ops::channel::{ChannelId, Ed25519PublicKey, MsgId},
|
||||
},
|
||||
};
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
@@ -52,6 +52,11 @@ impl MockBlockPublisher {
|
||||
}
|
||||
|
||||
impl BlockPublisherTrait for MockBlockPublisher {
|
||||
// Tests assume this node is always the one bootstrapping the channel.
|
||||
async fn channel_exists(_config: &BedrockConfig) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn new(
|
||||
config: &BedrockConfig,
|
||||
_bedrock_signing_key: Ed25519Key,
|
||||
@@ -85,6 +90,22 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
||||
})
|
||||
}
|
||||
|
||||
async fn publish_genesis_creating_channel(
|
||||
&self,
|
||||
block: &Block,
|
||||
_keys: Vec<Ed25519PublicKey>,
|
||||
) -> Result<PublishOutcome> {
|
||||
self.publish_block(block, Vec::new()).await
|
||||
}
|
||||
|
||||
async fn accredited_keys(&self) -> Result<Vec<Ed25519PublicKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn submit_channel_config(&self, _new_keys: Vec<Ed25519PublicKey>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
+688
-56
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,9 @@ use crate::{
|
||||
/// Fresh `(store, chain)` pair for a reconstruction target, as
|
||||
/// `start_from_config` would build them before the publisher starts.
|
||||
fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex<ChainState>) {
|
||||
let (store, state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(config);
|
||||
let bootstrap_sequencer_key = Some(test_bootstrap_sequencer_key(config));
|
||||
let (store, state) =
|
||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(config, bootstrap_sequencer_key);
|
||||
let chain = Mutex::new(SequencerCore::<MockBlockPublisher>::restore_chain_state(
|
||||
config, &store, &state,
|
||||
));
|
||||
@@ -56,8 +58,8 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
let config_a = setup_sequencer_config();
|
||||
let (mut seq_a, _handle_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
@@ -194,8 +196,8 @@ async fn fails_when_a_below_tip_channel_block_does_not_validate() {
|
||||
// A sequencer that committed blocks past genesis but never recorded an anchor.
|
||||
let config = setup_sequencer_config();
|
||||
let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.run_production_turn().await.unwrap();
|
||||
seq.run_production_turn().await.unwrap();
|
||||
|
||||
// A below-tip block re-served with a corrupted hash. Holding a different
|
||||
// block at that id is not itself grounds to abort — the head tier is
|
||||
@@ -289,7 +291,7 @@ async fn reconstruction_ignores_a_duplicate_height_the_final_tier_settled() {
|
||||
let config_a = setup_sequencer_config();
|
||||
let (mut seq_a, _mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
let mut messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let settled_slot = messages.last().unwrap().1;
|
||||
@@ -372,7 +374,7 @@ async fn reconstruction_replaces_a_conflicting_head_block_with_finalized_history
|
||||
let config_a = setup_sequencer_config();
|
||||
let (mut seq_a, _mempool_a) =
|
||||
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
@@ -506,7 +508,7 @@ fn deposit_event_record(
|
||||
// .push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
// .await
|
||||
// .unwrap();
|
||||
// seq_a.produce_new_block().await.unwrap();
|
||||
// seq_a.run_production_turn().await.unwrap();
|
||||
|
||||
// let withdraw_tx = build_public_withdraw_tx(
|
||||
// recipient,
|
||||
@@ -519,7 +521,7 @@ fn deposit_event_record(
|
||||
// .push((TransactionOrigin::User, withdraw_tx.clone()))
|
||||
// .await
|
||||
// .unwrap();
|
||||
// seq_a.produce_new_block().await.unwrap();
|
||||
// seq_a.run_production_turn().await.unwrap();
|
||||
|
||||
// let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
// let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
@@ -569,7 +571,7 @@ fn deposit_event_record(
|
||||
// "reconstruction must drop the re-delivered pending deposit record"
|
||||
// );
|
||||
|
||||
// seq_b.produce_new_block().await.unwrap();
|
||||
// seq_b.run_production_turn().await.unwrap();
|
||||
|
||||
// let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||
// let bridge_id = system_accounts::bridge_account_id();
|
||||
@@ -641,7 +643,7 @@ fn deposit_event_record(
|
||||
// .push((TransactionOrigin::User, withdraw_tx.clone()))
|
||||
// .await
|
||||
// .unwrap();
|
||||
// seq_a.produce_new_block().await.unwrap();
|
||||
// seq_a.run_production_turn().await.unwrap();
|
||||
|
||||
// let key = produced_withdraw_key(&withdraw_tx);
|
||||
// // Producing the withdraw counts it as unseen, awaiting its L1 event.
|
||||
@@ -694,7 +696,7 @@ async fn reconstruction_reconciles_already_finished_deposit() {
|
||||
.push((TransactionOrigin::Sequencer, deposit_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
let tip_slot = messages.last().unwrap().1;
|
||||
@@ -766,7 +768,7 @@ async fn reconstructed_delivery_settles_its_pending_record() {
|
||||
.dbio()
|
||||
.add_pending_cross_zone_dispatches(vec![record.clone()])
|
||||
.unwrap();
|
||||
seq_a.produce_new_block().await.unwrap();
|
||||
seq_a.run_production_turn().await.unwrap();
|
||||
|
||||
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
|
||||
let messages = channel_from_store(seq_a.block_store(), 10);
|
||||
@@ -816,7 +818,7 @@ async fn reconstructed_delivery_settles_its_pending_record() {
|
||||
payload,
|
||||
"the reconstructed delivery must reach its target program"
|
||||
);
|
||||
seq_b.produce_new_block().await.unwrap();
|
||||
seq_b.run_production_turn().await.unwrap();
|
||||
let produced = seq_b
|
||||
.block_store()
|
||||
.get_block_at_id(tip_b.id + 1)
|
||||
@@ -845,7 +847,7 @@ async fn a_verified_own_block_settles_its_delivery_records() {
|
||||
.add_pending_cross_zone_dispatches(vec![record])
|
||||
.unwrap();
|
||||
|
||||
let block_id = seq.produce_new_block().await.unwrap();
|
||||
let block_id = seq.run_production_turn().await.unwrap();
|
||||
let block = seq
|
||||
.block_store()
|
||||
.get_block_at_id(block_id)
|
||||
@@ -896,8 +898,8 @@ async fn committed_local_against_missing_channel_fails_without_anchor() {
|
||||
{
|
||||
let (mut seq, _handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.produce_new_block().await.unwrap();
|
||||
seq.run_production_turn().await.unwrap();
|
||||
seq.run_production_turn().await.unwrap();
|
||||
assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1);
|
||||
} // drop releases the store so we can reopen it
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ workspace = true
|
||||
sequencer_core = { workspace = true, features = ["testnet"] }
|
||||
sequencer_executor_actor.workspace = true
|
||||
sequencer_rpc_server_actor.workspace = true
|
||||
lee = { workspace = true, optional = true }
|
||||
programs = { workspace = true, optional = true }
|
||||
system_accounts = { workspace = true, optional = true }
|
||||
wallet = { workspace = true, optional = true }
|
||||
sequencer_stake_core = { workspace = true, optional = true }
|
||||
authenticated_transfer_core = { workspace = true, optional = true }
|
||||
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
anyhow.workspace = true
|
||||
@@ -25,7 +31,23 @@ tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "submit_stake"
|
||||
path = "src/bin/submit_stake.rs"
|
||||
required-features = ["submit_stake"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Runs the sequencer in standalone mode without depending on Bedrock and Indexer services.
|
||||
standalone = ["sequencer_core/mock"]
|
||||
# Enable mDNS-based local peer discovery for gossip.
|
||||
mdns = ["sequencer_core/mdns"]
|
||||
# Needed only by the submit_stake bin.
|
||||
submit_stake = [
|
||||
"dep:lee",
|
||||
"programs",
|
||||
"dep:system_accounts",
|
||||
"dep:wallet",
|
||||
"dep:sequencer_stake_core",
|
||||
"dep:authenticated_transfer_core",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"node_url": "http://localhost:18080",
|
||||
"funding_key": "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26"
|
||||
},
|
||||
"gossip": {
|
||||
"listen_addr": "/ip4/127.0.0.1/udp/0/quic-v1",
|
||||
"bootstrap_peers": []
|
||||
},
|
||||
"genesis": [
|
||||
{
|
||||
"supply_bridge_account": {
|
||||
@@ -79,4 +83,4 @@
|
||||
37,
|
||||
37
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,4 @@
|
||||
37,
|
||||
37
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Submits `Stake`/`UnstakeRequest` transactions for the self-join flow,
|
||||
//! signed with keys already held by a wallet at the given path.
|
||||
//!
|
||||
//! For `stake`, the funding account must already be owned by
|
||||
//! `authenticated_transfer` and hold at least `amount`. The ownership account
|
||||
//! should be fresh (or already backing the same sequencer key). Both must
|
||||
//! already exist in the wallet (e.g. via `wallet account new public`).
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use clap::{Parser, Subcommand};
|
||||
use lee::{AccountId, program::Program};
|
||||
use wallet::{AccountIdentity, WalletCore};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(version)]
|
||||
struct Args {
|
||||
/// Path to the wallet's home directory (holds `wallet_config.json`,
|
||||
/// `storage.json` and `statistics.json`).
|
||||
#[clap(long)]
|
||||
wallet: std::path::PathBuf,
|
||||
#[clap(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Locks `amount` into a stake ownership account for `sequencer_key`.
|
||||
Stake {
|
||||
/// Account funding the stake; must already be authenticated_transfer-owned.
|
||||
#[clap(long)]
|
||||
funding_account: AccountId,
|
||||
/// Stake ownership account for `sequencer_key`.
|
||||
#[clap(long)]
|
||||
ownership_account: AccountId,
|
||||
/// Bedrock sequencer key (hex) to stake for.
|
||||
#[clap(long)]
|
||||
sequencer_key: String,
|
||||
/// Amount to stake; must be at least the current minimum.
|
||||
#[clap(long)]
|
||||
amount: u128,
|
||||
},
|
||||
/// Records a request to release `amount` from `ownership_account` to `destination`.
|
||||
/// Moves no balance yet; must leave the account at zero or at/above the minimum.
|
||||
UnstakeRequest {
|
||||
/// Stake ownership account to release from.
|
||||
#[clap(long)]
|
||||
ownership_account: AccountId,
|
||||
/// Amount to release.
|
||||
#[clap(long)]
|
||||
amount: u128,
|
||||
/// Account credited once `FinalizeUnstake` later runs.
|
||||
#[clap(long)]
|
||||
destination: AccountId,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
#[expect(
|
||||
clippy::print_stdout,
|
||||
reason = "the submitted tx hash on stdout is this binary's output"
|
||||
)]
|
||||
async fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let wallet = WalletCore::new_update_chain(
|
||||
args.wallet.join("wallet_config.json"),
|
||||
args.wallet.join("storage.json"),
|
||||
args.wallet.join("statistics.json"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to open wallet")?;
|
||||
|
||||
let config_id = system_accounts::sequencer_stake_config_account_id();
|
||||
|
||||
let tx_hash = match args.command {
|
||||
Command::Stake {
|
||||
funding_account,
|
||||
ownership_account,
|
||||
sequencer_key,
|
||||
amount,
|
||||
} => {
|
||||
let sequencer_key = parse_sequencer_key(&sequencer_key)?;
|
||||
let mover_instruction_data = Program::serialize_instruction(
|
||||
authenticated_transfer_core::Instruction::Transfer { amount },
|
||||
)
|
||||
.context("Failed to serialize mover instruction")?;
|
||||
let instruction_data =
|
||||
Program::serialize_instruction(sequencer_stake_core::Instruction::Stake {
|
||||
sequencer_key,
|
||||
amount,
|
||||
mover_program_id: programs::authenticated_transfer().id(),
|
||||
mover_instruction_data,
|
||||
})
|
||||
.context("Failed to serialize Stake instruction")?;
|
||||
|
||||
wallet
|
||||
.send_pub_tx(
|
||||
vec![
|
||||
AccountIdentity::Public(funding_account),
|
||||
AccountIdentity::Public(ownership_account),
|
||||
AccountIdentity::PublicNoSign(config_id),
|
||||
],
|
||||
instruction_data,
|
||||
programs::sequencer_stake().id(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow!("Failed to submit Stake transaction: {err:?}"))?
|
||||
}
|
||||
Command::UnstakeRequest {
|
||||
ownership_account,
|
||||
amount,
|
||||
destination,
|
||||
} => {
|
||||
let instruction_data =
|
||||
Program::serialize_instruction(sequencer_stake_core::Instruction::UnstakeRequest {
|
||||
amount,
|
||||
destination,
|
||||
})
|
||||
.context("Failed to serialize UnstakeRequest instruction")?;
|
||||
|
||||
wallet
|
||||
.send_pub_tx(
|
||||
vec![
|
||||
AccountIdentity::Public(ownership_account),
|
||||
AccountIdentity::PublicNoSign(config_id),
|
||||
],
|
||||
instruction_data,
|
||||
programs::sequencer_stake().id(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow!("Failed to submit UnstakeRequest transaction: {err:?}"))?
|
||||
}
|
||||
};
|
||||
|
||||
println!("Submitted transaction {tx_hash}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_sequencer_key(hex_key: &str) -> Result<sequencer_stake_core::SequencerKey> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(hex_key, &mut bytes)
|
||||
.with_context(|| format!("Invalid hex-encoded key {hex_key}"))?;
|
||||
sequencer_stake_core::SequencerKey::new(bytes)
|
||||
.with_context(|| format!("{hex_key} is not a valid Ed25519 public key"))
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures::never::Never;
|
||||
use kameo::actor::Spawn as _;
|
||||
use kameo_actors::scheduler::{Scheduler, SetInterval};
|
||||
use log::info;
|
||||
pub use sequencer_core::config::*;
|
||||
use sequencer_core::load_or_create_signing_key;
|
||||
use sequencer_executor_actor::ExecutorActor;
|
||||
use sequencer_rpc_server_actor::RpcServerActor;
|
||||
use tokio::select;
|
||||
@@ -29,6 +30,9 @@ pub struct SequencerHandle {
|
||||
rpc_server: ActorHandle<RpcServerActor>,
|
||||
executor: ActorHandle<ExecutorActor<BlockPublisher>>,
|
||||
addr: SocketAddr,
|
||||
/// Held for its lifetime: dropping it stops the gossip drive task.
|
||||
/// `None` when gossip is unconfigured.
|
||||
gossip: Option<sequencer_core::gossip::GossipNetwork>,
|
||||
}
|
||||
|
||||
impl SequencerHandle {
|
||||
@@ -37,12 +41,14 @@ impl SequencerHandle {
|
||||
rpc_server: ActorHandle<RpcServerActor>,
|
||||
executor: ActorHandle<ExecutorActor<BlockPublisher>>,
|
||||
addr: SocketAddr,
|
||||
gossip: Option<sequencer_core::gossip::GossipNetwork>,
|
||||
) -> Self {
|
||||
Self {
|
||||
scheduler,
|
||||
rpc_server,
|
||||
executor,
|
||||
addr,
|
||||
gossip,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ impl SequencerHandle {
|
||||
rpc_server,
|
||||
executor,
|
||||
addr: _,
|
||||
gossip: _,
|
||||
} = self;
|
||||
|
||||
// NOTE: Order of shutdown matters. Make sure it follows the order of fields in the struct.
|
||||
@@ -73,6 +80,7 @@ impl SequencerHandle {
|
||||
rpc_server,
|
||||
scheduler,
|
||||
addr: _,
|
||||
gossip: _,
|
||||
} = self;
|
||||
|
||||
select! {
|
||||
@@ -99,6 +107,7 @@ impl SequencerHandle {
|
||||
rpc_server,
|
||||
scheduler,
|
||||
addr: _,
|
||||
gossip: _,
|
||||
} = self;
|
||||
|
||||
executor.is_healthy() && rpc_server.is_healthy() && scheduler.is_healthy()
|
||||
@@ -108,17 +117,66 @@ impl SequencerHandle {
|
||||
pub const fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
/// Multiaddrs (with the `/p2p/` peer id suffix) other nodes can use as
|
||||
/// gossip `bootstrap_peers`. `None` when gossip is unconfigured.
|
||||
#[must_use]
|
||||
pub fn gossip_bootstrap_addrs(&self) -> Option<Vec<sequencer_core::gossip::Multiaddr>> {
|
||||
self.gossip
|
||||
.as_ref()
|
||||
.map(sequencer_core::gossip::GossipNetwork::bootstrap_addrs)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<SequencerHandle> {
|
||||
let block_timeout = config.block_create_timeout;
|
||||
let max_block_size = config.max_block_size;
|
||||
|
||||
// Captured before `config` moves into the executor; gossip needs them after.
|
||||
let gossip_config = config.gossip.clone();
|
||||
let bedrock_config = config.bedrock_config.clone();
|
||||
let sequencer_home = config.home.clone();
|
||||
|
||||
let executor = ExecutorActor::new(config).await;
|
||||
let mempool_handle = executor.mempool_handle();
|
||||
let executor_ref = ExecutorActor::spawn(executor);
|
||||
info!("Executor Actor spawned");
|
||||
|
||||
let rpc_server = RpcServerActor::new(executor_ref.clone(), listen_addr, max_block_size).await?;
|
||||
// Gossip is constructed only when configured; a `None` config means no
|
||||
// sockets and no tasks. Startup failure here is a hard error
|
||||
// (misconfiguration); after startup, gossip never halts the node.
|
||||
let gossip_network = match gossip_config {
|
||||
None => None,
|
||||
Some(gossip_config) => {
|
||||
// The node's L1 bedrock signing key is deliberately reused as the
|
||||
// libp2p identity; `GossipNetwork::start` derives the keypair.
|
||||
let signing_key =
|
||||
load_or_create_signing_key(&sequencer_home.join("bedrock_signing_key"))?;
|
||||
let channel_id = *bedrock_config.channel_id.as_ref();
|
||||
let network = sequencer_core::gossip::GossipNetwork::start(
|
||||
gossip_config,
|
||||
channel_id,
|
||||
signing_key,
|
||||
mempool_handle,
|
||||
max_block_size.as_u64(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to start sequencer gossip network")?;
|
||||
info!("Gossip network started as {}", network.local_peer_id());
|
||||
Some(network)
|
||||
}
|
||||
};
|
||||
let tx_publisher = gossip_network
|
||||
.as_ref()
|
||||
.map(sequencer_core::gossip::GossipNetwork::tx_publisher);
|
||||
|
||||
let rpc_server = RpcServerActor::new(
|
||||
executor_ref.clone(),
|
||||
listen_addr,
|
||||
max_block_size,
|
||||
tx_publisher,
|
||||
)
|
||||
.await?;
|
||||
let addr = rpc_server.addr();
|
||||
let rpc_server_ref = RpcServerActor::spawn(rpc_server);
|
||||
info!("RPC Server Actor spawned");
|
||||
@@ -142,5 +200,6 @@ pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<Seq
|
||||
ActorHandle::new(rpc_server_ref),
|
||||
ActorHandle::new(executor_ref),
|
||||
addr,
|
||||
gossip_network,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ lee_core.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
clock_core.workspace = true
|
||||
sequencer_stake_core.workspace = true
|
||||
programs.workspace = true
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
//! This crate provides system accounts used by LEZ.
|
||||
|
||||
use std::str::FromStr as _;
|
||||
use std::{collections::BTreeMap, str::FromStr as _};
|
||||
|
||||
use clock_core::ClockAccountData;
|
||||
use lee_core::account::{Account, AccountId, Nonce};
|
||||
|
||||
// TODO: Replace with a real minimum value for testnet
|
||||
/// Minimum summed stake for a Bedrock sequencer key to be a committee candidate.
|
||||
pub const DEFAULT_MINIMUM_SEQUENCER_STAKE: u128 = 149;
|
||||
|
||||
/// Channel administration defaults.
|
||||
///
|
||||
/// Slots, not seconds (1 slot = 1s on the current devnet): 20-slot turns,
|
||||
/// reclaimed after 10 idle slots if a sequencer stops posting — non-zero so
|
||||
/// round robin can move on when a committee has more than one accredited key.
|
||||
/// A lone-signature threshold still suffices for config changes.
|
||||
pub const DEFAULT_SEQUENCER_POSTING_TIMEFRAME: Slots = 20;
|
||||
pub const DEFAULT_SEQUENCER_POSTING_TIMEOUT: Slots = 10;
|
||||
pub const DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD: u16 = 1;
|
||||
pub const DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD: u16 = 1;
|
||||
|
||||
pub type Slots = u32;
|
||||
|
||||
#[must_use]
|
||||
pub fn pinata_account_id() -> AccountId {
|
||||
// TODO: Use derivation from a public key?
|
||||
@@ -55,6 +72,28 @@ pub const fn clock_account_ids() -> [AccountId; 3] {
|
||||
clock_core::CLOCK_PROGRAM_ACCOUNT_IDS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sequencer_stake_config_account_id() -> AccountId {
|
||||
sequencer_stake_core::sequencer_stake_config_account_id(programs::sequencer_stake().id())
|
||||
}
|
||||
|
||||
/// Starts with no entries; every stake, including the bootstrap sequencer's
|
||||
/// own, is added by replaying a `Stake` transaction, not seeded here.
|
||||
#[must_use]
|
||||
pub fn sequencer_stake_config_account() -> Account {
|
||||
Account {
|
||||
program_owner: programs::sequencer_stake().id(),
|
||||
data: sequencer_stake_core::SequencerStakeConfig {
|
||||
minimum_sequencer_stake: DEFAULT_MINIMUM_SEQUENCER_STAKE,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("sequencer stake config data should fit"),
|
||||
..Account::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn clock_account() -> Account {
|
||||
Account {
|
||||
|
||||
@@ -212,6 +212,10 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
|
||||
.into_iter()
|
||||
.map(|clock_id| (clock_id, system_accounts::clock_account())),
|
||||
)
|
||||
.chain([(
|
||||
system_accounts::sequencer_stake_config_account_id(),
|
||||
system_accounts::sequencer_stake_config_account(),
|
||||
)])
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -225,6 +229,7 @@ fn initial_programs() -> Vec<Program> {
|
||||
programs::vault(),
|
||||
programs::faucet(),
|
||||
programs::bridge(),
|
||||
programs::sequencer_stake(),
|
||||
// Cross-zone programs are builtins: their bytecode is baked into every node,
|
||||
// so registering them in the base state (rather than shipping ELFs through
|
||||
// the genesis block, which exceeds the inscription size limit) keeps the two
|
||||
|
||||
@@ -16,6 +16,7 @@ lee.workspace = true
|
||||
lee_core = { workspace = true, features = ["host"] }
|
||||
sequencer_core = { workspace = true, features = ["default", "testnet"] }
|
||||
sequencer_service.workspace = true
|
||||
sequencer_stake_core.workspace = true
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
wallet.workspace = true
|
||||
programs.workspace = true
|
||||
|
||||
Binary file not shown.
@@ -52,6 +52,7 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> {
|
||||
let (sequencer_handle, temp_sequencer_dir) =
|
||||
SequencerSetup::new(config::SequencerPartialConfig::default(), bedrock_addr)
|
||||
.with_genesis(genesis)
|
||||
.with_bedrock_signing_key(config::SEQUENCER_BEDROCK_SIGNING_KEY)
|
||||
.setup()
|
||||
.await
|
||||
.context("Failed to setup Sequencer for fixture generation")?;
|
||||
|
||||
@@ -6,9 +6,13 @@ use indexer_service::{ChannelId, ClientConfig, IndexerConfig};
|
||||
use key_protocol::key_management::{KeyChain, secret_holders::SeedHolder};
|
||||
use lee::{AccountId, PrivateKey, PublicKey};
|
||||
use lee_core::Identifier;
|
||||
use logos_blockchain_key_management_system_service::keys::ZkPublicKey;
|
||||
use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkPublicKey};
|
||||
use num_bigint::BigUint;
|
||||
use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig};
|
||||
use sequencer_core::{
|
||||
config::{BedrockConfig, CrossZoneConfig, GenesisAction, GossipConfig, SequencerConfig},
|
||||
sign_genesis_stake,
|
||||
};
|
||||
use sequencer_stake_core::SequencerKey;
|
||||
use url::Url;
|
||||
use wallet::config::{MultiSequencerClientConfig, SequencerConnectionData, WalletConfig};
|
||||
|
||||
@@ -18,6 +22,13 @@ pub const INITIAL_PRIVATE_BALANCES_FOR_WALLET: [u128; 2] = [10_000, 20_000];
|
||||
/// Fixed sequencer signing key; exposed so the fixture generator can reopen the produced store.
|
||||
pub const SEQUENCER_SIGNING_KEY: [u8; 32] = [37; 32];
|
||||
|
||||
/// Key of the account holding the sequencer's genesis stake. Separate from
|
||||
/// [`SEQUENCER_SIGNING_KEY`]: block signing and stake control are distinct roles.
|
||||
pub const SEQUENCER_STAKE_KEY: [u8; 32] = [55; 32];
|
||||
|
||||
/// Bedrock signing key used by the prebuilt dump as first accredited key.
|
||||
pub const SEQUENCER_BEDROCK_SIGNING_KEY: [u8; 32] = [77; 32];
|
||||
|
||||
// Fixed entropy seeds for the default accounts: deterministic so one prebuilt database is reusable,
|
||||
// and distinct from the `testnet_initial_state` accounts to avoid depending on / double-funding
|
||||
// them.
|
||||
@@ -106,6 +117,7 @@ pub fn sequencer_config(
|
||||
genesis_transactions: Vec<GenesisAction>,
|
||||
cross_zone: Option<CrossZoneConfig>,
|
||||
signing_key: Option<[u8; 32]>,
|
||||
gossip: Option<GossipConfig>,
|
||||
) -> Result<SequencerConfig> {
|
||||
let SequencerPartialConfig {
|
||||
max_num_tx_in_block,
|
||||
@@ -133,6 +145,7 @@ pub fn sequencer_config(
|
||||
},
|
||||
cross_zone,
|
||||
metrics_address: Some(SequencerConfig::DEFAULT_METRICS_ADDRESS),
|
||||
gossip,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -304,6 +317,37 @@ pub fn sequencer_signing_key_from_seed(seed: u32) -> [u8; 32] {
|
||||
.unwrap_or_else(|_| unreachable!())
|
||||
}
|
||||
|
||||
/// Seed of the account owning sequencer `index`'s founding stake.
|
||||
fn founding_stake_owner_seed(index: usize) -> [u8; 32] {
|
||||
if index == 0 {
|
||||
return SEQUENCER_STAKE_KEY;
|
||||
}
|
||||
let mut seed = [0x70; 32];
|
||||
seed[0] = u8::try_from(index).expect("Test contexts never run enough sequencers to overflow");
|
||||
seed
|
||||
}
|
||||
|
||||
/// Genesis entries staking every sequencer in `sequencer_signing_keys`, so the
|
||||
/// creator opens the channel already accrediting all of them.
|
||||
pub fn genesis_sequencer_stakes(sequencer_signing_keys: &[[u8; 32]]) -> Result<Vec<GenesisAction>> {
|
||||
sequencer_signing_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, signing_key)| {
|
||||
let public_key = Ed25519Key::from_bytes(signing_key).public_key();
|
||||
let sequencer_key = SequencerKey::new(public_key.to_bytes())
|
||||
.context("Sequencer signing key is not a valid Ed25519 point")?;
|
||||
let owner = PrivateKey::try_new(founding_stake_owner_seed(index))
|
||||
.context("Failed to build the founding stake ownership key")?;
|
||||
Ok(GenesisAction::StakeSequencer {
|
||||
sequencer_key,
|
||||
ownership_public_key: PublicKey::new_from_private_key(&owner),
|
||||
stake_signature: sign_genesis_stake(index, sequencer_key, &owner),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate bedrock channel id from `u32` number via repeating le bytes 8 times.
|
||||
///
|
||||
/// Counting from the end of `u32` to guarantee, that it is different from
|
||||
|
||||
+87
-78
@@ -10,11 +10,8 @@ use indexer_service::{ChannelId, IndexerHandle};
|
||||
use lee::{AccountId, PrivacyPreservingTransaction, PrivateKey};
|
||||
use lee_core::Commitment;
|
||||
use log::{debug, error};
|
||||
use sequencer_core::{
|
||||
block_publisher::{Ed25519Key, post_channel_config},
|
||||
config::GenesisAction,
|
||||
};
|
||||
use sequencer_service::{BedrockConfig, CrossZoneConfig, SequencerHandle, default_priority_fee};
|
||||
use sequencer_core::config::GenesisAction;
|
||||
use sequencer_service::{CrossZoneConfig, GossipConfig, SequencerHandle};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use serde::Serialize;
|
||||
use tempfile::TempDir;
|
||||
@@ -25,10 +22,7 @@ use wallet::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{
|
||||
InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig,
|
||||
bedrock_funding_key,
|
||||
},
|
||||
config::{InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{
|
||||
SequencerSetup, setup_bedrock_node, setup_indexer,
|
||||
@@ -43,10 +37,6 @@ pub mod setup;
|
||||
|
||||
// TODO: Remove this and control time from tests
|
||||
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
|
||||
/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled
|
||||
/// turn after ~30 s (bounds the stall while B is accredited but not started).
|
||||
const POSTING_TIMEFRAME_SLOTS: u32 = 20;
|
||||
const POSTING_TIMEOUT_SLOTS: u32 = 30;
|
||||
|
||||
pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
|
||||
pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080;
|
||||
@@ -496,11 +486,17 @@ impl Drop for TestContext {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[expect(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "test-context builder toggles independent features; a state machine would obscure it"
|
||||
)]
|
||||
pub struct ZoneTestContextBuilder {
|
||||
genesis_transactions: Option<Vec<GenesisAction>>,
|
||||
sequencer_partial_config: Option<config::SequencerPartialConfig>,
|
||||
follower_sequencer_partial_config: Option<config::SequencerPartialConfig>,
|
||||
enable_indexer: bool,
|
||||
enable_wallet: bool,
|
||||
enable_gossip: bool,
|
||||
wallet_config_overrides: WalletConfigOverrides,
|
||||
from_scratch: bool,
|
||||
mn_config: MultiNodeTestContextConfig,
|
||||
@@ -513,8 +509,10 @@ impl ZoneTestContextBuilder {
|
||||
Self {
|
||||
genesis_transactions: None,
|
||||
sequencer_partial_config: None,
|
||||
follower_sequencer_partial_config: None,
|
||||
enable_indexer: true,
|
||||
enable_wallet: true,
|
||||
enable_gossip: false,
|
||||
wallet_config_overrides: WalletConfigOverrides::default(),
|
||||
from_scratch: false,
|
||||
mn_config,
|
||||
@@ -558,6 +556,25 @@ impl ZoneTestContextBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the sequencer partial config for the non-leader nodes only.
|
||||
/// If not set, followers use the same config as the leader.
|
||||
#[must_use]
|
||||
pub const fn with_follower_sequencer_partial_config(
|
||||
mut self,
|
||||
follower_sequencer_partial_config: config::SequencerPartialConfig,
|
||||
) -> Self {
|
||||
self.follower_sequencer_partial_config = Some(follower_sequencer_partial_config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable p2p gossip between the sequencers: the leader listens on an
|
||||
/// OS-assigned localhost port and every follower bootstraps from it.
|
||||
#[must_use]
|
||||
pub const fn with_gossip(mut self) -> Self {
|
||||
self.enable_gossip = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build from genesis live instead of loading the prebuilt fixture. Implied by
|
||||
/// [`Self::with_genesis`].
|
||||
#[must_use]
|
||||
@@ -600,8 +617,10 @@ impl ZoneTestContextBuilder {
|
||||
let Self {
|
||||
genesis_transactions,
|
||||
sequencer_partial_config,
|
||||
follower_sequencer_partial_config,
|
||||
enable_indexer,
|
||||
enable_wallet,
|
||||
enable_gossip,
|
||||
wallet_config_overrides,
|
||||
from_scratch,
|
||||
mn_config,
|
||||
@@ -610,6 +629,24 @@ impl ZoneTestContextBuilder {
|
||||
|
||||
debug!("Test context setup");
|
||||
|
||||
let mut sequencer_keys = vec![config::SEQUENCER_SIGNING_KEY];
|
||||
sequencer_keys.extend((1..mn_config.num_nodes).map(|i| {
|
||||
config::sequencer_signing_key_from_seed(
|
||||
u32::try_from(i).expect("Not being able to fit is realistically impossible"),
|
||||
)
|
||||
}));
|
||||
|
||||
let genesis_transactions = if mn_config.num_nodes == 1 {
|
||||
genesis_transactions
|
||||
} else {
|
||||
let mut actions = config::genesis_sequencer_stakes(&sequencer_keys)
|
||||
.context("Failed to build the founding sequencer stakes")?;
|
||||
actions.extend(genesis_transactions.unwrap_or_default());
|
||||
// Returning Some() forces a live build below: the prebuilt dump stakes only one
|
||||
// sequencer.
|
||||
Some(actions)
|
||||
};
|
||||
|
||||
// The fixture bakes in the default accounts + genesis, so custom genesis / from_scratch
|
||||
// must build live. Otherwise load the fixture (fails if it is missing).
|
||||
let use_prebuilt = !from_scratch && genesis_transactions.is_none();
|
||||
@@ -642,17 +679,13 @@ impl ZoneTestContextBuilder {
|
||||
let mut sequencer_addrs = vec![];
|
||||
let mut sequencer_components = vec![];
|
||||
|
||||
let mut sequencer_keys = vec![];
|
||||
|
||||
sequencer_keys.push(config::SEQUENCER_SIGNING_KEY);
|
||||
|
||||
sequencer_keys.extend((1..mn_config.num_nodes).map(|i| {
|
||||
config::sequencer_signing_key_from_seed(
|
||||
u32::try_from(i).expect("Not being able to fit is realistically impossible"),
|
||||
)
|
||||
}));
|
||||
|
||||
// First, need to start a leader.
|
||||
let leader_gossip = enable_gossip.then(|| GossipConfig {
|
||||
listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1"
|
||||
.parse()
|
||||
.expect("hardcoded gossip listen multiaddr is valid"),
|
||||
bootstrap_peers: vec![],
|
||||
});
|
||||
let (leader_addr, leader_components) = build_sequencer_components(
|
||||
partial_config,
|
||||
bedrock_addr,
|
||||
@@ -664,9 +697,22 @@ impl ZoneTestContextBuilder {
|
||||
config::SEQUENCER_SIGNING_KEY,
|
||||
mn_config.bedrock_channel,
|
||||
cross_zone_config.clone(),
|
||||
leader_gossip,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The leader listened on an OS-assigned port, so followers can only
|
||||
// learn its gossip address from the running handle.
|
||||
let follower_gossip = leader_components
|
||||
.sequencer_handle
|
||||
.gossip_bootstrap_addrs()
|
||||
.map(|bootstrap_peers| GossipConfig {
|
||||
listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1"
|
||||
.parse()
|
||||
.expect("hardcoded gossip listen multiaddr is valid"),
|
||||
bootstrap_peers,
|
||||
});
|
||||
|
||||
// Wait for genesis to be published
|
||||
wait_until_genesis(&leader_components.sequencer_client)
|
||||
.await
|
||||
@@ -677,19 +723,10 @@ impl ZoneTestContextBuilder {
|
||||
sequencer_addrs.push(leader_addr);
|
||||
sequencer_components.push(leader_components);
|
||||
|
||||
// Skip posting chain config with just one node.
|
||||
if mn_config.num_nodes != 1 {
|
||||
post_chain_config_with_default_parameters(
|
||||
mn_config.bedrock_channel,
|
||||
bedrock_addr,
|
||||
sequencer_keys.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Followers are already accredited by their genesis stakes.
|
||||
for sequencer_key in sequencer_keys.into_iter().skip(1) {
|
||||
let (sequencer_addr, sequencer_component) = build_sequencer_components(
|
||||
partial_config,
|
||||
follower_sequencer_partial_config.unwrap_or(partial_config),
|
||||
bedrock_addr,
|
||||
enable_wallet,
|
||||
use_prebuilt,
|
||||
@@ -699,6 +736,7 @@ impl ZoneTestContextBuilder {
|
||||
sequencer_key,
|
||||
mn_config.bedrock_channel,
|
||||
cross_zone_config.clone(),
|
||||
follower_gossip.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -992,6 +1030,12 @@ pub async fn verify_commitment_is_in_state(
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Initializes the global logger once, for tests that build their fixtures
|
||||
/// without going through [`TestContextBuilder`].
|
||||
pub fn init_logger() {
|
||||
*LOGGER;
|
||||
}
|
||||
|
||||
fn dir_size_bytes(path: &Path) -> u64 {
|
||||
let mut total = 0_u64;
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
@@ -1013,47 +1057,6 @@ fn dir_size_bytes(path: &Path) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
async fn post_chain_config_with_default_parameters(
|
||||
channel_id: ChannelId,
|
||||
bedrock_addr: SocketAddr,
|
||||
sequencer_keys: Vec<[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
log::info!(
|
||||
"Sequencer committee is {:?} at {channel_id}",
|
||||
sequencer_keys
|
||||
.iter()
|
||||
.map(|key| Ed25519Key::from_bytes(key).public_key().as_bytes().to_vec())
|
||||
.map(hex::encode)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
post_channel_config(
|
||||
&BedrockConfig {
|
||||
channel_id,
|
||||
node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?,
|
||||
auth: None,
|
||||
funding_key: bedrock_funding_key(),
|
||||
priority_fee: default_priority_fee(),
|
||||
},
|
||||
&Ed25519Key::from_bytes(
|
||||
sequencer_keys
|
||||
.first()
|
||||
.expect("Must be at least one sequencer"),
|
||||
),
|
||||
sequencer_keys
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|key| Ed25519Key::from_bytes(&key).public_key())
|
||||
.collect(),
|
||||
POSTING_TIMEFRAME_SLOTS,
|
||||
POSTING_TIMEOUT_SLOTS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.context("Failed to configure the channel committee")
|
||||
}
|
||||
|
||||
async fn wait_until_genesis(client: &SequencerClient) -> Result<()> {
|
||||
log::info!("Waiting for leader to send genesis");
|
||||
|
||||
@@ -1082,6 +1085,7 @@ async fn build_sequencer_components(
|
||||
sequencer_key: [u8; 32],
|
||||
bedrock_channel_id: ChannelId,
|
||||
cross_zone_config: Option<CrossZoneConfig>,
|
||||
gossip: Option<GossipConfig>,
|
||||
) -> Result<(SocketAddr, SequencerComponents)> {
|
||||
let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr);
|
||||
|
||||
@@ -1103,15 +1107,20 @@ async fn build_sequencer_components(
|
||||
genesis_transactions.unwrap_or_default()
|
||||
};
|
||||
|
||||
// The prebuilt dump carries a genesis stake for the key the fixture generator
|
||||
// ran with, so a node restoring it has to sign Bedrock with that same key.
|
||||
if !use_prebuilt {
|
||||
sequencer_setup = sequencer_setup.with_genesis(genesis_actions);
|
||||
sequencer_setup = sequencer_setup
|
||||
.with_genesis(genesis_actions)
|
||||
.with_bedrock_signing_key(sequencer_key);
|
||||
}
|
||||
|
||||
sequencer_setup = sequencer_setup.with_bedrock_signing_key(sequencer_key);
|
||||
sequencer_setup = sequencer_setup.with_channel_id(bedrock_channel_id);
|
||||
if let Some(cross_zone_config) = cross_zone_config.clone() {
|
||||
sequencer_setup = sequencer_setup.with_cross_zone(cross_zone_config);
|
||||
}
|
||||
if let Some(gossip) = gossip {
|
||||
sequencer_setup = sequencer_setup.with_gossip(gossip);
|
||||
}
|
||||
|
||||
let (sequencer_handle, temp_sequencer_dir) = sequencer_setup
|
||||
.setup()
|
||||
|
||||
@@ -36,6 +36,7 @@ pub struct SequencerSetup {
|
||||
genesis_transactions: Option<Vec<GenesisAction>>,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>,
|
||||
gossip: Option<sequencer_core::config::GossipConfig>,
|
||||
}
|
||||
|
||||
impl SequencerSetup {
|
||||
@@ -48,6 +49,7 @@ impl SequencerSetup {
|
||||
genesis_transactions: None,
|
||||
cross_zone: None,
|
||||
bedrock_signing_key: None,
|
||||
gossip: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +77,14 @@ impl SequencerSetup {
|
||||
self
|
||||
}
|
||||
|
||||
/// Build a sequencer that joins a channel another node already created,
|
||||
/// replaying its genesis from the channel instead of the prebuilt dump.
|
||||
#[must_use]
|
||||
pub fn joining_existing_channel(mut self) -> Self {
|
||||
self.genesis_transactions = Some(Vec::new());
|
||||
self
|
||||
}
|
||||
|
||||
/// Pre-write a bedrock (Ed25519, 32-byte seed) signing key into the home
|
||||
/// before boot, so tests know the sequencer's public key in advance (e.g.
|
||||
/// to accredit a committee member that has not started yet).
|
||||
@@ -84,6 +94,14 @@ impl SequencerSetup {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable p2p gossip with the given configuration.
|
||||
/// If not set, the sequencer runs without gossip.
|
||||
#[must_use]
|
||||
pub fn with_gossip(mut self, gossip: sequencer_core::config::GossipConfig) -> Self {
|
||||
self.gossip = Some(gossip);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set up the sequencer in a fresh temporary home directory, returning the
|
||||
/// owning [`TempDir`] alongside the handle.
|
||||
pub async fn setup(self) -> Result<(SequencerHandle, TempDir)> {
|
||||
@@ -106,14 +124,26 @@ impl SequencerSetup {
|
||||
genesis_transactions,
|
||||
cross_zone,
|
||||
bedrock_signing_key,
|
||||
gossip,
|
||||
} = self;
|
||||
|
||||
debug!("Using sequencer home at {}", home.display());
|
||||
|
||||
let bedrock_signing_key = bedrock_signing_key.or_else(|| {
|
||||
genesis_transactions
|
||||
.is_none()
|
||||
.then_some(config::SEQUENCER_BEDROCK_SIGNING_KEY)
|
||||
});
|
||||
if let Some(key_bytes) = bedrock_signing_key {
|
||||
std::fs::write(home.join("bedrock_signing_key"), key_bytes)
|
||||
.context("Failed to write pre-generated bedrock signing key")?;
|
||||
}
|
||||
// Pinned like the bedrock key: the prebuilt dump stakes this account.
|
||||
std::fs::write(
|
||||
home.join("sequencer_stake_signing_key"),
|
||||
config::SEQUENCER_STAKE_KEY,
|
||||
)
|
||||
.context("Failed to write pre-generated stake signing key")?;
|
||||
|
||||
let genesis_transactions = if let Some(genesis) = genesis_transactions {
|
||||
genesis
|
||||
@@ -142,6 +172,7 @@ impl SequencerSetup {
|
||||
genesis_transactions,
|
||||
cross_zone,
|
||||
bedrock_signing_key,
|
||||
gossip,
|
||||
)
|
||||
.context("Failed to create Sequencer config")?;
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ serde = { workspace = true, features = ["derive"] }
|
||||
axum.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
clippy::unused_async,
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::infinite_loop,
|
||||
reason = "Demo binary: stdout banner is the deliverable; ordinal/elapsed arithmetic is \
|
||||
bounded at chat scale; the per-zone scanners and finality poller are daemon \
|
||||
reason = "Demo binary: stdout banner is the deliverable; elapsed arithmetic is bounded at chat \
|
||||
scale; the per-zone scanners and finality poller are daemon \
|
||||
loops that run for the process lifetime; axum handlers must be `async` and take \
|
||||
their extractors (State/Json/Query) by value to satisfy the framework's bounds."
|
||||
)]
|
||||
@@ -57,11 +57,14 @@ use common::{block::BedrockStatus, transaction::LeeTransaction};
|
||||
use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId};
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use lee::{
|
||||
ProgramId, PublicTransaction,
|
||||
Account, ProgramId, PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use log::warn;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use log::{info, warn};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use test_fixtures::{
|
||||
@@ -71,6 +74,10 @@ use test_fixtures::{
|
||||
|
||||
const HTTP_PORT: u16 = 8088;
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// Ordinals probed for a free outbox slot before giving up.
|
||||
const ORDINAL_PROBE_LIMIT: u32 = 1_024;
|
||||
/// Transient RPC failures tolerated per probed slot.
|
||||
const RPC_RETRY_LIMIT: u32 = 5;
|
||||
|
||||
/// One chat message tracked through its cross-zone pipeline. Displayed in the
|
||||
/// receiving zone's column; the stage fields drive the on-page timeline.
|
||||
@@ -127,8 +134,12 @@ struct ZoneRuntime {
|
||||
/// The peer zone's channel id; the target of sends from this zone.
|
||||
other_zone: ZoneId,
|
||||
client: SequencerClient,
|
||||
/// Monotonic outbox ordinal per (this zone -> peer); each send must use a
|
||||
/// fresh value because the outbox PDA is claimed only when default.
|
||||
/// Next outbox ordinal to try for (this zone -> peer).
|
||||
///
|
||||
/// An outbox slot is written once and the ordinal names a slot in a space
|
||||
/// every user of `ping_sender` shares, so a fixed starting point collides
|
||||
/// with whatever the chain already holds. Seeded from a free slot found by
|
||||
/// [`next_free_ordinal`], then incremented per send.
|
||||
ordinal: AtomicU32,
|
||||
}
|
||||
|
||||
@@ -304,16 +315,26 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
|
||||
let client_a = sequencer_client(seq_a.addr())?;
|
||||
let client_b = sequencer_client(seq_b.addr())?;
|
||||
let ordinal_a = next_free_ordinal(&client_a, &zone_b)
|
||||
.await
|
||||
.context("Failed to find a free outbox ordinal for zone A")?;
|
||||
let ordinal_b = next_free_ordinal(&client_b, &zone_a)
|
||||
.await
|
||||
.context("Failed to find a free outbox ordinal for zone B")?;
|
||||
info!("Outbox ordinals start at A={ordinal_a} B={ordinal_b}");
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
zone_a: ZoneRuntime {
|
||||
other_zone: zone_b,
|
||||
client: sequencer_client(seq_a.addr())?,
|
||||
ordinal: AtomicU32::new(0),
|
||||
client: client_a,
|
||||
ordinal: AtomicU32::new(ordinal_a),
|
||||
},
|
||||
zone_b: ZoneRuntime {
|
||||
other_zone: zone_a,
|
||||
client: sequencer_client(seq_b.addr())?,
|
||||
ordinal: AtomicU32::new(0),
|
||||
client: client_b,
|
||||
ordinal: AtomicU32::new(ordinal_b),
|
||||
},
|
||||
next_id: AtomicU64::new(1),
|
||||
messages: Mutex::new(Vec::new()),
|
||||
@@ -365,6 +386,55 @@ fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// A free outbox slot for this zone's `ping_sender` to start counting from: the
|
||||
/// first unwritten one at or after a random ordinal.
|
||||
///
|
||||
/// An outbox slot is written once, so a send into an occupied one fails at block
|
||||
/// production, and `send_transaction` checks a transaction only statelessly, so
|
||||
/// nothing tells the sender. The predicate here is the one the guest asserts,
|
||||
/// asked before submitting instead of after.
|
||||
///
|
||||
/// Random rather than zero because the ordinal space is shared by every user of
|
||||
/// `ping_sender` and a slot costs one unsigned transaction to occupy, so any
|
||||
/// fixed starting point can be squatted. On a chain this process owns, which is
|
||||
/// what `just cross-zone-chat` boots, nothing is occupied and this returns on
|
||||
/// its first try; it earns its keep against a chain the tool did not create.
|
||||
///
|
||||
/// It only places the first send. Later ordinals come from incrementing, so a
|
||||
/// slot taken after this returns still collides, at a probability of the
|
||||
/// occupied count over 2^32.
|
||||
async fn next_free_ordinal(client: &SequencerClient, target_zone: &ZoneId) -> Result<u32> {
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let emitter = programs::ping_sender().id();
|
||||
let start: u32 = rand::random();
|
||||
|
||||
for offset in 0..ORDINAL_PROBE_LIMIT {
|
||||
let ordinal = start.wrapping_add(offset);
|
||||
let slot = outbox_pda(outbox_id, emitter, target_zone, ordinal);
|
||||
// Retried rather than propagated: by here the run has already paid for a
|
||||
// Bedrock bring-up and two sequencer boots, and every other RPC caller
|
||||
// in this tool rides out a transient error rather than ending the run.
|
||||
let mut attempt = 0_u32;
|
||||
let account = loop {
|
||||
match client.get_account(slot).await {
|
||||
Ok(account) => break account,
|
||||
Err(err) if attempt < RPC_RETRY_LIMIT => {
|
||||
attempt += 1;
|
||||
warn!("Outbox probe failed, retrying ({attempt}/{RPC_RETRY_LIMIT}): {err}");
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).context("Failed to read an outbox slot while probing");
|
||||
}
|
||||
}
|
||||
};
|
||||
if account == Account::default() {
|
||||
return Ok(ordinal);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No free outbox ordinal in {ORDINAL_PROBE_LIMIT} tried from {start}")
|
||||
}
|
||||
|
||||
/// Scans one zone's new blocks. A `ping_sender` tx marks the message's source
|
||||
/// block (its outbound leg); an inbox dispatch marks delivery on this zone.
|
||||
/// Runs forever; transient RPC errors are logged and retried.
|
||||
@@ -455,7 +525,9 @@ fn decode_inbox_text(instruction_data: &[u32]) -> Option<String> {
|
||||
fn decode_send_ordinal(instruction_data: &[u32]) -> Option<u32> {
|
||||
let instruction: SenderInstruction =
|
||||
risc0_zkvm::serde::from_slice::<SenderInstruction, u32>(instruction_data).ok()?;
|
||||
let SenderInstruction::Send { ordinal, .. } = instruction;
|
||||
let SenderInstruction::Send { ordinal, .. } = instruction else {
|
||||
return None;
|
||||
};
|
||||
Some(ordinal)
|
||||
}
|
||||
|
||||
@@ -470,7 +542,9 @@ fn decode_payload(payload: &[u8]) -> Option<String> {
|
||||
.collect();
|
||||
let instruction: ReceiverInstruction =
|
||||
risc0_zkvm::serde::from_slice::<ReceiverInstruction, u32>(&words).ok()?;
|
||||
let ReceiverInstruction::Record { payload: bytes } = instruction;
|
||||
let ReceiverInstruction::Record { payload: bytes } = instruction else {
|
||||
return None;
|
||||
};
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
@@ -487,18 +561,21 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone: other_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &other_zone, ordinal);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &other_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user