Merge branch 'Pravdyvy/request-distribution-for-multiple-sequencers' into Pravdyvyi/parallel-client-actualization

This commit is contained in:
Pravdyvy 2026-07-24 08:11:51 +03:00
commit bfb62d9b30
107 changed files with 7175 additions and 1939 deletions

View File

@ -62,6 +62,7 @@ allow-git = [
"https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git",
"https://github.com/logos-blockchain/sponges",
"https://github.com/arkworks-rs/spongefish.git",
"https://github.com/keycard-tech/keycard-rs",
]
unknown-git = "deny"
unknown-registry = "deny"

View File

@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
libssl-dev \
pkg-config \
libpcsclite-dev \
curl \
git \
jq \

4
.gitignore vendored
View File

@ -17,9 +17,5 @@ result
wallet-ffi/wallet_ffi.h
bedrock_signing_key
integration_tests/configs/debug/
venv/
keycard_wallet/python/__pycache__/
keycard_wallet/python/keycard-py/
.DS_Store

588
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ members = [
"lez",
"lez/system_accounts",
"lez/chain_state",
"lez/sequencer/core",
"lez/sequencer/service",
"lez/sequencer/service/protocol",
@ -72,6 +73,7 @@ members = [
lee = { path = "lee/state_machine" }
lee_core = { path = "lee/state_machine/core" }
common = { path = "lez/common" }
chain_state = { path = "lez/chain_state" }
mempool = { path = "lez/mempool" }
storage = { path = "lez/storage" }
key_protocol = { path = "lee/key_protocol" }
@ -178,11 +180,14 @@ logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/lo
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" }
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" }
keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" }
rocksdb = { version = "0.24.0", default-features = false, features = [
"snappy",
"bindgen-runtime",
] }
rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] }
pcsc = "2"
k256 = { version = "0.13.3", features = [
"ecdsa-core",
"arithmetic",
@ -199,7 +204,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [
axum = "0.8.4"
clap = { version = "4.5.42", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
pyo3 = { version = "0.29", features = ["auto-initialize"] }
zeroize = "1"
criterion = { version = "0.8", features = ["html_reports"] }

View File

@ -11,13 +11,19 @@ ARTIFACTS := "artifacts"
# Linux/CI, which is unaffected.
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
# Build risc0 program artifacts.
# Build risc0 program artifacts and test fixture.
build-artifacts:
@echo "🔨 Building artifacts"
@rm -rf {{ARTIFACTS}}
@just build-artifact lee/privacy_preserving_circuit
@just build-artifact lez/programs programs
@if [ "${GITHUB_ACTIONS:-}" = "true" ]; then \
echo "Skipping test fixture regeneration because CI doesn't need it"; \
else \
just regenerate-test-fixture; \
fi
build-artifact methods_path features="":
@echo "Building artifacts for {{methods_path}}"
@rm -rf target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin
@ -42,7 +48,7 @@ test:
# Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump).
regenerate-test-fixture:
@echo "🧪 Regenerating test fixtures"
@echo "🧪 Regenerating test fixture"
RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
@ -58,15 +64,17 @@ run-bedrock:
docker compose up
# Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
# Optional home/port let a second instance run off the same config, e.g.
# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo.
[working-directory: 'lez/sequencer/service']
run-sequencer standalone="":
run-sequencer standalone="" home="" port="3040":
@echo "🧠 Running sequencer"
@if [ "{{standalone}}" = "standalone" ]; then \
echo "🧪 Running in standalone mode"; \
RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \
RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
else \
echo "🚀 Running in normal mode"; \
RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \
RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
fi
# Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6,46 +6,45 @@ This tutorial walks you through using Keycard with Wallet CLI. Keycard is option
### Required hardware
- Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE.
- Smartcard reader
- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos.
- `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team).
- `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team)
### Firmware installation
Installation:
1. Install math applet on your keycard; this process only needs to be done once. In the root of repo:
```
sudo apt-get install -y default-jdk
wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P lez/keycard_wallet/keycard_applets
cd lez/keycard_wallet/keycard_applets
java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap
```
2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop)
- Keycard Desktop is used to install the LEE key protocol to a blank keycard.
- Select (Re)Install Applet and upload the key binary (`lez/keycard_wallet/keycard_applets/LEE_keycard.cap`).
![keycard-desktop.png](keycard-desktop.png)
- **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa.
## Wallet with Keycard
Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `lez/keycard_wallet/wallet_with_keycard.sh`.
LEE key protocol support (on top of standard Status Keycard commands) is built from source, from [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)'s default branch:
```bash
# Install appropriate version of `keycard-py`.
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py
# Set up virtual environment.
python3 -m venv venv
source venv/bin/activate
pip install pyscard mnemonic ecdsa pyaes
pip install -e lez/keycard_wallet/python/keycard-py
git clone --recurse-submodules https://github.com/keycard-tech/status-keycard.git
cd status-keycard
```
**Important**: Keycard wallet commands only work within the virtual environment.
The build requires **OpenJDK 11 specifically** (newer JDKs aren't compatible with its Gradle/plugin versions):
```bash
# In the root of LEE repo:
source venv/bin/activate
sudo apt-get install -y openjdk-11-jdk
export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
```
Gradle's default heap is too small for this build and will OOM during `buildSrc` compilation; bump it once:
```bash
echo "org.gradle.jvmargs=-Xmx2g" >> gradle.properties
```
Build and install onto a connected, blank card (disconnect all other card readers first):
```bash
./gradlew install
```
This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load it onto the card.
**Warning: `./gradlew install` uninstalls and reinstalls the applet, which erases any existing personalization.** If you run this against a card that's already personalized (identity certificate, PIN, PUK, and any loaded keys), all of that is wiped, regardless of whether the firmware source changed at all — reinstalling the exact same build twice has the same effect.
### Personalizing your card
**Personalization is mandatory, not optional — every card requires it before any command will work, immediately after installing the firmware.** A freshly installed (or freshly reinstalled) card has no identity certificate, and refuses every command.
**Important:** keycard can only connect with one application at a time; if another tool is using the keycard then Wallet CLI cannot access the same keycard, and vice-versa.
## PIN entry
Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable:
@ -60,43 +59,36 @@ Unset it when done:
unset KEYCARD_PIN
```
## Pairing password
## Default CA public key
The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair.
`keycard-rs` verifies every card's identity certificate against a trusted CA public key before anything else happens — no match, no commands, regardless of whether the firmware or PIN is correct. The baked-in default is:
The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password.
To use a custom pairing password, set it before `init`:
```bash
# Note: Keep the leading space before this command.
# Leading space prevents this command from being stored in shell history
# (when HISTCONTROL=ignorespace is enabled).
export KEYCARD_PAIRING_PASSWORD=my-custom-password
wallet keycard init
```
029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c
```
After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key — the pairing password is not needed again until the pairing is cleared.
**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail.
Unset the pairing password variable when done:
Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA instead, so the wallet needs to be told to trust it explicitly:
```bash
unset KEYCARD_PAIRING_PASSWORD
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
# unset KEYCARD_CA_PUBLIC_KEY when done testing against a dev card
```
If the card's certificate doesn't match whichever CA is in effect, every command reports the card as simply "not available."
## Keycard Commands
Keycard uses Secure Channel V2 (applet version >= 4.0) — the wallet authenticates the card via its identity certificate and opens a fresh ECDHE-derived channel every session. There's no pairing step and nothing cached between commands; you'll enter your PIN each time you connect.
### Keycard
| Command | Description |
|----------------------------------|-----------------------------------------------------------------------|
| `wallet keycard available` | Checks whether a Keycard reader and card are accessible |
| `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK |
| `wallet keycard connect` | Establishes and saves a pairing with the Keycard |
| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing |
| `wallet keycard connect` | Opens a secure channel with the Keycard and verifies the PIN |
| `wallet keycard load` | Loads a mnemonic phrase onto the Keycard |
| `wallet keycard factory-reset` | Wipes PIN/PUK/keys back to uninitialized, for re-`init`**debug builds only** (see below) |
| `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) |
1. Check keycard availability
@ -118,13 +110,13 @@ Record this PUK and store it somewhere safe. It cannot be recovered.
✅ Keycard initialized successfully.
```
3. Connect (pair and save pairing for subsequent commands)
3. Connect (open a secure channel and verify the PIN)
```bash
wallet keycard connect
# Output:
Keycard PIN:
✅ Keycard paired and ready.
✅ Keycard connected and PIN verified.
```
4. Load a mnemonic phrase
@ -140,25 +132,24 @@ Keycard PIN:
✅ Mnemonic phrase loaded successfully.
```
5. Disconnect (unpair and clear saved pairing)
5. `factory-reset`
Wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be re-`init`ialized — the counterpart to `init`. It does **not** remove the identity certificate, so the card doesn't need re-personalizing afterward. Irreversibly destroys any keys currently on the card, so it requires `--confirm`:
```bash
wallet keycard disconnect
wallet keycard factory-reset --confirm
# Output:
Keycard PIN:
✅ Keycard unpaired and pairing cleared.
✅ Keycard factory-reset. Run `wallet keycard init` to reinitialize it.
```
6. Get private keys for a BIP-32 path (**debug builds only**)
6. `get-private-keys` (**debug builds only**)
`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent.
First install the wallet with the `keycard-debug` feature:
Requires building the wallet with the `keycard-debug` feature:
```bash
cargo install --path lez/wallet --force --features keycard-debug
```
Then run the command:
Exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. Requires `--reveal` to confirm intent:
```bash
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal
@ -515,20 +506,4 @@ bash lez/keycard_wallet/tests/keycard_tests.sh
bash lez/keycard_wallet/tests/keycard_tests_2.sh
bash lez/keycard_wallet/tests/keycard_test_3.sh
bash lez/keycard_wallet/tests/keycard_power_recovery_tests.sh
```
## SigningGroup
`SigningGroup` (`lez/wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved.
Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing.
Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce.
```
SigningGroup {
local: [(AccountId, PrivateKey)], // signed in pure Rust
keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session
}
```
```

View File

@ -36,6 +36,7 @@ indexer_service_protocol.workspace = true
system_accounts.workspace = true
programs.workspace = true
test_programs.workspace = true
testnet_initial_state.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true

View File

@ -624,7 +624,7 @@ async fn prove_init_with_commitment_root(
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap();
let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let (output, _) = execute_and_prove(
vec![sender_pre, recipient],
@ -633,7 +633,7 @@ async fn prove_init_with_commitment_root(
})?,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk,
random_seed: [0; 32],
npk,

View File

@ -9,7 +9,7 @@
//! 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.
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
@ -17,14 +17,14 @@ use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
@ -62,18 +62,19 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
holder: holder_id,
amount: INITIAL_BALANCE,
}];
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_a)
.with_genesis(genesis_a)
.setup()
.await
.context("Failed to set up zone A sequencer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.await
.context("Failed to set up zone B sequencer")?;
let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.with_cross_zone(cross_zone.clone())
.setup()
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B indexer")?;
@ -87,9 +88,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
// Wait until zone B's indexer reflects the verified mint.
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
let indexer = indexer_client(idx_b.addr())
.await
.context("Failed to build indexer client")?;
@ -167,14 +166,6 @@ fn build_lock_tx(
LeeTransaction::Public(PublicTransaction::new(message, witness))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
let account_id = indexer_service_protocol::AccountId {

View File

@ -9,8 +9,6 @@
//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the
//! sequencer ingress guard is the only thing that stops this.
use std::net::SocketAddr;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_inbox_core::{
@ -18,13 +16,13 @@ use cross_zone_inbox_core::{
};
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{setup_bedrock_node, setup_sequencer},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
};
use lee::{
PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
#[test]
@ -34,7 +32,10 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
.context("Failed to set up Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel = config::bedrock_channel_id();
let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None)
let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up sequencer")?;
@ -70,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
);
Ok(())
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}

View File

@ -11,20 +11,20 @@
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
//! sequencer-trusted, with no indexer re-derivation (that is M4).
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{setup_bedrock_node, setup_sequencer},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
@ -54,13 +54,19 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> {
}],
};
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_a)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up zone A sequencer")?;
let (seq_b, _seq_b_home) =
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B sequencer")?;
let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.with_cross_zone(cross_zone)
.setup()
.await
.context("Failed to set up zone B sequencer")?;
// Submit the ping on zone A, addressed to ping_receiver on zone B.
let ping = build_ping_tx(zone_b, receiver_id);
@ -117,14 +123,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's sequencer until the ping record PDA holds a payload.
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
let wait = async {

View File

@ -9,7 +9,7 @@
//! payload landing in the indexer's state proves verification passed; a forgery
//! would have halted the indexer instead.
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
@ -53,21 +53,22 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
// Zone A: source. Zone B: destination, with the watcher on its sequencer and
// the verifier on its indexer.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_a)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up zone A sequencer")?;
let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
.await
.context("Failed to set up zone A indexer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.await
.context("Failed to set up zone B sequencer")?;
let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.with_cross_zone(cross_zone.clone())
.setup()
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B indexer")?;
@ -82,9 +83,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
// Wait until zone B's indexer records the delivered payload. The indexer only
// applies the dispatch after re-deriving and verifying it.
let record_id = ping_record_pda(receiver_id);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
let indexer = indexer_client(idx_b.addr())
.await
.context("Failed to build indexer client")?;
@ -129,14 +128,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the ping record PDA holds a payload.
async fn wait_for_indexer_delivery(
indexer: &IndexerClient,

View File

@ -0,0 +1,236 @@
#![expect(
clippy::tests_outside_test_module,
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
//! produce on their turns, and A, B and an indexer converge on the same chain.
use std::time::Duration;
use anyhow::{Context as _, Result, ensure};
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
use tokio::test;
/// 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;
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).
const ROTATION_BLOCKS: u64 = 8;
#[test]
async fn multi_sequencer_committee_converges() -> Result<()> {
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up Bedrock node")?;
// Fixed seeds so A can accredit B's public key before B exists.
let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE];
let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE];
let pub_a = Ed25519Key::from_bytes(&key_a).public_key();
let pub_b = Ed25519Key::from_bytes(&key_b).public_key();
let partial = SequencerPartialConfig {
block_create_timeout: Duration::from_secs(5),
..SequencerPartialConfig::default()
};
// Phase 1: A solo (its first inscription creates the channel), plus an indexer.
let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_genesis(vec![])
.with_bedrock_signing_key(key_a)
.setup()
.await
.context("Failed to set up sequencer A")?;
let a = sequencer_client(seq_a.addr())?;
let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
.await
.context("Failed to set up indexer")?;
let indexer = indexer_client(idx.addr()).await?;
wait_for_height(&a, 2, "sequencer A to produce past genesis").await?;
// Phase 2: live roster change to [A, B] with rotation enabled, posted
// straight to bedrock with A's admin key (the operator one-shot path).
post_channel_config(
&BedrockConfig {
channel_id: config::bedrock_channel_id(),
node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?,
auth: None,
},
&Ed25519Key::from_bytes(&key_a),
vec![pub_a, pub_b],
POSTING_TIMEFRAME_SLOTS,
POSTING_TIMEOUT_SLOTS,
1,
1,
)
.await
.context("Failed to configure the channel committee")?;
let height_at_config = a.get_last_block_id().await?;
wait_for_height(
&a,
height_at_config + 1,
"A to produce after the roster change",
)
.await?;
// Phase 3: B joins live and syncs the existing chain.
let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr)
.with_genesis(vec![])
.with_bedrock_signing_key(key_b)
.setup()
.await
.context("Failed to set up sequencer B")?;
let b = sequencer_client(seq_b.addr())?;
let join_height = a.get_last_block_id().await?;
wait_for_height(&b, join_height, "B to sync to A's height at join").await?;
// Phase 4: rotation + convergence over ≈4 turn windows.
let rotation_target = join_height + ROTATION_BLOCKS;
wait_for_height(
&a,
rotation_target,
"the chain to advance across turn windows",
)
.await?;
wait_for_height(&b, rotation_target, "B to follow across turn windows").await?;
assert_same_chain(&a, &b).await?;
// Phase 5: 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 = 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,
to,
TRANSFER_AMOUNT,
&sign_key,
);
b.send_transaction(tx)
.await
.context("Failed to submit the transfer to B")?;
wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?;
// Phase 6: the indexer finalizes the same chain, with no stall.
wait_for_finalized(&indexer, 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 = a
.get_block(id)
.await?
.with_context(|| format!("A is missing block {id}"))?;
ensure!(
block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash),
"Indexer diverges from A at block {id}"
);
}
let status = indexer.get_status().await?;
ensure!(
status.stall_reason.is_none(),
"Indexer is stalled: {:?}",
status.stall_reason
);
Ok(())
}
/// Polls the sequencer until its chain height reaches `target`.
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
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<()> {
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 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<()> {
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")?
}
/// Asserts A and B hold byte-identical block hashes over their common prefix.
async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> {
let common = a
.get_last_block_id()
.await?
.min(b.get_last_block_id().await?);
for id in 1..=common {
let block_a = a
.get_block(id)
.await?
.with_context(|| format!("A is missing block {id}"))?;
let block_b = b
.get_block(id)
.await?
.with_context(|| format!("B is missing block {id}"))?;
ensure!(
block_a.header.hash == block_b.header.hash,
"Chain divergence at block {id}: A {:?} vs B {:?}",
block_a.header.hash,
block_b.header.hash
);
}
Ok(())
}

View File

@ -0,0 +1,538 @@
//! End-to-end tests for the sequencer's startup bootstrap/reconstruction from
//! Bedrock (verify-and-reconstruct). Each test drives a real Bedrock node and one
//! or more sequencer instances sharing the same channel, exercising how a
//! sequencer reconciles its local store against what the channel serves.
#![expect(
clippy::tests_outside_test_module,
reason = "Integration tests live at crate root and don't care about these lints"
)]
use std::{path::Path, time::Duration};
use anyhow::{Context as _, Result, bail};
use indexer_service_rpc::RpcClient as _;
use integration_tests::L2_TO_L1_TIMEOUT;
use lee::{AccountId, PrivateKey, PublicKey};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use sequencer_core::config::GenesisAction;
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use test_fixtures::{
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
indexer_client::IndexerClient,
setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer},
};
use tokio::test;
/// Block cadence for the tests: short so we don't wait long for local production.
fn fast_blocks() -> SequencerPartialConfig {
SequencerPartialConfig {
block_create_timeout: Duration::from_secs(2),
..SequencerPartialConfig::default()
}
}
/// Block cadence for a sequencer we don't want producing during the inspection
/// window, so its post-reconstruction tip stays put while we read it.
fn slow_blocks() -> SequencerPartialConfig {
SequencerPartialConfig {
block_create_timeout: Duration::from_secs(30),
..SequencerPartialConfig::default()
}
}
/// Polls the indexer's last finalized block id until it reaches `target`. The
/// indexer reads finalized channel history, so this is our oracle for "block
/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer
/// can read back.
async fn wait_for_finalized(
indexer: &IndexerClient,
target: u64,
timeout: Duration,
) -> Result<u64> {
let poll = async {
loop {
let finalized = indexer
.get_last_finalized_block_id()
.await
.context("Failed to read indexer last finalized block id")?
.unwrap_or(0);
if finalized >= target {
return Ok::<_, anyhow::Error>(finalized);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
tokio::time::timeout(timeout, poll)
.await
.with_context(|| format!("Timed out waiting for indexer to finalize block {target}"))?
}
/// Polls the sequencer's last block id until it reaches `target` or `timeout` elapses.
async fn wait_for_block_id(
client: &SequencerClient,
target: u64,
timeout: Duration,
) -> Result<u64> {
let poll = async {
loop {
let id = client
.get_last_block_id()
.await
.context("Failed to read sequencer last block id")?;
if id >= target {
return Ok::<_, anyhow::Error>(id);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
};
tokio::time::timeout(timeout, poll)
.await
.with_context(|| format!("Timed out waiting for block id {target}"))?
}
/// Best-effort extraction of a panic payload's message (panics carry a `String`
/// or `&str`), for asserting a startup aborted for the *expected* reason.
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned()))
.unwrap_or_else(|| "<non-string panic payload>".to_owned())
}
/// A `SupplyAccount` genesis action for a fresh account, returning the vault
/// account id the funds land in (genesis supply goes into a claimable vault, not
/// the account directly), so tests can assert genesis state is present.
fn supplied_account(balance: u128) -> (AccountId, GenesisAction) {
let account_id = AccountId::from(&PublicKey::new_from_private_key(
&PrivateKey::new_os_random(),
));
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), account_id);
(
vault_id,
GenesisAction::SupplyAccount {
account_id,
balance,
},
)
}
/// Recursively copies the contents of `src` into `dst` (used to snapshot/restore a
/// sequencer's rocksdb directory while it is stopped).
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)
.with_context(|| format!("Failed to create dir {}", dst.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("Failed to read dir {}", src.display()))?
{
let entry = entry.context("Failed to read dir entry")?;
let target = dst.join(entry.file_name());
if entry
.file_type()
.context("Failed to read file type")?
.is_dir()
{
copy_dir_recursive(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)
.with_context(|| format!("Failed to copy into {}", target.display()))?;
}
}
Ok(())
}
/// Case 1: local store is empty and the Bedrock channel is empty.
///
/// The sequencer bootstraps genesis state, finds nothing to reconstruct, publishes
/// its own genesis to open the channel, and starts producing.
#[test]
async fn empty_local_and_empty_bedrock_bootstraps_from_genesis() -> Result<()> {
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let (vault_id, supply) = supplied_account(12_345);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
let handle = SequencerSetup::new(fast_blocks(), bedrock_addr)
.with_genesis(genesis)
.setup_at(home.path())
.await
.context("Failed to start sequencer")?;
let client = sequencer_client(handle.addr())?;
// Fresh store + empty channel: startup bootstrapped genesis state directly.
assert_eq!(
client.get_account_balance(vault_id).await?,
12_345,
"genesis-supplied vault balance must be present after bootstrap"
);
// The sequencer is live and producing on the freshly opened channel.
let last = wait_for_block_id(&client, 3, Duration::from_secs(60)).await?;
assert!(
last >= 3,
"sequencer should keep producing blocks, last={last}"
);
assert!(handle.is_healthy(), "sequencer must stay healthy");
Ok(())
}
/// Case 2: local store is empty, but the Bedrock channel already has blocks.
///
/// A first sequencer opens the channel and produces blocks; a second sequencer
/// starts from an empty store on the same channel (reusing the first's bedrock
/// signing key) and reconstructs the finalized history into its state.
#[test]
async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> {
const PRODUCED_TARGET: u64 = 3;
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(
bedrock_addr,
test_fixtures::config::bedrock_channel_id(),
None,
)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(7_777);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// Sequencer A opens the channel and produces a few blocks.
let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?;
let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr)
.with_genesis(genesis.clone())
.setup_at(home_a.path())
.await
.context("Failed to start sequencer A")?;
let client_a = sequencer_client(handle_a.addr())?;
wait_for_block_id(&client_a, PRODUCED_TARGET, Duration::from_secs(60)).await?;
// Wait until those blocks are finalized on Bedrock — reconstruction only
// reads finalized history. A stays alive so its publish task keeps flushing.
let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?;
// Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it
// restarts from an empty store on the same channel/identity — a sequencer that
// lost its local DB.
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
std::fs::remove_dir_all(home_a.path().join("rocksdb"))
.context("Failed to wipe sequencer L2 store")?;
// Sequencer B restarts on the same home from that empty store and reconstructs.
let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr)
.with_genesis(genesis)
.setup_at(home_a.path())
.await
.context("Failed to start sequencer B")?;
let client_b = sequencer_client(handle_b.addr())?;
// Reconstruction ran synchronously during B's startup: even though its local
// store was empty, its tip is past genesis, matching the finalized channel.
let tip_b = client_b.get_last_block_id().await?;
assert!(
tip_b >= finalized,
"B should reconstruct at least the finalized blocks; tip_b={tip_b}, finalized={finalized}"
);
assert!(
tip_b > 1,
"B should have reconstructed blocks beyond genesis; tip_b={tip_b}"
);
// Genesis state was rebuilt as part of the reconstruction.
assert_eq!(
client_b.get_account_balance(vault_id).await?,
7_777,
"reconstructed genesis vault balance must be present"
);
assert!(handle_b.is_healthy(), "sequencer B must stay healthy");
Ok(())
}
/// Case 3: local store is not empty, but the Bedrock channel is empty.
///
/// A sequencer produces blocks (committing to a channel), is stopped, and is
/// restarted against a fresh/empty channel — i.e. the channel it committed to
/// was wiped or the node points at a different chain. Startup must fail rather
/// than silently resume onto a foreign channel. Crucially this must hold even
/// though the sequencer only ever *produced* (so it never recorded a per-block
/// anchor): the committed-but-missing-channel invariant catches it.
#[test]
async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> {
const PRODUCED_TARGET: u64 = 3;
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (_vault_id, supply) = supplied_account(1);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// A opens the channel and produces blocks. They land in its local store
// immediately, so no need to wait for finalization.
let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?;
let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr)
.with_genesis(genesis.clone())
.setup_at(home_a.path())
.await
.context("Failed to start sequencer A")?;
wait_for_block_id(
&sequencer_client(handle_a.addr())?,
PRODUCED_TARGET,
Duration::from_secs(60),
)
.await?;
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
// Restart on the SAME home (A's committed store: blocks + checkpoint) but
// pointed at a fresh, never-used channel — the channel it committed to is gone.
let empty_channel = ChannelId::from([0x5a_u8; 32]);
// Startup aborts on the missing-channel invariant (a panic in
// `start_from_config`). Run it on a dedicated OS thread with its own runtime
// so the panic is isolated to `join()` instead of failing the test thread.
// The `timeout` future must be created *inside* `block_on` (it needs a running
// reactor), so build it in an `async` block rather than as an eager argument.
let home_a_path = home_a.path().to_owned();
let outcome = std::thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().expect("Failed to build runtime");
runtime.block_on(async {
tokio::time::timeout(
Duration::from_secs(90),
SequencerSetup::new(slow_blocks(), bedrock_addr)
.with_channel_id(empty_channel)
.with_genesis(genesis)
.setup_at(&home_a_path),
)
.await
})
})
.join();
match outcome {
// Expected: `start_from_config` panicked on the missing-channel invariant.
// Assert the *reason*, so an unrelated panic fails the test rather than
// masquerading as success.
Err(panic) => {
let message = panic_message(&*panic);
assert!(
message.contains("Refusing to resume onto a foreign channel"),
"startup panicked for an unexpected reason: {message}"
);
}
Ok(Err(_elapsed)) => {
bail!("Sequencer startup hung instead of failing against an empty channel")
}
Ok(Ok(Err(err))) => {
bail!("Sequencer expected to panic, but it failed with error: {err:#?}")
}
Ok(Ok(Ok(_handle))) => {
bail!("Sequencer startup unexpectedly succeeded against an empty channel")
}
}
Ok(())
}
/// Case 4: both non-empty, but the local store is *ahead* of the finalized channel.
///
/// A sequencer produces blocks faster than Bedrock finalizes them (the normal
/// steady state), so on restart its local tip leads the channel's finalized tip.
/// Startup must succeed: reconstruction re-verifies the finalized blocks it already
/// holds and leaves the extra, not-yet-finalized local blocks untouched.
#[test]
async fn local_ahead_of_channel_resumes() -> Result<()> {
const FINALIZED_TARGET: u64 = 2;
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(
bedrock_addr,
test_fixtures::config::bedrock_channel_id(),
None,
)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(4_242);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
// A produces continuously (fast cadence) while Bedrock finalizes slowly, so
// its local tip runs well ahead of the channel's finalized tip.
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr)
.with_genesis(genesis.clone())
.setup_at(home.path())
.await
.context("Failed to start sequencer A")?;
let client_a = sequencer_client(handle_a.addr())?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
let tip_before = client_a.get_last_block_id().await?;
assert!(
tip_before > finalized,
"local tip {tip_before} should lead the finalized tip {finalized}"
);
// Restart on the same home; slow cadence so its tip stays put while we inspect.
drop(handle_a);
tokio::time::sleep(Duration::from_secs(2)).await;
let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr)
.with_genesis(genesis)
.setup_at(home.path())
.await
.context("Failed to restart sequencer")?;
let client_b = sequencer_client(handle_b.addr())?;
// Reconstruction verified the finalized prefix and preserved the extra blocks.
let tip_b = client_b.get_last_block_id().await?;
assert!(
tip_b >= tip_before,
"restart must not lose locally-produced blocks; tip_b={tip_b}, before={tip_before}"
);
assert_eq!(
client_b.get_account_balance(vault_id).await?,
4_242,
"genesis state must survive the restart"
);
assert!(handle_b.is_healthy(), "sequencer must stay healthy");
Ok(())
}
/// Case 5: both non-empty, but the local store is *behind* the finalized channel.
///
/// We snapshot a sequencer's store at an early tip, let it keep extending and
/// finalizing the channel, then restore the early snapshot and restart. Startup
/// must reconstruct forward — replay the finalized blocks the local store is
/// missing — catching the local tip up to the channel.
///
/// The snapshot/restore is essential here (not a gratuitous copy): a live
/// sequencer's local tip always leads finalization, so the only way to obtain a
/// local store that *lags* the finalized channel is to preserve an earlier state
/// while the same channel advances past it.
#[test]
async fn local_behind_channel_reconstructs_forward() -> Result<()> {
const SNAPSHOT_TIP: u64 = 2;
const FINALIZED_TARGET: u64 = 4;
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock")?;
let (indexer_handle, _indexer_dir) = setup_indexer(
bedrock_addr,
test_fixtures::config::bedrock_channel_id(),
None,
)
.await
.context("Failed to setup indexer")?;
let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let (vault_id, supply) = supplied_account(5_005);
let genesis = vec![
supply,
GenesisAction::SupplyBridgeAccount { balance: 1_000_000 },
];
let home = tempfile::tempdir().context("Failed to create sequencer home")?;
let rocksdb = home.path().join("rocksdb");
// Bring the sequencer up to an early tip, then stop it so its store is at rest.
{
let handle = SequencerSetup::new(slow_blocks(), bedrock_addr)
.with_genesis(genesis.clone())
.setup_at(home.path())
.await
.context("Failed to start sequencer")?;
wait_for_block_id(
&sequencer_client(handle.addr())?,
SNAPSHOT_TIP,
Duration::from_secs(120),
)
.await?;
drop(handle);
tokio::time::sleep(Duration::from_secs(2)).await;
}
// Snapshot the early store (tip == SNAPSHOT_TIP), safe because it is at rest.
let snapshot = tempfile::tempdir().context("Failed to create snapshot dir")?;
copy_dir_recursive(&rocksdb, snapshot.path()).context("Failed to snapshot store")?;
// Resume the sequencer (fast cadence) so it extends and finalizes the channel
// beyond the snapshot.
let finalized = {
let handle = SequencerSetup::new(fast_blocks(), bedrock_addr)
.with_genesis(genesis.clone())
.setup_at(home.path())
.await
.context("Failed to resume sequencer")?;
let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?;
drop(handle);
tokio::time::sleep(Duration::from_secs(2)).await;
finalized
};
// Restore the early snapshot: the local store now lags the finalized channel.
std::fs::remove_dir_all(&rocksdb).context("Failed to remove store before restore")?;
copy_dir_recursive(snapshot.path(), &rocksdb).context("Failed to restore snapshot")?;
// Restart: reconstruction must catch the lagging store up to the channel.
let handle = SequencerSetup::new(slow_blocks(), bedrock_addr)
.with_genesis(genesis)
.setup_at(home.path())
.await
.context("Failed to restart sequencer from a lagging store")?;
let client = sequencer_client(handle.addr())?;
let tip = client.get_last_block_id().await?;
assert!(
tip >= finalized,
"lagging store must reconstruct forward to the finalized tip; tip={tip}, finalized={finalized}"
);
assert!(
tip > SNAPSHOT_TIP,
"reconstruction must advance beyond the snapshot tip; tip={tip}"
);
assert_eq!(
client.get_account_balance(vault_id).await?,
5_005,
"genesis state must be intact after reconstruction"
);
assert!(handle.is_healthy(), "sequencer must stay healthy");
Ok(())
}

View File

@ -272,7 +272,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
let recipient_npk = NullifierPublicKey::from(&recipient_nsk);
let recipient_pre = AccountWithMetadata::new(
Account::default(),
false,
true,
AccountId::for_regular_private_account(&recipient_npk, &recipient_vpk, 0),
);
@ -294,11 +294,12 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_vpk,
random_seed: [0; 32],
view_tag: 0,
nsk: sender_nsk,
membership_proof: proof,
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_vpk,
random_seed: [0; 32],
npk: recipient_npk,

View File

@ -13,7 +13,7 @@ use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder};
use tokio::test;
@ -35,13 +35,19 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> {
let channel_b = config::bedrock_channel_id_b();
// Empty genesis is enough: the clock transaction drives block production.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_a)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up zone A sequencer")?;
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
.await
.context("Failed to set up zone A indexer")?;
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr)
.with_channel_id(channel_b)
.with_genesis(vec![])
.setup()
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)

View File

@ -4,7 +4,7 @@ use lee_core::{
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
account::{Account, AccountId, Nonce},
compute_digest_for_path,
encryption::ViewingPublicKey,
encryption::{ViewTag, ViewingPublicKey},
};
use crate::execution_state::ExecutionState;
@ -66,6 +66,7 @@ pub fn compute_circuit_output(
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk);
emit_private_output(
&mut output,
@ -73,7 +74,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
&npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -83,6 +84,7 @@ pub fn compute_circuit_output(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk,
random_seed,
view_tag,
nsk,
membership_proof,
identifier,
@ -110,14 +112,14 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
&npk,
*view_tag,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk,
random_seed,
npk,
@ -133,8 +135,8 @@ pub fn compute_circuit_output(
"Found new private account with non default values",
);
assert!(
!pre_state.is_authorized,
"Found new private account marked as authorized."
pre_state.is_authorized,
"Found new private account marked as unauthorized."
);
let new_nullifier = (
@ -142,6 +144,7 @@ pub fn compute_circuit_output(
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
emit_private_output(
&mut output,
@ -149,7 +152,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -190,6 +193,7 @@ pub fn compute_circuit_output(
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaInit position must be in pda_seed_by_position");
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
emit_private_output(
&mut output,
&mut output_index,
@ -200,7 +204,7 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -210,6 +214,7 @@ pub fn compute_circuit_output(
InputAccountIdentity::PrivatePdaUpdate {
vpk,
random_seed,
view_tag,
nsk,
membership_proof,
identifier,
@ -234,7 +239,6 @@ pub fn compute_circuit_output(
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
let account_id = pre_state.account_id;
let npk = NullifierPublicKey::from(nsk);
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
@ -248,7 +252,7 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
&npk,
*view_tag,
vpk,
random_seed,
new_nullifier,
@ -271,7 +275,7 @@ fn emit_private_output(
post_state: Account,
account_id: &AccountId,
kind: &PrivateAccountKind,
npk: &NullifierPublicKey,
view_tag: ViewTag,
vpk: &ViewingPublicKey,
random_seed: &[u8; 32],
new_nullifier: (Nullifier, CommitmentSetDigest),
@ -287,15 +291,6 @@ fn emit_private_output(
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk);
// Currently the view tag is properlty generated for all accounts.
// To increase privacy, this will be changed in the later version
// to only be generated explicitly for initialized accounts and
// fed by the prover directly for updated accounts.
//
// See issue 573:
// https://github.com/logos-blockchain/logos-execution-zone/issues/573
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let encrypted_account = EncryptionScheme::encrypt(
&post_with_updated_nonce,
kind,

View File

@ -4,7 +4,7 @@ use crate::{
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey,
account::{Account, AccountWithMetadata},
encryption::{EncryptedAccountData, ViewingPublicKey},
encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey},
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
};
@ -42,13 +42,14 @@ pub enum InputAccountIdentity {
PrivateAuthorizedUpdate {
vpk: ViewingPublicKey,
random_seed: [u8; 32],
view_tag: ViewTag,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
},
/// Init of a standalone private account the caller does not own (e.g. a recipient who
/// doesn't yet exist on chain). No `nsk`, no membership proof.
PrivateUnauthorized {
PrivateForeignInit {
vpk: ViewingPublicKey,
random_seed: [u8; 32],
npk: NullifierPublicKey,
@ -79,6 +80,7 @@ pub enum InputAccountIdentity {
PrivatePdaUpdate {
vpk: ViewingPublicKey,
random_seed: [u8; 32],
view_tag: ViewTag,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
@ -125,7 +127,7 @@ impl InputAccountIdentity {
Self::Public
| Self::PrivateAuthorizedInit { .. }
| Self::PrivateAuthorizedUpdate { .. }
| Self::PrivateUnauthorized { .. } => None,
| Self::PrivateForeignInit { .. } => None,
}
}
}

View File

@ -131,6 +131,11 @@ pub enum InvalidProgramBehaviorError {
#[error("Called program {program_id:?} which is not listed in dependencies")]
UndeclaredProgramDependency { program_id: ProgramId },
#[error(
"Account {account_id} was declared in the transaction but is missing from the program output"
)]
DeclaredAccountMissingFromOutput { account_id: AccountId },
}
#[cfg(test)]

View File

@ -77,6 +77,14 @@ mod test_methods {
)
}
#[must_use]
pub const fn dropped_account() -> Program {
Program::new_unchecked(
test_methods::DROPPED_ACCOUNT_ID,
Cow::Borrowed(test_methods::DROPPED_ACCOUNT_ELF),
)
}
#[must_use]
pub const fn program_owner_changer() -> Program {
Program::new_unchecked(

View File

@ -8,8 +8,8 @@ mod default_values;
type Value = [u8; 32];
type Node = [u8; 32];
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct MerkleTree {
nodes: Vec<Node>,
capacity: usize,

View File

@ -1,8 +1,8 @@
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use lee_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier,
PrivacyPreservingCircuitOutput, SharedSecretKey,
Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::{PdaSeed, PrivateAccountKind},
};
@ -60,7 +60,7 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts()
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let balance_to_move: u128 = 37;
@ -89,7 +89,7 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts()
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -144,7 +144,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let balance_to_move: u128 = 37;
let mut commitment_set = CommitmentSet::with_capacity(2);
@ -198,13 +198,14 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: commitment_set
.get_proof_for(&commitment_sender)
.expect("sender's commitment must be in the set"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -242,12 +243,82 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
assert_eq!(recipient_post, expected_private_account_2);
}
#[test]
fn init_note_view_tag_is_derived_from_account_keys() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let account = AccountWithMetadata::new(Account::default(), true, account_id);
let (output, proof) = execute_and_prove(
vec![account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateForeignInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk: keys.npk(),
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert_eq!(output.encrypted_private_post_states.len(), 1);
assert_eq!(
output.encrypted_private_post_states[0].view_tag,
EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
);
}
#[test]
fn update_note_view_tag_is_the_supplied_value() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let account = Account {
program_owner: program.id(),
balance: 1,
..Account::default()
};
let commitment = Commitment::new(&account_id, &account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&commitment));
let sender = AccountWithMetadata::new(account, true, account_id);
// A tag deliberately different from the address-derived one, so a passthrough is
// distinguishable from re-derivation.
let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1);
let (output, proof) = execute_and_prove(
vec![sender],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: fed_tag,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
}],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert_eq!(output.encrypted_private_post_states.len(), 1);
assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag);
}
#[test]
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
true,
AccountId::for_regular_private_account(&account_keys.npk(), &account_keys.vpk(), 0),
);
@ -271,7 +342,7 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
let result = execute_and_prove(
vec![pre],
instruction,
vec![InputAccountIdentity::PrivateUnauthorized {
vec![InputAccountIdentity::PrivateForeignInit {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),
@ -422,7 +493,7 @@ fn private_pda_withdraw() {
/// Shared regular private account: receives funds via `authenticated_transfer` directly,
/// no custom program needed. This demonstrates the non-PDA shared account flow where
/// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account
/// uses the standard unauthorized private account path and works with auth-transfer's
/// uses the standard foreign private account path and works with auth-transfer's
/// transfer path like any other private account.
#[test]
fn shared_account_receives_via_simple_transfer() {
@ -443,9 +514,9 @@ fn shared_account_receives_via_simple_transfer() {
sender_id,
);
// Recipient: shared private account (new, unauthorized)
// Recipient: shared private account (new, foreign)
let shared_account_id = AccountId::from((&shared_npk, &shared_keys.vpk(), shared_identifier));
let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id);
let recipient = AccountWithMetadata::new(Account::default(), true, shared_account_id);
let balance_to_move: u128 = 100;
let instruction = Program::serialize_instruction(balance_to_move).unwrap();
@ -455,7 +526,7 @@ fn shared_account_receives_via_simple_transfer() {
instruction,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: shared_keys.vpk(),
random_seed: [0; 32],
npk: shared_npk,
@ -507,10 +578,10 @@ fn private_authorized_init_encrypts_regular_kind_with_identifier() {
);
}
/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts
/// `PrivateForeignInit` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
fn private_foreign_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
@ -521,12 +592,12 @@ fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
&Nonce::private_account_nonce_init(&recipient_id),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let recipient = AccountWithMetadata::new(Account::default(), true, recipient_id);
let (output, _) = execute_and_prove(
vec![recipient],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vec![InputAccountIdentity::PrivateForeignInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk: keys.npk(),
@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
@ -658,7 +731,7 @@ fn private_pda_init_identifier_mismatch_fails() {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let pre_state = AccountWithMetadata::new(Account::default(), true, account_id);
let result = execute_and_prove(
vec![pre_state],
@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,

View File

@ -20,8 +20,8 @@ use crate::{
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
pub struct CommitmentSet {
merkle_tree: MerkleTree,
commitments: HashMap<Commitment, usize>,
@ -67,8 +67,8 @@ impl CommitmentSet {
}
}
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone)]
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, PartialEq, Eq)]
struct NullifierSet(BTreeSet<Nullifier>);
impl NullifierSet {
@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet {
}
}
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
pub struct V03State {
public_state: HashMap<AccountId, Account>,
private_state: (CommitmentSet, NullifierSet),

View File

@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,

View File

@ -49,7 +49,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
);
let private_account_2 = AccountWithMetadata::new(
Account::default(),
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -64,11 +64,12 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: recipient_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -102,7 +103,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
balance: 1,
..Account::default()
},
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -113,11 +114,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -151,7 +153,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
..Account::default()
},
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -162,11 +164,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -200,7 +203,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
data: b"hola mundo".to_vec().try_into().unwrap(),
..Account::default()
},
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -211,11 +214,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -249,7 +253,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
nonce: Nonce(0xdead_beef),
..Account::default()
},
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -260,11 +264,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -279,7 +284,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
}
#[test]
fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_authorized()
fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_unauthorized()
{
let program = crate::test_methods::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
@ -295,8 +300,8 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
);
let private_account_2 = AccountWithMetadata::new(
Account::default(),
// This should be set to false in normal circumstances
true,
// This should be set to true in normal circumstances
false,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -307,11 +312,12 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
@ -773,7 +781,7 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() {
// remains allowed.
let unauthorized_account = AccountWithMetadata::new(
Account::default(),
false,
true,
(&private_keys.npk(), &private_keys.vpk(), 0),
);
@ -782,7 +790,7 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() {
let (output, proof) = execute_and_prove(
vec![unauthorized_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vec![InputAccountIdentity::PrivateForeignInit {
vpk: private_keys.vpk(),
random_seed: [0; 32],
npk: private_keys.npk(),
@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_0)
@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1)
@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1_after_spend)

View File

@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: from_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: from_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&from_commitment)
@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: to_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: to_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&to_commitment)

View File

@ -48,6 +48,7 @@ impl V03State {
self.insert_program(crate::test_methods::nonce_changer());
self.insert_program(crate::test_methods::extra_output());
self.insert_program(crate::test_methods::missing_output());
self.insert_program(crate::test_methods::dropped_account());
self.insert_program(crate::test_methods::program_owner_changer());
self.insert_program(crate::test_methods::data_changer());
self.insert_program(crate::test_methods::minter());
@ -269,7 +270,7 @@ fn shielded_balance_transfer_for_tests(
let recipient = AccountWithMetadata::new(
Account::default(),
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -278,7 +279,7 @@ fn shielded_balance_transfer_for_tests(
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -319,7 +320,7 @@ fn private_balance_transfer_for_tests(
);
let recipient_pre = AccountWithMetadata::new(
Account::default(),
false,
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
@ -330,13 +331,14 @@ fn private_balance_transfer_for_tests(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
InputAccountIdentity::PrivateForeignInit {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
@ -384,6 +386,7 @@ fn deshielded_balance_transfer_for_tests(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)

View File

@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: recipient_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&recipient_commitment)

View File

@ -79,6 +79,58 @@ fn program_should_fail_with_missing_output_accounts() {
));
}
/// A program can drop an entire account from its own output — both its `pre_state` and
/// `post_state` together, not just one side — while staying internally consistent
/// (`pre_states.len() == post_states.len()` within its own report, so `validate_execution`'s
/// length check alone can't catch it). This must still be rejected: every account the caller
/// declared in the transaction must appear somewhere in the final diff.
#[test]
fn program_should_fail_if_it_drops_a_declared_account() {
// Both accounts need a non-default program_owner: an account left at DEFAULT_PROGRAM_ID with
// non-default data would itself violate the (separate, pre-existing) "claim before mutating a
// default-owned account" rule the moment it's echoed back — unrelated to what this test
// targets. `with_public_account_balances` leaves program_owner at DEFAULT_PROGRAM_ID, so use
// `with_public_accounts` to set it explicitly instead.
let mut state = V03State::new()
.with_public_accounts([
(
AccountId::new([1; 32]),
Account {
program_owner: crate::test_methods::dropped_account().id(),
balance: 100,
..Account::default()
},
),
(
AccountId::new([2; 32]),
Account {
program_owner: crate::test_methods::dropped_account().id(),
balance: 0,
..Account::default()
},
),
])
.with_test_programs();
let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])];
let program_id = crate::test_methods::dropped_account().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput { account_id }
)) if account_id == AccountId::new([2; 32])
),
"expected DeclaredAccountMissingFromOutput for the dropped account, got {result:?}"
);
}
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
let initial_data = [(

View File

@ -126,7 +126,7 @@ fn validity_window_works_in_privacy_preserving_transactions(
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
true,
(&account_keys.npk(), &account_keys.vpk(), 0),
);
let mut state = V03State::new().with_test_programs();
@ -138,7 +138,7 @@ fn validity_window_works_in_privacy_preserving_transactions(
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vec![InputAccountIdentity::PrivateForeignInit {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),
@ -191,7 +191,7 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions(
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
true,
(&account_keys.npk(), &account_keys.vpk(), 0),
);
let mut state = V03State::new().with_test_programs();
@ -203,7 +203,7 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions(
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vec![InputAccountIdentity::PrivateForeignInit {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),

View File

@ -293,6 +293,17 @@ impl ValidatedStateDiff {
);
}
// Every account the caller declared as part of the transaction must appear in the final
// diff.
for account_id in &message.account_ids {
ensure!(
state_diff.contains_key(account_id),
InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput {
account_id: *account_id
}
);
}
Ok(Self(StateDiff {
signer_account_ids,
public_diff: state_diff,

View File

@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,

View File

@ -0,0 +1,35 @@
use lee_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_lee_inputs};
type Instruction = ();
/// Silently drops the second account entirely from its own output: given two `pre_states`, it
/// returns only one `(pre, post)` pair, echoing the first account back unchanged.
///
/// Differs from `missing_output` because the `pre_state` and `post_states` lengths match. We
/// simply drop the account from both before returning them as part of the program's output.
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
..
},
instruction_words,
) = read_lee_inputs::<Instruction>();
let Ok([pre1, _pre2]) = <[_; 2]>::try_from(pre_states) else {
return;
};
let account_pre1 = pre1.account.clone();
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![pre1],
vec![AccountPostState::new(account_pre1)],
)
.write();
}

View File

@ -0,0 +1,27 @@
[package]
name = "chain_state"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
common.workspace = true
lee.workspace = true
lee_core.workspace = true
logos-blockchain-core.workspace = true
logos-blockchain-zone-sdk.workspace = true
anyhow.workspace = true
borsh.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio.workspace = true
[dev-dependencies]
testnet_initial_state.workspace = true
serde_json.workspace = true

View File

@ -0,0 +1,308 @@
//! The single validate-then-apply entry point shared by the sequencer and the
//! indexer. Pure and storage-free: callers apply on a scratch clone of state and
//! commit only on `Ok`.
use common::{
HashType,
block::{Block, BlockMeta},
transaction::{LeeTransaction, clock_invocation},
};
use lee::{GENESIS_BLOCK_ID, V03State};
use crate::ingest_error::BlockIngestError;
/// The parent the next block must chain on.
// `l1_slot` will be added here when the `ChainState` anchor layer lands.
#[derive(Debug, Clone)]
pub struct Tip {
pub block_id: u64,
pub hash: HashType,
}
impl From<&Block> for Tip {
fn from(block: &Block) -> Self {
Self {
block_id: block.header.block_id,
hash: block.header.hash,
}
}
}
impl From<BlockMeta> for Tip {
fn from(meta: BlockMeta) -> Self {
Self {
block_id: meta.id,
hash: meta.hash,
}
}
}
impl From<&Tip> for BlockMeta {
fn from(tip: &Tip) -> Self {
Self {
id: tip.block_id,
hash: tip.hash,
}
}
}
/// Outcome of feeding a parsed L2 block to a validated tip.
pub enum AcceptOutcome {
/// Chained and applied; the tip advances.
Applied,
/// A duplicate re-delivery of an already-applied block. No state change.
AlreadyApplied,
/// Did not chain or failed to apply; the tip stays frozen.
Parked(BlockIngestError),
/// Chained but failed to apply, possibly transiently
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
/// untouched. The caller retries and parks once it gives up.
///
/// TODO: Only the indexer's `accept_block` emits this today; the sequencer's
/// `ChainState` parks on all failures without retrying (see `on_follow`).
RetryableFailure(BlockIngestError),
}
/// Validates `block` against `tip`, then applies it to `state`.
///
/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`.
pub fn apply_block(
tip: Option<&Tip>,
block: &Block,
state: &mut V03State,
) -> Result<(), BlockIngestError> {
validate_against_tip(tip, block)?;
apply_block_to_state(block, state)?;
Ok(())
}
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
/// (cold state) expects the genesis block.
pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
let computed = block.recompute_hash();
if computed != block.header.hash {
return Err(BlockIngestError::HashMismatch {
computed,
header: block.header.hash,
});
}
match tip {
None => {
if block.header.block_id != GENESIS_BLOCK_ID {
return Err(BlockIngestError::UnexpectedBlockId {
expected: GENESIS_BLOCK_ID,
got: block.header.block_id,
});
}
}
Some(tip) => {
let expected = tip
.block_id
.checked_add(1)
.expect("block id should not overflow");
if block.header.block_id != expected {
return Err(BlockIngestError::UnexpectedBlockId {
expected,
got: block.header.block_id,
});
}
if block.header.prev_block_hash != tip.hash {
return Err(BlockIngestError::BrokenChainLink {
expected_prev: tip.hash,
got_prev: block.header.prev_block_hash,
});
}
}
}
Ok(())
}
/// Applies a block's transactions to `state`, mapping every failure to a
/// [`BlockIngestError`] so the caller can park rather than crash. Operates in
/// place; the caller commits only on `Ok`.
pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
let (clock_tx, user_txs) = block
.body
.transactions
.split_last()
.ok_or(BlockIngestError::EmptyBlock)?;
let LeeTransaction::Public(clock_tx) = clock_tx else {
return Err(BlockIngestError::InvalidClockTransaction);
};
if *clock_tx != clock_invocation(block.header.timestamp) {
return Err(BlockIngestError::InvalidClockTransaction);
}
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
for (tx_index, transaction) in user_txs.iter().enumerate() {
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
tx_index: tx_index.try_into().expect("tx index fits in u64"),
reason: format!("{err:#}"),
};
if is_genesis {
let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::NonPublicGenesisTransaction);
};
state
.transition_from_public_transaction(
public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| state_transition(err.into()))?;
} else {
transaction
.clone()
.execute_on_state(state, block.header.block_id, block.header.timestamp)
.map_err(|err| state_transition(err.into()))?;
}
}
state
.transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp)
.map_err(|err| BlockIngestError::StateTransition {
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
reason: format!("{:#}", anyhow::Error::from(err)),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use common::{
block::HashableBlockData,
test_utils::{
create_transaction_native_token_transfer, produce_dummy_block,
produce_dummy_empty_transaction, sequencer_sign_key_for_testing,
},
};
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state};
use super::*;
fn tip_of(block: &Block) -> Tip {
Tip::from(block)
}
#[test]
fn genesis_applies_on_empty_tip() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
}
#[test]
fn non_genesis_first_block_is_unexpected_id() {
let mut state = initial_state();
let block = produce_dummy_block(2, None, vec![]);
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(
err,
BlockIngestError::UnexpectedBlockId {
expected: 1,
got: 2
}
));
}
#[test]
fn skip_ahead_block_is_unexpected_id() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
// Tip is at 1; a block with id 3 skips ahead.
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
let err =
apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject");
assert!(matches!(
err,
BlockIngestError::UnexpectedBlockId {
expected: 2,
got: 3
}
));
}
#[test]
fn broken_chain_link_detected() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
// Correct id (2), wrong parent hash.
let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]);
let err =
apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::BrokenChainLink { .. }));
}
#[test]
fn hash_mismatch_detected() {
let mut state = initial_state();
let mut genesis = produce_dummy_block(1, None, vec![]);
// Tampering with the header invalidates the stored hash.
genesis.header.timestamp = 999;
let err = apply_block(None, &genesis, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::HashMismatch { .. }));
}
#[test]
fn empty_block_rejected() {
let mut state = initial_state();
// A block with no transactions at all (not even the mandatory clock tx).
let block = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0_u8; 32]),
timestamp: 0,
transactions: vec![],
}
.into_pending_block(&sequencer_sign_key_for_testing());
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::EmptyBlock));
}
#[test]
fn missing_clock_tail_is_invalid_clock() {
let mut state = initial_state();
// Last tx is not the expected clock invocation for the timestamp.
let block = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0_u8; 32]),
timestamp: 50,
transactions: vec![produce_dummy_empty_transaction()],
}
.into_pending_block(&sequencer_sign_key_for_testing());
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::InvalidClockTransaction));
}
#[test]
fn applies_transfers_and_advances_state() {
let mut state = initial_state();
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
// Genesis (block 1): clock-only.
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
let mut tip = tip_of(&genesis);
// Blocks 2..=11: one native transfer of 10 each (nonces 0..=9).
for i in 0..10_u64 {
let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key);
let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]);
apply_block(Some(&tip), &block, &mut state).expect("transfer applies");
tip = tip_of(&block);
}
assert_eq!(state.get_account_by_id(from).balance, 9900);
assert_eq!(state.get_account_by_id(to).balance, 20100);
}
}

1034
lez/chain_state/src/chain.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,18 @@
//! Startup check that the local store still belongs to the chain the
//! connected channel serves.
//! Startup check that a local store still belongs to the chain the connected
//! channel serves.
use anyhow::Result;
use common::{HashType, block::Block};
use futures::StreamExt as _;
use lee_core::BlockId;
use log::warn;
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter::Node as _};
use crate::IndexerCore;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter, indexer::ZoneIndexer};
/// Upper bound on the channel reads of the startup consistency check.
const CHANNEL_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Result of comparing the indexer's stored chain against the channel.
/// Result of comparing a caller's stored chain against the channel.
pub enum ChainConsistency {
/// Channel still serves our anchor block (the stored tip position, or the
/// parked block while stalled).
@ -24,7 +24,7 @@ pub enum ChainConsistency {
/// - or the channel read was inconclusive (timeout / error / empty stream)
///
/// NOTE: None of these prove a reset, so the caller proceeds.
/// A genuine divergence is still caught later when the ingest loop tries to apply and parks.
/// A genuine divergence is still caught later when applying the channel history.
Inconclusive,
/// Positive evidence that the channel is a different chain than the store.
///
@ -36,13 +36,13 @@ pub enum ChainConsistency {
pub enum ChainMismatch {
/// The channel serves a different block at the anchor's id.
Block {
ours: (u64, HashType),
channel: (u64, HashType),
ours: (BlockId, HashType),
channel: (BlockId, HashType),
},
/// The channel serves a block at/below the anchor's id past the anchor
/// slot; on the same chain those ids live at earlier slots.
ReinscribedBlock {
channel: (u64, HashType),
channel: (BlockId, HashType),
slot: Slot,
anchor_slot: Slot,
},
@ -97,18 +97,26 @@ impl std::fmt::Display for ChainMismatch {
/// A block that must still be inscribed at `slot` if the channel is the chain
/// the store was built from: the tip at the read cursor, or the recorded
/// parked block while stalled.
struct Anchor {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor {
slot: Slot,
/// The anchor block's `(id, hash)`.
///
/// `None` when parked on an undeserializable inscription (no header was recorded).
block: Option<(u64, HashType)>,
/// `None` when anchored on an undeserializable inscription (no header was recorded).
block: Option<(BlockId, HashType)>,
}
impl Anchor {
/// Builds an anchor at `slot` on the block `(id, hash)`, or a headerless
/// anchor (`None`) when only the slot is known.
#[must_use]
pub const fn new(slot: Slot, block: Option<(BlockId, HashType)>) -> Self {
Self { slot, block }
}
/// Probes a channel message read at/after the anchor slot.
/// See [`IndexerCore::verify_chain_at_anchor`].
pub fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe {
/// See [`verify_chain_consistency`].
fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe {
if slot < self.slot {
return AnchorProbe::KeepLooking;
}
@ -157,6 +165,66 @@ impl Anchor {
}
}
/// Classifies a stored chain against the channel one message at a time.
///
/// The shared driver behind both consistency consumers:
/// [`verify_chain_consistency`] runs it over a channel it reads itself, while a
/// caller that already streams the channel history (e.g. a reconstructing
/// sequencer) feeds it the same messages it replays. Run [`Self::check_frontier`]
/// once the channel tip is known, then feed messages in slot order via
/// [`Self::observe`] until a verdict is reached.
pub struct AnchorConsistencyCheck {
anchor: Anchor,
verdict: Option<ChainConsistency>,
}
impl AnchorConsistencyCheck {
/// New checker for `anchor` with an undetermined verdict.
#[must_use]
pub const fn new(anchor: Anchor) -> Self {
Self {
anchor,
verdict: None,
}
}
/// Applies the frontier check against a known channel tip. Skip it when the
/// tip could not be read, leaving the verdict to the message scan.
pub fn check_frontier(&mut self, channel_tip_slot: Option<Slot>) {
if self.verdict.is_none() {
self.verdict = frontier_verdict(self.anchor.slot, channel_tip_slot)
.map(ChainConsistency::Inconsistent);
}
}
/// Feeds the next channel message in slot order. Returns the verdict once it
/// is known so the caller can stop, or `None` while still undetermined.
pub fn observe(&mut self, msg: &ZoneMessage, slot: Slot) -> Option<&ChainConsistency> {
if self.verdict.is_none() {
self.verdict = match self.anchor.probe_anchor_slot(msg, slot) {
AnchorProbe::SameChain => Some(ChainConsistency::Consistent),
AnchorProbe::Mismatch(mismatch) => Some(ChainConsistency::Inconsistent(mismatch)),
AnchorProbe::Bail => Some(ChainConsistency::Inconclusive),
AnchorProbe::KeepLooking => None,
};
}
self.verdict.as_ref()
}
/// The verdict reached so far, or `None` while undetermined.
#[must_use]
pub const fn verdict(&self) -> Option<&ChainConsistency> {
self.verdict.as_ref()
}
/// Consumes the checker, defaulting an undetermined verdict (the anchor slot
/// was never observed) to [`ChainConsistency::Inconclusive`].
#[must_use]
pub fn finish(self) -> ChainConsistency {
self.verdict.unwrap_or(ChainConsistency::Inconclusive)
}
}
/// What a single channel message tells the anchored consistency check.
enum AnchorProbe {
/// The anchor is still in place: same chain.
@ -168,114 +236,65 @@ enum AnchorProbe {
KeepLooking,
}
#[expect(
clippy::multiple_inherent_impl,
reason = "split for clarity & isolation of relevant code"
)]
impl IndexerCore {
/// Verifies whether the channel still serves the same chain the store was built from.
/// This may change frequently during development where we reset the chain from time to
/// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in
/// production.
///
/// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block
/// while stalled, or the tip L2 block at its own inscription L1 slot.
pub(crate) async fn verify_chain_consistency(&self) -> Result<ChainConsistency> {
let Some(anchor) = self.get_startup_anchor()? else {
// empty or cold store: nothing to compare
return Ok(ChainConsistency::Inconclusive);
};
self.verify_chain_at_anchor(&anchor).await
/// Detects when a local store belongs to a different chain than the connected
/// L1 (e.g. a wiped/restarted Bedrock) so startup can react instead of silently
/// diverging.
///
/// Verifies the channel still carries the anchor block at its slot. The anchor
/// was finalized at `anchor.slot`, so the same chain must still serve it there,
/// while a reset chain re-inscribes its content only at later wall-clock slots.
/// Only positive evidence of a different chain yields
/// [`ChainConsistency::Inconsistent`]; absence of data stays
/// [`ChainConsistency::Inconclusive`].
///
/// `node` need only implement the zone-sdk [`adapter::Node`] trait; a throwaway
/// [`ZoneIndexer`] is built internally for the channel read.
pub async fn verify_chain_consistency<N>(
node: &N,
channel_id: ChannelId,
anchor: &Anchor,
) -> Result<ChainConsistency>
where
N: adapter::Node + Clone + Sync,
{
let mut check = AnchorConsistencyCheck::new(anchor.clone());
match node.channel_state(channel_id).await {
Ok(state) => check.check_frontier(state.map(|s| s.tip_slot)),
Err(err) => warn!("Failed to read channel state for the consistency check: {err:#}"),
}
if check.verdict().is_some() {
return Ok(check.finish());
}
/// Builds the anchor for the startup check.
///
/// - If stalled, returns the recorded _parked_ block
/// - If not stalled, returns the validated tip at its _own_ inscription slot.
/// - If the store is empty, returns `None`.
fn get_startup_anchor(&self) -> Result<Option<Anchor>> {
if let Some(stall) = self.store.get_stall_reason()? {
return Ok(Some(Anchor {
slot: stall.l1_slot,
block: stall.block_id.zip(stall.block_hash),
}));
}
// `next_messages` is exclusive, so `slot - 1` includes the anchor slot.
let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else {
return Ok(ChainConsistency::Inconclusive);
};
// not stalled, so anchor on the tip at its own inscription slot
let Some(slot) = self.store.get_tip_slot()?.or(self.store.get_zone_cursor()?) else {
return Ok(None);
};
let Some(tip_id) = self.store.get_last_block_id()? else {
return Ok(None);
};
let Some(tip) = self.store.get_block_at_id(tip_id)? else {
return Ok(None);
};
Ok(Some(Anchor {
slot,
block: Some((tip_id, tip.header.hash)),
}))
}
let zone_indexer = ZoneIndexer::new(channel_id, node.clone());
let scan = async {
let stream = zone_indexer
.next_messages(Some(Slot::from(from_slot)))
.await?;
let mut stream = std::pin::pin!(stream);
/// Verifies the channel still carries the anchor block at its slot.
///
/// The anchor was finalized at `anchor.slot`, so the same chain must still
/// serve it there, while a reset chain re-inscribes its content only at
/// later wall-clock slots.
///
/// Only positive evidence of a different chain yields `Inconsistent`.
/// Absence of data stays `Inconclusive`.
async fn verify_chain_at_anchor(&self, anchor: &Anchor) -> Result<ChainConsistency> {
match self.node.channel_state(self.config.channel_id).await {
Ok(state) => {
if let Some(mismatch) = frontier_verdict(anchor.slot, state.map(|s| s.tip_slot)) {
return Ok(ChainConsistency::Inconsistent(mismatch));
}
}
Err(err) => {
warn!("Failed to read channel state for the consistency check: {err:#}");
while let Some((msg, slot)) = stream.next().await {
if check.observe(&msg, slot).is_some() {
break;
}
}
Ok::<_, anyhow::Error>(())
};
// `next_messages` is exclusive, so `slot - 1` includes the anchor slot.
let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else {
return Ok(ChainConsistency::Inconclusive);
};
let scan = async {
let stream = self
.zone_indexer
.next_messages(Some(Slot::from(from_slot)))
.await?;
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
match anchor.probe_anchor_slot(&msg, slot) {
AnchorProbe::SameChain => return Ok(Some(ChainConsistency::Consistent)),
AnchorProbe::Mismatch(mismatch) => {
return Ok(Some(ChainConsistency::Inconsistent(mismatch)));
}
AnchorProbe::Bail => return Ok(Some(ChainConsistency::Inconclusive)),
AnchorProbe::KeepLooking => { /* dont do anything */ }
}
}
Ok::<_, anyhow::Error>(None)
};
match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await {
Ok(Ok(Some(outcome))) => Ok(outcome),
Ok(Ok(None)) => Ok(ChainConsistency::Inconclusive),
Ok(Err(err)) => {
warn!(
"Failed to read the anchor slot for the consistency check; proceeding: {err:#}"
);
Ok(ChainConsistency::Inconclusive)
}
Err(_elapsed) => {
warn!("Timed out reading the anchor slot for the consistency check; proceeding");
Ok(ChainConsistency::Inconclusive)
}
match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await {
Ok(Ok(())) => Ok(check.finish()),
Ok(Err(err)) => {
warn!("Failed to read the anchor slot for the consistency check; proceeding: {err:#}");
Ok(ChainConsistency::Inconclusive)
}
Err(_elapsed) => {
warn!("Timed out reading the anchor slot for the consistency check; proceeding");
Ok(ChainConsistency::Inconclusive)
}
}
}
@ -297,35 +316,13 @@ fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option<Slot>) -> Option
#[cfg(test)]
mod tests {
use std::time::Duration;
use common::block::HashableBlockData;
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
use logos_blockchain_zone_sdk::ZoneBlock;
use super::*;
use crate::{
BlockIngestError,
block_store::AcceptOutcome,
config::{ChannelId, ClientConfig, IndexerConfig},
};
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
let config = IndexerConfig {
consensus_info_polling_interval: Duration::from_secs(1),
bedrock_config: ClientConfig {
addr: "http://localhost:1".parse().expect("url"),
auth: None,
},
channel_id: ChannelId::from([1; 32]),
allow_chain_reset: false,
cross_zone: None,
bridge_lock_holdings: Vec::new(),
};
IndexerCore::open(config, dir).expect("open core")
}
fn test_block(block_id: u64, timestamp: u64) -> Block {
fn test_block(block_id: BlockId, timestamp: u64) -> Block {
HashableBlockData {
block_id,
prev_block_hash: HashType([0; 32]),
@ -344,98 +341,7 @@ mod tests {
}
fn anchor_for(block: &Block, slot: Slot) -> Anchor {
Anchor {
slot,
block: Some((block.header.block_id, block.header.hash)),
}
}
#[tokio::test]
async fn cold_store_is_inconclusive() {
// An empty store has no cursor, so there is nothing to compare: the check
// must be Inconclusive (not Consistent), and it returns before any L1 read.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn parked_store_with_unreachable_node_is_inconclusive() {
// Network failure is not evidence of a reset: a parked store must stay
// parked (Inconclusive), not error out or trip the wipe path.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let parked = test_block(5, 42);
core.store
.record_stall(
Some(&parked.header),
Slot::from(1_000),
BlockIngestError::EmptyBlock,
)
.expect("record stall");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn caught_up_store_with_unreachable_node_is_inconclusive() {
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
assert!(matches!(
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept"),
AcceptOutcome::Applied
));
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() {
// Cursor persist failures are warn-only, so the read cursor can lag the
// tip by several blocks. The anchor must pair the tip with its own
// inscription slot; pairing it with the stale cursor would make the scan
// misread the chain's intermediate blocks as re-inscriptions.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept");
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
core.store
.accept_block(&block2, Slot::from(1_005))
.await
.expect("accept");
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
core.store
.accept_block(&block3, Slot::from(1_010))
.await
.expect("accept");
// Cursor last persisted at the genesis slot: two blocks behind the tip.
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
let anchor = core.get_startup_anchor().expect("anchor").expect("present");
assert_eq!(anchor.slot, Slot::from(1_010));
assert_eq!(anchor.block, Some((3, block3.header.hash)));
Anchor::new(slot, Some((block.header.block_id, block.header.hash)))
}
#[test]
@ -516,10 +422,7 @@ mod tests {
fn probe_accepts_any_message_for_a_headerless_anchor() {
// A deserialize park records no header: any message still present at
// the anchor slot means the history is intact.
let anchor = Anchor {
slot: Slot::from(1_000),
block: None,
};
let anchor = Anchor::new(Slot::from(1_000), None);
let garbage = ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0_u8; 32]),
data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"),

View File

@ -1,10 +1,10 @@
use common::HashType;
use serde::{Deserialize, Serialize};
/// Why the indexer could not apply an L2 block from the channel.
/// Why an L2 block from the channel could not be applied.
///
/// Persisted in `RocksDB`, so every variant must have the following
/// traits: `Clone + Serialize + Deserialize`.
/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant
/// must be `Clone + Serialize + Deserialize`.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum BlockIngestError {
#[error("Failed to deserialize L2 block: {0}")]

View File

@ -0,0 +1,17 @@
//! Storage-free chain-state core shared by the LEZ sequencer and indexer:
//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`],
//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model.
pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip};
pub use chain::{ChainState, HeadEntry};
pub use consistency::{
Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency,
};
pub use ingest_error::BlockIngestError;
pub use stall_reason::StallReason;
pub mod apply;
pub mod chain;
pub mod consistency;
pub mod ingest_error;
pub mod stall_reason;

View File

@ -1,4 +1,4 @@
use common::HashType;
use common::{HashType, block::BlockHeader};
use logos_blockchain_zone_sdk::Slot;
use serde::{Deserialize, Serialize};
@ -23,3 +23,28 @@ pub struct StallReason {
/// just count them.
pub orphans_since: u64,
}
impl StallReason {
/// First stall for a break, built from the breaking block's header
/// (`None` for a deserialize break).
#[must_use]
pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self {
Self {
block_id: header.map(|header| header.block_id),
block_hash: header.map(|header| header.hash),
prev_block_hash: header.map(|header| header.prev_block_hash),
first_seen: header.map(|header| header.timestamp),
l1_slot,
error,
orphans_since: 0,
}
}
/// A later stall on the same break: bumps `orphans_since`, preserving the
/// original cause.
#[must_use]
pub const fn escalate(mut self) -> Self {
self.orphans_since = self.orphans_since.saturating_add(1);
self
}
}

View File

@ -13,6 +13,15 @@ pub struct BlockMeta {
pub hash: BlockHash,
}
impl From<&Block> for BlockMeta {
fn from(block: &Block) -> Self {
Self {
id: block.header.block_id,
hash: block.header.hash,
}
}
}
#[derive(Debug, Clone)]
/// Our own hasher.
/// Currently it is SHA256 hasher wrapper. May change in a future.

View File

@ -12,6 +12,7 @@ default = []
testnet = []
[dependencies]
chain_state.workspace = true
common.workspace = true
logos-blockchain-zone-sdk.workspace = true
lee.workspace = true
@ -32,7 +33,6 @@ futures.workspace = true
url.workspace = true
logos-blockchain-core.workspace = true
serde_json.workspace = true
thiserror.workspace = true
async-stream.workspace = true
tokio.workspace = true
risc0-zkvm.workspace = true

View File

@ -1,12 +1,14 @@
use std::{path::Path, sync::Arc};
use anyhow::{Context as _, Result};
use common::{
HashType,
block::{BedrockStatus, Block, BlockHeader},
transaction::{LeeTransaction, clock_invocation},
use chain_state::{
AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip,
};
use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State};
use common::{
block::{BedrockStatus, Block, BlockHeader},
transaction::LeeTransaction,
};
use lee::{Account, AccountId, V03State};
use lee_core::BlockId;
use log::warn;
use logos_blockchain_core::header::HeaderId;
@ -14,28 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
use storage::indexer::RocksDBIO;
use tokio::sync::RwLock;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
struct Tip {
block_id: u64,
hash: HashType,
}
/// Outcome of feeding a parsed L2 block to the validated tip.
pub enum AcceptOutcome {
/// Chained and applied; tip and L1 read cursor both advance.
Applied,
/// A duplicate re-delivery of the current tip. Just L2 advances.
AlreadyApplied,
/// Did not chain or failed to apply; tip stays frozen, stall recorded.
Parked(BlockIngestError),
/// Chained but failed to apply, possibly transiently
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
/// untouched. The caller retries and parks via
/// [`IndexerStore::record_stall`] once it gives up.
RetryableFailure(BlockIngestError),
}
#[derive(Clone)]
pub struct IndexerStore {
dbio: Arc<RocksDBIO>,
@ -207,10 +187,7 @@ impl IndexerStore {
let Some(block) = self.dbio.get_block(block_id)? else {
return Ok(None);
};
Ok(Some(Tip {
block_id,
hash: block.header.hash,
}))
Ok(Some(Tip::from(&block)))
}
/// Record the stall reason.
@ -223,22 +200,10 @@ impl IndexerStore {
l1_slot: Slot,
error: BlockIngestError,
) -> Result<()> {
let stall = match self.get_stall_reason()? {
Some(mut existing) => {
existing.orphans_since = existing.orphans_since.saturating_add(1);
existing
}
None => StallReason {
// need to map out of `header` because they are not ser/de
block_id: header.map(|h| h.block_id),
block_hash: header.map(|h| h.hash),
prev_block_hash: header.map(|h| h.prev_block_hash),
first_seen: header.map(|h| h.timestamp),
l1_slot,
error,
orphans_since: 0,
},
};
let stall = self.get_stall_reason()?.map_or_else(
|| StallReason::new(header, l1_slot, error),
StallReason::escalate,
);
self.set_stall_reason(&Some(stall))
}
@ -258,6 +223,8 @@ impl IndexerStore {
return Ok(AcceptOutcome::AlreadyApplied);
}
// Validate before paying for the scratch clone; validation failures
// are never retryable, so parking immediately is exact.
if let Err(err) = validate_against_tip(tip.as_ref(), block) {
self.record_stall(Some(&block.header), l1_slot, err.clone())?;
return Ok(AcceptOutcome::Parked(err));
@ -265,7 +232,7 @@ impl IndexerStore {
// TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is
let mut scratch = self.current_state.read().await.clone();
if let Err(err) = apply_block_to_scratch(block, &mut scratch) {
if let Err(err) = apply_block_to_state(block, &mut scratch) {
if err.is_retryable() {
return Ok(AcceptOutcome::RetryableFailure(err));
}
@ -290,112 +257,11 @@ impl IndexerStore {
}
}
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
/// (cold store) expects the genesis block.
fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
let computed = block.recompute_hash();
if computed != block.header.hash {
return Err(BlockIngestError::HashMismatch {
computed,
header: block.header.hash,
});
}
match tip {
None => {
if block.header.block_id != GENESIS_BLOCK_ID {
return Err(BlockIngestError::UnexpectedBlockId {
expected: GENESIS_BLOCK_ID,
got: block.header.block_id,
});
}
}
Some(tip) => {
let expected = tip
.block_id
.checked_add(1)
.expect("block id should not overflow");
if block.header.block_id != expected {
return Err(BlockIngestError::UnexpectedBlockId {
expected,
got: block.header.block_id,
});
}
if block.header.prev_block_hash != tip.hash {
return Err(BlockIngestError::BrokenChainLink {
expected_prev: tip.hash,
got_prev: block.header.prev_block_hash,
});
}
}
}
Ok(())
}
/// Applies a block's transactions to `state`, mapping every failure to a
/// [`BlockIngestError`] so the caller can park rather than crash. Operates on a
/// scratch state; the caller commits only on `Ok`.
fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
let (clock_tx, user_txs) = block
.body
.transactions
.split_last()
.ok_or(BlockIngestError::EmptyBlock)?;
let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp));
if *clock_tx != expected_clock {
return Err(BlockIngestError::InvalidClockTransaction);
}
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
for (tx_index, transaction) in user_txs.iter().enumerate() {
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
tx_index: tx_index.try_into().expect("tx index fits in u64"),
reason: format!("{err:#}"),
};
if is_genesis {
let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::NonPublicGenesisTransaction);
};
state
.transition_from_public_transaction(
public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| state_transition(err.into()))?;
} else {
transaction
.clone()
.execute_on_state(state, block.header.block_id, block.header.timestamp)
.map_err(|err| state_transition(err.into()))?;
}
}
let LeeTransaction::Public(clock_public_tx) = clock_tx else {
return Err(BlockIngestError::InvalidClockTransaction);
};
state
.transition_from_public_transaction(
clock_public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| BlockIngestError::StateTransition {
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
reason: format!("{:#}", anyhow::Error::from(err)),
})?;
Ok(())
}
#[cfg(test)]
mod stall_reason_tests {
use common::HashType;
use super::*;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
#[tokio::test]
async fn stall_reason_roundtrips_and_clears() {
@ -544,7 +410,6 @@ mod accept_tests {
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
use super::*;
use crate::ingest_error::BlockIngestError;
fn signing_key() -> lee::PrivateKey {
lee::PrivateKey::try_new([7_u8; 32]).expect("valid key")

View File

@ -2,31 +2,28 @@ use std::{path::Path, sync::Arc};
use anyhow::Result;
use arc_swap::ArcSwap;
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
use chain_state::{Anchor, ChainConsistency};
use common::block::Block;
// TODO: Remove after testnet
use futures::StreamExt as _;
pub use ingest_error::BlockIngestError;
use log::{error, info, warn};
use logos_blockchain_zone_sdk::{
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use retry::ApplyRetryGate;
pub use stall_reason::StallReason;
use crate::{
block_store::{AcceptOutcome, IndexerStore},
chain_consistency::ChainConsistency,
block_store::IndexerStore,
config::IndexerConfig,
cross_zone_verifier::CrossZoneVerifier,
status::{IndexerStatus, IndexerSyncStatus},
};
pub mod block_store;
pub mod chain_consistency;
pub mod config;
pub mod cross_zone_verifier;
pub mod ingest_error;
mod retry;
pub mod stall_reason;
pub mod status;
/// Consecutive failed apply attempts of the same block before parking.
@ -125,6 +122,52 @@ impl IndexerCore {
})
}
/// Verifies whether the channel still serves the same chain the store was built from.
/// This may change frequently during development where we reset the chain from time to
/// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in
/// production.
///
/// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block
/// while stalled, or the tip L2 block at its own inscription L1 slot.
pub(crate) async fn verify_chain_consistency(&self) -> Result<ChainConsistency> {
let Some(anchor) = self.get_startup_anchor()? else {
// empty or cold store: nothing to compare
return Ok(ChainConsistency::Inconclusive);
};
chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await
}
/// Builds the anchor for the startup check.
///
/// - If stalled, returns the recorded _parked_ block
/// - If not stalled, returns the validated tip at its _own_ inscription slot.
/// - If the store is empty, returns `None`.
fn get_startup_anchor(&self) -> Result<Option<Anchor>> {
if let Some(stall) = self.store.get_stall_reason()? {
return Ok(Some(Anchor::new(
stall.l1_slot,
stall.block_id.zip(stall.block_hash),
)));
}
// not stalled, so anchor on the tip at its own inscription slot
let Some(slot) = self
.store
.get_tip_slot()?
.map_or_else(|| self.store.get_zone_cursor(), |slot| Ok(Some(slot)))?
else {
return Ok(None);
};
let Some(tip_id) = self.store.get_last_block_id()? else {
return Ok(None);
};
let Some(tip) = self.store.get_block_at_id(tip_id)? else {
return Ok(None);
};
Ok(Some(Anchor::new(slot, Some((tip_id, tip.header.hash)))))
}
/// Snapshot of the current ingestion status (sync state + indexed tip).
///
/// Combines the ingest loop's live status with the L2 tip read fresh from the
@ -207,8 +250,8 @@ impl IndexerCore {
let mut cursor = initial_cursor;
let mut retry_gate = ApplyRetryGate::new();
if cursor.is_some() {
info!("Resuming indexer from cursor {cursor:?}");
if let Some(slot) = &cursor {
info!("Resuming indexer from cursor {slot:?}");
} else {
info!("Starting indexer from beginning of channel");
}
@ -370,3 +413,127 @@ impl IndexerCore {
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use common::{HashType, block::HashableBlockData};
use logos_blockchain_zone_sdk::Slot;
use super::*;
use crate::config::{ChannelId, ClientConfig, IndexerConfig};
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
let config = IndexerConfig {
consensus_info_polling_interval: Duration::from_secs(1),
bedrock_config: ClientConfig {
addr: "http://localhost:1".parse().expect("url"),
auth: None,
},
channel_id: ChannelId::from([1; 32]),
allow_chain_reset: false,
cross_zone: None,
bridge_lock_holdings: Vec::new(),
};
IndexerCore::open(config, dir).expect("open core")
}
fn test_block(block_id: u64, timestamp: u64) -> Block {
HashableBlockData {
block_id,
prev_block_hash: HashType([0; 32]),
timestamp,
transactions: vec![],
}
.into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key"))
}
#[tokio::test]
async fn cold_store_is_inconclusive() {
// An empty store has no cursor, so there is nothing to compare: the check
// must be Inconclusive (not Consistent), and it returns before any L1 read.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn parked_store_with_unreachable_node_is_inconclusive() {
// Network failure is not evidence of a reset: a parked store must stay
// parked (Inconclusive), not error out or trip the wipe path.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let parked = test_block(5, 42);
core.store
.record_stall(
Some(&parked.header),
Slot::from(1_000),
BlockIngestError::EmptyBlock,
)
.expect("record stall");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn caught_up_store_with_unreachable_node_is_inconclusive() {
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
assert!(matches!(
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept"),
AcceptOutcome::Applied
));
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() {
// Cursor persist failures are warn-only, so the read cursor can lag the
// tip by several blocks. The anchor must pair the tip with its own
// inscription slot; pairing it with the stale cursor would make the scan
// misread the chain's intermediate blocks as re-inscriptions.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept");
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
core.store
.accept_block(&block2, Slot::from(1_005))
.await
.expect("accept");
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
core.store
.accept_block(&block3, Slot::from(1_010))
.await
.expect("accept");
// Cursor last persisted at the genesis slot: two blocks behind the tip.
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
let anchor = core.get_startup_anchor().expect("anchor").expect("present");
let expected = Anchor::new(Slot::from(1_010), Some((3, block3.header.hash)));
assert_eq!(anchor, expected);
}
}

View File

@ -1,7 +1,6 @@
use chain_state::StallReason;
use serde::Serialize;
use crate::stall_reason::StallReason;
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
/// "still catching up" apart from "something went wrong".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@ -117,10 +116,9 @@ mod tests {
#[test]
fn stalled_status_serializes_with_stall_reason() {
use chain_state::{BlockIngestError, StallReason};
use logos_blockchain_zone_sdk::Slot;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
let status = IndexerStatus {
sync: IndexerSyncStatus::stalled("broken chain link".to_owned()),
indexed_block_id: Some(41),

View File

@ -9,8 +9,11 @@ workspace = true
[dependencies]
lee.workspace = true
pyo3.workspace = true
keycard-rs.workspace = true
bip39.workspace = true
hex.workspace = true
log.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
pcsc.workspace = true
rand.workspace = true
thiserror.workspace = true
zeroize.workspace = true

View File

@ -1,221 +0,0 @@
from smartcard.System import readers
from keycard.exceptions import APDUError, TransportError
from ecdsa import VerifyingKey, SECP256k1
from keycard.keycard import KeyCard
from keycard.commands.export_lee_key import export_lee_key
from mnemonic import Mnemonic
from keycard import constants
import os
import secrets
DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing"
def _pairing_password() -> str:
return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD)
class KeycardWallet:
def __init__(self):
self.card = KeyCard()
def _is_smart_card_reader_detected(self) -> bool:
try:
return len(readers()) > 0
except Exception:
return False
def _is_keycard_detected(self) -> bool:
try:
KeyCard().select()
return True
except (TransportError, APDUError, Exception):
# No readers, no card, or card doesn't respond.
return False
def is_unpaired_keycard_available(self) -> bool:
if not self._is_smart_card_reader_detected():
return False
elif not self._is_keycard_detected():
return False
return True
def initialize(self, pin: str, pairing_password: str | None = None) -> bool:
try:
self.card.select()
if self.card.is_initialized:
raise RuntimeError("Card is already initialized")
puk = ''.join(secrets.choice('0123456789') for _ in range(12))
self.card.init(pin, puk, pairing_password or _pairing_password())
print(f"Keycard PUK: {puk}")
print("Record this PUK and store it somewhere safe. It cannot be recovered.")
return True
except Exception as e:
raise RuntimeError(f"Error initializing keycard: {e}") from e
def _reconnect(self) -> None:
self.card = KeyCard()
self.card.select()
def _pair(self, pin: str, password: str) -> tuple[int, bytes]:
self.card.select()
if not self.card.is_initialized:
raise RuntimeError("Card is not initialized — run 'wallet keycard init' first")
pairing_index, pairing_key = self.card.pair(password)
self.pairing_index = pairing_index
self.pairing_key = pairing_key
try:
self.card.open_secure_channel(pairing_index, pairing_key)
self.card.verify_pin(pin)
except Exception as e:
try:
self.card.unpair(pairing_index)
except Exception:
pass
raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e
return pairing_index, pairing_key
def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]:
password = password or _pairing_password()
try:
return self._pair(pin, password)
except TransportError as e:
print(f"Transport error during fresh pair ({e}), attempting card reset and retry...")
try:
self._reconnect()
result = self._pair(pin, password)
print("Retry succeeded after card reset.")
return result
except TransportError as e2:
raise RuntimeError(
"Card lost power and did not recover after reset. "
"Try reseating the card in the reader."
) from e2
def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
self.card.select()
if not self.card.is_initialized:
raise RuntimeError("Card is not initialized — run 'wallet keycard init' first")
self.pairing_index = pairing_index
self.pairing_key = pairing_key
try:
self.card.open_secure_channel(pairing_index, pairing_key)
self.card.verify_pin(pin)
except Exception as e:
raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e
return True
def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
try:
return self._setup_communication_with_pairing(pin, pairing_index, pairing_key)
except TransportError as e:
print(f"Transport error during stored pairing ({e}), attempting card reset and retry...")
try:
self._reconnect()
result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key)
print("Retry succeeded after card reset.")
return result
except TransportError as e2:
raise RuntimeError(
"Card lost power and did not recover after reset. "
"Try reseating the card in the reader."
) from e2
def close_session(self) -> bool:
return True
def load_mnemonic(self, mnemonic: str) -> bool:
try:
# Convert mnemonic to seed
mnemo = Mnemonic("english")
if not mnemo.check(mnemonic):
raise RuntimeError("Invalid mnemonic phrase — check spelling and word count")
seed = mnemo.to_seed(mnemonic)
# Load the LEE seed onto the card
result = self.card.load_key(
key_type = constants.LoadKeyType.LEE_SEED,
lee_seed = seed
)
return True
except Exception as e:
raise RuntimeError(f"Error loading mnemonic: {e}") from e
def disconnect(self) -> bool:
try:
if not self.card.is_secure_channel_open:
return False
self.card.unpair(self.pairing_index)
return True
except Exception as e:
raise RuntimeError(f"Error during disconnect: {e}") from e
def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None:
try:
if not self.card.is_secure_channel_open or not self.card.is_pin_verified:
return None
public_key = self.card.export_key(
derivation_option = constants.DerivationOption.DERIVE,
public_only = True,
keypath = path
)
public_key = public_key.public_key
public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1)
public_key = public_key.to_string("compressed")[1:]
return public_key
except Exception as e:
raise RuntimeError(f"Error getting public key: {e}") from e
def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None:
try:
if not self.card.is_secure_channel_open or not self.card.is_pin_verified:
return None
signature = self.card.sign_with_path(
digest = message,
path = path,
algorithm = constants.SigningAlgorithm.SCHNORR_BIP340,
make_current = False
)
return signature.signature
except Exception as e:
raise RuntimeError(f"Error signing message: {e}") from e
def get_private_keys_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None:
try:
if not self.card.is_secure_channel_open or not self.card.is_pin_verified:
return None
private_keys = export_lee_key(
self.card,
constants.DerivationOption.DERIVE,
path
)
nsk = private_keys.lee_nsk
vsk = private_keys.lee_vsk
return (nsk, vsk)
except Exception as e:
raise RuntimeError(f"Error getting private keys: {e}") from e

View File

@ -0,0 +1,52 @@
#![expect(
clippy::print_stdout,
reason = "This is a CLI test helper, printing to stdout is expected and convenient"
)]
//! Forces the card in the first available reader into the unpowered state via PC/SC
//! `SCARD_UNPOWER_CARD`. Run immediately before a wallet command to simulate the power-loss
//! condition reported on some USB reader/driver combinations.
//!
//! Either:
//! - pcscd re-powers the card on the next `SCardConnect`, so wallet commands will succeed without
//! triggering the retry path.
//! - the card stays unpowered, triggering a PC/SC transport error (`keycard_rs::Error::Io`) and
//! exercising the reconnect-and-retry wrapper in `KeycardWallet::connect()`.
fn main() {
let context = match pcsc::Context::establish(pcsc::Scope::User) {
Ok(context) => context,
Err(e) => {
println!("force_unpower: failed to establish PC/SC context ({e}), skipping.");
return;
}
};
let readers = match context.list_readers_owned() {
Ok(readers) => readers,
Err(e) => {
println!("force_unpower: failed to list readers ({e}), skipping.");
return;
}
};
let Some(reader) = readers.first() else {
println!("force_unpower: no readers found, skipping.");
return;
};
let card = match context.connect(reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) {
Ok(card) => card,
Err(e) => {
println!("force_unpower: connect failed ({e}), skipping.");
return;
}
};
if let Err((_card, e)) = card.disconnect(pcsc::Disposition::UnpowerCard) {
println!("force_unpower: disconnect failed ({e}), skipping.");
return;
}
println!("force_unpower: card powered down.");
}

View File

@ -1,159 +1,230 @@
use std::path::PathBuf;
use std::str::FromStr as _;
use keycard_rs::{
KeycardCommandSet, PcscChannel,
constants::sign_p2,
parsing::Bip32KeyPair,
secure_channel::SecureChannelVersion,
tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE},
};
use lee::{AccountId, PublicKey, Signature};
use pyo3::{prelude::*, types::PyAny};
use serde::{Deserialize, Serialize};
use rand::Rng as _;
use zeroize::Zeroizing;
pub mod python_path;
/// LEE-applet extension tags. `keycard-rs` implements *sending* the LEE commands
/// (`load_lee_key`, `export_lee_key`) but never added parsing for their LEE-specific responses,
/// so these tag values — owned by the applet, not either client library — have to be hardcoded
/// here. Matches `KeycardApplet.TLV_LEE_NSK`/`TLV_LEE_VSK` in `status-keycard`
/// (`KeycardApplet.java:97-98`), the applet `LEE_keycard.cap` was almost certainly built from.
const TLV_LEE_NSK: u8 = 0x83;
const TLV_LEE_VSK: u8 = 0x84;
/// Raw Schnorr signature (64 bytes: `r || s`, no ASN.1/DER wrapping) nested inside the standard
/// `TLV_SIGNATURE_TEMPLATE` alongside the usual `TLV_PUB_KEY`. Confirmed against real hardware —
/// the LEE applet's `SIGN` response for `sign_p2::BIP340_SCHNORR` is
/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }` — and matches
/// `SECP256k1.TLV_RAW_SIGNATURE` in `status-keycard` (`SECP256k1.java:64`).
const TLV_LEE_RAW_SIGNATURE: u8 = 0x88;
/// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte
/// arrays.
type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>);
// TODO: encrypt at rest alongside broader wallet storage encryption work.
#[derive(Serialize, Deserialize)]
pub struct KeycardPairingData {
pub index: u8,
pub key: Vec<u8>,
#[derive(Debug, thiserror::Error)]
pub enum KeycardWalletError {
#[error(transparent)]
Keycard(#[from] keycard_rs::Error),
#[error("keycard is already initialized")]
AlreadyInitialized,
#[error(
"this wallet only supports Secure Channel V2 keycards (applet version >= 4.0); detected {0:?}"
)]
UnsupportedSecureChannel(Option<SecureChannelVersion>),
#[error("invalid mnemonic phrase: {0}")]
InvalidMnemonic(String),
#[error("invalid key material from keycard: {0}")]
InvalidKeyMaterial(String),
#[error("keycard returned a signature that does not verify against its own public key")]
SignatureVerificationFailed,
#[error("invalid KEYCARD_CA_PUBLIC_KEY: {0}")]
InvalidCaPublicKey(String),
}
impl KeycardPairingData {
const fn is_valid(&self) -> bool {
self.key.len() == 32 && self.index <= 4
}
}
/// Rust wrapper around the Python `KeycardWallet` class.
/// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC.
/// Only Secure Channel V2 cards are supported — see `require_secure_channel_v2`.
pub struct KeycardWallet {
instance: Py<PyAny>,
command_set: KeycardCommandSet,
}
impl KeycardWallet {
/// Create a new Python `KeycardWallet` instance.
pub fn new(py: Python) -> PyResult<Self> {
let module = py.import("keycard_wallet")?;
let class = module.getattr("KeycardWallet")?;
let instance = class.call0()?;
/// Connects to the first available PC/SC reader. Does not select the applet yet — callers
/// that need application info (`initialize`, `connect`, ...) do that themselves.
///
/// Verifies the card's identity certificate against `keycard-rs`'s default production CA,
/// unless overridden via `KEYCARD_CA_PUBLIC_KEY` — see `ca_public_key_override`.
pub fn new() -> Result<Self, KeycardWalletError> {
let channel = PcscChannel::connect()?;
Ok(Self {
instance: instance.into(),
command_set: Self::build_command_set(channel)?,
})
}
pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult<bool> {
self.instance
.bind(py)
.call_method0("is_unpaired_keycard_available")?
.extract()
fn build_command_set(channel: PcscChannel) -> Result<KeycardCommandSet, KeycardWalletError> {
Ok(match ca_public_key_override()? {
Some(ca) => KeycardCommandSet::new_with_ca(channel, ca),
None => KeycardCommandSet::new(channel),
})
}
pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult<bool> {
self.instance
.bind(py)
.call_method1("initialize", (pin,))?
.extract()
}
pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec<u8>)> {
self.instance
.bind(py)
.call_method1("pair", (pin,))?
.extract()
}
pub fn setup_communication_with_pairing(
&self,
py: Python<'_>,
pin: &str,
index: u8,
key: &[u8],
) -> PyResult<bool> {
self.instance
.bind(py)
.call_method1(
"setup_communication_with_pairing",
(pin, index, key.to_vec()),
)?
.extract()
}
pub fn close_session(&self, py: Python<'_>) -> PyResult<bool> {
self.instance
.bind(py)
.call_method0("close_session")?
.extract()
}
/// Connect using a stored pairing if available, falling back to a fresh pair.
/// Saves any newly established pairing to disk.
pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> {
if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid)
&& self
.setup_communication_with_pairing(py, pin, pairing.index, &pairing.key)
.is_ok()
{
return Ok(());
/// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both
/// present.
#[must_use]
pub fn is_keycard_available() -> bool {
let Ok(channel) = PcscChannel::connect() else {
return false;
};
let Ok(mut command_set) = Self::build_command_set(channel) else {
return false;
};
if !command_set.select().is_ok_and(|resp| resp.is_ok()) {
return false;
}
let (index, key) = self.pair(py, pin)?;
save_pairing(&KeycardPairingData { index, key });
command_set.secure_channel_version() == Some(SecureChannelVersion::V2)
}
fn select(&mut self) -> Result<(), KeycardWalletError> {
self.command_set.select()?.check_ok()?;
Ok(())
}
pub fn disconnect(&self, py: Python) -> PyResult<bool> {
self.instance.bind(py).call_method0("disconnect")?.extract()
/// Rejects any card that isn't running Secure Channel V2 (older applets, or a card that
/// hasn't advertised a secure channel at all). Call right after `select()`.
fn require_secure_channel_v2(&self) -> Result<(), KeycardWalletError> {
match self.command_set.secure_channel_version() {
Some(SecureChannelVersion::V2) => Ok(()),
other => Err(KeycardWalletError::UnsupportedSecureChannel(other)),
}
}
pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult<PublicKey> {
let public_key: Vec<u8> = self
.instance
.bind(py)
.call_method1("get_public_key_for_path", (path,))?
.extract()?;
let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec<u8>| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"expected 32-byte public key from keycard, got {} bytes",
vec.len()
))
})?;
PublicKey::try_new(public_key)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
/// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a
/// transport-level error (e.g. the card lost power mid-session).
fn with_reconnect_on_transport_error<T>(
&mut self,
op: impl Fn(&mut Self) -> Result<T, KeycardWalletError>,
) -> Result<T, KeycardWalletError> {
match op(self) {
Err(KeycardWalletError::Keycard(keycard_rs::Error::Io(io_err))) => {
log::warn!(
"transport error during keycard operation ({io_err}), reconnecting and retrying once"
);
*self = Self::new()?;
op(self)
}
result => result,
}
}
pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult<PublicKey> {
Python::attach(|py| {
python_path::add_python_path(py)?;
let wallet = Self::new(py)?;
wallet.connect(py, pin)?;
let pub_key = wallet.get_public_key_for_path(py, path);
drop(wallet.close_session(py));
pub_key
/// Initializes an uninitialized card, returning the generated PUK. The caller is responsible
/// for surfacing the PUK to the operator — it cannot be recovered afterward.
pub fn initialize(&mut self, pin: &str) -> Result<String, KeycardWalletError> {
self.select()?;
self.require_secure_channel_v2()?;
let already_initialized = self
.command_set
.app_info()
.expect("select() populates app_info on success")
.is_initialized();
if already_initialized {
return Err(KeycardWalletError::AlreadyInitialized);
}
// V2's INIT has no shared-secret field (confirmed against real hardware and the
// applet's own reference command set: a payload containing one is rejected outright) —
// pass an empty secret and let the card default the PIN/PUK retry counts.
let puk = generate_puk();
self.command_set
.init_with_secret(pin, None, &puk, &[], 0, 0)?
.check_ok()?;
Ok(puk)
}
/// Wipes the card's PIN, PUK, and loaded keys, returning it to an uninitialized state —
/// the counterpart to `initialize()`. Does **not** remove the identity certificate
/// provisioned via `IdentApplet`, so the card can be re-`initialize()`d afterward without
/// re-personalizing it. Irreversibly destroys any keys currently loaded on the card.
pub fn factory_reset(&mut self) -> Result<(), KeycardWalletError> {
self.select()?;
self.require_secure_channel_v2()?;
self.command_set.factory_reset()?.check_ok()?;
Ok(())
}
/// Opens the secure channel and verifies the PIN. Secure Channel V2 re-authenticates from
/// the card's certificate every session — there's no pairing step and nothing to persist.
pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> {
self.with_reconnect_on_transport_error(|wallet| {
wallet.select()?;
wallet.require_secure_channel_v2()?;
wallet.command_set.auto_open_secure_channel()?;
wallet.command_set.verify_pin(pin)?.check_auth_ok()?;
Ok(())
})
}
pub fn get_public_key_for_path(&mut self, path: &str) -> Result<PublicKey, KeycardWalletError> {
let resp = self.command_set.export_key(path, false, true)?;
resp.check_ok()?;
let keypair = Bip32KeyPair::from_tlv(resp.data())?;
// Uncompressed SEC1 point (0x04 || X || Y); the BIP340 x-only public key is just its X
// coordinate, since secp256k1 (what the card signs with) and k256's Schnorr verifying key
// are the same curve.
let public_key = keypair.public_key();
let x_only: [u8; 32] = match public_key.split_first() {
Some((&0x04, xy)) if xy.len() == 64 => xy
.split_at(32)
.0
.try_into()
.expect("split_at(32) of a 64-byte slice"),
_ => {
return Err(KeycardWalletError::InvalidKeyMaterial(format!(
"expected a 65-byte uncompressed secp256k1 public key from keycard, got {} bytes",
public_key.len()
)));
}
};
PublicKey::try_new(x_only)
.map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string()))
}
pub fn get_public_key_for_path_with_connect(
pin: &str,
path: &str,
) -> Result<PublicKey, KeycardWalletError> {
let mut wallet = Self::new()?;
wallet.connect(pin)?;
wallet.get_public_key_for_path(path)
}
pub fn sign_message_for_path(
&self,
py: Python,
&mut self,
path: &str,
message: &[u8; 32],
) -> PyResult<(Signature, PublicKey)> {
let py_signature: Vec<u8> = self
.instance
.bind(py)
.call_method1("sign_message_for_path", (message, path))?
.extract()?;
) -> Result<(Signature, PublicKey), KeycardWalletError> {
let resp = self.command_set.sign_with_path_and_algo(
message,
path,
sign_p2::BIP340_SCHNORR,
false,
)?;
resp.check_ok()?;
let sig = Signature {
value: normalize_keycard_signature(py_signature)?,
value: parse_schnorr_signature(resp.data())?,
};
let pub_key = self.get_public_key_for_path(py, path)?;
let pub_key = self.get_public_key_for_path(path)?;
if !sig.is_valid_for(message, &pub_key) {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"keycard returned a signature that does not verify against its own public key",
));
return Err(KeycardWalletError::SignatureVerificationFailed);
}
Ok((sig, pub_key))
}
@ -162,39 +233,40 @@ impl KeycardWallet {
pin: &str,
path: &str,
message: &[u8; 32],
) -> PyResult<(Signature, PublicKey)> {
Python::attach(|py| {
python_path::add_python_path(py)?;
let wallet = Self::new(py)?;
wallet.connect(py, pin)?;
let result = wallet.sign_message_for_path(py, path, message);
drop(wallet.close_session(py));
result
})
) -> Result<(Signature, PublicKey), KeycardWalletError> {
let mut wallet = Self::new()?;
wallet.connect(pin)?;
wallet.sign_message_for_path(path, message)
}
pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> {
self.instance
.bind(py)
.call_method1("load_mnemonic", (mnemonic,))?;
pub fn load_mnemonic(&mut self, mnemonic: &str) -> Result<(), KeycardWalletError> {
let mnemonic = bip39::Mnemonic::from_str(mnemonic)
.map_err(|e| KeycardWalletError::InvalidMnemonic(e.to_string()))?;
let seed = mnemonic.to_seed("");
self.command_set.load_lee_key(&seed)?.check_ok()?;
Ok(())
}
pub fn get_public_account_id_for_path_with_connect(
pin: &str,
key_path: &str,
) -> PyResult<String> {
) -> Result<String, KeycardWalletError> {
let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?;
Ok(format!("Public/{}", AccountId::from(&public_key)))
}
pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult<PrivateKeyPair> {
let (raw_nsk, raw_vsk): (Vec<u8>, Vec<u8>) = self
.instance
.bind(py)
.call_method1("get_private_keys_for_path", (path,))?
.extract()?;
pub fn get_private_keys_for_path(
&mut self,
path: &str,
) -> Result<PrivateKeyPair, KeycardWalletError> {
let resp = self.command_set.export_lee_key(path)?;
resp.check_ok()?;
let mut reader = BerTlvReader::new(resp.data());
reader.enter_constructed(TLV_KEY_TEMPLATE)?;
let raw_nsk = reader.read_primitive(TLV_LEE_NSK)?;
let raw_vsk = reader.read_primitive(TLV_LEE_VSK)?;
let nsk = zeroizing_fixed_bytes::<32>("nullifier secret key", Zeroizing::new(raw_nsk))?;
let vsk = zeroizing_fixed_bytes::<64>("view secret key", Zeroizing::new(raw_vsk))?;
@ -205,46 +277,66 @@ impl KeycardWallet {
pub fn get_private_keys_for_path_with_connect(
pin: &str,
path: &str,
) -> PyResult<PrivateKeyPair> {
Python::attach(|py| {
python_path::add_python_path(py)?;
let wallet = Self::new(py)?;
wallet.connect(py, pin)?;
let result = wallet.get_private_keys_for_path(py, path);
drop(wallet.disconnect(py));
result
})
) -> Result<PrivateKeyPair, KeycardWalletError> {
let mut wallet = Self::new()?;
wallet.connect(pin)?;
wallet.get_private_keys_for_path(path)
}
}
/// The keycard Python library strips leading zeros from S when S < 2^(8k) for some k.
/// Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S).
#[expect(
clippy::arithmetic_side_effects,
reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]"
)]
fn normalize_keycard_signature(py_signature: Vec<u8>) -> PyResult<[u8; 64]> {
if py_signature.len() < 64 {
if py_signature.len() < 32 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"signature from keycard too short: {} bytes",
py_signature.len()
)));
}
let s_stripped = &py_signature[32..];
let mut padded = [0_u8; 64];
padded[..32].copy_from_slice(&py_signature[..32]);
padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped);
Ok(padded)
} else {
py_signature.try_into().map_err(|vec: Vec<u8>| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})",
vec.len(),
vec
))
})
fn generate_puk() -> String {
let mut rng = rand::rngs::OsRng;
std::iter::repeat_with(|| char::from(rng.gen_range(b'0'..=b'9')))
.take(12)
.collect()
}
/// Optional override for the CA public key used to verify a card's identity certificate, read
/// from `KEYCARD_CA_PUBLIC_KEY` as 66 hex characters (a 33-byte compressed secp256k1 key).
/// Falls back to `keycard-rs`'s production default when unset.
///
/// This exists purely for testing against cards that weren't personalized through the real
/// production process — e.g. `status-keycard`'s own `JUnit` suite signs test cards with a fixed,
/// throwaway CA that will never match the production default. Real users' cards should need no
/// override at all.
fn ca_public_key_override() -> Result<Option<[u8; 33]>, KeycardWalletError> {
let Ok(hex_str) = std::env::var("KEYCARD_CA_PUBLIC_KEY") else {
return Ok(None);
};
let mut bytes = [0_u8; 33];
hex::decode_to_slice(hex_str.trim(), &mut bytes)
.map_err(|e| KeycardWalletError::InvalidCaPublicKey(e.to_string()))?;
Ok(Some(bytes))
}
/// Parses a BIP340 Schnorr signature from a LEE `SIGN` response.
///
/// Confirmed against real hardware: `TLV_SIGNATURE_TEMPLATE` (0xA0) contains the usual
/// `TLV_PUB_KEY` (0x80, 65 bytes, unused here — the caller fetches the pubkey separately via
/// `export_key`) followed by `TLV_LEE_RAW_SIGNATURE` (0x88, 64 bytes: `r || s` with no ASN.1/DER
/// wrapping). `keycard-rs`'s own `RecoverableSignature` parser doesn't apply — it's ECDSA-only
/// (attempts point recovery, which Schnorr doesn't have).
fn parse_schnorr_signature(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> {
parse_schnorr_signature_inner(data).map_err(|e| {
KeycardWalletError::InvalidKeyMaterial(format!(
"failed to parse schnorr signature response ({e}); raw response bytes: {data:02x?}"
))
})
}
fn parse_schnorr_signature_inner(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> {
let mut reader = BerTlvReader::new(data);
reader.enter_constructed(TLV_SIGNATURE_TEMPLATE)?;
if reader.next_tag_is(TLV_PUB_KEY) {
reader.read_primitive(TLV_PUB_KEY)?;
}
let sig = reader.read_primitive(TLV_LEE_RAW_SIGNATURE)?;
sig.try_into().map_err(|v: Vec<u8>| {
KeycardWalletError::InvalidKeyMaterial(format!(
"expected a 64-byte raw schnorr signature, got {} bytes",
v.len()
))
})
}
#[expect(
@ -254,9 +346,9 @@ fn normalize_keycard_signature(py_signature: Vec<u8>) -> PyResult<[u8; 64]> {
fn zeroizing_fixed_bytes<const N: usize>(
label: &str,
raw: Zeroizing<Vec<u8>>,
) -> PyResult<Zeroizing<[u8; N]>> {
) -> Result<Zeroizing<[u8; N]>, KeycardWalletError> {
if raw.len() != N {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
return Err(KeycardWalletError::InvalidKeyMaterial(format!(
"expected {N}-byte {label} from keycard, got {} bytes",
raw.len()
)));
@ -265,35 +357,3 @@ fn zeroizing_fixed_bytes<const N: usize>(
arr.copy_from_slice(&raw);
Ok(arr)
}
fn pairing_file_path() -> Option<PathBuf> {
let home = std::env::var("LEE_WALLET_HOME_DIR")
.map(PathBuf::from)
.or_else(|_| {
std::env::home_dir()
.map(|h| h.join(".lee").join("wallet"))
.ok_or(())
})
.ok()?;
Some(home.join("keycard_pairing.json"))
}
fn load_pairing() -> Option<KeycardPairingData> {
let path = pairing_file_path()?;
let file = std::fs::File::open(path).ok()?;
serde_json::from_reader(file).ok()
}
fn save_pairing(data: &KeycardPairingData) {
if let Some(path) = pairing_file_path()
&& let Ok(json) = serde_json::to_vec_pretty(data)
{
drop(std::fs::write(path, json));
}
}
pub fn clear_pairing() {
if let Some(path) = pairing_file_path() {
drop(std::fs::remove_file(path));
}
}

View File

@ -1,69 +0,0 @@
use std::{env, path::PathBuf};
use pyo3::{prelude::*, types::PyList};
fn collect_python_paths() -> Vec<PathBuf> {
let current_dir = env::current_dir().expect("Failed to get current working directory");
let python_base = env::var("VIRTUAL_ENV")
.ok()
.and_then(|v| PathBuf::from(v).parent().map(PathBuf::from))
.unwrap_or_else(|| current_dir.clone());
let mut paths = vec![
python_base
.join("lez")
.join("keycard_wallet")
.join("python"),
python_base
.join("lez")
.join("keycard_wallet")
.join("python")
.join("keycard-py"),
];
// pyo3's embedded interpreter does not inherit sys.path from the shell,
// so venv site-packages must be added explicitly.
if let Ok(venv) = env::var("VIRTUAL_ENV") {
let lib = PathBuf::from(&venv).join("lib");
if let Ok(entries) = std::fs::read_dir(&lib) {
for entry in entries.flatten() {
let site_packages = entry.path().join("site-packages");
if site_packages.exists() {
paths.push(site_packages);
}
}
}
}
paths
}
/// Adds the project's `python/` directory and venv site-packages to Python's sys.path.
pub fn add_python_path(py: Python<'_>) -> PyResult<()> {
let paths = collect_python_paths();
for path in &paths {
if !path.exists() {
log::info!("Warning: Python path does not exist: {}", path.display());
}
}
let sys = PyModule::import(py, "sys")?;
let binding = sys.getattr("path")?;
let sys_path = binding.cast::<PyList>()?;
for path in &paths {
let path_str = path.to_str().expect("Invalid path");
let already_present = sys_path
.iter()
.any(|p| p.extract::<&str>().is_ok_and(|s| s == path_str));
if !already_present {
sys_path.insert(0, path_str)?;
}
}
Ok(())
}

View File

@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""
Forces the card in the first available reader into the unpowered state via
PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate
the power-loss condition reported on some USB reader/driver combinations.
Either:
- pcscd re-powers the card on the next SCardConnect, so wallet
commands will succeed without triggering the retry path.
- the card stays unpowered, triggering TransportError
and exercising the retry wrapper in pair() / setup_communication_with_pairing().
"""
import sys
from smartcard.scard import (
SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect,
SCARD_SCOPE_USER, SCARD_SHARE_SHARED,
SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1,
SCARD_UNPOWER_CARD,
)
hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER)
hresult, reader_list = SCardListReaders(hcontext, [])
if not reader_list:
print("force_unpower: no readers found, skipping.")
sys.exit(0)
hresult, hcard, _ = SCardConnect(
hcontext,
reader_list[0],
SCARD_SHARE_SHARED,
SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1,
)
if hresult != 0:
print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.")
sys.exit(0)
SCardDisconnect(hcard, SCARD_UNPOWER_CARD)
print("force_unpower: card powered down.")

View File

@ -4,14 +4,13 @@
# Forces a card power cycle before each keycard-backed wallet command to verify
# commands survive mid-session power loss.
source venv/bin/activate
export KEYCARD_PIN=111111
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
unpower() {
python "$SCRIPT_DIR/force_unpower.py"
cargo run -q --manifest-path "$SCRIPT_DIR/../Cargo.toml" --bin force_unpower
}
echo "Test: wallet keycard available"

View File

@ -2,14 +2,12 @@
# keycard_test_3.sh — tests for `wallet keycard get-private-keys`.
#
# Prerequisites:
# 1. Run wallet_with_keycard.sh once to install dependencies.
# 2. Keycard reader inserted with card loaded (wallet keycard load has been run).
source venv/bin/activate
# 1. Keycard reader inserted with card loaded (wallet keycard load has been run).
cargo install --path lez/wallet --force --features keycard-debug
export KEYCARD_PIN=111111
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
echo "=== Test: wallet keycard get-private-keys path 10 ==="
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal

View File

@ -1,9 +1,8 @@
#!/bin/bash
# Run wallet_with_keycard.sh first
source venv/bin/activate # Load the appropriate virtual environment
# Run `cargo install --path lez/wallet --force` first
export KEYCARD_PIN=111111
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
# Tests wallet keycard available
# - Checks whether smart reader and keycard are both available.

View File

@ -2,7 +2,7 @@
# keycard_tests_2.sh — comprehensive token + AMM keycard integration tests.
#
# Prerequisites:
# 1. Run wallet_with_keycard.sh once to install dependencies.
# 1. Run `cargo install --path lez/wallet --force` once to install the wallet CLI.
# 2. Reset the local chain so all accounts are uninitialized.
# 3. Keycard reader inserted with card loaded.
#
@ -23,8 +23,8 @@
# amm-lee-fund → public LEE holding used to seed the AMM pool
# (LP holding for amm new is created fresh each run — no persistent label)
source venv/bin/activate
export KEYCARD_PIN=111111
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
# =============================================================================
# Keycard setup

View File

@ -1,12 +0,0 @@
#!/bin/bash
cargo install --path lez/wallet --force
# Install appropriate version of `keycard-py`.
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py
# Set up virtual environment.
python3 -m venv venv
source venv/bin/activate
pip install pyscard mnemonic ecdsa pyaes
pip install -e lez/keycard_wallet/python/keycard-py

View File

@ -65,6 +65,11 @@ impl<T> MemPoolHandle<T> {
pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError<T>> {
self.sender.send(item).await
}
/// Send an item to the mempool, failing _immediately_ if it is full.
pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError<T>> {
self.sender.try_send(item)
}
}
#[cfg(test)]
@ -123,6 +128,19 @@ mod tests {
assert_eq!(pool.pop(), Some(2));
}
#[test]
async fn try_push_fails_when_full_without_blocking() {
let (mut pool, handle) = MemPool::new(1);
handle.try_push(1).unwrap();
assert!(handle.try_push(2).is_err(), "full mempool must not accept");
// Popping frees capacity again.
assert_eq!(pool.pop(), Some(1));
handle.try_push(2).unwrap();
assert_eq!(pool.pop(), Some(2));
}
#[test]
async fn push_front() {
let (mut pool, handle) = MemPool::new(10);

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
lee.workspace = true
lee_core.workspace = true
chain_state.workspace = true
common.workspace = true
storage.workspace = true
mempool.workspace = true
@ -33,6 +34,7 @@ tempfile.workspace = true
chrono.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio-util.workspace = true
rand.workspace = true
borsh.workspace = true
bytesize.workspace = true

View File

@ -1,21 +1,44 @@
use std::{pin::Pin, sync::Arc, time::Duration};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result, anyhow, ensure};
use common::block::Block;
use futures::Stream;
use log::{info, warn};
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription};
pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey};
pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId};
use logos_blockchain_core::{
mantle::{
MantleTx, SignedMantleTx, Transaction as _,
channel::{SlotTimeframe, SlotTimeout},
ops::{
Op, OpProof,
channel::{
ChannelId,
config::{ChannelConfigOp, Keys},
inscribe::Inscription,
},
},
},
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
};
pub use logos_blockchain_key_management_system_service::keys::{
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey,
};
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
use logos_blockchain_zone_sdk::{
CommonHttpClient,
adapter::NodeHttpClient,
CommonHttpClient, Slot, ZoneMessage,
adapter::{Node as _, NodeHttpClient},
indexer::ZoneIndexer,
sequencer::{
DepositInfo, Event, FinalizedOp, InscriptionInfo,
SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer,
DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx,
SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, WithdrawInfo,
ZoneSequencer,
},
};
use tokio::{sync::mpsc, task::JoinHandle};
use tokio::{
sync::{mpsc, oneshot, watch},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use crate::config::BedrockConfig;
@ -41,8 +64,38 @@ pub type OnDepositEventSink =
pub type OnWithdrawEventSink =
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
/// The channel delta the follow path consumes from one `Event::BlocksProcessed`,
/// with inscription payloads decoded into `(MsgId, Block)` pairs.
pub struct FollowUpdate {
/// Inscriptions newly on the followed L1 branch, in channel order: they
/// extend (or, after a reorg, replace part of) the `head` tier.
pub adopted: Vec<(MsgId, Block)>,
/// Inscriptions dropped from the branch by an L1 reorg: their blocks are
/// reverted from the `head` and their user txs resubmitted to the mempool.
pub orphaned: Vec<(MsgId, Block)>,
/// Inscriptions whose containing L1 block reached finality: their blocks
/// move into the irreversible `final` tier.
pub finalized: Vec<(MsgId, Block)>,
}
/// Sink for the follow path: apply adopted/finalized blocks to chain state and
/// revert orphaned ones.
pub type OnFollowSink = Box<dyn Fn(FollowUpdate) + Send + 'static>;
/// Commands the drive task executes with `&mut sequencer`.
enum Command {
/// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`.
Publish {
inscription: Inscription,
withdrawals: Vec<WithdrawArg>,
resp: oneshot::Sender<Result<MsgId>>,
},
}
type CommandSender = mpsc::Sender<Command>;
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
pub trait BlockPublisherTrait: Clone {
pub trait BlockPublisherTrait: Sized {
#[expect(
clippy::too_many_arguments,
reason = "Looks better than bundling all those callbacks into a struct"
@ -56,22 +109,49 @@ pub trait BlockPublisherTrait: Clone {
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self>;
/// Fire-and-forget publish. Zone-sdk drives the actual submission and
/// retries internally; this just hands the payload off.
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()>;
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
/// Zone-sdk drives the actual submission and retries internally.
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
fn channel_id(&self) -> ChannelId;
/// Whether this sequencer is currently authorized to write to the channel.
fn is_our_turn(&self) -> bool;
/// A [`CancellationToken`] cancelled when the publisher's background driver
/// terminates (a panicked sink, an ended event stream). No channel events
/// are processed past that point, so the node must halt.
fn driver_cancellation(&self) -> CancellationToken;
/// 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>>;
/// Finalized channel messages from `after_slot` (exclusive) up to LIB, used
/// for the startup consistency check and reconstruction. Pass `None` to read
/// from the channel's genesis.
async fn read_channel_after(
&self,
after_slot: Option<Slot>,
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_>;
}
/// Real block publisher backed by zone-sdk's `ZoneSequencer`.
#[derive(Clone)]
pub struct ZoneSdkPublisher {
channel_id: ChannelId,
publish_tx: mpsc::Sender<(Inscription, Vec<WithdrawArg>)>,
/// Direct node handle retained for channel reads (startup consistency check
/// and reconstruction); the sequencer itself lives in the drive task.
node: NodeHttpClient,
command_tx: CommandSender,
turn_rx: watch::Receiver<TurnNotification>,
// Cancelled when the drive task ends for any reason, including a panic.
driver_cancellation: CancellationToken,
// Aborts the drive task when the last clone is dropped.
_drive_task: Arc<DriveTaskGuard>,
indexer: ZoneIndexer<NodeHttpClient>,
}
struct DriveTaskGuard(JoinHandle<()>);
@ -92,6 +172,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self> {
let basic_auth = config.auth.clone().map(Into::into);
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
@ -104,7 +185,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
let mut sequencer = ZoneSequencer::init_with_config(
config.channel_id,
bedrock_signing_key,
node,
node.clone(),
zone_sdk_config,
initial_checkpoint,
);
@ -112,11 +193,18 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
// Grab readiness receiver before moving the sequencer into the drive
// task so we can await cold-start completion below.
let mut ready_rx = sequencer.subscribe_ready();
// Grab the turn watch before the move; the sdk actor keeps it current.
let turn_rx = sequencer.subscribe_turn_to_write();
let (publish_tx, mut publish_rx) =
mpsc::channel::<(Inscription, Vec<WithdrawArg>)>(PUBLISH_INBOX_CAPACITY);
let (command_tx, mut command_rx): (CommandSender, _) =
mpsc::channel(PUBLISH_INBOX_CAPACITY);
let driver_cancellation = CancellationToken::new();
let driver_guard = driver_cancellation.clone().drop_guard();
let drive_task = tokio::spawn(async move {
// Dropped when this task ends (including panics in the sinks),
// cancelling every `driver_cancellation`.
let _driver_guard = driver_guard;
loop {
#[expect(
clippy::integer_division_remainder_used,
@ -124,32 +212,38 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
)]
{
tokio::select! {
// Drain external publish requests by calling the
// borrowing handle — `&mut sequencer` is only
// available here.
Some((data_bounded, withdrawals)) = publish_rx.recv() => {
let data_byte_size = data_bounded.len();
if withdrawals.is_empty() {
if let Err(e) = sequencer.handle()
.publish(data_bounded)
.context("Failed to publish block") {
warn!("zone-sdk publish failed: {e:?}");
}
info!("Published block with the size of {data_byte_size} bytes");
} else {
// Drain external commands by calling the borrowing
// handle — `&mut sequencer` is only available here.
Some(command) = command_rx.recv() => match command {
Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => {
let data_byte_size = data_bounded.len();
let withdraw_count = withdrawals.len();
if let Err(e) = sequencer.handle()
.publish_atomic_withdraw(data_bounded, withdrawals)
.context("Failed to publish block with withdrawals") {
warn!("zone-sdk publish failed: {e:?}");
}
let published = if withdrawals.is_empty() {
sequencer.handle()
.publish(data_bounded)
.context("Failed to publish block")
} else {
sequencer.handle()
.publish_atomic_withdraw(data_bounded, withdrawals)
.context("Failed to publish block with withdrawals")
};
info!(
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
);
let msg_result = published
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
match &msg_result {
Ok(_) if withdraw_count == 0 => {
info!("Published block with the size of {data_byte_size} bytes");
}
Ok(_) => {
info!(
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
);
}
Err(e) => warn!("zone-sdk publish failed: {e:?}"),
}
let _dontcare = resp_tx.send(msg_result);
}
}
},
event = sequencer.next_event() => {
let Some(event) = event else {
continue;
@ -157,17 +251,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
match event {
Event::BlocksProcessed {
checkpoint,
channel_update: _,
channel_update,
finalized,
} => {
on_checkpoint(checkpoint);
let adopted = channel_update
.adopted
.iter()
.filter_map(block_from_inscription)
.collect();
let orphaned = channel_update
.orphaned
.iter()
.map(orphan_inscription)
.filter_map(block_from_inscription)
.collect();
let mut finalized_blocks = Vec::new();
for op in finalized.into_iter().flat_map(|item| item.ops) {
match op {
FinalizedOp::Inscription(inscription) => {
if let Some(block_id) =
block_id_from_inscription(&inscription)
if let Some((msg, block)) =
block_from_inscription(&inscription)
{
on_finalized_block(block_id);
on_finalized_block(block.header.block_id);
finalized_blocks.push((msg, block));
}
}
FinalizedOp::Deposit(deposit) => {
@ -178,8 +287,22 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
}
}
}
on_follow(FollowUpdate {
adopted,
orphaned,
finalized: finalized_blocks,
});
}
Event::Ready => {}
Event::TurnNotification { notification } => {
info!(
"Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}",
notification.our_turn_to_write,
notification.starting_slot,
notification.ends_at_slot
);
}
Event::Ready | Event::TurnNotification { .. } => {}
}
}
}
@ -196,37 +319,151 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
Ok(Self {
channel_id: config.channel_id,
publish_tx,
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
node,
command_tx,
turn_rx,
driver_cancellation,
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
})
}
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()> {
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId> {
let data = borsh::to_vec(block).context("Failed to serialize block")?;
let data_bounded: Inscription = data
.try_into()
.context("Block data exceeds maximum allowed size")?;
self.publish_tx
.send((data_bounded, withdrawals))
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"))?;
Ok(())
resp_rx
.await
.map_err(|_closed| anyhow!("Drive task dropped the publish response"))?
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
self.turn_rx.borrow().our_turn_to_write
}
fn driver_cancellation(&self) -> CancellationToken {
self.driver_cancellation.clone()
}
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
Ok(self
.node
.channel_state(self.channel_id)
.await
.context("Failed to read channel state")?
.map(|state| state.tip_slot))
}
async fn read_channel_after(
&self,
after_slot: Option<Slot>,
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_> {
let stream = self
.indexer
.next_messages(after_slot)
.await
.context("Failed to start channel read stream")?;
Ok(stream)
}
}
/// Deserialize inscription payload as a `Block` and return it's`block_id`.
/// Bad payloads are logged and skipped.
fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option<u64> {
/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are
/// logged and skipped.
fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> {
borsh::from_slice::<Block>(&inscription.payload)
.inspect_err(|err| {
warn!("Failed to deserialize block from inscription: {err:?}");
})
.ok()
.map(|block| block.header.block_id)
.map(|block| (inscription.this_msg, block))
}
/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle).
const fn orphan_inscription(orphan: &OrphanedTx) -> &InscriptionInfo {
match orphan {
OrphanedTx::Inscription(info) => info,
OrphanedTx::AtomicWithdraw(bundle) => &bundle.inscription,
}
}
/// Signs a `ChannelConfig` op (accredited keys + rotation params) with
/// `signing_key` and posts it straight to the bedrock node.
///
/// A standalone one-shot — no running sequencer involved, so authorization is
/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the
/// node accepted the transaction; channel acceptance is asynchronous and a
/// rejection only shows up in node logs and on-chain behavior.
pub async fn post_channel_config(
config: &BedrockConfig,
signing_key: &Ed25519Key,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> Result<()> {
ensure!(!keys.is_empty(), "Channel key list must not be empty");
for (name, threshold) in [
("configuration_threshold", configuration_threshold),
("withdraw_threshold", withdraw_threshold),
] {
ensure!(
threshold >= 1 && usize::from(threshold) <= keys.len(),
"{name} must be between 1 and the key count ({}), got {threshold}",
keys.len()
);
}
ensure!(
posting_timeframe > 0 && posting_timeout >= posting_timeframe,
"posting_timeframe must be nonzero and posting_timeout at least as long, \
got {posting_timeframe} and {posting_timeout}"
);
let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
let config_op = ChannelConfigOp {
channel: config.channel_id,
keys,
posting_timeframe: SlotTimeframe::from(posting_timeframe),
posting_timeout: SlotTimeout::from(posting_timeout),
configuration_threshold,
withdraw_threshold,
};
let mantle_tx = MantleTx([Op::ChannelConfig(config_op)].into());
let tx_hash = mantle_tx.hash();
// The admin key is `keys[0]`, hence signature index 0.
let signature = IndexedSignature::new(
0,
signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()),
);
let proof = ChannelMultiSigProof::new(vec![signature])
.map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?;
let signed_tx = SignedMantleTx {
ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)],
mantle_tx,
};
let node = NodeHttpClient::new(
CommonHttpClient::new(config.auth.clone().map(Into::into)),
config.node_url.clone(),
);
node.post_transaction(signed_tx)
.await
.context("Failed to post channel config transaction")
}

View File

@ -12,7 +12,7 @@ use log::info;
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
};
pub use storage::{DbResult, sequencer::DbDump};
@ -28,29 +28,17 @@ impl SequencerStore {
/// Open existing database at the given location. Fails if no database is found.
pub fn open_db(location: &Path, signing_key: lee::PrivateKey) -> DbResult<Self> {
let dbio = Arc::new(RocksDBIO::open(location)?);
let genesis_id = dbio.get_meta_first_block_in_db()?;
let last_id = dbio.latest_block_meta()?.id;
Self::from_dbio_and_signing_key(dbio, signing_key)
}
info!("Preparing block cache");
let mut tx_hash_to_block_map = HashMap::new();
for i in genesis_id..=last_id {
let block = dbio
.get_block(i)?
.expect("Block should be present in the database");
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
}
info!(
"Block cache prepared. Total blocks in cache: {}",
tx_hash_to_block_map.len()
);
Ok(Self {
dbio,
tx_hash_to_block_map,
genesis_id,
signing_key,
})
/// Create a fresh rocksdb at `location` from `dump`.
pub fn restore_db_from_dump(
location: &Path,
dump: &DbDump,
signing_key: lee::PrivateKey,
) -> DbResult<Self> {
let dbio = Arc::new(RocksDBIO::restore_from_dump(location, dump)?);
Self::from_dbio_and_signing_key(dbio, signing_key)
}
/// Starting database at the start of new chain.
@ -75,6 +63,38 @@ impl SequencerStore {
})
}
fn from_dbio_and_signing_key(
dbio: Arc<RocksDBIO>,
signing_key: lee::PrivateKey,
) -> DbResult<Self> {
let genesis_id = dbio.get_meta_first_block_in_db()?;
let last_id = dbio.latest_block_meta()?.map(|meta| meta.id);
let mut tx_hash_to_block_map = HashMap::new();
if let Some(last_id) = last_id {
info!("Preparing block cache");
for i in genesis_id..=last_id {
let block = dbio
.get_block(i)?
.expect("Block should be present in the database");
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
}
info!(
"Block cache prepared. Total blocks in cache: {}",
tx_hash_to_block_map.len()
);
}
Ok(Self {
dbio,
tx_hash_to_block_map,
genesis_id,
signing_key,
})
}
/// Shared handle to the underlying rocksdb. Used to persist the zone-sdk
/// checkpoint from the sequencer's drive task without needing &mut to the
/// store.
@ -114,7 +134,7 @@ impl SequencerStore {
);
}
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
self.dbio.latest_block_meta()
}
@ -165,13 +185,6 @@ impl SequencerStore {
self.dbio.dump_all()
}
/// Create a fresh rocksdb at `location` from `dump`, closing it before returning so a sequencer
/// can open it normally afterwards.
pub fn restore_db_from_dump(location: &Path, dump: &DbDump) -> DbResult<()> {
RocksDBIO::restore_from_dump(location, dump)?;
Ok(())
}
pub fn get_zone_checkpoint(&self) -> Result<Option<SequencerCheckpoint>> {
let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else {
return Ok(None);
@ -188,9 +201,33 @@ impl SequencerStore {
Ok(())
}
/// The last channel block read back and verified from Bedrock (L1 slot +
/// `id`/`hash`), or `None` before any block has been read from the channel.
pub fn get_zone_anchor(&self) -> DbResult<Option<ZoneAnchorRecord>> {
self.dbio.get_zone_anchor()
}
pub fn set_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> {
self.dbio.put_zone_anchor(anchor)
}
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
self.dbio.get_pending_deposit_events()
}
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
self.dbio.is_deposit_event_submitted(deposit_op_id)
}
/// Marks the given deposit events submitted in `block_id`, in one write.
pub fn mark_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
) -> DbResult<()> {
self.dbio
.mark_deposit_events_submitted(deposit_op_ids, submitted_block_id)
}
}
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
@ -275,7 +312,7 @@ mod tests {
.unwrap();
// Verify that initially the latest block hash equals genesis hash
let latest_meta = node_store.latest_block_meta().unwrap();
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
assert_eq!(latest_meta.hash, genesis_hash);
}
@ -313,7 +350,7 @@ mod tests {
.unwrap();
// Verify that the latest block meta now equals the new block's hash
let latest_meta = node_store.latest_block_meta().unwrap();
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
assert_eq!(latest_meta.hash, block_hash);
}

View File

@ -1,19 +1,30 @@
use std::{path::Path, sync::Arc, time::Instant};
use std::{
path::Path,
sync::{Arc, Mutex},
time::Instant,
};
use anyhow::{Context as _, Result, anyhow};
use borsh::BorshDeserialize;
use chain_state::{
AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip,
};
use common::{
HashType,
block::{BedrockStatus, Block, HashableBlockData},
block::{BedrockStatus, Block, BlockMeta, HashableBlockData},
transaction::{LeeTransaction, clock_invocation},
};
use config::{GenesisAction, SequencerConfig};
use futures::StreamExt as _;
use itertools::Itertools as _;
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::GENESIS_BLOCK_ID;
use log::{error, info, warn};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg};
use logos_blockchain_zone_sdk::{
Slot, ZoneMessage,
sequencer::{DepositInfo, WithdrawArg},
};
use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
@ -21,11 +32,11 @@ use num_bigint::BigUint;
pub use storage::error::DbError;
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord},
};
use crate::{
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
block_store::SequencerStore,
};
@ -51,18 +62,13 @@ struct DepositMetadata {
recipient_id: lee::AccountId,
}
impl DepositMetadata {
fn decode(bytes: &[u8]) -> Result<Self, std::io::Error> {
Self::try_from_slice(bytes)
}
}
pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
state: lee::V03State,
/// Two-tier chain state: production builds on its head; the publisher's
/// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it.
chain: Arc<Mutex<ChainState>>,
store: SequencerStore,
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
sequencer_config: SequencerConfig,
chain_height: u64,
block_publisher: BP,
}
@ -72,7 +78,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// 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, Block) {
fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) {
let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap();
let db_path = config.home.join("rocksdb");
@ -86,11 +92,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let state = store
.get_lee_state()
.expect("Failed to read state from store");
let genesis_block = store
.get_block_at_id(store.genesis_id())
.expect("Failed to read genesis block from store")
.expect("Genesis block not found in store");
(store, state, genesis_block)
(store, state)
} else {
warn!(
"Database not found at {}, starting from genesis",
@ -115,22 +117,70 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
)
.expect("Failed to create database with genesis block");
(store, genesis_state, genesis_block)
(store, genesis_state)
}
}
/// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted
/// final snapshot (pre-genesis state when absent), the head tier by replaying
/// every stored block above it, so a post-restart orphan can still revert.
fn restore_chain_state(
config: &SequencerConfig,
store: &SequencerStore,
stored_head_state: &lee::V03State,
) -> ChainState {
let final_snapshot = store
.dbio()
.get_final_snapshot()
.expect("Failed to read final snapshot from store");
let (final_state, final_tip) = match final_snapshot {
Some((state, meta)) => (state, Some(Tip::from(meta))),
// Nothing finalized yet: replay the whole stored chain.
None => (build_initial_state(config), None),
};
let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id);
let mut head_blocks = store
.get_all_blocks()
.filter_ok(|block| block.header.block_id > boundary)
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read blocks from store while restoring chain state");
head_blocks.sort_unstable_by_key(|block| block.header.block_id);
let mut chain = ChainState::from_final(final_state, final_tip);
for block in head_blocks {
let block_id = block.header.block_id;
chain.restore_head_block(block).unwrap_or_else(|err| {
panic!("Stored block {block_id} does not replay while restoring chain state: {err}")
});
}
// The replayed head must reproduce the persisted state, else store
// and config disagree (e.g. edited genesis actions).
assert!(
chain.head_state() == stored_head_state,
"Persisted state does not match the replayed chain; reset the store or restore the original config"
);
chain
}
pub async fn start_from_config(
config: SequencerConfig,
) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) {
let bedrock_signing_key =
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
.expect("Failed to load or create bedrock signing key");
info!(
"Bedrock signing public key: {}",
hex::encode(bedrock_signing_key.public_key().to_bytes())
);
let (store, state, _genesis_block) = Self::open_or_create_store(&config);
let (store, state) = Self::open_or_create_store(&config);
let latest_block_meta = store
.latest_block_meta()
.expect("Failed to read latest block meta from store");
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
&config, &store, &state,
)));
let initial_checkpoint = store
.get_zone_checkpoint()
@ -149,12 +199,33 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
Self::on_finalized_block(store.dbio()),
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
Self::on_withdraw_event(store.dbio()),
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
)
.await
.expect("Failed to initialize Block Publisher");
// Fresh start (no checkpoint): republish all pending blocks
if is_fresh_start {
// Cross-zone messaging: start a watcher per configured peer. The inbox
// config account is seeded into genesis state in `build_genesis_state`.
if let Some(cross_zone) = &config.cross_zone {
cross_zone_watcher::spawn_watchers(
&config.bedrock_config,
cross_zone,
config.block_create_timeout,
&mempool_handle,
);
}
// Before producing, verify our local state still belongs to the chain
// the channel serves and replay any channel blocks we are missing
// (e.g. from other sequencers).
let channel_was_empty =
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
.await
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
// Publish our blocks only when bootstrapping an empty channel. If the
// channel already has blocks (another sequencer bootstrapped it), we
// adopted them during reconstruction instead.
if is_fresh_start && channel_was_empty {
let mut pending_blocks = store
.get_all_blocks()
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
@ -182,22 +253,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
// Cross-zone messaging: start a watcher per configured peer. The inbox
// config account is seeded into genesis state in `build_genesis_state`.
if let Some(cross_zone) = &config.cross_zone {
cross_zone_watcher::spawn_watchers(
&config.bedrock_config,
cross_zone,
config.block_create_timeout,
&mempool_handle,
);
}
let sequencer_core = Self {
state,
chain,
store,
mempool,
chain_height: latest_block_meta.id,
sequencer_config: config,
block_publisher,
};
@ -205,6 +264,220 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
(sequencer_core, mempool_handle)
}
/// Verifies the local store still belongs to the chain the connected channel
/// serves and replays any finalized channel blocks missing locally into
/// `state`/`store`, recording each block's L1 inscription slot as the new
/// anchor. Fails (never parks) on any divergence.
///
/// Returns whatever channel was empty or not.
async fn verify_and_reconstruct(
publisher: &BP,
store: &SequencerStore,
chain: &Mutex<ChainState>,
is_fresh_start: bool,
) -> Result<bool> {
let anchor_record = store
.get_zone_anchor()
.context("Failed to read zone anchor")?;
let after_slot = anchor_record
.and_then(|record| record.slot.checked_sub(1))
.map(Slot::from);
let channel_tip_slot = publisher
.channel_tip_slot()
.await
.context("Failed to read channel tip slot")?;
// If this sequencer has already committed blocks to the channel, that
// channel must still exist. A missing channel then means a wiped/rewound
// Bedrock or a node pointing at a different chain, so refuse to resume
// onto a foreign channel.
//
// "Committed" requires *both* a non-genesis tip and a checkpoint that was
// persisted before this startup: the tip alone is set the moment we produce
// (before the channel confirms it), while a checkpoint alone is written by
// zone-sdk's cold-start backfill even on a brand-new empty channel before we
// publish genesis. We must read the checkpoint presence from before `BP::new`
// ran (`!is_fresh_start`), because its cold-start backfill re-persists a
// checkpoint by the time we reach here — reading the store now would always
// see one. Together they mean we produced blocks and zone-sdk processed
// channel activity in a prior run.
let local_tip = store
.latest_block_meta()
.context("Failed to read latest block meta")?
.map(|meta| meta.id);
let had_checkpoint_before_start = !is_fresh_start;
if let Some(local_tip) = local_tip
&& had_checkpoint_before_start
&& channel_tip_slot.is_none()
{
return Err(anyhow!(
"Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \
no longer exists on the connected chain the channel was wiped or the node \
points at a different chain. Refusing to resume onto a foreign channel."
));
}
let divergence_error = |mismatch: &ChainMismatch| {
anyhow!(
"Sequencer store diverges from the Bedrock channel ({mismatch}). \
Delete the sequencer storage directory or point at the correct channel."
)
};
// With a recorded anchor, probe the channel for positive evidence of a
// different chain: the frontier upfront (a missing/behind channel serves
// no messages to scan), then the anchor block as messages stream in.
let mut consistency_check = anchor_record.map(|record| {
let anchor = Anchor::new(
Slot::from(record.slot),
Some((record.block_id, record.hash)),
);
let mut check = AnchorConsistencyCheck::new(anchor);
check.check_frontier(channel_tip_slot);
check
});
if let Some(ChainConsistency::Inconsistent(mismatch)) = consistency_check
.as_ref()
.and_then(AnchorConsistencyCheck::verdict)
{
return Err(divergence_error(mismatch));
}
// Verify each message against the anchor and replay the
// blocks (applying the ones we miss, checking the ones we hold).
let messages = publisher
.read_channel_after(after_slot)
.await
.context("Failed to read channel history for reconstruction")?;
let mut messages = std::pin::pin!(messages);
let mut channel_is_empty = true;
while let Some((message, slot)) = messages.next().await {
if let Some(check) = &mut consistency_check
&& let Some(ChainConsistency::Inconsistent(mismatch)) =
check.observe(&message, slot)
{
return Err(divergence_error(mismatch));
}
let ZoneMessage::Block(zone_block) = message else {
continue;
};
let block: Block = borsh::from_slice(&zone_block.data).map_err(|err| {
anyhow!(
"Failed to deserialize channel block at slot {}: {err}",
slot.into_inner()
)
})?;
channel_is_empty = false;
// Locked per message (not across the stream `await`): concurrent
// follow events interleave safely — both paths apply idempotently
// and persist under this same lock.
let mut chain = chain.lock().expect("chain state mutex poisoned");
Self::apply_reconstructed_block(store, &mut chain, &block, slot)?;
}
Ok(channel_is_empty)
}
/// Applies a single channel block during reconstruction: idempotent for
/// blocks we already hold (verifying their hash), a validated continuation
/// for new ones. Advances the persisted anchor to the block's slot.
fn apply_reconstructed_block(
store: &SequencerStore,
chain: &mut ChainState,
block: &Block,
slot: Slot,
) -> Result<()> {
let tip = store
.latest_block_meta()
.context("Failed to read latest block meta")?;
let block_id = block.header.block_id;
let block_hash = block.header.hash;
let record = ZoneAnchorRecord {
slot: slot.into_inner(),
block_id,
hash: block_hash,
};
// A block at/below the tip must match what we already stored, otherwise
// the channel is a different chain.
if let Some(tip) = &tip
&& block_id <= tip.id
{
match store
.get_block_at_id(block_id)
.context("Failed to read stored block")?
{
Some(stored) if stored.header.hash == block_hash => {
store
.set_zone_anchor(&record)
.context("Failed to persist zone anchor")?;
return Ok(());
}
Some(stored) => {
return Err(anyhow!(
"Channel block {block_id} hash {block_hash} does not match stored hash {}",
stored.header.hash
));
}
None => {
return Err(anyhow!(
"Channel block {block_id} is at/below local tip {} but is missing locally",
tip.id
));
}
}
}
// New continuation: channel history is finalized, so it goes through
// the final tier — validation happens inside `apply_finalized`.
match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) {
AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {}
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
return Err(anyhow!(
"Channel block {block_id} does not extend local tip {:?}: {err}",
tip.map(|tip| tip.id)
));
}
}
// Persist like the follow path: the tip meta stays pinned to the head
// tip even when the reconstructed block lands below it.
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
store
.dbio()
.store_followed_blocks(
&[(block, true)],
head_tip.as_ref(),
chain.head_state(),
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
)
.context("Failed to persist reconstructed block")?;
// Mark the deposits' pending records submitted so the production-time
// guard drops the mints cold-start backfill re-queued for them. Withdraw
// intents are deliberately not counted: backfill already re-delivered and
// dropped their finalized L1 events, so an increment here would never be
// consumed and would leave a phantom count.
let deposit_event_ids: Vec<_> = block
.body
.transactions
.iter()
.filter_map(extract_bridge_deposit_id)
.collect();
store
.mark_deposit_events_submitted(&deposit_event_ids, block_id)
.context("Failed to mark reconstructed deposits submitted")?;
store
.set_zone_anchor(&record)
.context("Failed to persist zone anchor")?;
Ok(())
}
fn on_checkpoint(dbio: Arc<RocksDBIO>) -> block_publisher::CheckpointSink {
Box::new(move |cp| {
let bytes = match serde_json::to_vec(&cp) {
@ -335,6 +608,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
})
}
/// Publisher sink adapter over [`apply_follow_update`].
fn on_follow(
dbio: Arc<RocksDBIO>,
chain: Arc<Mutex<ChainState>>,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) -> block_publisher::OnFollowSink {
Box::new(move |update: block_publisher::FollowUpdate| {
apply_follow_update(&dbio, &chain, &mempool_handle, update);
})
}
/// 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 {
@ -351,26 +635,77 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.collect::<Result<_>>()
.context("Failed to build reconciliation keys for block withdrawals")?;
self.block_publisher
let this_msg = self
.block_publisher
.publish_block(&block, withdrawals)
.await
.context("Failed to publish block to Bedrock")?;
self.store.update(
self.record_produced_block(
this_msg,
&block,
&deposit_event_ids,
withdrawal_reconciliation_keys,
&self.state,
)?;
Ok(self.chain_height)
Ok(block.header.block_id)
}
/// 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.
///
/// Persistence is gated on the block actually becoming the head: if a peer
/// block won this height while we were publishing (`AlreadyApplied`, or
/// `Parked` when the head reorged to a different parent), the canonical
/// block is persisted by the follow path instead, and our invalidated
/// inscription comes back via `orphaned`.
fn record_produced_block(
&mut self,
this_msg: MsgId,
block: &Block,
deposit_event_ids: &[HashType],
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
) -> Result<()> {
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
match chain.apply_produced(this_msg, block) {
AcceptOutcome::Applied => {
// Persisted under the lock so disk writes land in apply order
// with the follow path.
self.store.update(
block,
deposit_event_ids,
withdrawal_reconciliation_keys,
chain.head_state(),
)?;
}
AcceptOutcome::AlreadyApplied => {
warn!(
"Produced block {} lost a competing-write race, skipping persistence",
block.header.block_id
);
}
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
warn!(
"Produced block {} no longer chains on the head, skipping persistence: {err}",
block.header.block_id
);
}
}
Ok(())
}
/// Validates and applies a single mempool transaction to the current state.
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
/// it was skipped due to validation failure.
#[expect(
clippy::too_many_arguments,
reason = "splitting the produce-path accumulators into a struct buys nothing"
)]
fn apply_mempool_transaction(
&mut self,
store: &SequencerStore,
state: &mut lee::V03State,
origin: TransactionOrigin,
tx: &LeeTransaction,
block_height: u64,
@ -381,11 +716,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let tx_hash = tx.hash();
match origin {
TransactionOrigin::User => {
let validated_diff = match tx.validate_on_state(
&self.state,
block_height,
timestamp,
) {
let validated_diff = match tx.validate_on_state(state, block_height, timestamp) {
Ok(diff) => diff,
Err(err) => {
error!(
@ -399,7 +730,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
withdrawals.push(withdraw_data);
}
self.state.apply_state_diff(validated_diff);
state.apply_state_diff(validated_diff);
}
TransactionOrigin::Sequencer => {
let LeeTransaction::Public(public_tx) = tx else {
@ -407,10 +738,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
};
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
if store
.is_deposit_event_submitted(deposit_op_id)
.context("Failed to check whether deposit was already submitted")?
{
info!("Skipping already-submitted bridge deposit {deposit_op_id}");
return Ok(false);
}
deposit_event_ids.push(deposit_op_id);
}
self.state
state
.transition_from_public_transaction(public_tx, block_height, timestamp)
.context("Failed to execute sequencer-generated transaction")?;
}
@ -423,7 +761,18 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
let now = Instant::now();
let new_block_height = self.next_block_id();
// Build on the head: its tip is the parent, its state the validation base.
let (prev_block_hash, new_block_height, mut working_state) = {
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| {
head.block_id
.checked_add(1)
.expect("block id should not overflow")
});
let prev = tip.map_or(HashType([0; 32]), |head| head.hash);
(prev, height, chain.head_state().clone())
};
let mut valid_transactions = Vec::new();
let mut deposit_event_ids = Vec::new();
@ -432,11 +781,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
.expect("`max_block_size` should fit into usize");
let latest_block_meta = self
.store
.latest_block_meta()
.context("Failed to get latest block meta from store")?;
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive");
@ -455,7 +799,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let temp_hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: temp_valid_transactions,
prev_block_hash: latest_block_meta.hash,
prev_block_hash,
timestamp: new_block_timestamp,
};
@ -472,7 +816,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
break;
}
if self.apply_mempool_transaction(
if Self::apply_mempool_transaction(
&self.store,
&mut working_state,
origin,
&tx,
new_block_height,
@ -488,7 +834,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
self.state
working_state
.transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp)
.context("Clock transaction failed. Aborting block production.")?;
valid_transactions.push(clock_lee_tx);
@ -496,7 +842,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: valid_transactions,
prev_block_hash: latest_block_meta.hash,
prev_block_hash,
timestamp: new_block_timestamp,
};
@ -504,8 +850,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.clone()
.into_pending_block(self.store.signing_key());
self.chain_height = new_block_height;
log::info!(
"Created block with {} transactions in {} seconds",
hashable_data.transactions.len(),
@ -519,16 +863,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
})
}
pub const fn state(&self) -> &lee::V03State {
&self.state
/// Reads the current head state under the lock without cloning it, so callers
/// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy.
pub fn with_state<R>(&self, f: impl FnOnce(&lee::V03State) -> R) -> R {
f(self
.chain
.lock()
.expect("chain state mutex poisoned")
.head_state())
}
pub const fn block_store(&self) -> &SequencerStore {
&self.store
}
pub const fn chain_height(&self) -> u64 {
self.chain_height
#[must_use]
pub fn chain_height(&self) -> u64 {
self.chain
.lock()
.expect("chain state mutex poisoned")
.head_tip()
.map_or(0, |tip| tip.block_id)
}
pub const fn sequencer_config(&self) -> &SequencerConfig {
@ -561,14 +916,21 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.collect())
}
pub fn block_publisher(&self) -> BP {
self.block_publisher.clone()
pub const fn block_publisher(&self) -> &BP {
&self.block_publisher
}
fn next_block_id(&self) -> u64 {
self.chain_height
.checked_add(1)
.unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height))
/// Whether this sequencer is currently authorized to write to the channel.
#[must_use]
pub fn is_our_turn(&self) -> bool {
self.block_publisher.is_our_turn()
}
/// Shared handle to the two-tier follow state, for tests to drive the
/// follow path directly.
#[cfg(all(test, feature = "mock"))]
fn chain(&self) -> Arc<Mutex<ChainState>> {
Arc::clone(&self.chain)
}
}
@ -578,6 +940,105 @@ struct BlockWithMeta {
withdrawals: Vec<WithdrawArg>,
}
/// Feed one channel delta into the follow state and mirror it to the store:
/// revert orphaned, then apply and persist adopted and finalized blocks.
/// Production builds on this same head. Wired to the publisher via
/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly.
///
/// TODO: unlike the indexer's ingest loop, this path does not retry
/// `is_retryable` (transient) apply failures — a failed block just parks and
/// relies on a valid successor or a restart. `ChainState` never emits
/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a
/// follow-up.
fn apply_follow_update(
dbio: &RocksDBIO,
chain: &Mutex<ChainState>,
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
update: block_publisher::FollowUpdate,
) {
let block_publisher::FollowUpdate {
adopted,
orphaned,
finalized,
} = update;
// The lock is held across the persist below so disk writes land in apply
// order — the produce path persists under this same lock.
let resubmit_txs = {
let mut chain = chain.lock().expect("chain state mutex poisoned");
// User txs of orphaned blocks, returned to the mempool below.
let resubmit_txs: Vec<LeeTransaction> = orphaned
.iter()
.flat_map(|(_, block)| resubmittable_txs(block))
.collect();
// Outcomes align with `adopted`.
let outcomes = chain.apply_channel_update(&orphaned, &adopted);
let mut to_persist: Vec<(&Block, bool)> = adopted
.iter()
.zip(&outcomes)
.filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied))
.map(|((_, block), _)| (block, false))
.collect();
let mut final_advanced = false;
for (this_msg, block) in &finalized {
// FIXME: thread the finalized inscription's L1 slot once the
// sdk surfaces it; only used for the invalid-finalized stall.
if matches!(
chain.apply_finalized(*this_msg, block, Slot::from(0)),
AcceptOutcome::Applied
) {
to_persist.push((block, true));
final_advanced = true;
}
}
// Snapshot the advanced final tier so a restart re-anchors on it.
let final_meta = final_advanced.then(|| {
let tip = chain.final_tip().expect("advanced final tier has a tip");
BlockMeta::from(&tip)
});
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
// One atomic write for the whole update: blocks, tip meta and the
// state after the last block land together, so a crash can never
// leave the stored state ahead of the stored blocks. A persist
// failure is fatal: the in-memory chain has already advanced, and
// continuing would leave a permanent gap in the store.
//
// The `panic!` ends the drive task, whose cancellation halts the node.
//
// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint`
// before this write; a crash in between resumes past these blocks
// without them ever landing in the store. Full `BlocksProcessed`
// atomicity (checkpoint + blocks + state in one batch, per the sdk's
// event contract) is a follow-up.
dbio.store_followed_blocks(
&to_persist,
head_tip.as_ref(),
chain.head_state(),
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
)
.unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}"));
resubmit_txs
};
// Rebuild orphaned work: return its user txs to the mempool so the
// next on-turn production re-includes them on the new head.
//
// We use [`try_push`] here because this is called from the
// publisher's drive task, and only the block production drains the mempool.
// A blocking push on a full mempool would deadlock here.
for tx in resubmit_txs {
let tx_hash = tx.hash();
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}");
}
}
}
/// Checks the database for any pending deposit events that have not yet been marked as submitted in
/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next
/// block.
@ -627,41 +1088,15 @@ fn replay_unfulfilled_deposit_events(
});
}
/// Builds the initial genesis state from `testnet_initial_state` plus configured 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>) {
/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside
/// any transaction (bridge-lock holdings, the cross-zone inbox config).
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
#[cfg(not(feature = "testnet"))]
let mut state = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut state = testnet_initial_state::initial_state_testnet();
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted below: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
.expect("Failed to execute genesis transaction");
})
.map(LeeTransaction::Public)
.collect();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
@ -679,6 +1114,41 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
state.insert_genesis_account(config_id, config_account);
}
state
}
/// Builds the initial genesis state from [`build_initial_state`] plus configured
/// 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>) {
let mut state = build_initial_state(config);
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted in `build_initial_state`: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
.expect("Failed to execute genesis transaction");
})
.map(LeeTransaction::Public)
.collect();
(state, genesis_txs)
}
@ -743,7 +1213,7 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec
}
fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result<LeeTransaction> {
let metadata = DepositMetadata::decode(&event.metadata)
let metadata = DepositMetadata::try_from_slice(&event.metadata)
.context("Failed to decode finalized Bedrock deposit metadata")?;
let bridge_program_id = programs::bridge().id();
@ -771,6 +1241,26 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
)))
}
/// 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
/// by the watcher; the ingress guard rejects them as `User`).
fn resubmittable_txs(block: &Block) -> Vec<LeeTransaction> {
let Some((_clock, rest)) = block.body.transactions.split_last() else {
return Vec::new();
};
rest.iter()
.filter(|tx| extract_bridge_deposit_id(tx).is_none() && !is_sequencer_only_tx(tx))
.cloned()
.collect()
}
#[must_use]
fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool {
matches!(tx, LeeTransaction::Public(tx)
if is_sequencer_only_program(tx.message().program_id))
}
#[must_use]
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
let LeeTransaction::Public(tx) = tx else {
@ -860,7 +1350,7 @@ fn withdraw_event_reconciliation_key(
}
/// Load signing key from file or generate a new one if it doesn't exist.
fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
if path.exists() {
let key_bytes = std::fs::read(path)?;

View File

@ -2,13 +2,15 @@ use std::time::Duration;
use anyhow::Result;
use common::block::Block;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use futures::Stream;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
use tokio_util::sync::CancellationToken;
use crate::{
block_publisher::{
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
OnWithdrawEventSink, SequencerCheckpoint,
},
config::BedrockConfig,
@ -19,6 +21,31 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>
#[derive(Clone)]
pub struct MockBlockPublisher {
channel_id: ChannelId,
// Never cancelled: the mock driver never dies.
driver_cancellation: CancellationToken,
/// Canned channel frontier returned by [`Self::channel_tip_slot`].
tip_slot: Option<Slot>,
/// Canned finalized channel history returned by [`Self::read_channel_after`].
messages: Vec<(ZoneMessage, Slot)>,
}
impl MockBlockPublisher {
/// Builds a mock publisher backed by a canned channel, for reconstruction
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
/// serves an empty channel.
#[must_use]
pub fn with_canned_channel(
channel_id: ChannelId,
tip_slot: Option<Slot>,
messages: Vec<(ZoneMessage, Slot)>,
) -> Self {
Self {
channel_id,
driver_cancellation: CancellationToken::new(),
tip_slot,
messages,
}
}
}
impl BlockPublisherTrait for MockBlockPublisher {
@ -31,21 +58,53 @@ impl BlockPublisherTrait for MockBlockPublisher {
_on_finalized_block: FinalizedBlockSink,
_on_deposit_event: OnDepositEventSink,
_on_withdraw_event: OnWithdrawEventSink,
_on_follow: OnFollowSink,
) -> Result<Self> {
Ok(Self {
channel_id: config.channel_id,
driver_cancellation: CancellationToken::new(),
tip_slot: None,
messages: Vec::new(),
})
}
async fn publish_block(
&self,
_block: &Block,
block: &Block,
_bridge_withdrawals: Vec<WithdrawArg>,
) -> Result<()> {
Ok(())
) -> Result<MsgId> {
// Deterministic per-block id so head dedup behaves in tests.
//
// TODO: should we allow more "mockability" here?
Ok(MsgId::from(block.header.hash.0))
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
true
}
fn driver_cancellation(&self) -> CancellationToken {
self.driver_cancellation.clone()
}
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
Ok(self.tip_slot)
}
async fn read_channel_after(
&self,
after_slot: Option<Slot>,
) -> Result<impl Stream<Item = (ZoneMessage, Slot)> + '_> {
// Mirror `next_messages`: `after_slot` is exclusive.
let messages = self
.messages
.iter()
.filter(move |(_, slot)| after_slot.is_none_or(|after| *slot > after))
.cloned();
Ok(futures::stream::iter(messages))
}
}

View File

@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration};
use common::{
HashType,
block::HashableBlockData,
block::{BedrockStatus, HashableBlockData},
test_utils::sequencer_sign_key_for_testing,
transaction::{LeeTransaction, clock_invocation},
};
@ -22,21 +22,25 @@ use lee_core::{
account::{AccountWithMetadata, Nonce},
program::PdaSeed,
};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
use mempool::MemPoolHandle;
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
use tempfile::tempdir;
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
use crate::{
TransactionOrigin,
TransactionOrigin, apply_follow_update,
block_publisher::FollowUpdate,
block_store::SequencerStore,
build_genesis_state,
build_bridge_deposit_tx_from_event, build_genesis_state,
config::{BedrockConfig, SequencerConfig},
is_sequencer_only_program,
mock::SequencerCoreWithMockClients,
resubmittable_txs,
};
mod reconstruction;
#[derive(borsh::BorshSerialize)]
struct DepositMetadataForEncoding {
recipient_id: lee::AccountId,
@ -146,14 +150,14 @@ async fn start_from_config() {
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
assert_eq!(sequencer.chain_height, 1);
assert_eq!(sequencer.chain_height(), 1);
assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10);
let acc1_account_id = initial_public_user_accounts()[0].account_id;
let acc2_account_id = initial_public_user_accounts()[1].account_id;
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
assert_eq!(10000, balance_acc_1);
assert_eq!(20000, balance_acc_2);
@ -186,7 +190,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() {
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
assert_eq!(sequencer.chain_height, 1);
assert_eq!(sequencer.chain_height(), 1);
assert!(sequencer.store.latest_block_meta().is_ok());
}
@ -295,7 +299,7 @@ async fn transaction_pre_check_native_transfer_valid() {
#[tokio::test]
async fn transaction_pre_check_native_transfer_other_signature() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -309,7 +313,15 @@ async fn transaction_pre_check_native_transfer_other_signature() {
let tx = tx.transaction_stateless_check().unwrap();
// Signature is not from sender. Execution fails
let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0);
let result = tx.execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
);
assert!(matches!(
result,
@ -319,7 +331,7 @@ async fn transaction_pre_check_native_transfer_other_signature() {
#[tokio::test]
async fn transaction_pre_check_native_transfer_sent_too_much() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -335,9 +347,15 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
// Passed pre-check
assert!(result.is_ok());
let result = result
.unwrap()
.execute_check_on_state(&mut sequencer.state, 0, 0);
let result = result.unwrap().execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
);
let is_failed_at_balance_mismatch = matches!(
result.err().unwrap(),
lee::error::LeeError::ProgramExecutionFailed(_)
@ -348,7 +366,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
#[tokio::test]
async fn transaction_execute_native_transfer() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -359,11 +377,19 @@ async fn transaction_execute_native_transfer() {
acc1, 0, acc2, 100, &sign_key1,
);
tx.execute_check_on_state(&mut sequencer.state, 0, 0)
.unwrap();
tx.execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
)
.unwrap();
let bal_from = sequencer.state.get_account_by_id(acc1).balance;
let bal_to = sequencer.state.get_account_by_id(acc2).balance;
let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance);
let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance);
assert_eq!(bal_from, 9900);
assert_eq!(bal_to, 20100);
@ -400,7 +426,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() {
#[tokio::test]
async fn build_block_from_mempool() {
let (mut sequencer, mempool_handle) = common_setup().await;
let genesis_height = sequencer.chain_height;
let genesis_height = sequencer.chain_height();
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
@ -410,7 +436,8 @@ async fn build_block_from_mempool() {
let result = sequencer.build_block_from_mempool();
assert!(result.is_ok());
assert_eq!(sequencer.chain_height, genesis_height + 1);
// Building itself does not advance the head; only apply-after-publish does.
assert_eq!(sequencer.chain_height(), genesis_height);
}
#[tokio::test]
@ -442,7 +469,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -477,7 +504,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
assert_eq!(
@ -496,7 +523,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
// The replay is rejected, so only the clock tx is in the block.
@ -538,7 +565,7 @@ async fn restart_from_storage() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
assert_eq!(
@ -554,8 +581,8 @@ async fn restart_from_storage() {
// with the above transaction and update the state to reflect that.
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
// Balances should be consistent with the stored block
assert_eq!(
@ -625,7 +652,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
sequencer.produce_new_block().await.unwrap();
// Get the metadata of the last block produced
sequencer.store.latest_block_meta().unwrap()
sequencer.store.latest_block_meta().unwrap().unwrap()
};
// Step 2: Restart sequencer from the same storage
@ -653,7 +680,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
// Step 5: Verify the new block has correct previous block metadata
let new_block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -705,7 +732,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() {
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -760,7 +787,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() {
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -779,10 +806,13 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() {
// Corrupt the clock 01 account data so the clock program panics on deserialization.
let clock_account_id = system_accounts::clock_account_ids()[0];
let mut corrupted = sequencer.state.get_account_by_id(clock_account_id);
let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id));
corrupted.data = vec![0xff; 3].try_into().unwrap();
sequencer
.state
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut()
.force_insert_account(clock_account_id, corrupted);
// Push a dummy transaction so the mempool is non-empty.
@ -862,6 +892,7 @@ fn private_bridge_withdraw_invocation_is_dropped() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.viewing_public_key.clone(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.private_key_holder.nullifier_secret_key,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
@ -1245,3 +1276,509 @@ fn pda_mechanism_with_pinata_token_program() {
expected_winner_token_holding_post
);
}
#[test]
fn resubmittable_txs_drops_clock_and_bridge_deposits() {
let user_tx = common::test_utils::produce_dummy_empty_transaction();
let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord {
deposit_op_id: HashType([13; 32]),
source_tx_hash: HashType([7; 32]),
amount: 1,
metadata: borsh::to_vec(&DepositMetadataForEncoding {
recipient_id: initial_public_user_accounts()[0].account_id,
})
.unwrap(),
submitted_in_block_id: None,
})
.unwrap();
let withdraw_tx = {
let message = lee::public_transaction::Message::try_new(
programs::bridge().id(),
vec![system_accounts::bridge_account_id()],
vec![],
bridge_core::Instruction::Withdraw {
amount: 1,
bedrock_account_pk: [0; 32],
},
)
.unwrap();
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
};
let block = common::test_utils::produce_dummy_block(
2,
Some(HashType([1; 32])),
vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()],
);
// The trailing clock tx and the sequencer-generated deposit are dropped;
// user txs (withdrawals included) are returned.
assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]);
}
#[test]
fn resubmittable_txs_of_blocks_without_user_txs_is_empty() {
// No transactions at all (not even the mandatory clock tx).
let empty = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0; 32]),
timestamp: 0,
transactions: vec![],
}
.into_pending_block(&sequencer_sign_key_for_testing());
assert!(resubmittable_txs(&empty).is_empty());
let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]);
assert!(resubmittable_txs(&clock_only).is_empty());
}
#[tokio::test]
async fn follow_adopted_peer_block_applies_and_persists() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer
.store
.latest_block_meta()
.unwrap()
.expect("genesis meta is set");
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
orphaned: vec![],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 2);
let stored = sequencer
.store
.get_block_at_id(2)
.unwrap()
.expect("adopted peer block should be persisted");
assert_eq!(stored.header.hash, peer_block.header.hash);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
20010
);
}
#[tokio::test]
async fn follow_redelivery_of_own_block_is_deduped() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
// The channel redelivers our own block under the MsgId the mock publisher
// assigned at publish time.
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
orphaned: vec![],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 2);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
20010,
"the transfer must not be double-applied"
);
}
#[tokio::test]
async fn follow_orphan_reverts_head_and_requeues_user_txs() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 1);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
10000,
"the orphaned transfer must be reverted from the head"
);
let (origin, requeued) = sequencer
.mempool
.pop()
.expect("orphaned user tx should be requeued");
assert!(matches!(origin, TransactionOrigin::User));
assert_eq!(requeued, tx);
assert!(
sequencer.mempool.pop().is_none(),
"the clock tx must not be requeued"
);
}
#[tokio::test]
async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
},
);
let final_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.final_tip()
.expect("final tip set");
assert_eq!(final_tip.block_id, 2);
assert_eq!(sequencer.chain_height(), 2, "head is unchanged");
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer
.store
.latest_block_meta()
.unwrap()
.expect("genesis meta is set");
// A peer block we never saw as adopted arrives straight from the
// finalized (backfill) stream.
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
},
);
assert_eq!(
sequencer.chain_height(),
2,
"head mirrors final on backfill"
);
let stored = sequencer
.store
.get_block_at_id(2)
.unwrap()
.expect("backfilled block should be persisted");
assert_eq!(stored.header.hash, peer_block.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn restart_restores_head_tier_and_recovers_from_orphan() {
let config = setup_sequencer_config();
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
// Produce block 2 (a user transfer), then "crash" before it finalizes.
let (tx, block2) = {
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
(tx, sequencer.store.get_block_at_id(2).unwrap().unwrap())
};
// Restart: nothing is finalized, so block 2 must come back as *head*, not
// final — the L1 can still orphan it.
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
assert_eq!(sequencer.chain_height(), 2);
// The L1 orphans block 2 under its real MsgId (which we never persisted)
// and adopts a competing empty block 2'.
let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap();
let block2_prime =
common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
orphaned: vec![(MsgId::from([20; 32]), block2)],
finalized: vec![],
},
);
// The head reorged onto 2': transfer reverted, store overwritten, and the
// orphaned user tx returned to the mempool.
assert_eq!(sequencer.chain_height(), 2);
let head_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_tip()
.expect("head tip set");
assert_eq!(head_tip.hash, block2_prime.header.hash);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
10000,
"the orphaned transfer must be reverted"
);
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert_eq!(stored.header.hash, block2_prime.header.hash);
let (origin, requeued) = sequencer
.mempool
.pop()
.expect("orphaned user tx should be requeued");
assert!(matches!(origin, TransactionOrigin::User));
assert_eq!(requeued, tx);
}
#[tokio::test]
async fn restart_reanchors_on_the_persisted_final_snapshot() {
let config = setup_sequencer_config();
// Produce block 2 and follow its finalization, which persists the final
// snapshot; then "crash".
{
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
},
);
}
// Restart: the final tier re-anchors on the snapshot instead of treating
// the whole stored chain as final.
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let chain = sequencer.chain();
let chain = chain.lock().expect("chain mutex poisoned");
assert_eq!(chain.final_tip().expect("final tip set").block_id, 2);
assert_eq!(chain.head_tip().expect("head tip set").block_id, 2);
}
#[tokio::test]
async fn record_produced_block_skips_persistence_on_lost_race() {
let config = setup_sequencer_config();
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer
.store
.latest_block_meta()
.unwrap()
.expect("genesis meta is set");
// A peer block wins height 2 while "our" block is in flight.
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.apply_adopted(MsgId::from([9; 32]), &peer_block);
// Our competing block at the same height: same parent, different content.
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
sequencer
.record_produced_block(
MsgId::from(our_block.header.hash.0),
&our_block,
&[],
vec![],
)
.unwrap();
// The lost-race block must not reach the store; the head keeps the peer block.
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
let head_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_tip()
.expect("head tip");
assert_eq!(head_tip.hash, peer_block.header.hash);
}
#[tokio::test]
async fn record_produced_block_skips_persistence_when_block_no_longer_chains() {
let config = setup_sequencer_config();
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
// The head reorged under us: our block's parent is no longer the tip.
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
sequencer
.record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![])
.unwrap();
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
assert_eq!(sequencer.chain_height(), 1, "head is unchanged");
}
#[tokio::test]
async fn follow_update_persists_blocks_meta_and_state_atomically() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer
.store
.latest_block_meta()
.unwrap()
.expect("genesis meta is set");
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
// One update carrying several blocks: both adopted, block 2 also finalized.
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![
(MsgId::from([2; 32]), block2.clone()),
(MsgId::from([3; 32]), block3.clone()),
],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), block2)],
},
);
// Blocks, tip meta and state all reflect the end of the batch: a late
// finalized entry for an earlier block must not drag the tip meta back.
let meta = sequencer
.store
.latest_block_meta()
.unwrap()
.expect("meta is set");
assert_eq!(meta.id, 3);
assert_eq!(meta.hash, block3.header.hash);
let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
let stored_balance = sequencer
.store
.get_lee_state()
.unwrap()
.get_account_by_id(acc2)
.balance;
assert_eq!(stored_balance, 20010);
}

View File

@ -0,0 +1,610 @@
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
reason = "We don't care about it in tests"
)]
use std::sync::Mutex;
use chain_state::ChainState;
use common::block::Block;
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage};
use storage::sequencer::sequencer_cells::ZoneAnchorRecord;
use super::*;
use crate::{
SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher,
};
/// 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 chain = Mutex::new(SequencerCore::<MockBlockPublisher>::restore_chain_state(
config, &store, &state,
));
(store, chain)
}
fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
let bytes = borsh::to_vec(block).expect("serialize block");
let message = ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0_u8; 32]),
data: Inscription::try_from(bytes.as_slice()).expect("inscription"),
});
(message, Slot::from(slot))
}
/// Collects a sequencer's whole chain (genesis..=tip) into a canned channel,
/// one block per slot at `slot_step` spacing.
fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> {
let genesis_id = store.genesis_id();
let tip_id = store.latest_block_meta().expect("tip").expect("present").id;
(genesis_id..=tip_id)
.enumerate()
.map(|(index, id)| {
let block = store.get_block_at_id(id).expect("read").expect("present");
block_to_channel_message(&block, (index as u64 + 1) * slot_step)
})
.collect()
}
#[tokio::test]
async fn reconstructs_missing_channel_blocks_into_fresh_store() {
// Sequencer A produces a few blocks; treat its chain as the channel.
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();
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;
let channel_id = config_a.bedrock_config.channel_id;
// Sequencer B starts from a fresh store and reconstructs A's chain.
let config_b = setup_sequencer_config();
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b, &store_b, &chain_b, true,
)
.await
.expect("reconstruct");
assert!(!channel_was_empty);
let tip_b = store_b.latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
// State matches: initial account balances agree with sequencer A.
let state_b = chain_b.lock().unwrap().head_state().clone();
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
for account in initial_public_user_accounts() {
assert_eq!(
state_b.get_account_by_id(account.account_id).balance,
state_a.get_account_by_id(account.account_id).balance,
);
}
let anchor = store_b.get_zone_anchor().unwrap().expect("anchor recorded");
assert_eq!(anchor.block_id, tip_a.id);
assert_eq!(anchor.slot, tip_slot.into_inner());
// Re-running is idempotent: everything is already applied, no error.
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b, &store_b, &chain_b, true,
)
.await
.expect("reconstruct idempotent");
assert!(!channel_was_empty);
assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id);
}
#[tokio::test]
async fn fails_when_channel_serves_a_divergent_block() {
let config = setup_sequencer_config();
let (store, chain) = fresh_store_and_chain(&config);
// Anchor on the local genesis at some slot.
let genesis_id = store.genesis_id();
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
let anchor_slot = 100_u64;
store
.set_zone_anchor(&ZoneAnchorRecord {
slot: anchor_slot,
block_id: genesis_id,
hash: genesis.header.hash,
})
.unwrap();
// The channel serves a different block at the anchor id/slot.
let mut tampered = genesis.clone();
tampered.header.hash = HashType([9_u8; 32]);
let messages = vec![block_to_channel_message(&tampered, anchor_slot)];
let mock = MockBlockPublisher::with_canned_channel(
config.bedrock_config.channel_id,
Some(Slot::from(anchor_slot)),
messages,
);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(result.is_err(), "divergent channel must abort startup");
}
#[tokio::test]
async fn fails_when_channel_is_missing() {
let config = setup_sequencer_config();
let (store, chain) = fresh_store_and_chain(&config);
let genesis_id = store.genesis_id();
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
store
.set_zone_anchor(&ZoneAnchorRecord {
slot: 100,
block_id: genesis_id,
hash: genesis.header.hash,
})
.unwrap();
// Anchor present, but the channel does not exist on the connected chain.
let mock =
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(result.is_err(), "missing channel must abort startup");
}
// The following cases exercise the divergence branches of
// `apply_reconstructed_block` reached with no recorded anchor, so the block's own
// validation fires rather than the up-front `AnchorConsistencyCheck`.
#[tokio::test]
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
let config = setup_sequencer_config();
let (store, chain) = fresh_store_and_chain(&config);
// Fresh store, no anchor. The channel serves a genesis at the same id but a
// different hash — a foreign chain reinscribing genesis.
let mut reinscribed = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
reinscribed.header.hash = HashType([0xAB_u8; 32]);
let messages = vec![block_to_channel_message(&reinscribed, 10)];
let mock = MockBlockPublisher::with_canned_channel(
config.bedrock_config.channel_id,
Some(Slot::from(10)),
messages,
);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a reinscribed genesis with a different hash must abort startup"
);
}
#[tokio::test]
async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
// 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();
// A below-tip block re-served with a corrupted hash: we already hold this id
// with a different hash, so the channel is a different chain.
let below_tip_id = seq.block_store().genesis_id() + 1;
let mut block = seq
.block_store()
.get_block_at_id(below_tip_id)
.unwrap()
.unwrap();
block.header.hash = HashType([0xCD_u8; 32]);
let messages = vec![block_to_channel_message(&block, 10)];
let mock = MockBlockPublisher::with_canned_channel(
config.bedrock_config.channel_id,
Some(Slot::from(10)),
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &seq.store, &seq.chain, true,
)
.await;
assert!(
result.is_err(),
"a diverging below-tip block hash must abort startup"
);
}
#[tokio::test]
async fn fails_when_a_channel_block_is_missing_locally() {
let config = setup_sequencer_config();
let (store, chain) = fresh_store_and_chain(&config);
// A block numbered below our genesis is at/below the local tip yet absent from
// the store — a foreign chain with a lower numbering.
let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
foreign.header.block_id = store.genesis_id() - 1;
let messages = vec![block_to_channel_message(&foreign, 10)];
let mock = MockBlockPublisher::with_canned_channel(
config.bedrock_config.channel_id,
Some(Slot::from(10)),
messages,
);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a channel block below the local range must abort startup"
);
}
#[tokio::test]
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
let config = setup_sequencer_config();
let (store, chain) = fresh_store_and_chain(&config);
// A block claiming an id far past genesis does not chain onto the local tip.
let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
orphan.header.block_id = store.genesis_id() + 5;
let messages = vec![block_to_channel_message(&orphan, 10)];
let mock = MockBlockPublisher::with_canned_channel(
config.bedrock_config.channel_id,
Some(Slot::from(10)),
messages,
);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a non-contiguous channel block must abort startup"
);
}
/// A sequencer config whose genesis funds the bridge account, so replayed bridge
/// deposit transactions have a source balance to mint from.
fn bridge_funded_config() -> SequencerConfig {
let mut config = setup_sequencer_config();
config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }];
config
}
/// Builds an unfulfilled pending deposit event for `recipient`, matching the
/// encoding `build_bridge_deposit_tx_from_event` expects.
fn deposit_event_record(
op_id: [u8; 32],
amount: u64,
recipient: lee::AccountId,
) -> PendingDepositEventRecord {
PendingDepositEventRecord {
deposit_op_id: HashType(op_id),
source_tx_hash: HashType([0_u8; 32]),
amount,
metadata: borsh::to_vec(&DepositMetadataForEncoding {
recipient_id: recipient,
})
.unwrap(),
submitted_in_block_id: None,
}
}
/// Builds a signed public bridge `Withdraw` transaction (the normal user path).
fn build_public_withdraw_tx(
sender: lee::AccountId,
nonce: u128,
amount: u64,
bedrock_account_pk: [u8; 32],
signing_key: &lee::PrivateKey,
) -> LeeTransaction {
let message = lee::public_transaction::Message::try_new(
programs::bridge().id(),
vec![sender, system_accounts::bridge_account_id()],
vec![nonce.into()],
bridge_core::Instruction::Withdraw {
amount,
bedrock_account_pk,
},
)
.unwrap();
let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]);
LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set))
}
/// The cold-start backfill re-delivers an already-finalized deposit into the
/// mempool before reconstruction applies the same deposit block, and the queued
/// mint cannot be pulled back out. Since the bridge program does not dedup on
/// `l1_deposit_op_id`, block production must skip the already-submitted deposit
/// so the vault is minted exactly once.
#[tokio::test]
async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let recipient = initial_public_user_accounts()[0].account_id;
let deposit_amount = 500_u64;
let withdraw_amount = 100_u64;
let bedrock_account_pk = [0x22_u8; 32];
let deposit_op_id = [0x0d_u8; 32];
// Sequencer A produces a deposit block then a withdraw block.
let config_a = bridge_funded_config();
let (mut seq_a, mempool_a) =
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient);
let deposit_tx =
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
mempool_a
.push((TransactionOrigin::Sequencer, deposit_tx.clone()))
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
let withdraw_tx = build_public_withdraw_tx(
recipient,
0,
withdraw_amount,
bedrock_account_pk,
&create_signing_key_for_account1(),
);
mempool_a
.push((TransactionOrigin::User, withdraw_tx.clone()))
.await
.unwrap();
seq_a.produce_new_block().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;
let channel_id = config_a.bedrock_config.channel_id;
let config_b = bridge_funded_config();
let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await;
// Backfill re-delivery: persist the pending record and queue the mint, as
// `on_deposit_event` does, before reconstruction runs.
assert!(
seq_b
.block_store()
.dbio()
.add_pending_deposit_event(deposit_record.clone())
.unwrap()
);
mempool_b
.push((TransactionOrigin::Sequencer, deposit_tx))
.await
.unwrap();
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&seq_b.store,
&seq_b.chain,
true,
)
.await
.expect("reconstruct");
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
seq_b.produce_new_block().await.unwrap();
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
let bridge_id = system_accounts::bridge_account_id();
let state_b = seq_b.chain().lock().unwrap().head_state().clone();
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
for account in [vault_id, bridge_id, recipient] {
assert_eq!(
state_b.get_account_by_id(account).balance,
state_a.get_account_by_id(account).balance,
"reconstructed balance mismatch for {account:?}",
);
}
assert_eq!(
state_b.get_account_by_id(vault_id).balance,
u128::from(deposit_amount),
"deposit must mint into the recipient vault exactly once, not twice"
);
let produced = seq_b
.block_store()
.get_block_at_id(tip_b.id + 1)
.unwrap()
.expect("produced block present");
assert!(
!produced
.body
.transactions
.iter()
.any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))),
"the re-delivered mint must be skipped, not re-included in a block"
);
// A reconstructed withdraw's finalized L1 event was already re-delivered (and
// dropped) by cold-start backfill, so it will never be consumed again.
// Reconstruction must not count it, or the count stays phantom-inflated forever.
let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data");
let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key");
assert!(
!seq_b
.block_store()
.dbio()
.consume_unseen_withdraw_count(key)
.unwrap(),
"reconstruction must not leave a phantom unseen-withdraw count"
);
}
/// A reconstructed withdraw block must not touch the unseen-withdraw counter.
/// Its finalized L1 Withdraw event was already re-delivered (and dropped as a
/// no-op) by cold-start backfill, so counting it during reconstruction would
/// leave a permanent phantom that nothing ever consumes.
#[tokio::test]
async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() {
let recipient = initial_public_user_accounts()[0].account_id;
let withdraw_amount = 100_u64;
let bedrock_account_pk = [0x33_u8; 32];
// Sequencer A produces a single withdraw block; treat its chain as the channel.
let config_a = bridge_funded_config();
let (mut seq_a, mempool_a) =
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
let withdraw_tx = build_public_withdraw_tx(
recipient,
0,
withdraw_amount,
bedrock_account_pk,
&create_signing_key_for_account1(),
);
mempool_a
.push((TransactionOrigin::User, withdraw_tx.clone()))
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data");
let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key");
// Producing the withdraw counts it as unseen, awaiting its L1 event.
assert!(
seq_a
.block_store()
.dbio()
.consume_unseen_withdraw_count(key)
.unwrap(),
"producing a withdraw must count it as unseen"
);
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
let channel_id = config_a.bedrock_config.channel_id;
// Sequencer B reconstructs A's chain from a fresh store.
let config_b = bridge_funded_config();
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
.await
.expect("reconstruct");
assert!(
!store_b.dbio().consume_unseen_withdraw_count(key).unwrap(),
"reconstruction must not leave a phantom unseen-withdraw count"
);
}
/// A deposit whose L1 event was observed (an unfulfilled pending record
/// exists) and whose L2 mint is already contained in a finalized channel block.
/// Reconstruction must reconcile the pending record against that block — marking
/// it submitted so the startup replay does not re-inject it — and apply the mint
/// exactly once.
#[tokio::test]
async fn reconstruction_reconciles_already_finished_deposit() {
let recipient = initial_public_user_accounts()[0].account_id;
let deposit_amount = 400_u64;
let deposit_op_id = [0x1a_u8; 32];
// Sequencer A: a single block that fully processes the bridge deposit.
let config_a = bridge_funded_config();
let (mut seq_a, mempool_a) =
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient);
let deposit_tx =
crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx");
mempool_a
.push((TransactionOrigin::Sequencer, deposit_tx))
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id;
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
let channel_id = config_a.bedrock_config.channel_id;
// Sequencer B: fresh store, but with the *unfulfilled* pending deposit event
// pre-seeded, as the cold-start backfill would when it re-observes this
// already-finalized deposit.
let config_b = bridge_funded_config();
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
assert!(
store_b
.dbio()
.add_pending_deposit_event(deposit_record.clone())
.unwrap()
);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
.await
.expect("reconstruct");
// The mint was applied exactly once.
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
assert_eq!(
chain_b
.lock()
.unwrap()
.head_state()
.get_account_by_id(vault_id)
.balance,
u128::from(deposit_amount),
"already-finished deposit must be applied exactly once"
);
// The pending event is now marked submitted in the reconstructed block, so the
// startup replay would not re-queue it — no double mint on restart.
let record = store_b
.get_unfulfilled_deposit_events()
.unwrap()
.into_iter()
.find(|event| event.deposit_op_id == HashType(deposit_op_id))
.expect("pending deposit event should still be recorded");
assert_eq!(
record.submitted_in_block_id,
Some(deposit_block_id),
"reconstruction must reconcile the already-finished deposit against its channel block"
);
}
#[tokio::test]
async fn committed_local_against_missing_channel_fails_without_anchor() {
// A sequencer that has committed blocks — a non-genesis tip plus a persisted
// checkpoint — but only ever produced (so it never recorded a per-block
// anchor). Restarting it against a wiped/missing channel must still fail,
// driven by the committed-blocks invariant rather than an anchor probe.
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();
assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1);
} // drop releases the store so we can reopen it
// Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in
// for a checkpoint persisted by a prior sync (the mock never emits one).
let (store, chain) = fresh_store_and_chain(&config);
assert!(store.get_zone_anchor().unwrap().is_none());
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
// The channel is gone: no tip, no messages.
let mock =
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, false)
.await;
assert!(
result.is_err(),
"committed blocks against a missing channel must abort startup"
);
}

View File

@ -1,6 +1,7 @@
[package]
name = "sequencer_service"
version = "0.1.0"
default-run = "sequencer_service"
edition = "2024"
license = { workspace = true }
@ -19,6 +20,7 @@ programs.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
anyhow.workspace = true
env_logger.workspace = true
hex.workspace = true
log.workspace = true
tokio.workspace = true
tokio-util.workspace = true

View File

@ -90,6 +90,4 @@ pub trait Rpc {
#[method(name = "getChannelId")]
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
// =============================================================================================
}

View File

@ -0,0 +1,35 @@
//! Prints the sequencer's bedrock signing public key (hex) without booting it.
//!
//! Loads `<home>/bedrock_signing_key` from the given sequencer config, creating
//! the key if it doesn't exist yet, so that a node can be accredited into the
//! channel committee before its first boot.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
#[clap(name = "config")]
config_path: PathBuf,
/// Override the config's home directory, matching the sequencer's --home.
#[clap(long)]
home: Option<PathBuf>,
}
#[expect(
clippy::print_stdout,
reason = "the hex pubkey on stdout is this binary's output"
)]
fn main() -> Result<()> {
let Args { config_path, home } = Args::parse();
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
let home = home.unwrap_or(config.home);
let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
println!("{}", hex::encode(key.public_key().to_bytes()));
Ok(())
}

View File

@ -0,0 +1,71 @@
//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock,
//! signed with `<home>/bedrock_signing_key`, without booting the sequencer.
//!
//! Authorization is holding the admin key file — the L1 rejects non-admin
//! signers. Acceptance is asynchronous: a rejection only shows up in node
//! logs and on-chain behavior.
use std::path::PathBuf;
use anyhow::{Context as _, Result, anyhow};
use clap::Parser;
use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config};
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
#[clap(name = "config")]
config_path: PathBuf,
/// Override the config's home directory, matching the sequencer's --home.
#[clap(long)]
home: Option<PathBuf>,
/// Accredited ed25519 public keys (hex), admin (this node's key) first.
#[clap(long, required = true, value_delimiter = ',')]
keys: Vec<String>,
/// Slots a sequencer's posting turn lasts.
#[clap(long)]
posting_timeframe: u32,
/// Slots after which a stalled turn can be taken over.
#[clap(long)]
posting_timeout: u32,
/// Signatures required for future config changes.
#[clap(long, default_value_t = 1)]
configuration_threshold: u16,
/// Signatures required for channel withdrawals.
#[clap(long, default_value_t = 1)]
withdraw_threshold: u16,
}
#[tokio::main]
async fn main() -> Result<()> {
env_logger::init();
let args = Args::parse();
let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?;
let home = args.home.unwrap_or(config.home);
let signing_key =
sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
let keys = args
.keys
.iter()
.map(|key| parse_key(key))
.collect::<Result<Vec<_>>>()?;
post_channel_config(
&config.bedrock_config,
&signing_key,
keys,
args.posting_timeframe,
args.posting_timeout,
args.configuration_threshold,
args.withdraw_threshold,
)
.await
}
fn parse_key(hex_key: &str) -> Result<Ed25519PublicKey> {
let mut bytes = [0_u8; 32];
hex::decode_to_slice(hex_key, &mut bytes)
.with_context(|| format!("Invalid hex-encoded key {hex_key}"))?;
Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}"))
}

View File

@ -11,10 +11,11 @@ use mempool::MemPoolHandle;
use sequencer_core::SequencerCore;
#[cfg(feature = "standalone")]
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
use sequencer_core::TransactionOrigin;
pub use sequencer_core::config::*;
use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _};
use sequencer_service_rpc::RpcServer as _;
use tokio::{sync::Mutex, task::JoinHandle};
use tokio_util::sync::CancellationToken;
pub mod service;
@ -28,6 +29,9 @@ pub struct SequencerHandle {
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
server_handle: Option<ServerHandle>,
main_loop_handle: JoinHandle<Result<Never>>,
/// Cancelled when the publisher's drive task terminates (e.g. a panicked
/// persist sink); no channel events are processed past that point.
driver_cancellation: CancellationToken,
}
impl SequencerHandle {
@ -35,11 +39,13 @@ impl SequencerHandle {
addr: SocketAddr,
server_handle: ServerHandle,
main_loop_handle: JoinHandle<Result<Never>>,
driver_cancellation: CancellationToken,
) -> Self {
Self {
addr,
server_handle: Some(server_handle),
main_loop_handle,
driver_cancellation,
}
}
@ -53,6 +59,7 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation,
} = &mut self;
let server_handle = server_handle.take().expect("Server handle is set");
@ -65,6 +72,9 @@ impl SequencerHandle {
.context("Main loop task panicked")?
.context("Main loop exited unexpectedly")
}
() = driver_cancellation.cancelled() => {
Err(anyhow!("Publisher drive task terminated"))
}
}
}
@ -78,10 +88,12 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation,
} = self;
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
|| main_loop_handle.is_finished();
|| main_loop_handle.is_finished()
|| driver_cancellation.is_cancelled();
!stopped
}
@ -97,6 +109,7 @@ impl Drop for SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation: _,
} = self;
main_loop_handle.abort();
@ -111,21 +124,23 @@ impl Drop for SequencerHandle {
}
}
pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle> {
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;
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await;
let (sequencer_core, mempool_handle): (SequencerCore, _) =
SequencerCore::start_from_config(config).await;
info!("Sequencer core set up");
let driver_cancellation = sequencer_core.block_publisher().driver_cancellation();
let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
let mempool_handle_for_server = mempool_handle.clone();
let (server_handle, addr) = run_server(
Arc::clone(&seq_core_wrapped),
mempool_handle_for_server,
port,
listen_addr,
max_block_size.as_u64(),
)
.await?;
@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle>
let _ = mempool_handle;
Ok(SequencerHandle::new(addr, server_handle, main_loop_handle))
Ok(SequencerHandle::new(
addr,
server_handle,
main_loop_handle,
driver_cancellation,
))
}
async fn run_server(
sequencer: Arc<Mutex<SequencerCore>>,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
port: u16,
listen_addr: SocketAddr,
max_block_size: u64,
) -> Result<(ServerHandle, SocketAddr)> {
let server = jsonrpsee::server::ServerBuilder::with_config(
@ -153,7 +173,7 @@ async fn run_server(
)
.build(),
)
.build(SocketAddr::from(([0, 0, 0, 0], port)))
.build(listen_addr)
.await
.context("Failed to build RPC server")?;
@ -172,16 +192,15 @@ async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration)
loop {
tokio::time::sleep(block_timeout).await;
info!("Collecting transactions from mempool, block creation");
let mut state = seq_core.lock().await;
let id = {
let mut state = seq_core.lock().await;
state.produce_new_block().await?
};
// Only produce on our turn.
if !state.is_our_turn() {
continue;
}
info!("Our turn: collecting transactions from mempool, creating block");
let id = state.produce_new_block().await?;
info!("Block with id {id} created");
info!("Waiting for new transactions");
}
}

View File

@ -1,4 +1,7 @@
use std::path::PathBuf;
use std::{
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use anyhow::Result;
use clap::Parser;
@ -12,6 +15,14 @@ struct Args {
config_path: PathBuf,
#[clap(short, long, default_value = "3040")]
port: u16,
/// Interface the RPC server binds to. The RPC has no caller auth —
/// bind loopback unless the port is firewalled.
#[clap(long, default_value = "0.0.0.0")]
listen_address: IpAddr,
/// Override the config's home directory (`RocksDB` + bedrock signing key),
/// so multiple instances can share one config file.
#[clap(long)]
home: Option<PathBuf>,
}
#[tokio::main]
@ -22,14 +33,23 @@ struct Args {
async fn main() -> Result<()> {
env_logger::init();
let Args { config_path, port } = Args::parse();
let Args {
config_path,
port,
listen_address,
home,
} = Args::parse();
// TODO: handle this cancellation token more gracefully within Sequencer service
// similar to how we do in Indexer
let cancellation_token = listen_for_shutdown_signal();
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
let sequencer_handle = sequencer_service::run(config, port).await?;
let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?;
if let Some(home) = home {
config.home = home;
}
let sequencer_handle =
sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?;
tokio::select! {
() = cancellation_token.cancelled() => {

View File

@ -40,7 +40,7 @@ impl<BC: BlockPublisherTrait> SequencerService<BC> {
}
#[async_trait]
impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
impl<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::RpcServer
for SequencerService<BC>
{
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
@ -138,8 +138,8 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
async fn get_account_balance(&self, account_id: AccountId) -> Result<u128, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let account = sequencer.state().get_account_by_id(account_id);
Ok(account.balance)
let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance);
Ok(balance)
}
async fn get_transaction(
@ -155,10 +155,12 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
account_ids: Vec<AccountId>,
) -> Result<Vec<Nonce>, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let nonces = account_ids
.into_iter()
.map(|account_id| sequencer.state().get_account_by_id(account_id).nonce)
.collect();
let nonces = sequencer.with_state(|state| {
account_ids
.into_iter()
.map(|account_id| state.get_account_by_id(account_id).nonce)
.collect()
});
Ok(nonces)
}
@ -167,17 +169,18 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
commitments: Vec<Commitment>,
) -> Result<(Vec<Option<MembershipProof>>, CommitmentSetDigest), ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let state = sequencer.state();
let proofs = commitments
.iter()
.map(|commitment| state.get_proof_for_commitment(commitment))
.collect();
Ok((proofs, state.commitment_root()))
Ok(sequencer.with_state(|state| {
let proofs = commitments
.iter()
.map(|commitment| state.get_proof_for_commitment(commitment))
.collect();
(proofs, state.commitment_root())
}))
}
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
Ok(sequencer.state().get_account_by_id(account_id))
Ok(sequencer.with_state(|state| state.get_account_by_id(account_id)))
}
async fn get_program_ids(&self) -> Result<BTreeMap<String, ProgramId>, ErrorObjectOwned> {

View File

@ -19,10 +19,12 @@ use crate::{
},
error::DbError,
sequencer::sequencer_cells::{
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned,
ZoneSdkCheckpointCellRef,
},
};
@ -34,6 +36,10 @@ pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id";
pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta";
/// Key base for storing the zone-sdk sequencer checkpoint (opaque bytes).
pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint";
/// Key base for storing the last channel block read back and verified from
/// Bedrock (its L1 slot + `id`/`hash`) — the anchor for the startup
/// consistency check and the resume point for reconstruction.
pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor";
/// Key base for storing queued deposit events that were not yet
/// fulfilled on L2.
pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events";
@ -42,6 +48,10 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count";
/// Key base for storing the LEE state.
pub const DB_LEE_STATE_KEY: &str = "lee_state";
/// Key base for storing the LEE state at the last L1-finalized block.
pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state";
/// Key base for storing `(id, hash)` of the last L1-finalized block.
pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta";
/// Name of state column family.
pub const CF_LEE_STATE_NAME: &str = "cf_lee_state";
@ -340,8 +350,9 @@ impl RocksDBIO {
self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch)
}
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
self.get::<LatestBlockMetaCellOwned>(()).map(|val| val.0)
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
self.get_opt::<LatestBlockMetaCellOwned>(())
.map(|val| val.map(|cell| cell.0))
}
pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult<Option<Vec<u8>>> {
@ -359,6 +370,14 @@ impl RocksDBIO {
self.del::<ZoneSdkCheckpointCellOwned>(())
}
pub fn get_zone_anchor(&self) -> DbResult<Option<ZoneAnchorRecord>> {
Ok(self.get_opt::<ZoneAnchorCell>(())?.map(|cell| cell.0))
}
pub fn put_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> {
self.put(&ZoneAnchorCell(*anchor), ())
}
pub fn get_pending_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
Ok(self
.get_opt::<PendingDepositEventsCellOwned>(())?
@ -390,6 +409,25 @@ impl RocksDBIO {
Ok(true)
}
/// Marks the given deposit events submitted in `block_id`, in one write.
pub fn mark_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
) -> DbResult<()> {
if deposit_op_ids.is_empty() {
return Ok(());
}
let mut batch = WriteBatch::default();
self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to mark deposit events submitted".to_owned()),
)
})
}
fn mark_pending_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
@ -434,6 +472,14 @@ impl RocksDBIO {
Ok(removed)
}
/// Whether a bridge deposit for `deposit_op_id` is already recorded as
/// included in a block (its pending record is marked submitted).
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
Ok(self.get_pending_deposit_events()?.iter().any(|record| {
record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some()
}))
}
fn increment_unseen_withdraw_count(
&self,
withdrawal: WithdrawalReconciliationKey,
@ -482,23 +528,36 @@ impl RocksDBIO {
}
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
if !first {
// A produced block is the new head tip by construction: pin the
// tip meta and drop any stale higher blocks a preceding reorg left
// behind (mirrors `store_followed_blocks`).
let last_curr_block = self.get_meta_last_block_in_db()?;
if block.header.block_id > last_curr_block {
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
self.put_meta_latest_block_meta_batch(
&BlockMeta {
id: block.header.block_id,
hash: block.header.hash,
},
batch,
)?;
for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block {
self.delete_block_payload(stale_id, batch)?;
}
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?;
}
self.put_block_payload(block, batch)
}
/// Stages deletion of a block payload into `batch`.
fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
batch.delete_cf(
&cf_block,
borsh::to_vec(&block_id).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned()))
})?,
);
Ok(())
}
/// Stages just the block payload into `batch`, without touching the tip meta.
fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
batch.put_cf(
&cf_block,
borsh::to_vec(&block.header.block_id).map_err(|err| {
@ -516,6 +575,26 @@ impl RocksDBIO {
.map(|opt| opt.map(|val| val.0))
}
/// `(state, meta)` at the last L1-finalized block; `None` until the first
/// finalization is observed.
pub fn get_final_snapshot(&self) -> DbResult<Option<(V03State, BlockMeta)>> {
let Some(meta) = self.get_opt::<FinalBlockMetaCellOwned>(())? else {
return Ok(None);
};
let state = self.get::<FinalLeeStateCellOwned>(())?;
Ok(Some((state.0, meta.0)))
}
fn put_final_snapshot_batch(
&self,
state: &V03State,
meta: &BlockMeta,
batch: &mut WriteBatch,
) -> DbResult<()> {
self.put_batch(&FinalLeeStateCellRef(state), (), batch)?;
self.put_batch(&FinalBlockMetaCellRef(meta), (), batch)
}
pub fn get_lee_state(&self) -> DbResult<V03State> {
self.get::<LEEStateCellOwned>(()).map(|val| val.0)
}
@ -612,6 +691,97 @@ impl RocksDBIO {
Ok(())
}
/// One-block form of [`Self::store_followed_blocks`], with the block as the
/// head tip and no final snapshot. Production always uses the batch form.
#[cfg(test)]
fn store_followed_block(
&self,
block: &Block,
state: &V03State,
finalized: bool,
) -> DbResult<()> {
self.store_followed_blocks(
&[(block, finalized)],
Some(&BlockMeta::from(block)),
state,
None,
)
}
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
/// the optional final-tier snapshot in one atomic write.
///
/// The tip meta is pinned to `head_tip`, and blocks stored above it (left
/// behind by a net-shortening reorg) are deleted in the same write, so
/// restart replay never walks past the tip.
///
/// Per block: skips the payload write when the store already holds it (by
/// id and hash), unless `finalized` is set, which rewrites it with the
/// finalized status. A no-op update (nothing to write, tip unchanged)
/// writes nothing.
///
/// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before*
/// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state
/// and orphan reverts in one batch) is a follow-up.
pub fn store_followed_blocks(
&self,
blocks: &[(&Block, bool)],
head_tip: Option<&BlockMeta>,
state: &V03State,
final_snapshot: Option<(&V03State, &BlockMeta)>,
) -> DbResult<()> {
let last_block_in_db = self.get_meta_last_block_in_db()?;
let mut batch = WriteBatch::default();
for (block, finalized) in blocks {
let already_stored = self
.get_block(block.header.block_id)?
.filter(|stored| stored.header.hash == block.header.hash);
let mut to_write = match already_stored {
Some(_) if !finalized => continue,
Some(stored) => stored,
None => (*block).clone(),
};
if *finalized {
to_write.bedrock_status = BedrockStatus::Finalized;
}
self.put_block_payload(&to_write, &mut batch)?;
}
// `head_tip` is `None` only for a chain holding no blocks at all, which
// implies nothing was applied — and the store, created with genesis,
// cannot represent it. No tip to pin, nothing to persist.
let Some(tip) = head_tip else {
debug_assert!(batch.is_empty() && final_snapshot.is_none());
return Ok(());
};
// A shrink-only update (orphans without adopted replacements) has no
// payloads to write but must still rewind the tip meta, or the stored
// state tears against the stale disk head on the next produce.
if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db {
return Ok(());
}
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
self.delete_block_payload(stale_id, &mut batch)?;
}
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
self.put_lee_state_in_db_batch(state, &mut batch)?;
if let Some((final_state, final_meta)) = final_snapshot {
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;
}
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to write followed blocks batch".to_owned()),
)
})
}
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
let cf_block = self.block_column();
self.db
@ -661,3 +831,6 @@ impl RocksDBIO {
})
}
}
#[cfg(test)]
mod tests;

View File

@ -7,9 +7,10 @@ use crate::{
cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell},
error::DbError,
sequencer::{
CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY,
DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY,
DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY,
DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY,
DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
},
};
@ -43,6 +44,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> {
}
}
/// State at the last L1-finalized block, written atomically with
/// [`FinalBlockMetaCellRef`].
#[derive(BorshDeserialize)]
pub struct FinalLeeStateCellOwned(pub V03State);
impl SimpleStorableCell for FinalLeeStateCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
}
impl SimpleReadableCell for FinalLeeStateCellOwned {}
#[derive(BorshSerialize)]
pub struct FinalLeeStateCellRef<'state>(pub &'state V03State);
impl SimpleStorableCell for FinalLeeStateCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
}
impl SimpleWritableCell for FinalLeeStateCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned()))
})
}
}
/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`].
#[derive(BorshDeserialize)]
pub struct FinalBlockMetaCellOwned(pub BlockMeta);
impl SimpleStorableCell for FinalBlockMetaCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for FinalBlockMetaCellOwned {}
#[derive(BorshSerialize)]
pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta);
impl SimpleStorableCell for FinalBlockMetaCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleWritableCell for FinalBlockMetaCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize final block meta".to_owned()),
)
})
}
}
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct LastFinalizedBlockIdCell(pub Option<u64>);
@ -132,6 +199,39 @@ impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> {
}
}
/// The last channel block read back and verified from Bedrock.
///
/// Holds its L1 inscription `slot` plus the block's `id`/`hash`, and serves as
/// both the anchor for the startup consistency check and the resume point for
/// reconstruction. `slot` is stored as a raw `u64` because the zone-sdk `Slot`
/// does not derive borsh; the caller converts to/from `Slot`.
#[derive(Debug, Clone, Copy, BorshSerialize, BorshDeserialize)]
pub struct ZoneAnchorRecord {
pub slot: u64,
pub block_id: u64,
pub hash: HashType,
}
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct ZoneAnchorCell(pub ZoneAnchorRecord);
impl SimpleStorableCell for ZoneAnchorCell {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_ZONE_CURSOR_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for ZoneAnchorCell {}
impl SimpleWritableCell for ZoneAnchorCell {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize zone cursor".to_owned()))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PendingDepositEventRecord {
pub deposit_op_id: HashType,

View File

@ -0,0 +1,283 @@
use common::test_utils::produce_dummy_block;
use lee::{Account, AccountId};
use tempfile::tempdir;
use super::*;
fn marker_id() -> AccountId {
AccountId::new([1; 32])
}
/// A state distinguishable by the marker account's balance, so tests can tell
/// which snapshot a write persisted.
///
/// TODO: is this a bit too much of a hot-fix for test snapshot?
fn state_with_balance(balance: u128) -> V03State {
V03State::new().with_public_accounts([(
marker_id(),
Account {
balance,
..Account::default()
},
)])
}
fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
let genesis = produce_dummy_block(1, None, vec![]);
let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap();
(dbio, genesis)
}
fn stored_balance(dbio: &RocksDBIO) -> u128 {
dbio.get_lee_state()
.unwrap()
.get_account_by_id(marker_id())
.balance
}
#[test]
fn store_followed_block_persists_new_block_and_state() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(
dbio.latest_block_meta().unwrap().expect("meta is set").id,
2
);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn store_followed_block_finalized_marks_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[test]
fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
dbio.store_followed_block(&block2, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(
matches!(stored.bedrock_status, BedrockStatus::Finalized),
"re-delivery must not demote a finalized block"
);
assert_eq!(
stored_balance(&dbio),
200,
"re-delivery must not overwrite the persisted state"
);
}
#[test]
fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Block 2 is already stored (own production); one update then finalizes it
// and adopts block 3.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
let head_tip = BlockMeta {
id: 3,
hash: block3.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true), (&block3, false)],
Some(&head_tip),
&state_with_balance(300),
None,
)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored");
assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending));
// Meta and state land together on the last block of the batch.
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 3);
assert_eq!(meta.hash, block3.header.hash);
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Fresh store: no finalization observed yet.
assert!(dbio.get_final_snapshot().unwrap().is_none());
// A follow update that finalizes block 2 lands the snapshot in the same batch.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let final_meta = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true)],
Some(&final_meta),
&state_with_balance(300),
Some((&state_with_balance(200), &final_meta)),
)
.unwrap();
let (final_state, meta) = dbio
.get_final_snapshot()
.unwrap()
.expect("final snapshot is stored");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200);
// The head state is stored independently of the final snapshot.
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn store_followed_block_overwrites_competing_block_at_same_id() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
// A reorg replaces block 2: the competing block wins the slot.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
dbio.store_followed_block(&block2b, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2b.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(stored_balance(&dbio), 300);
// The tip meta must follow the reorg winner, or a restart seeds the chain
// from the orphaned block's hash.
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
}
#[test]
fn net_shortening_reorg_drops_stale_blocks() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// A shorter competing chain wins: block 2 is replaced, block 3 gets no
// replacement.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
let head_tip = BlockMeta {
id: 2,
hash: block2b.header.hash,
};
dbio.store_followed_blocks(
&[(&block2b, false)],
Some(&head_tip),
&state_with_balance(400),
None,
)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(
dbio.get_block(3).unwrap().is_none(),
"stale block above the new head must be deleted, or restart replay panics on its broken link"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}
#[test]
fn shrink_only_reorg_rewinds_tip_meta() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Orphan-only update: block 3 falls off the branch with no replacement.
let head_tip = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None)
.unwrap();
assert!(
dbio.get_block(3).unwrap().is_none(),
"the orphaned block must not survive the tip rewind"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn produced_block_below_disk_head_pins_meta_and_prunes() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Producing at height 2 while the disk head is still 3: the produce path
// pins the tip meta to the produced block and drops the stale suffix in
// the same write, mirroring the follow path.
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400))
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(dbio.get_block(3).unwrap().is_none());
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}

View File

@ -25,7 +25,6 @@ system_accounts.workspace = true
associated_token_account_core.workspace = true
bip39.workspace = true
pyo3.workspace = true
rpassword = "7"
zeroize.workspace = true

View File

@ -1,14 +1,14 @@
use core::fmt;
use anyhow::Result;
use keycard_wallet::{KeycardWallet, python_path};
use keycard_wallet::KeycardWallet;
use lee::{AccountId, PrivateKey, PublicKey, Signature};
use lee_core::{
Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof,
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
account::{AccountWithMetadata, Nonce},
compute_digest_for_path,
encryption::ViewingPublicKey,
encryption::{ViewTag, ViewingPublicKey},
};
use rand::{RngCore as _, rngs::OsRng};
@ -237,14 +237,7 @@ impl AccountManager {
if pin.is_none() {
pin = Some(
crate::helperfunctions::read_pin()
.map_err(|e| {
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<
pyo3::exceptions::PyRuntimeError,
_,
>(
e.to_string()
))
})?
.map_err(ExecutionFailureKind::SignError)?
.as_str()
.to_owned(),
);
@ -263,7 +256,7 @@ impl AccountManager {
identifier,
} => {
let acc = lee_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, (&npk, &vpk, identifier));
let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier));
let mut random_seed: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut random_seed);
let pre = AccountPreparedData {
@ -411,6 +404,7 @@ impl AccountManager {
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
vpk: pre.vpk.clone(),
random_seed: pre.random_seed,
view_tag: random_view_tag(),
nsk,
membership_proof,
identifier: pre.identifier,
@ -430,6 +424,7 @@ impl AccountManager {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: pre.vpk.clone(),
random_seed: pre.random_seed,
view_tag: random_view_tag(),
nsk,
membership_proof,
identifier: pre.identifier,
@ -442,7 +437,7 @@ impl AccountManager {
identifier: pre.identifier,
commitment_root: self.dummy_commitment_root,
},
(None, _) => InputAccountIdentity::PrivateUnauthorized {
(None, _) => InputAccountIdentity::PrivateForeignInit {
vpk: pre.vpk.clone(),
random_seed: pre.random_seed,
npk: pre.npk,
@ -498,17 +493,11 @@ impl AccountManager {
.collect();
if let Some(pin) = self.pin.clone() {
pyo3::Python::attach(|py| -> pyo3::PyResult<()> {
python_path::add_python_path(py)?;
let wallet = KeycardWallet::new(py)?;
wallet.connect(py, &pin)?;
for path in keycard_paths {
sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?);
}
let _res = wallet.close_session(py);
Ok(())
})
.map_err(anyhow::Error::from)?;
let mut wallet = KeycardWallet::new()?;
wallet.connect(&pin)?;
for path in keycard_paths {
sigs.push(wallet.sign_message_for_path(path, &message_hash)?);
}
}
Ok(sigs)
@ -650,6 +639,13 @@ fn validate_proofs_against_root(
Ok(())
}
/// Generate random byte using OS randomness.
fn random_view_tag() -> ViewTag {
let mut byte: [u8; 1] = [0; 1];
OsRng.fill_bytes(&mut byte);
byte[0]
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -111,8 +111,9 @@ pub enum NewSubcommand {
#[arg(long, requires = "pda")]
/// Program ID as hex string.
program_id: Option<String>,
#[arg(long, requires = "pda")]
/// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family.
#[arg(long)]
/// Identifier selecting the shared account.
/// Co-owners must supply the same value to derive the same account.
/// Defaults to a random value if not specified.
identifier: Option<u128>,
},
@ -197,7 +198,7 @@ impl NewSubcommand {
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
fn handle_private_gms(
async fn handle_private_gms(
group: &Label,
label: Option<Label>,
pda: bool,
@ -229,14 +230,22 @@ impl NewSubcommand {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
wallet_core.create_shared_pda_account(
group.clone(),
pda_seed,
pid,
identifier.unwrap_or_else(rand::random),
)?
wallet_core
.create_shared_pda_account(
group.clone(),
pda_seed,
pid,
identifier.unwrap_or_else(rand::random),
)
.await?
} else if let Some(id) = identifier {
wallet_core
.create_shared_regular_account_with_identifier(group.clone(), id)
.await?
} else {
wallet_core.create_shared_regular_account(group.clone())?
wallet_core
.create_shared_regular_account(group.clone())
.await?
};
if let Some(label) = label {
@ -295,15 +304,18 @@ impl WalletSubcommand for NewSubcommand {
seed,
program_id,
identifier,
} => Self::handle_private_gms(
&group,
label,
pda,
seed,
program_id,
identifier,
wallet_core,
),
} => {
Self::handle_private_gms(
&group,
label,
pda,
seed,
program_id,
identifier,
wallet_core,
)
.await
}
Self::PrivateAccountsKey { cci } => Self::handle_private_accounts_key(cci, wallet_core),
}
}

View File

@ -5,8 +5,7 @@
use anyhow::Result;
use clap::Subcommand;
use keycard_wallet::{KeycardWallet, clear_pairing, python_path};
use pyo3::prelude::*;
use keycard_wallet::KeycardWallet;
use crate::{
WalletCore,
@ -18,9 +17,16 @@ use crate::{
pub enum KeycardSubcommand {
Available,
Connect,
Disconnect,
Init,
Load,
/// Wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be
/// re-initialized with `wallet keycard init`. Irreversibly destroys any keys currently on
/// the card. Requires --confirm.
FactoryReset {
/// Confirm that the card's current keys should be irreversibly destroyed.
#[arg(long)]
confirm: bool,
},
/// Retrieve the private keys (NSK, VSK) for a given BIP-32 key path.
///
/// Prints raw key material to stdout — intended for debugging only.
@ -39,22 +45,11 @@ pub enum KeycardSubcommand {
impl KeycardSubcommand {
fn handle_available(_wallet_core: &mut WalletCore) -> SubcommandReturnValue {
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::available`: unable to setup python path");
let wallet = KeycardWallet::new(py)
.expect("`wallet::keycard::available`: invalid data received for pin");
let available = wallet
.is_unpaired_keycard_available(py)
.expect("`wallet::keycard::available`: received invalid data from Keycard wrapper");
if available {
println!("\u{2705} Keycard is available.");
} else {
println!("\u{274c} Keycard is not available.");
}
});
if KeycardWallet::is_keycard_available() {
println!("\u{2705} Keycard is available.");
} else {
println!("\u{274c} Keycard is not available.");
}
SubcommandReturnValue::Empty
}
@ -62,45 +57,9 @@ impl KeycardSubcommand {
fn handle_connect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
let pin = read_pin()?;
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::connect`: unable to setup python path");
let wallet = KeycardWallet::new(py)
.expect("`wallet::keycard::connect`: invalid keycard wallet provided");
wallet
.connect(py, &pin)
.expect("`wallet::keycard::connect`: failed to connect to keycard");
println!("\u{2705} Keycard paired and ready.");
drop(wallet.close_session(py));
});
Ok(SubcommandReturnValue::Empty)
}
fn handle_disconnect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
let pin = read_pin()?;
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::disconnect`: unable to setup python path");
let wallet = KeycardWallet::new(py)
.expect("`wallet::keycard::disconnect`: invalid keycard wallet provided");
wallet
.connect(py, &pin)
.expect("`wallet::keycard::disconnect`: failed to open session");
wallet
.disconnect(py)
.expect("`wallet::keycard::disconnect`: failed to unpair keycard");
clear_pairing();
println!("\u{2705} Keycard unpaired and pairing cleared.");
});
let mut wallet = KeycardWallet::new()?;
wallet.connect(&pin)?;
println!("\u{2705} Keycard connected and PIN verified.");
Ok(SubcommandReturnValue::Empty)
}
@ -108,22 +67,12 @@ impl KeycardSubcommand {
fn handle_init(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
let pin = read_pin()?;
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::init`: unable to setup python path");
let mut wallet = KeycardWallet::new()?;
let puk = wallet.initialize(&pin)?;
let wallet = KeycardWallet::new(py)
.expect("`wallet::keycard::init`: invalid keycard wallet provided");
let initialized = wallet
.initialize(py, &pin)
.expect("`wallet::keycard::init`: failed to initialize keycard");
if initialized {
clear_pairing();
println!("\u{2705} Keycard initialized successfully.");
}
});
println!("Keycard PUK: {puk}");
println!("Record this PUK and store it somewhere safe. It cannot be recovered.");
println!("\u{2705} Keycard initialized successfully.");
Ok(SubcommandReturnValue::Empty)
}
@ -132,25 +81,31 @@ impl KeycardSubcommand {
let pin = read_pin()?;
let mnemonic = read_mnemonic()?;
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::load`: unable to setup python path");
let mut wallet = KeycardWallet::new()?;
wallet.connect(&pin)?;
println!("\u{2705} Keycard is now connected to wallet.");
let wallet = KeycardWallet::new(py)
.expect("`wallet::keycard::load`: invalid keycard wallet provided");
wallet.load_mnemonic(&mnemonic)?;
println!("\u{2705} Mnemonic phrase loaded successfully.");
wallet
.connect(py, &pin)
.expect("`wallet::keycard::load`: failed to connect to keycard");
Ok(SubcommandReturnValue::Empty)
}
println!("\u{2705} Keycard is now connected to wallet.");
if wallet.load_mnemonic(py, &mnemonic).is_ok() {
println!("\u{2705} Mnemonic phrase loaded successfully.");
} else {
println!("\u{274c} Failed to load mnemonic phrase.");
}
drop(wallet.close_session(py));
});
fn handle_factory_reset(
confirm: bool,
_wallet_core: &mut WalletCore,
) -> Result<SubcommandReturnValue> {
if !confirm {
eprintln!(
"WARNING: pass --confirm to factory-reset the keycard. \
This irreversibly destroys any keys currently loaded on it."
);
return Ok(SubcommandReturnValue::Empty);
}
let mut wallet = KeycardWallet::new()?;
wallet.factory_reset()?;
println!("\u{2705} Keycard factory-reset. Run `wallet keycard init` to reinitialize it.");
Ok(SubcommandReturnValue::Empty)
}
@ -189,9 +144,9 @@ impl WalletSubcommand for KeycardSubcommand {
match self {
Self::Available => Ok(Self::handle_available(wallet_core)),
Self::Connect => Self::handle_connect(wallet_core),
Self::Disconnect => Self::handle_disconnect(wallet_core),
Self::Init => Self::handle_init(wallet_core),
Self::Load => Self::handle_load(wallet_core),
Self::FactoryReset { confirm } => Self::handle_factory_reset(confirm, wallet_core),
#[cfg(feature = "keycard-debug")]
Self::GetPrivateKeys { key_path, reveal } => {
Self::handle_get_private_keys(&key_path, reveal, wallet_core)

View File

@ -51,7 +51,6 @@ pub mod helperfunctions;
pub mod multi_client;
pub mod poller;
pub mod program_facades;
pub mod signing;
pub mod storage;
pub const HOME_DIR_ENV_VAR: &str = "LEE_WALLET_HOME_DIR";
@ -86,8 +85,6 @@ pub enum ExecutionFailureKind {
TransactionBuildError(#[from] lee::error::LeeError),
#[error("Failed to sign transaction: {0}")]
SignError(anyhow::Error),
#[error(transparent)]
KeycardError(#[from] pyo3::PyErr),
#[error("Sending transaction failed for each client")]
MultiSequencerTransactionSendError,
}
@ -273,7 +270,7 @@ impl WalletCore {
pub async fn client_rotation(&mut self) -> Result<()> {
self.multi_sequencer_client
.update_statistics(&mut self.statistics)
.await;
.await?;
self.multi_sequencer_client
.rotate(
@ -426,8 +423,60 @@ impl WalletCore {
);
}
/// Re-derive a shared account's state by scanning its keypair from genesis to the current
/// synced block. The init note's nullifier is deterministic on ID, so we await it and let
/// the nullifier pass decode the init and every subsequent update.
///
/// If no initialization is found, will return `Ok` and default to usual hot-sync.
async fn catch_up_shared_account(&mut self, account_id: AccountId) -> Result<()> {
use futures::TryStreamExt as _;
let cursor = self.storage.last_synced_block();
if cursor == 0
|| self
.storage
.key_chain()
.shared_private_account(account_id)
.is_none()
{
return Ok(());
}
info!("Scanning shared account {account_id:#?} from genesis to block {cursor}");
let mut index = NullifierIndex::default();
index.track_initialization(account_id);
let poller = self.poller_helm();
let mut blocks = std::pin::pin!(poller.poll_block_range(1..=cursor));
while let Some(block) = blocks.try_next().await? {
for tx in block.body.transactions {
let LeeTransaction::PrivacyPreserving(pp_tx) = &tx else {
continue;
};
pp_tx.message.validate_note_lengths()?;
// Sync updates while watching only the init nullifier.
self.storage
.key_chain_mut()
.sync_updates_via_nullifiers(&pp_tx.message, &mut index);
}
}
let now = self.storage.last_synced_block();
// This is a defence-in-depth. Currently during the async update the cursor
// cannot advance. However, de-sync can be possible later. This hard error
// will signal this.
if now != cursor {
return Err(anyhow::anyhow!(
"Shared-account catched-up to {cursor} with a cursor de-sync advancing to {now}"
));
}
Ok(())
}
/// Create a shared PDA account from a group's GMS. Returns the `AccountId` and derived keys.
pub fn create_shared_pda_account(
pub async fn create_shared_pda_account(
&mut self,
group_name: Label,
pda_seed: lee_core::program::PdaSeed,
@ -452,6 +501,7 @@ impl WalletCore {
Some(pda_seed),
Some(program_id),
);
self.catch_up_shared_account(account_id).await?;
Ok(SharedAccountInfo {
account_id,
@ -460,14 +510,21 @@ impl WalletCore {
})
}
/// Create a shared regular private account from a group's GMS. Returns the `AccountId` and
/// derived keys. The derivation seed is computed deterministically from a random identifier.
pub fn create_shared_regular_account(
/// Create a shared regular private account from a group's GMS with a random identifier.
pub async fn create_shared_regular_account(
&mut self,
group_name: Label,
) -> Result<SharedAccountInfo> {
let identifier: lee_core::Identifier = rand::random();
self.create_shared_regular_account_with_identifier(group_name, rand::random())
.await
}
/// Create a shared regular private account from a group's GMS under the given `identifier`.
pub async fn create_shared_regular_account_with_identifier(
&mut self,
group_name: Label,
identifier: lee_core::Identifier,
) -> Result<SharedAccountInfo> {
let holder = self
.storage
.key_chain()
@ -480,6 +537,7 @@ impl WalletCore {
let account_id = AccountId::from((&npk, &vpk, identifier));
self.register_shared_account(account_id, group_name, identifier, None, None);
self.catch_up_shared_account(account_id).await?;
Ok(SharedAccountInfo {
account_id,

View File

@ -159,7 +159,11 @@ impl MultiSequencerClient {
)
.ok_or_else(|| anyhow::anyhow!("Failed to find leader"))?;
assert_ne!(leader_list.len(), 0);
if leader_list.is_empty() {
anyhow::bail!(
"Leader search algorithm failure: Sorted out all clients during leader search"
);
}
log::info!("Chosen leaders is {leader_list:?}");
@ -331,18 +335,20 @@ impl MultiSequencerClient {
}
/// Update statistics of a leader, clear statistic updates log.
pub async fn update_statistics(&self, statistics: &mut HashMap<Url, Statistics>) {
pub async fn update_statistics(&self, statistics: &mut HashMap<Url, Statistics>) -> Result<()> {
let mut statistic_updates = self.statistic_updates.write().await;
#[expect(clippy::iter_over_hash_type, reason = "Ordering is unnecesary here")]
for (addr, statistic_updates_vec) in statistic_updates.iter() {
let leader_statistic = statistics
.get_mut(addr)
.expect("Leader statistic must be present after setup");
.ok_or_else(|| anyhow::anyhow!("Leader statistic must be present after setup"))?;
leader_statistic.apply_updates(statistic_updates_vec.as_slice());
}
statistic_updates.clear();
Ok(())
}
}

View File

@ -1,33 +0,0 @@
use keycard_wallet::{KeycardWallet, python_path};
use pyo3::Python;
/// Lazily opens and reuses a single Keycard session for all keycard signers in one transaction.
pub struct KeycardSessionContext {
pin: String,
wallet: Option<KeycardWallet>,
}
impl KeycardSessionContext {
pub fn new(pin: impl Into<String>) -> Self {
Self {
pin: pin.into(),
wallet: None,
}
}
pub fn get_or_connect(&mut self, py: Python<'_>) -> pyo3::PyResult<&KeycardWallet> {
if self.wallet.is_none() {
python_path::add_python_path(py)?;
let wallet = KeycardWallet::new(py)?;
wallet.connect(py, &self.pin)?;
self.wallet = Some(wallet);
}
Ok(self.wallet.as_ref().expect("wallet was just inserted"))
}
pub fn close(self, py: Python<'_>) {
if let Some(w) = self.wallet {
let _res = w.close_session(py);
}
}
}

Some files were not shown because too many files have changed in this diff Show More