mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-07-24 00:23:13 +00:00
Merge origin/dev
This commit is contained in:
commit
124bbee6c6
@ -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"
|
||||
|
||||
1
.github/docker/ci.Dockerfile
vendored
1
.github/docker/ci.Dockerfile
vendored
@ -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
4
.gitignore
vendored
@ -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
|
||||
|
||||
572
Cargo.lock
generated
572
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ members = [
|
||||
|
||||
"lez",
|
||||
"lez/system_accounts",
|
||||
"lez/chain_state",
|
||||
"lez/sequencer/core",
|
||||
"lez/sequencer/service",
|
||||
"lez/sequencer/service/protocol",
|
||||
@ -32,7 +33,6 @@ members = [
|
||||
"lez/wallet",
|
||||
"lez/wallet-ffi",
|
||||
"lez/common",
|
||||
"lez/chain_consistency",
|
||||
"lez/programs",
|
||||
"lez/programs/amm",
|
||||
"lez/programs/associated_token_account",
|
||||
@ -73,7 +73,7 @@ members = [
|
||||
lee = { path = "lee/state_machine" }
|
||||
lee_core = { path = "lee/state_machine/core" }
|
||||
common = { path = "lez/common" }
|
||||
chain_consistency = { path = "lez/chain_consistency" }
|
||||
chain_state = { path = "lez/chain_state" }
|
||||
mempool = { path = "lez/mempool" }
|
||||
storage = { path = "lez/storage" }
|
||||
key_protocol = { path = "lee/key_protocol" }
|
||||
@ -180,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",
|
||||
@ -201,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"] }
|
||||
|
||||
|
||||
8
Justfile
8
Justfile
@ -64,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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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`).
|
||||

|
||||
- **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
|
||||
}
|
||||
```
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
//! needs source verification, where a value-bearing target checks the message
|
||||
//! originated from `bridge_lock`; that is out of scope for the demo.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
@ -24,14 +24,14 @@ use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
||||
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);
|
||||
@ -95,9 +95,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")?;
|
||||
|
||||
@ -171,14 +169,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 {
|
||||
|
||||
@ -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::{SequencerSetup, setup_bedrock_node},
|
||||
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]
|
||||
@ -73,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")
|
||||
}
|
||||
|
||||
@ -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::{SequencerSetup, setup_bedrock_node},
|
||||
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);
|
||||
@ -123,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 {
|
||||
|
||||
@ -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::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
||||
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);
|
||||
@ -83,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")?;
|
||||
|
||||
@ -130,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,
|
||||
|
||||
236
integration_tests/tests/multi_sequencer.rs
Normal file
236
integration_tests/tests/multi_sequencer.rs
Normal 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(())
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
reason = "Integration tests live at crate root and don't care about these lints"
|
||||
)]
|
||||
|
||||
use std::{net::SocketAddr, path::Path, time::Duration};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
@ -16,11 +16,11 @@ 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, SequencerClientBuilder};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use test_fixtures::{
|
||||
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
||||
setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||
};
|
||||
use tokio::test;
|
||||
|
||||
@ -41,13 +41,6 @@ fn slow_blocks() -> SequencerPartialConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = addr_to_url(UrlProtocol::Http, addr).context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@ -294,6 +294,7 @@ 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,
|
||||
|
||||
@ -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,7 +112,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Regular(*identifier),
|
||||
&npk,
|
||||
*view_tag,
|
||||
vpk,
|
||||
random_seed,
|
||||
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,
|
||||
|
||||
@ -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,6 +42,7 @@ pub enum InputAccountIdentity {
|
||||
PrivateAuthorizedUpdate {
|
||||
vpk: ViewingPublicKey,
|
||||
random_seed: [u8; 32],
|
||||
view_tag: ViewTag,
|
||||
nsk: NullifierSecretKey,
|
||||
membership_proof: MembershipProof,
|
||||
identifier: Identifier,
|
||||
@ -79,6 +80,7 @@ pub enum InputAccountIdentity {
|
||||
PrivatePdaUpdate {
|
||||
vpk: ViewingPublicKey,
|
||||
random_seed: [u8; 32],
|
||||
view_tag: ViewTag,
|
||||
nsk: NullifierSecretKey,
|
||||
membership_proof: MembershipProof,
|
||||
identifier: Identifier,
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
};
|
||||
@ -198,6 +198,7 @@ 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)
|
||||
@ -242,6 +243,76 @@ 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(), false, account_id);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![account],
|
||||
Program::serialize_instruction(()).unwrap(),
|
||||
vec![InputAccountIdentity::PrivateUnauthorized {
|
||||
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();
|
||||
@ -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,
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -64,6 +64,7 @@ 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,
|
||||
@ -113,6 +114,7 @@ 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,
|
||||
@ -162,6 +164,7 @@ 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,
|
||||
@ -211,6 +214,7 @@ 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,
|
||||
@ -260,6 +264,7 @@ 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,
|
||||
@ -307,6 +312,7 @@ 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,
|
||||
@ -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,
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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());
|
||||
@ -330,6 +331,7 @@ 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)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = [(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
//! Validating and applying channel L2 blocks to a `V03State`.
|
||||
//!
|
||||
//! These primitives are shared by the consumers that reconstruct L2 state from
|
||||
//! the Bedrock channel.
|
||||
|
||||
use common::{
|
||||
HashType,
|
||||
block::Block,
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
};
|
||||
use lee::{GENESIS_BLOCK_ID, V03State};
|
||||
use lee_core::BlockId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Why a channel L2 block could not be validated or applied.
|
||||
///
|
||||
/// Persisted in `RocksDB` (via the Indexer's stall reason), 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}")]
|
||||
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
|
||||
Deserialize(String),
|
||||
#[error("Unexpected block id: expected {expected}, got {got}")]
|
||||
UnexpectedBlockId { expected: BlockId, got: BlockId },
|
||||
#[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
|
||||
BrokenChainLink {
|
||||
expected_prev: HashType,
|
||||
got_prev: HashType,
|
||||
},
|
||||
#[error("Block hash mismatch: computed {computed}, header {header}")]
|
||||
HashMismatch {
|
||||
computed: HashType,
|
||||
header: HashType,
|
||||
},
|
||||
#[error("Block has no transactions")]
|
||||
EmptyBlock,
|
||||
#[error("Last transaction must be the public clock invocation for the block timestamp")]
|
||||
InvalidClockTransaction,
|
||||
#[error("Genesis block must contain only public transactions")]
|
||||
NonPublicGenesisTransaction,
|
||||
#[error("State transition failed at transaction {tx_index}: {reason}")]
|
||||
StateTransition {
|
||||
/// Index of the failing transaction within the block body.
|
||||
tx_index: u64,
|
||||
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
|
||||
///
|
||||
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
|
||||
/// cannot store it directly.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BlockIngestError {
|
||||
/// Whether the failure may be transient rather than a property of the block.
|
||||
///
|
||||
/// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine
|
||||
/// state-transition rejections with infra failures (risc0 executor teardown,
|
||||
/// storage errors). Once it carries a structured cause, narrow this so only
|
||||
/// infra failures retry.
|
||||
#[must_use]
|
||||
pub const fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::StateTransition { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The last successfully applied block a candidate must extend.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct Tip {
|
||||
pub block_id: BlockId,
|
||||
pub hash: HashType,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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`.
|
||||
pub fn apply_block(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 tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serializes_and_round_trips_externally_tagged() {
|
||||
let err = BlockIngestError::UnexpectedBlockId {
|
||||
expected: 5,
|
||||
got: 7,
|
||||
};
|
||||
let value = serde_json::to_value(&err).expect("serialize");
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } })
|
||||
);
|
||||
let back: BlockIngestError = serde_json::from_value(value).expect("deserialize");
|
||||
assert!(matches!(
|
||||
back,
|
||||
BlockIngestError::UnexpectedBlockId {
|
||||
expected: 5,
|
||||
got: 7
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
//! Reconstructing and verifying L2 chain state from a Bedrock (L1) channel.
|
||||
|
||||
pub use apply::{BlockIngestError, Tip, apply_block, validate_against_tip};
|
||||
pub use consistency::{
|
||||
Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency,
|
||||
};
|
||||
|
||||
pub mod apply;
|
||||
pub mod consistency;
|
||||
@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "chain_consistency"
|
||||
name = "chain_state"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@ -11,9 +11,9 @@ workspace = true
|
||||
common.workspace = true
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
borsh.workspace = true
|
||||
futures.workspace = true
|
||||
@ -23,4 +23,5 @@ thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
testnet_initial_state.workspace = true
|
||||
serde_json.workspace = true
|
||||
308
lez/chain_state/src/apply.rs
Normal file
308
lez/chain_state/src/apply.rs
Normal 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
1034
lez/chain_state/src/chain.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
lez/chain_state/src/ingest_error.rs
Normal file
80
lez/chain_state/src/ingest_error.rs
Normal file
@ -0,0 +1,80 @@
|
||||
use common::HashType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Why an L2 block from the channel could not be applied.
|
||||
///
|
||||
/// 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}")]
|
||||
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
|
||||
Deserialize(String),
|
||||
#[error("Unexpected block id: expected {expected}, got {got}")]
|
||||
UnexpectedBlockId { expected: u64, got: u64 },
|
||||
#[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
|
||||
BrokenChainLink {
|
||||
expected_prev: HashType,
|
||||
got_prev: HashType,
|
||||
},
|
||||
#[error("Block hash mismatch: computed {computed}, header {header}")]
|
||||
HashMismatch {
|
||||
computed: HashType,
|
||||
header: HashType,
|
||||
},
|
||||
#[error("Block has no transactions")]
|
||||
EmptyBlock,
|
||||
#[error("Last transaction must be the public clock invocation for the block timestamp")]
|
||||
InvalidClockTransaction,
|
||||
#[error("Genesis block must contain only public transactions")]
|
||||
NonPublicGenesisTransaction,
|
||||
#[error("State transition failed at transaction {tx_index}: {reason}")]
|
||||
StateTransition {
|
||||
/// Index of the failing transaction within the block body.
|
||||
tx_index: u64,
|
||||
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
|
||||
///
|
||||
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
|
||||
/// cannot store it directly.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BlockIngestError {
|
||||
/// Whether the failure may be transient rather than a property of the block.
|
||||
///
|
||||
/// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine
|
||||
/// state-transition rejections with infra failures (risc0 executor teardown,
|
||||
/// storage errors). Once it carries a structured cause, narrow this so only
|
||||
/// infra failures retry.
|
||||
#[must_use]
|
||||
pub const fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::StateTransition { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serializes_and_round_trips_externally_tagged() {
|
||||
let err = BlockIngestError::UnexpectedBlockId {
|
||||
expected: 5,
|
||||
got: 7,
|
||||
};
|
||||
let value = serde_json::to_value(&err).expect("serialize");
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } })
|
||||
);
|
||||
let back: BlockIngestError = serde_json::from_value(value).expect("deserialize");
|
||||
assert!(matches!(
|
||||
back,
|
||||
BlockIngestError::UnexpectedBlockId {
|
||||
expected: 5,
|
||||
got: 7
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
17
lez/chain_state/src/lib.rs
Normal file
17
lez/chain_state/src/lib.rs
Normal 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;
|
||||
50
lez/chain_state/src/stall_reason.rs
Normal file
50
lez/chain_state/src/stall_reason.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use common::{HashType, block::BlockHeader};
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ingest_error::BlockIngestError;
|
||||
|
||||
/// Diagnostic record of the first block that broke the L2 chain.
|
||||
///
|
||||
/// The block-derived fields are `None` for a deserialize break (no header was
|
||||
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
|
||||
/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StallReason {
|
||||
pub block_id: Option<u64>,
|
||||
pub block_hash: Option<HashType>,
|
||||
pub prev_block_hash: Option<HashType>,
|
||||
pub l1_slot: Slot,
|
||||
pub error: BlockIngestError,
|
||||
pub first_seen: Option<u64>,
|
||||
/// Number of later non-chaining blocks (orphans, since the tip is frozen).
|
||||
///
|
||||
/// TODO: We could store a different "branch" of blocks following this break, but for now we
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -12,8 +12,8 @@ default = []
|
||||
testnet = []
|
||||
|
||||
[dependencies]
|
||||
chain_state.workspace = true
|
||||
common.workspace = true
|
||||
chain_consistency.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use chain_consistency::{BlockIngestError, Tip};
|
||||
use chain_state::{
|
||||
AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip,
|
||||
};
|
||||
use common::{
|
||||
block::{BedrockStatus, Block, BlockHeader},
|
||||
transaction::LeeTransaction,
|
||||
@ -14,23 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
|
||||
use storage::indexer::RocksDBIO;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::status::StallReason;
|
||||
|
||||
/// 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>,
|
||||
@ -201,10 +186,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.
|
||||
@ -217,22 +199,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))
|
||||
}
|
||||
|
||||
@ -252,14 +222,16 @@ impl IndexerStore {
|
||||
return Ok(AcceptOutcome::AlreadyApplied);
|
||||
}
|
||||
|
||||
if let Err(err) = chain_consistency::validate_against_tip(tip, block) {
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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) = chain_consistency::apply_block(block, &mut scratch) {
|
||||
if let Err(err) = apply_block_to_state(block, &mut scratch) {
|
||||
if err.is_retryable() {
|
||||
return Ok(AcceptOutcome::RetryableFailure(err));
|
||||
}
|
||||
@ -434,7 +406,6 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod accept_tests {
|
||||
use chain_consistency::BlockIngestError;
|
||||
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
|
||||
|
||||
use super::*;
|
||||
|
||||
@ -2,8 +2,8 @@ use std::{path::Path, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
pub use chain_consistency::BlockIngestError;
|
||||
use chain_consistency::{Anchor, ChainConsistency};
|
||||
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
|
||||
use chain_state::{Anchor, ChainConsistency};
|
||||
use common::block::Block;
|
||||
// TODO: Remove after testnet
|
||||
use futures::StreamExt as _;
|
||||
@ -12,10 +12,9 @@ use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||
};
|
||||
use retry::ApplyRetryGate;
|
||||
pub use status::StallReason;
|
||||
|
||||
use crate::{
|
||||
block_store::{AcceptOutcome, IndexerStore},
|
||||
block_store::IndexerStore,
|
||||
config::IndexerConfig,
|
||||
cross_zone_verifier::CrossZoneVerifier,
|
||||
status::{IndexerStatus, IndexerSyncStatus},
|
||||
@ -128,8 +127,7 @@ impl IndexerCore {
|
||||
return Ok(ChainConsistency::Inconclusive);
|
||||
};
|
||||
|
||||
chain_consistency::verify_chain_consistency(&self.node, self.config.channel_id, &anchor)
|
||||
.await
|
||||
chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await
|
||||
}
|
||||
|
||||
/// Builds the anchor for the startup check.
|
||||
|
||||
@ -1,27 +1,5 @@
|
||||
use chain_consistency::BlockIngestError;
|
||||
use common::HashType;
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Diagnostic record of the first block that broke the L2 chain.
|
||||
///
|
||||
/// The block-derived fields are `None` for a deserialize break (no header was
|
||||
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
|
||||
/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StallReason {
|
||||
pub block_id: Option<u64>,
|
||||
pub block_hash: Option<HashType>,
|
||||
pub prev_block_hash: Option<HashType>,
|
||||
pub l1_slot: Slot,
|
||||
pub error: BlockIngestError,
|
||||
pub first_seen: Option<u64>,
|
||||
/// Number of later non-chaining blocks (orphans, since the tip is frozen).
|
||||
///
|
||||
/// TODO: We could store a different "branch" of blocks following this break, but for now we
|
||||
/// just count them.
|
||||
pub orphans_since: u64,
|
||||
}
|
||||
use chain_state::StallReason;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
|
||||
/// "still catching up" apart from "something went wrong".
|
||||
@ -138,6 +116,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stalled_status_serializes_with_stall_reason() {
|
||||
use chain_state::{BlockIngestError, StallReason};
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
|
||||
let status = IndexerStatus {
|
||||
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
52
lez/keycard_wallet/src/bin/force_unpower.rs
Normal file
52
lez/keycard_wallet/src/bin/force_unpower.rs
Normal 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.");
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
@ -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.")
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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);
|
||||
|
||||
@ -10,8 +10,8 @@ workspace = true
|
||||
[dependencies]
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
chain_state.workspace = true
|
||||
common.workspace = true
|
||||
chain_consistency.workspace = true
|
||||
storage.workspace = true
|
||||
mempool.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
@ -34,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
|
||||
|
||||
@ -1,23 +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, 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;
|
||||
|
||||
@ -43,6 +64,36 @@ 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: Sized {
|
||||
#[expect(
|
||||
@ -58,14 +109,23 @@ pub trait BlockPublisherTrait: Sized {
|
||||
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>>;
|
||||
@ -85,7 +145,10 @@ pub struct ZoneSdkPublisher {
|
||||
/// Direct node handle retained for channel reads (startup consistency check
|
||||
/// and reconstruction); the sequencer itself lives in the drive task.
|
||||
node: NodeHttpClient,
|
||||
publish_tx: mpsc::Sender<(Inscription, Vec<WithdrawArg>)>,
|
||||
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>,
|
||||
@ -109,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());
|
||||
@ -129,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,
|
||||
@ -141,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;
|
||||
@ -174,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) => {
|
||||
@ -195,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 { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,29 +321,46 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
channel_id: config.channel_id,
|
||||
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
|
||||
node,
|
||||
publish_tx,
|
||||
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
|
||||
@ -260,13 +383,87 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
@ -218,6 +218,16 @@ impl SequencerStore {
|
||||
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> {
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
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_consistency::{Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, Tip};
|
||||
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, V03State, public_transaction::Message};
|
||||
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};
|
||||
@ -30,7 +36,7 @@ use storage::sequencer::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
|
||||
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
|
||||
block_store::SequencerStore,
|
||||
};
|
||||
|
||||
@ -56,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,
|
||||
}
|
||||
|
||||
@ -120,14 +121,66 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (mut store, mut state) = Self::open_or_create_store(&config);
|
||||
let (store, state) = Self::open_or_create_store(&config);
|
||||
|
||||
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
|
||||
&config, &store, &state,
|
||||
)));
|
||||
|
||||
let initial_checkpoint = store
|
||||
.get_zone_checkpoint()
|
||||
@ -146,6 +199,7 @@ 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");
|
||||
@ -163,15 +217,15 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
// 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 chanel_was_empty =
|
||||
Self::verify_and_reconstruct(&block_publisher, &mut store, &mut state, is_fresh_start)
|
||||
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 && chanel_was_empty {
|
||||
if is_fresh_start && channel_was_empty {
|
||||
let mut pending_blocks = store
|
||||
.get_all_blocks()
|
||||
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
|
||||
@ -199,16 +253,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
let latest_block_meta = store
|
||||
.latest_block_meta()
|
||||
.expect("Failed to read latest block meta from store")
|
||||
.expect("Sequencer store should have at least the genesis block after reconstruction");
|
||||
|
||||
let sequencer_core = Self {
|
||||
state,
|
||||
chain,
|
||||
store,
|
||||
mempool,
|
||||
chain_height: latest_block_meta.id,
|
||||
sequencer_config: config,
|
||||
block_publisher,
|
||||
};
|
||||
@ -224,8 +272,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
/// Returns whatever channel was empty or not.
|
||||
async fn verify_and_reconstruct(
|
||||
publisher: &BP,
|
||||
store: &mut SequencerStore,
|
||||
state: &mut V03State,
|
||||
store: &SequencerStore,
|
||||
chain: &Mutex<ChainState>,
|
||||
is_fresh_start: bool,
|
||||
) -> Result<bool> {
|
||||
let anchor_record = store
|
||||
@ -322,7 +370,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
)
|
||||
})?;
|
||||
channel_is_empty = false;
|
||||
Self::apply_reconstructed_block(store, state, &block, slot)?;
|
||||
// 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)
|
||||
@ -332,8 +384,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
/// 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: &mut SequencerStore,
|
||||
state: &mut V03State,
|
||||
store: &SequencerStore,
|
||||
chain: &mut ChainState,
|
||||
block: &Block,
|
||||
slot: Slot,
|
||||
) -> Result<()> {
|
||||
@ -379,21 +431,31 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
}
|
||||
}
|
||||
|
||||
// New continuation: validate it chains onto the tip, then apply.
|
||||
let cc_tip = tip.as_ref().map(|tip| Tip {
|
||||
block_id: tip.id,
|
||||
hash: tip.hash,
|
||||
});
|
||||
chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| {
|
||||
anyhow!(
|
||||
"Channel block {block_id} does not extend local tip {:?}: {err}",
|
||||
tip.map(|tip| 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut scratch = state.clone();
|
||||
chain_consistency::apply_block(block, &mut scratch)
|
||||
.map_err(|err| anyhow!("Failed to apply channel block {block_id}: {err}"))?;
|
||||
// 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
|
||||
@ -406,11 +468,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
.iter()
|
||||
.filter_map(extract_bridge_deposit_id)
|
||||
.collect();
|
||||
|
||||
store
|
||||
.update(block, &deposit_event_ids, Vec::new(), &scratch)
|
||||
.context("Failed to persist reconstructed block")?;
|
||||
*state = scratch;
|
||||
.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")?;
|
||||
@ -547,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 {
|
||||
@ -563,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,
|
||||
@ -593,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!(
|
||||
@ -611,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 {
|
||||
@ -619,8 +738,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
};
|
||||
|
||||
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
|
||||
if self
|
||||
.store
|
||||
if store
|
||||
.is_deposit_event_submitted(deposit_op_id)
|
||||
.context("Failed to check whether deposit was already submitted")?
|
||||
{
|
||||
@ -630,7 +748,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
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")?;
|
||||
}
|
||||
@ -643,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();
|
||||
@ -652,12 +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")?
|
||||
.context("Sequencer store should have at least the genesis block")?;
|
||||
|
||||
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
|
||||
.expect("Timestamp must be positive");
|
||||
|
||||
@ -676,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,
|
||||
};
|
||||
|
||||
@ -693,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,
|
||||
@ -709,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);
|
||||
@ -717,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,
|
||||
};
|
||||
|
||||
@ -725,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(),
|
||||
@ -740,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 {
|
||||
@ -786,10 +920,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -799,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.
|
||||
@ -848,21 +1088,30 @@ 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 the bridge-lock holdings,
|
||||
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
||||
/// by genesis InitConfig transactions and reconstructed by replaying them.
|
||||
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||
#[cfg(not(feature = "testnet"))]
|
||||
let base = testnet_initial_state::initial_state();
|
||||
|
||||
#[cfg(feature = "testnet")]
|
||||
let base = testnet_initial_state::initial_state_testnet();
|
||||
|
||||
// Bridge-lock holdings belong to the source side, seeded regardless of receiving config.
|
||||
let holdings: Vec<_> = bridge_lock_holdings(&config.genesis)
|
||||
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount))
|
||||
.collect();
|
||||
let mut state = base.with_public_accounts(holdings);
|
||||
// Bridge-lock holder balances belong to the source side and are not produced by
|
||||
// any transaction, so seed them directly. Cross-zone config is seeded by genesis
|
||||
// InitConfig transactions in `build_genesis_state`, not here.
|
||||
let holdings = bridge_lock_holdings(&config.genesis)
|
||||
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount));
|
||||
base.with_public_accounts(holdings)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
|
||||
info!(
|
||||
@ -892,12 +1141,11 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
GenesisAction::SupplyBridgeAccount { balance } => {
|
||||
Some(build_supply_bridge_account_genesis_transaction(*balance))
|
||||
}
|
||||
// Seeded directly (holdings via `build_holding_account`), not by a genesis tx.
|
||||
// Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a
|
||||
// genesis tx.
|
||||
GenesisAction::SupplyBridgeLockHolding { .. } => None,
|
||||
});
|
||||
|
||||
// Applied in order with the mandatory clock last, then committed to the genesis
|
||||
// block so external observers can replay them. Every genesis tx is public.
|
||||
let genesis_txs = wrapped_token_config_tx
|
||||
.chain(inbox_config_tx)
|
||||
.chain(supply_txs)
|
||||
@ -984,7 +1232,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();
|
||||
@ -1012,6 +1260,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 {
|
||||
@ -1101,7 +1369,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)?;
|
||||
|
||||
|
||||
@ -3,13 +3,14 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use common::block::Block;
|
||||
use futures::Stream;
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
||||
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
|
||||
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,
|
||||
@ -20,6 +21,8 @@ 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`].
|
||||
@ -31,13 +34,14 @@ impl MockBlockPublisher {
|
||||
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
|
||||
/// serves an empty channel.
|
||||
#[must_use]
|
||||
pub const fn with_canned_channel(
|
||||
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,
|
||||
}
|
||||
@ -54,9 +58,11 @@ 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(),
|
||||
})
|
||||
@ -64,16 +70,27 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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,19 +22,21 @@ 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;
|
||||
@ -148,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);
|
||||
@ -188,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());
|
||||
}
|
||||
|
||||
@ -297,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;
|
||||
@ -311,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,
|
||||
@ -321,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;
|
||||
@ -337,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(_)
|
||||
@ -350,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;
|
||||
@ -361,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);
|
||||
@ -402,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
|
||||
@ -412,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]
|
||||
@ -444,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();
|
||||
|
||||
@ -479,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!(
|
||||
@ -498,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.
|
||||
@ -540,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!(
|
||||
@ -556,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!(
|
||||
@ -655,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();
|
||||
|
||||
@ -707,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();
|
||||
|
||||
@ -762,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();
|
||||
|
||||
@ -781,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.
|
||||
@ -864,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)
|
||||
@ -1247,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);
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
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};
|
||||
@ -14,6 +17,16 @@ 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 {
|
||||
@ -53,15 +66,11 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
|
||||
// Sequencer B starts from a fresh store and reconstructs A's chain.
|
||||
let config_b = setup_sequencer_config();
|
||||
let (mut store_b, mut state_b) =
|
||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
||||
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,
|
||||
&mut store_b,
|
||||
&mut state_b,
|
||||
true,
|
||||
&mock_b, &store_b, &chain_b, true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
@ -72,10 +81,12 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
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,
|
||||
seq_a.state().get_account_by_id(account.account_id).balance,
|
||||
state_a.get_account_by_id(account.account_id).balance,
|
||||
);
|
||||
}
|
||||
|
||||
@ -85,10 +96,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
|
||||
// Re-running is idempotent: everything is already applied, no error.
|
||||
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b,
|
||||
&mut store_b,
|
||||
&mut state_b,
|
||||
true,
|
||||
&mock_b, &store_b, &chain_b, true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct idempotent");
|
||||
@ -99,7 +107,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
||||
#[tokio::test]
|
||||
async fn fails_when_channel_serves_a_divergent_block() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
|
||||
let (store, chain) = fresh_store_and_chain(&config);
|
||||
|
||||
// Anchor on the local genesis at some slot.
|
||||
let genesis_id = store.genesis_id();
|
||||
@ -123,17 +131,16 @@ async fn fails_when_channel_serves_a_divergent_block() {
|
||||
messages,
|
||||
);
|
||||
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock, &mut store, &mut state, true,
|
||||
)
|
||||
.await;
|
||||
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 (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&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
|
||||
@ -147,10 +154,9 @@ async fn fails_when_channel_is_missing() {
|
||||
// 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, &mut store, &mut state, true,
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(result.is_err(), "missing channel must abort startup");
|
||||
}
|
||||
|
||||
@ -161,7 +167,7 @@ async fn fails_when_channel_is_missing() {
|
||||
#[tokio::test]
|
||||
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&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.
|
||||
@ -174,10 +180,9 @@ async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock, &mut store, &mut state, true,
|
||||
)
|
||||
.await;
|
||||
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"
|
||||
@ -209,10 +214,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
|
||||
messages,
|
||||
);
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock,
|
||||
&mut seq.store,
|
||||
&mut seq.state,
|
||||
true,
|
||||
&mock, &seq.store, &seq.chain, true,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@ -224,7 +226,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
|
||||
#[tokio::test]
|
||||
async fn fails_when_a_channel_block_is_missing_locally() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&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.
|
||||
@ -237,10 +239,9 @@ async fn fails_when_a_channel_block_is_missing_locally() {
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock, &mut store, &mut state, true,
|
||||
)
|
||||
.await;
|
||||
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"
|
||||
@ -250,7 +251,7 @@ async fn fails_when_a_channel_block_is_missing_locally() {
|
||||
#[tokio::test]
|
||||
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
|
||||
let config = setup_sequencer_config();
|
||||
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&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();
|
||||
@ -262,10 +263,9 @@ async fn fails_when_a_channel_block_does_not_extend_the_tip() {
|
||||
Some(Slot::from(10)),
|
||||
messages,
|
||||
);
|
||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock, &mut store, &mut state, true,
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a non-contiguous channel block must abort startup"
|
||||
@ -386,13 +386,12 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b,
|
||||
&mut seq_b.store,
|
||||
&mut seq_b.state,
|
||||
&seq_b.store,
|
||||
&seq_b.chain,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().unwrap().id;
|
||||
|
||||
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
|
||||
assert_eq!(tip_b.id, tip_a.id);
|
||||
@ -402,15 +401,17 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
|
||||
|
||||
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!(
|
||||
seq_b.state().get_account_by_id(account).balance,
|
||||
seq_a.state().get_account_by_id(account).balance,
|
||||
state_b.get_account_by_id(account).balance,
|
||||
state_a.get_account_by_id(account).balance,
|
||||
"reconstructed balance mismatch for {account:?}",
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
seq_b.state().get_account_by_id(vault_id).balance,
|
||||
state_b.get_account_by_id(vault_id).balance,
|
||||
u128::from(deposit_amount),
|
||||
"deposit must mint into the recipient vault exactly once, not twice"
|
||||
);
|
||||
@ -489,17 +490,11 @@ async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() {
|
||||
|
||||
// Sequencer B reconstructs A's chain from a fresh store.
|
||||
let config_b = bridge_funded_config();
|
||||
let (mut store_b, mut state_b) =
|
||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
||||
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,
|
||||
&mut store_b,
|
||||
&mut state_b,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
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(),
|
||||
@ -540,8 +535,7 @@ async fn reconstruction_reconciles_already_finished_deposit() {
|
||||
// pre-seeded, as the cold-start backfill would when it re-observes this
|
||||
// already-finalized deposit.
|
||||
let config_b = bridge_funded_config();
|
||||
let (mut store_b, mut state_b) =
|
||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
||||
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||
assert!(
|
||||
store_b
|
||||
.dbio()
|
||||
@ -550,19 +544,19 @@ async fn reconstruction_reconciles_already_finished_deposit() {
|
||||
);
|
||||
|
||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||
&mock_b,
|
||||
&mut store_b,
|
||||
&mut state_b,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("reconstruct");
|
||||
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!(
|
||||
state_b.get_account_by_id(vault_id).balance,
|
||||
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"
|
||||
);
|
||||
@ -599,17 +593,16 @@ async fn committed_local_against_missing_channel_fails_without_anchor() {
|
||||
|
||||
// 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 (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
|
||||
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, &mut store, &mut state, false,
|
||||
)
|
||||
.await;
|
||||
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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -90,6 +90,4 @@ pub trait Rpc {
|
||||
|
||||
#[method(name = "getChannelId")]
|
||||
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
|
||||
|
||||
// =============================================================================================
|
||||
}
|
||||
|
||||
35
lez/sequencer/service/src/bin/bedrock_pubkey.rs
Normal file
35
lez/sequencer/service/src/bin/bedrock_pubkey.rs
Normal 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(())
|
||||
}
|
||||
71
lez/sequencer/service/src/bin/configure_channel.rs
Normal file
71
lez/sequencer/service/src/bin/configure_channel.rs
Normal 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}"))
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() => {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -19,10 +19,12 @@ use crate::{
|
||||
},
|
||||
error::DbError,
|
||||
sequencer::sequencer_cells::{
|
||||
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
|
||||
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
|
||||
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
|
||||
ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
|
||||
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
|
||||
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
|
||||
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
|
||||
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
|
||||
WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned,
|
||||
ZoneSdkCheckpointCellRef,
|
||||
},
|
||||
};
|
||||
|
||||
@ -46,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";
|
||||
@ -403,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],
|
||||
@ -503,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| {
|
||||
@ -537,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)
|
||||
}
|
||||
@ -633,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
|
||||
@ -682,3 +831,6 @@ impl RocksDBIO {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@ -7,10 +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_CURSOR_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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -44,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>);
|
||||
|
||||
|
||||
283
lez/storage/src/sequencer/tests.rs
Normal file
283
lez/storage/src/sequencer/tests.rs
Normal 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);
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
@ -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,
|
||||
@ -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::*;
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
pub struct WalletCore {
|
||||
@ -415,8 +412,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.optimal_poller();
|
||||
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,
|
||||
@ -441,6 +490,7 @@ impl WalletCore {
|
||||
Some(pda_seed),
|
||||
Some(program_id),
|
||||
);
|
||||
self.catch_up_shared_account(account_id).await?;
|
||||
|
||||
Ok(SharedAccountInfo {
|
||||
account_id,
|
||||
@ -449,14 +499,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()
|
||||
@ -469,6 +526,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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,6 +90,14 @@ impl NullifierIndex {
|
||||
);
|
||||
}
|
||||
|
||||
/// Indexes `account_id` by the nullifier its initialization publishes.
|
||||
pub fn track_initialization(&mut self, account_id: AccountId) {
|
||||
self.0.insert(
|
||||
Nullifier::for_account_initialization(&account_id),
|
||||
account_id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces a spent nullifier with the account's `next` one.
|
||||
pub fn update(&mut self, spent: &Nullifier, next: Nullifier, account_id: AccountId) {
|
||||
self.0.remove(spent);
|
||||
@ -987,6 +995,93 @@ mod tests {
|
||||
assert!(index.account_for(&old_nullifier).is_none());
|
||||
}
|
||||
|
||||
// The genesis catch-up seeds only the init nullifier and lets the nullifier pass decode the
|
||||
// init note and every subsequent (randomly-tagged) update. Verify a shared account rolls from
|
||||
// default through its init to a later update purely by nullifier — the path the catch-up runs.
|
||||
#[test]
|
||||
fn nullifier_sync_catches_up_shared_account_from_init() {
|
||||
let mut kc = UserKeyChain::default();
|
||||
|
||||
let label = Label::new("group");
|
||||
let holder = GroupKeyHolder::new();
|
||||
let identifier = 0;
|
||||
let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier);
|
||||
let npk = keys.generate_nullifier_public_key();
|
||||
let vpk = keys.generate_viewing_public_key();
|
||||
let nsk = keys.nullifier_secret_key;
|
||||
let account_id = AccountId::from((&npk, &vpk, identifier));
|
||||
|
||||
kc.insert_group_key_holder(label.clone(), holder);
|
||||
kc.insert_shared_private_account(
|
||||
account_id,
|
||||
SharedAccountEntry {
|
||||
group_label: label,
|
||||
identifier,
|
||||
pda_seed: None,
|
||||
authority_program_id: None,
|
||||
account: Account::default(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut index = NullifierIndex::default();
|
||||
index.track_initialization(account_id);
|
||||
|
||||
// A note publishing `spent` and carrying the state `next`.
|
||||
let make_message = |spent: Nullifier, next: &Account| {
|
||||
let commitment = Commitment::new(&account_id, next);
|
||||
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
|
||||
let ciphertext = EncryptionScheme::encrypt(
|
||||
next,
|
||||
&PrivateAccountKind::Regular(identifier),
|
||||
&sender_ss,
|
||||
&commitment,
|
||||
0,
|
||||
);
|
||||
let note = EncryptedAccountData::new(ciphertext, &npk, &vpk, epk);
|
||||
Message {
|
||||
encrypted_private_post_states: vec![note],
|
||||
new_commitments: vec![commitment],
|
||||
new_nullifiers: vec![(spent, [0; 32])],
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Init: default -> initialized, discovered via the seeded init nullifier.
|
||||
let initialized = Account {
|
||||
balance: 250,
|
||||
..Account::default()
|
||||
};
|
||||
let init_msg = make_message(
|
||||
Nullifier::for_account_initialization(&account_id),
|
||||
&initialized,
|
||||
);
|
||||
assert_eq!(
|
||||
kc.sync_updates_via_nullifiers(&init_msg, &mut index),
|
||||
HashSet::from([0])
|
||||
);
|
||||
assert_eq!(
|
||||
kc.shared_private_account(account_id).unwrap().account,
|
||||
initialized
|
||||
);
|
||||
|
||||
// Update: initialized -> updated, discovered via the now-tracked update nullifier.
|
||||
let updated = Account {
|
||||
balance: 500,
|
||||
..Account::default()
|
||||
};
|
||||
let update_spent =
|
||||
Nullifier::for_account_update(&Commitment::new(&account_id, &initialized), &nsk);
|
||||
let update_msg = make_message(update_spent, &updated);
|
||||
assert_eq!(
|
||||
kc.sync_updates_via_nullifiers(&update_msg, &mut index),
|
||||
HashSet::from([0])
|
||||
);
|
||||
assert_eq!(
|
||||
kc.shared_private_account(account_id).unwrap().account,
|
||||
updated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullifier_sync_ignores_unindexed_nullifier() {
|
||||
let mut kc = UserKeyChain::default();
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user