mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-25 07:03: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/logos-blockchain-rust-rapidsnark.git",
|
||||||
"https://github.com/logos-blockchain/sponges",
|
"https://github.com/logos-blockchain/sponges",
|
||||||
"https://github.com/arkworks-rs/spongefish.git",
|
"https://github.com/arkworks-rs/spongefish.git",
|
||||||
|
"https://github.com/keycard-tech/keycard-rs",
|
||||||
]
|
]
|
||||||
unknown-git = "deny"
|
unknown-git = "deny"
|
||||||
unknown-registry = "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 \
|
libclang-dev \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
libpcsclite-dev \
|
||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
jq \
|
jq \
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -17,9 +17,5 @@ result
|
|||||||
wallet-ffi/wallet_ffi.h
|
wallet-ffi/wallet_ffi.h
|
||||||
bedrock_signing_key
|
bedrock_signing_key
|
||||||
integration_tests/configs/debug/
|
integration_tests/configs/debug/
|
||||||
venv/
|
|
||||||
|
|
||||||
keycard_wallet/python/__pycache__/
|
|
||||||
keycard_wallet/python/keycard-py/
|
|
||||||
|
|
||||||
.DS_Store
|
.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",
|
||||||
"lez/system_accounts",
|
"lez/system_accounts",
|
||||||
|
"lez/chain_state",
|
||||||
"lez/sequencer/core",
|
"lez/sequencer/core",
|
||||||
"lez/sequencer/service",
|
"lez/sequencer/service",
|
||||||
"lez/sequencer/service/protocol",
|
"lez/sequencer/service/protocol",
|
||||||
@ -32,7 +33,6 @@ members = [
|
|||||||
"lez/wallet",
|
"lez/wallet",
|
||||||
"lez/wallet-ffi",
|
"lez/wallet-ffi",
|
||||||
"lez/common",
|
"lez/common",
|
||||||
"lez/chain_consistency",
|
|
||||||
"lez/programs",
|
"lez/programs",
|
||||||
"lez/programs/amm",
|
"lez/programs/amm",
|
||||||
"lez/programs/associated_token_account",
|
"lez/programs/associated_token_account",
|
||||||
@ -73,7 +73,7 @@ members = [
|
|||||||
lee = { path = "lee/state_machine" }
|
lee = { path = "lee/state_machine" }
|
||||||
lee_core = { path = "lee/state_machine/core" }
|
lee_core = { path = "lee/state_machine/core" }
|
||||||
common = { path = "lez/common" }
|
common = { path = "lez/common" }
|
||||||
chain_consistency = { path = "lez/chain_consistency" }
|
chain_state = { path = "lez/chain_state" }
|
||||||
mempool = { path = "lez/mempool" }
|
mempool = { path = "lez/mempool" }
|
||||||
storage = { path = "lez/storage" }
|
storage = { path = "lez/storage" }
|
||||||
key_protocol = { path = "lee/key_protocol" }
|
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-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" }
|
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 = [
|
rocksdb = { version = "0.24.0", default-features = false, features = [
|
||||||
"snappy",
|
"snappy",
|
||||||
"bindgen-runtime",
|
"bindgen-runtime",
|
||||||
] }
|
] }
|
||||||
rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] }
|
rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] }
|
||||||
|
pcsc = "2"
|
||||||
k256 = { version = "0.13.3", features = [
|
k256 = { version = "0.13.3", features = [
|
||||||
"ecdsa-core",
|
"ecdsa-core",
|
||||||
"arithmetic",
|
"arithmetic",
|
||||||
@ -201,7 +204,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [
|
|||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||||
pyo3 = { version = "0.29", features = ["auto-initialize"] }
|
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
criterion = { version = "0.8", features = ["html_reports"] }
|
criterion = { version = "0.8", features = ["html_reports"] }
|
||||||
|
|
||||||
|
|||||||
8
Justfile
8
Justfile
@ -64,15 +64,17 @@ run-bedrock:
|
|||||||
docker compose up
|
docker compose up
|
||||||
|
|
||||||
# Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
|
# 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']
|
[working-directory: 'lez/sequencer/service']
|
||||||
run-sequencer standalone="":
|
run-sequencer standalone="" home="" port="3040":
|
||||||
@echo "🧠 Running sequencer"
|
@echo "🧠 Running sequencer"
|
||||||
@if [ "{{standalone}}" = "standalone" ]; then \
|
@if [ "{{standalone}}" = "standalone" ]; then \
|
||||||
echo "🧪 Running in standalone mode"; \
|
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 \
|
else \
|
||||||
echo "🚀 Running in normal mode"; \
|
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
|
fi
|
||||||
|
|
||||||
# Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
|
# 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
|
### Required hardware
|
||||||
- Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE.
|
- Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE.
|
||||||
- Smartcard reader
|
- 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
|
### Firmware installation
|
||||||
Installation:
|
|
||||||
|
|
||||||
1. Install math applet on your keycard; this process only needs to be done once. In the root of repo:
|
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:
|
||||||
```
|
|
||||||
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`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install appropriate version of `keycard-py`.
|
git clone --recurse-submodules https://github.com/keycard-tech/status-keycard.git
|
||||||
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py
|
cd status-keycard
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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
|
```bash
|
||||||
# In the root of LEE repo:
|
sudo apt-get install -y openjdk-11-jdk
|
||||||
source venv/bin/activate
|
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
|
## PIN entry
|
||||||
|
|
||||||
Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable:
|
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
|
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.
|
```
|
||||||
|
029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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:
|
||||||
|
|
||||||
**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:
|
|
||||||
|
|
||||||
```bash
|
```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 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
|
### Keycard
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|----------------------------------|-----------------------------------------------------------------------|
|
|----------------------------------|-----------------------------------------------------------------------|
|
||||||
| `wallet keycard available` | Checks whether a Keycard reader and card are accessible |
|
| `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 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 connect` | Opens a secure channel with the Keycard and verifies the PIN |
|
||||||
| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing |
|
|
||||||
| `wallet keycard load` | Loads a mnemonic phrase onto the Keycard |
|
| `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) |
|
| `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) |
|
||||||
|
|
||||||
1. Check keycard availability
|
1. Check keycard availability
|
||||||
@ -118,13 +110,13 @@ Record this PUK and store it somewhere safe. It cannot be recovered.
|
|||||||
✅ Keycard initialized successfully.
|
✅ Keycard initialized successfully.
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Connect (pair and save pairing for subsequent commands)
|
3. Connect (open a secure channel and verify the PIN)
|
||||||
```bash
|
```bash
|
||||||
wallet keycard connect
|
wallet keycard connect
|
||||||
|
|
||||||
# Output:
|
# Output:
|
||||||
Keycard PIN:
|
Keycard PIN:
|
||||||
✅ Keycard paired and ready.
|
✅ Keycard connected and PIN verified.
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Load a mnemonic phrase
|
4. Load a mnemonic phrase
|
||||||
@ -140,25 +132,24 @@ Keycard PIN:
|
|||||||
✅ Mnemonic phrase loaded successfully.
|
✅ 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
|
```bash
|
||||||
wallet keycard disconnect
|
wallet keycard factory-reset --confirm
|
||||||
|
|
||||||
# Output:
|
# Output:
|
||||||
Keycard PIN:
|
✅ Keycard factory-reset. Run `wallet keycard init` to reinitialize it.
|
||||||
✅ Keycard unpaired and pairing cleared.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Requires building the wallet with the `keycard-debug` feature:
|
||||||
|
|
||||||
First install the wallet with the `keycard-debug` feature:
|
|
||||||
```bash
|
```bash
|
||||||
cargo install --path lez/wallet --force --features keycard-debug
|
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
|
```bash
|
||||||
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal
|
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal
|
||||||
|
|
||||||
@ -516,19 +507,3 @@ bash lez/keycard_wallet/tests/keycard_tests_2.sh
|
|||||||
bash lez/keycard_wallet/tests/keycard_test_3.sh
|
bash lez/keycard_wallet/tests/keycard_test_3.sh
|
||||||
bash lez/keycard_wallet/tests/keycard_power_recovery_tests.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
|
system_accounts.workspace = true
|
||||||
programs.workspace = true
|
programs.workspace = true
|
||||||
test_programs.workspace = true
|
test_programs.workspace = true
|
||||||
|
testnet_initial_state.workspace = true
|
||||||
|
|
||||||
logos-blockchain-http-api-common.workspace = true
|
logos-blockchain-http-api-common.workspace = true
|
||||||
logos-blockchain-core.workspace = true
|
logos-blockchain-core.workspace = true
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
//! needs source verification, where a value-bearing target checks the message
|
//! needs source verification, where a value-bearing target checks the message
|
||||||
//! originated from `bridge_lock`; that is out of scope for the demo.
|
//! 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 anyhow::{Context as _, Result};
|
||||||
use common::transaction::LeeTransaction;
|
use common::transaction::LeeTransaction;
|
||||||
@ -24,14 +24,14 @@ use cross_zone_outbox_core::outbox_pda;
|
|||||||
use integration_tests::{
|
use integration_tests::{
|
||||||
config::{self, SequencerPartialConfig},
|
config::{self, SequencerPartialConfig},
|
||||||
indexer_client::IndexerClient,
|
indexer_client::IndexerClient,
|
||||||
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||||
};
|
};
|
||||||
use lee::{
|
use lee::{
|
||||||
AccountId, PrivateKey, PublicKey, PublicTransaction,
|
AccountId, PrivateKey, PublicKey, PublicTransaction,
|
||||||
public_transaction::{Message, WitnessSet},
|
public_transaction::{Message, WitnessSet},
|
||||||
};
|
};
|
||||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
|
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
|
||||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
use sequencer_service_rpc::RpcClient as _;
|
||||||
use tokio::test;
|
use tokio::test;
|
||||||
|
|
||||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
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.
|
// Wait until zone B's indexer reflects the verified mint.
|
||||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
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())
|
let indexer = indexer_client(idx_b.addr())
|
||||||
.context("Failed to build indexer URL")?;
|
|
||||||
let indexer = IndexerClient::new(&indexer_url)
|
|
||||||
.await
|
.await
|
||||||
.context("Failed to build indexer client")?;
|
.context("Failed to build indexer client")?;
|
||||||
|
|
||||||
@ -171,14 +169,6 @@ fn build_lock_tx(
|
|||||||
LeeTransaction::Public(PublicTransaction::new(message, witness))
|
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.
|
/// 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> {
|
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
|
||||||
let account_id = indexer_service_protocol::AccountId {
|
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
|
//! 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.
|
//! sequencer ingress guard is the only thing that stops this.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use common::transaction::LeeTransaction;
|
use common::transaction::LeeTransaction;
|
||||||
use cross_zone_inbox_core::{
|
use cross_zone_inbox_core::{
|
||||||
@ -18,13 +16,13 @@ use cross_zone_inbox_core::{
|
|||||||
};
|
};
|
||||||
use integration_tests::{
|
use integration_tests::{
|
||||||
config::{self, SequencerPartialConfig},
|
config::{self, SequencerPartialConfig},
|
||||||
setup::{SequencerSetup, setup_bedrock_node},
|
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
|
||||||
};
|
};
|
||||||
use lee::{
|
use lee::{
|
||||||
PublicTransaction,
|
PublicTransaction,
|
||||||
public_transaction::{Message, WitnessSet},
|
public_transaction::{Message, WitnessSet},
|
||||||
};
|
};
|
||||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
use sequencer_service_rpc::RpcClient as _;
|
||||||
use tokio::test;
|
use tokio::test;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -73,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
|
|||||||
);
|
);
|
||||||
Ok(())
|
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,
|
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
|
||||||
//! sequencer-trusted, with no indexer re-derivation (that is M4).
|
//! 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 anyhow::{Context as _, Result};
|
||||||
use common::transaction::LeeTransaction;
|
use common::transaction::LeeTransaction;
|
||||||
use cross_zone_outbox_core::outbox_pda;
|
use cross_zone_outbox_core::outbox_pda;
|
||||||
use integration_tests::{
|
use integration_tests::{
|
||||||
config::{self, SequencerPartialConfig},
|
config::{self, SequencerPartialConfig},
|
||||||
setup::{SequencerSetup, setup_bedrock_node},
|
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
|
||||||
};
|
};
|
||||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||||
use lee_core::program::ProgramId;
|
use lee_core::program::ProgramId;
|
||||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||||
use tokio::test;
|
use tokio::test;
|
||||||
|
|
||||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
|
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.
|
/// 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>> {
|
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
|
||||||
let wait = async {
|
let wait = async {
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
//! payload landing in the indexer's state proves verification passed; a forgery
|
//! payload landing in the indexer's state proves verification passed; a forgery
|
||||||
//! would have halted the indexer instead.
|
//! would have halted the indexer instead.
|
||||||
|
|
||||||
use std::{net::SocketAddr, time::Duration};
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use common::transaction::LeeTransaction;
|
use common::transaction::LeeTransaction;
|
||||||
@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda;
|
|||||||
use integration_tests::{
|
use integration_tests::{
|
||||||
config::{self, SequencerPartialConfig},
|
config::{self, SequencerPartialConfig},
|
||||||
indexer_client::IndexerClient,
|
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::{AccountId, PublicTransaction, public_transaction::Message};
|
||||||
use lee_core::program::ProgramId;
|
use lee_core::program::ProgramId;
|
||||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
use sequencer_service_rpc::RpcClient as _;
|
||||||
use tokio::test;
|
use tokio::test;
|
||||||
|
|
||||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
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
|
// Wait until zone B's indexer records the delivered payload. The indexer only
|
||||||
// applies the dispatch after re-deriving and verifying it.
|
// applies the dispatch after re-deriving and verifying it.
|
||||||
let record_id = ping_record_pda(receiver_id);
|
let record_id = ping_record_pda(receiver_id);
|
||||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
let indexer = indexer_client(idx_b.addr())
|
||||||
.context("Failed to build indexer URL")?;
|
|
||||||
let indexer = IndexerClient::new(&indexer_url)
|
|
||||||
.await
|
.await
|
||||||
.context("Failed to build indexer client")?;
|
.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.
|
/// Polls zone B's indexer until the ping record PDA holds a payload.
|
||||||
async fn wait_for_indexer_delivery(
|
async fn wait_for_indexer_delivery(
|
||||||
indexer: &IndexerClient,
|
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"
|
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 anyhow::{Context as _, Result, bail};
|
||||||
use indexer_service_rpc::RpcClient as _;
|
use indexer_service_rpc::RpcClient as _;
|
||||||
@ -16,11 +16,11 @@ use integration_tests::L2_TO_L1_TIMEOUT;
|
|||||||
use lee::{AccountId, PrivateKey, PublicKey};
|
use lee::{AccountId, PrivateKey, PublicKey};
|
||||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||||
use sequencer_core::config::GenesisAction;
|
use sequencer_core::config::GenesisAction;
|
||||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||||
use test_fixtures::{
|
use test_fixtures::{
|
||||||
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
|
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
|
||||||
indexer_client::IndexerClient,
|
indexer_client::IndexerClient,
|
||||||
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
|
setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer},
|
||||||
};
|
};
|
||||||
use tokio::test;
|
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
|
/// 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
|
/// indexer reads finalized channel history, so this is our oracle for "block
|
||||||
/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer
|
/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer
|
||||||
|
|||||||
@ -294,6 +294,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_vpk,
|
vpk: sender_vpk,
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_nsk,
|
nsk: sender_nsk,
|
||||||
membership_proof: proof,
|
membership_proof: proof,
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use lee_core::{
|
|||||||
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
|
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
|
||||||
account::{Account, AccountId, Nonce},
|
account::{Account, AccountId, Nonce},
|
||||||
compute_digest_for_path,
|
compute_digest_for_path,
|
||||||
encryption::ViewingPublicKey,
|
encryption::{ViewTag, ViewingPublicKey},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::execution_state::ExecutionState;
|
use crate::execution_state::ExecutionState;
|
||||||
@ -66,6 +66,7 @@ pub fn compute_circuit_output(
|
|||||||
*commitment_root,
|
*commitment_root,
|
||||||
);
|
);
|
||||||
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
||||||
|
let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk);
|
||||||
|
|
||||||
emit_private_output(
|
emit_private_output(
|
||||||
&mut output,
|
&mut output,
|
||||||
@ -73,7 +74,7 @@ pub fn compute_circuit_output(
|
|||||||
post_state,
|
post_state,
|
||||||
&account_id,
|
&account_id,
|
||||||
&PrivateAccountKind::Regular(*identifier),
|
&PrivateAccountKind::Regular(*identifier),
|
||||||
&npk,
|
view_tag,
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
new_nullifier,
|
new_nullifier,
|
||||||
@ -83,6 +84,7 @@ pub fn compute_circuit_output(
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
|
view_tag,
|
||||||
nsk,
|
nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier,
|
identifier,
|
||||||
@ -110,7 +112,7 @@ pub fn compute_circuit_output(
|
|||||||
post_state,
|
post_state,
|
||||||
&account_id,
|
&account_id,
|
||||||
&PrivateAccountKind::Regular(*identifier),
|
&PrivateAccountKind::Regular(*identifier),
|
||||||
&npk,
|
*view_tag,
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
new_nullifier,
|
new_nullifier,
|
||||||
@ -142,6 +144,7 @@ pub fn compute_circuit_output(
|
|||||||
*commitment_root,
|
*commitment_root,
|
||||||
);
|
);
|
||||||
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
||||||
|
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
|
||||||
|
|
||||||
emit_private_output(
|
emit_private_output(
|
||||||
&mut output,
|
&mut output,
|
||||||
@ -149,7 +152,7 @@ pub fn compute_circuit_output(
|
|||||||
post_state,
|
post_state,
|
||||||
&account_id,
|
&account_id,
|
||||||
&PrivateAccountKind::Regular(*identifier),
|
&PrivateAccountKind::Regular(*identifier),
|
||||||
npk,
|
view_tag,
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
new_nullifier,
|
new_nullifier,
|
||||||
@ -190,6 +193,7 @@ pub fn compute_circuit_output(
|
|||||||
let (authority_program_id, seed) = pda_seed_by_position
|
let (authority_program_id, seed) = pda_seed_by_position
|
||||||
.get(&pos)
|
.get(&pos)
|
||||||
.expect("PrivatePdaInit position must be in pda_seed_by_position");
|
.expect("PrivatePdaInit position must be in pda_seed_by_position");
|
||||||
|
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
|
||||||
emit_private_output(
|
emit_private_output(
|
||||||
&mut output,
|
&mut output,
|
||||||
&mut output_index,
|
&mut output_index,
|
||||||
@ -200,7 +204,7 @@ pub fn compute_circuit_output(
|
|||||||
seed: *seed,
|
seed: *seed,
|
||||||
identifier: *identifier,
|
identifier: *identifier,
|
||||||
},
|
},
|
||||||
npk,
|
view_tag,
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
new_nullifier,
|
new_nullifier,
|
||||||
@ -210,6 +214,7 @@ pub fn compute_circuit_output(
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
|
view_tag,
|
||||||
nsk,
|
nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier,
|
identifier,
|
||||||
@ -234,7 +239,6 @@ pub fn compute_circuit_output(
|
|||||||
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
|
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
|
||||||
|
|
||||||
let account_id = pre_state.account_id;
|
let account_id = pre_state.account_id;
|
||||||
let npk = NullifierPublicKey::from(nsk);
|
|
||||||
let (authority_program_id, seed) = pda_seed_by_position
|
let (authority_program_id, seed) = pda_seed_by_position
|
||||||
.get(&pos)
|
.get(&pos)
|
||||||
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
|
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
|
||||||
@ -248,7 +252,7 @@ pub fn compute_circuit_output(
|
|||||||
seed: *seed,
|
seed: *seed,
|
||||||
identifier: *identifier,
|
identifier: *identifier,
|
||||||
},
|
},
|
||||||
&npk,
|
*view_tag,
|
||||||
vpk,
|
vpk,
|
||||||
random_seed,
|
random_seed,
|
||||||
new_nullifier,
|
new_nullifier,
|
||||||
@ -271,7 +275,7 @@ fn emit_private_output(
|
|||||||
post_state: Account,
|
post_state: Account,
|
||||||
account_id: &AccountId,
|
account_id: &AccountId,
|
||||||
kind: &PrivateAccountKind,
|
kind: &PrivateAccountKind,
|
||||||
npk: &NullifierPublicKey,
|
view_tag: ViewTag,
|
||||||
vpk: &ViewingPublicKey,
|
vpk: &ViewingPublicKey,
|
||||||
random_seed: &[u8; 32],
|
random_seed: &[u8; 32],
|
||||||
new_nullifier: (Nullifier, CommitmentSetDigest),
|
new_nullifier: (Nullifier, CommitmentSetDigest),
|
||||||
@ -287,15 +291,6 @@ fn emit_private_output(
|
|||||||
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
|
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
|
||||||
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk);
|
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(
|
let encrypted_account = EncryptionScheme::encrypt(
|
||||||
&post_with_updated_nonce,
|
&post_with_updated_nonce,
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use crate::{
|
|||||||
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
|
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
|
||||||
NullifierSecretKey,
|
NullifierSecretKey,
|
||||||
account::{Account, AccountWithMetadata},
|
account::{Account, AccountWithMetadata},
|
||||||
encryption::{EncryptedAccountData, ViewingPublicKey},
|
encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey},
|
||||||
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
|
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,6 +42,7 @@ pub enum InputAccountIdentity {
|
|||||||
PrivateAuthorizedUpdate {
|
PrivateAuthorizedUpdate {
|
||||||
vpk: ViewingPublicKey,
|
vpk: ViewingPublicKey,
|
||||||
random_seed: [u8; 32],
|
random_seed: [u8; 32],
|
||||||
|
view_tag: ViewTag,
|
||||||
nsk: NullifierSecretKey,
|
nsk: NullifierSecretKey,
|
||||||
membership_proof: MembershipProof,
|
membership_proof: MembershipProof,
|
||||||
identifier: Identifier,
|
identifier: Identifier,
|
||||||
@ -79,6 +80,7 @@ pub enum InputAccountIdentity {
|
|||||||
PrivatePdaUpdate {
|
PrivatePdaUpdate {
|
||||||
vpk: ViewingPublicKey,
|
vpk: ViewingPublicKey,
|
||||||
random_seed: [u8; 32],
|
random_seed: [u8; 32],
|
||||||
|
view_tag: ViewTag,
|
||||||
nsk: NullifierSecretKey,
|
nsk: NullifierSecretKey,
|
||||||
membership_proof: MembershipProof,
|
membership_proof: MembershipProof,
|
||||||
identifier: Identifier,
|
identifier: Identifier,
|
||||||
|
|||||||
@ -131,6 +131,11 @@ pub enum InvalidProgramBehaviorError {
|
|||||||
|
|
||||||
#[error("Called program {program_id:?} which is not listed in dependencies")]
|
#[error("Called program {program_id:?} which is not listed in dependencies")]
|
||||||
UndeclaredProgramDependency { program_id: ProgramId },
|
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)]
|
#[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]
|
#[must_use]
|
||||||
pub const fn program_owner_changer() -> Program {
|
pub const fn program_owner_changer() -> Program {
|
||||||
Program::new_unchecked(
|
Program::new_unchecked(
|
||||||
|
|||||||
@ -8,8 +8,8 @@ mod default_values;
|
|||||||
type Value = [u8; 32];
|
type Value = [u8; 32];
|
||||||
type Node = [u8; 32];
|
type Node = [u8; 32];
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
#[cfg_attr(test, derive(Debug))]
|
||||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||||
pub struct MerkleTree {
|
pub struct MerkleTree {
|
||||||
nodes: Vec<Node>,
|
nodes: Vec<Node>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
|
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
|
||||||
|
|
||||||
use lee_core::{
|
use lee_core::{
|
||||||
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier,
|
Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
|
||||||
PrivacyPreservingCircuitOutput, SharedSecretKey,
|
Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey,
|
||||||
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
|
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
|
||||||
program::{PdaSeed, PrivateAccountKind},
|
program::{PdaSeed, PrivateAccountKind},
|
||||||
};
|
};
|
||||||
@ -198,6 +198,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: commitment_set
|
membership_proof: commitment_set
|
||||||
.get_proof_for(&commitment_sender)
|
.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);
|
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]
|
#[test]
|
||||||
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
|
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
|
||||||
let account_keys = test_private_account_keys_1();
|
let account_keys = test_private_account_keys_1();
|
||||||
@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
|
|||||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: keys.vpk(),
|
vpk: keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: keys.nsk,
|
nsk: keys.nsk,
|
||||||
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
|
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
|
||||||
identifier,
|
identifier,
|
||||||
@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: keys.vpk(),
|
vpk: keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: keys.nsk,
|
nsk: keys.nsk,
|
||||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||||
identifier,
|
identifier,
|
||||||
@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() {
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: keys.vpk(),
|
vpk: keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: keys.nsk,
|
nsk: keys.nsk,
|
||||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||||
identifier: 99,
|
identifier: 99,
|
||||||
|
|||||||
@ -20,8 +20,8 @@ use crate::{
|
|||||||
|
|
||||||
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
|
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
|
||||||
|
|
||||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
#[cfg_attr(test, derive(Debug))]
|
||||||
pub struct CommitmentSet {
|
pub struct CommitmentSet {
|
||||||
merkle_tree: MerkleTree,
|
merkle_tree: MerkleTree,
|
||||||
commitments: HashMap<Commitment, usize>,
|
commitments: HashMap<Commitment, usize>,
|
||||||
@ -67,8 +67,8 @@ impl CommitmentSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
#[cfg_attr(test, derive(Debug))]
|
||||||
#[derive(Clone)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
struct NullifierSet(BTreeSet<Nullifier>);
|
struct NullifierSet(BTreeSet<Nullifier>);
|
||||||
|
|
||||||
impl NullifierSet {
|
impl NullifierSet {
|
||||||
@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, BorshSerialize, BorshDeserialize)]
|
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
#[cfg_attr(test, derive(Debug))]
|
||||||
pub struct V03State {
|
pub struct V03State {
|
||||||
public_state: HashMap<AccountId, Account>,
|
public_state: HashMap<AccountId, Account>,
|
||||||
private_state: (CommitmentSet, NullifierSet),
|
private_state: (CommitmentSet, NullifierSet),
|
||||||
|
|||||||
@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() {
|
|||||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() {
|
|||||||
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
|
|||||||
@ -64,6 +64,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: recipient_keys.nsk,
|
nsk: recipient_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -113,6 +114,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -162,6 +164,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -211,6 +214,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -260,6 +264,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -307,6 +312,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (0, vec![]),
|
membership_proof: (0, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (1, vec![]),
|
membership_proof: (1, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: (1, vec![]),
|
membership_proof: (1, vec![]),
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: alice_keys.vpk(),
|
vpk: alice_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: alice_keys.nsk,
|
nsk: alice_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&commitment_pda_0)
|
.get_proof_for_commitment(&commitment_pda_0)
|
||||||
@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: alice_keys.vpk(),
|
vpk: alice_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: alice_keys.nsk,
|
nsk: alice_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&commitment_pda_1)
|
.get_proof_for_commitment(&commitment_pda_1)
|
||||||
@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() {
|
|||||||
InputAccountIdentity::PrivatePdaUpdate {
|
InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: alice_keys.vpk(),
|
vpk: alice_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: alice_keys.nsk,
|
nsk: alice_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&commitment_pda_1_after_spend)
|
.get_proof_for_commitment(&commitment_pda_1_after_spend)
|
||||||
|
|||||||
@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&sender_commitment)
|
.get_proof_for_commitment(&sender_commitment)
|
||||||
@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: from_keys.vpk(),
|
vpk: from_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: from_keys.nsk,
|
nsk: from_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&from_commitment)
|
.get_proof_for_commitment(&from_commitment)
|
||||||
@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: to_keys.vpk(),
|
vpk: to_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: to_keys.nsk,
|
nsk: to_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&to_commitment)
|
.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::nonce_changer());
|
||||||
self.insert_program(crate::test_methods::extra_output());
|
self.insert_program(crate::test_methods::extra_output());
|
||||||
self.insert_program(crate::test_methods::missing_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::program_owner_changer());
|
||||||
self.insert_program(crate::test_methods::data_changer());
|
self.insert_program(crate::test_methods::data_changer());
|
||||||
self.insert_program(crate::test_methods::minter());
|
self.insert_program(crate::test_methods::minter());
|
||||||
@ -330,6 +331,7 @@ fn private_balance_transfer_for_tests(
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&sender_commitment)
|
.get_proof_for_commitment(&sender_commitment)
|
||||||
@ -384,6 +386,7 @@ fn deshielded_balance_transfer_for_tests(
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.vpk(),
|
vpk: sender_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.nsk,
|
nsk: sender_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&sender_commitment)
|
.get_proof_for_commitment(&sender_commitment)
|
||||||
|
|||||||
@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: recipient_keys.vpk(),
|
vpk: recipient_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: recipient_keys.nsk,
|
nsk: recipient_keys.nsk,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&recipient_commitment)
|
.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]
|
#[test]
|
||||||
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
|
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
|
||||||
let initial_data = [(
|
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 {
|
Ok(Self(StateDiff {
|
||||||
signer_account_ids,
|
signer_account_ids,
|
||||||
public_diff: state_diff,
|
public_diff: state_diff,
|
||||||
|
|||||||
@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: attacker_keys.vpk(),
|
vpk: attacker_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: attacker_keys.nsk,
|
nsk: attacker_keys.nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier: 0,
|
identifier: 0,
|
||||||
@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: attacker_keys.vpk(),
|
vpk: attacker_keys.vpk(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: attacker_keys.nsk,
|
nsk: attacker_keys.nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier: 0,
|
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]
|
[package]
|
||||||
name = "chain_consistency"
|
name = "chain_state"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@ -11,9 +11,9 @@ workspace = true
|
|||||||
common.workspace = true
|
common.workspace = true
|
||||||
lee.workspace = true
|
lee.workspace = true
|
||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
|
|
||||||
logos-blockchain-zone-sdk.workspace = true
|
|
||||||
logos-blockchain-core.workspace = true
|
logos-blockchain-core.workspace = true
|
||||||
|
logos-blockchain-zone-sdk.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
borsh.workspace = true
|
borsh.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
@ -23,4 +23,5 @@ thiserror.workspace = true
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
testnet_initial_state.workspace = true
|
||||||
serde_json.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,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
/// Our own hasher.
|
/// Our own hasher.
|
||||||
/// Currently it is SHA256 hasher wrapper. May change in a future.
|
/// Currently it is SHA256 hasher wrapper. May change in a future.
|
||||||
|
|||||||
@ -12,8 +12,8 @@ default = []
|
|||||||
testnet = []
|
testnet = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chain_state.workspace = true
|
||||||
common.workspace = true
|
common.workspace = true
|
||||||
chain_consistency.workspace = true
|
|
||||||
logos-blockchain-zone-sdk.workspace = true
|
logos-blockchain-zone-sdk.workspace = true
|
||||||
lee.workspace = true
|
lee.workspace = true
|
||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
use std::{path::Path, sync::Arc};
|
use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
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::{
|
use common::{
|
||||||
block::{BedrockStatus, Block, BlockHeader},
|
block::{BedrockStatus, Block, BlockHeader},
|
||||||
transaction::LeeTransaction,
|
transaction::LeeTransaction,
|
||||||
@ -14,23 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
|
|||||||
use storage::indexer::RocksDBIO;
|
use storage::indexer::RocksDBIO;
|
||||||
use tokio::sync::RwLock;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct IndexerStore {
|
pub struct IndexerStore {
|
||||||
dbio: Arc<RocksDBIO>,
|
dbio: Arc<RocksDBIO>,
|
||||||
@ -201,10 +186,7 @@ impl IndexerStore {
|
|||||||
let Some(block) = self.dbio.get_block(block_id)? else {
|
let Some(block) = self.dbio.get_block(block_id)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(Some(Tip {
|
Ok(Some(Tip::from(&block)))
|
||||||
block_id,
|
|
||||||
hash: block.header.hash,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record the stall reason.
|
/// Record the stall reason.
|
||||||
@ -217,22 +199,10 @@ impl IndexerStore {
|
|||||||
l1_slot: Slot,
|
l1_slot: Slot,
|
||||||
error: BlockIngestError,
|
error: BlockIngestError,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let stall = match self.get_stall_reason()? {
|
let stall = self.get_stall_reason()?.map_or_else(
|
||||||
Some(mut existing) => {
|
|| StallReason::new(header, l1_slot, error),
|
||||||
existing.orphans_since = existing.orphans_since.saturating_add(1);
|
StallReason::escalate,
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
self.set_stall_reason(&Some(stall))
|
self.set_stall_reason(&Some(stall))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,14 +222,16 @@ impl IndexerStore {
|
|||||||
return Ok(AcceptOutcome::AlreadyApplied);
|
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())?;
|
self.record_stall(Some(&block.header), l1_slot, err.clone())?;
|
||||||
return Ok(AcceptOutcome::Parked(err));
|
return Ok(AcceptOutcome::Parked(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is
|
// 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();
|
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() {
|
if err.is_retryable() {
|
||||||
return Ok(AcceptOutcome::RetryableFailure(err));
|
return Ok(AcceptOutcome::RetryableFailure(err));
|
||||||
}
|
}
|
||||||
@ -434,7 +406,6 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod accept_tests {
|
mod accept_tests {
|
||||||
use chain_consistency::BlockIngestError;
|
|
||||||
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
|
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -2,8 +2,8 @@ use std::{path::Path, sync::Arc};
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
pub use chain_consistency::BlockIngestError;
|
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
|
||||||
use chain_consistency::{Anchor, ChainConsistency};
|
use chain_state::{Anchor, ChainConsistency};
|
||||||
use common::block::Block;
|
use common::block::Block;
|
||||||
// TODO: Remove after testnet
|
// TODO: Remove after testnet
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
@ -12,10 +12,9 @@ use logos_blockchain_zone_sdk::{
|
|||||||
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||||
};
|
};
|
||||||
use retry::ApplyRetryGate;
|
use retry::ApplyRetryGate;
|
||||||
pub use status::StallReason;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
block_store::{AcceptOutcome, IndexerStore},
|
block_store::IndexerStore,
|
||||||
config::IndexerConfig,
|
config::IndexerConfig,
|
||||||
cross_zone_verifier::CrossZoneVerifier,
|
cross_zone_verifier::CrossZoneVerifier,
|
||||||
status::{IndexerStatus, IndexerSyncStatus},
|
status::{IndexerStatus, IndexerSyncStatus},
|
||||||
@ -128,8 +127,7 @@ impl IndexerCore {
|
|||||||
return Ok(ChainConsistency::Inconclusive);
|
return Ok(ChainConsistency::Inconclusive);
|
||||||
};
|
};
|
||||||
|
|
||||||
chain_consistency::verify_chain_consistency(&self.node, self.config.channel_id, &anchor)
|
chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the anchor for the startup check.
|
/// Builds the anchor for the startup check.
|
||||||
|
|||||||
@ -1,27 +1,5 @@
|
|||||||
use chain_consistency::BlockIngestError;
|
use chain_state::StallReason;
|
||||||
use common::HashType;
|
use serde::Serialize;
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
|
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
|
||||||
/// "still catching up" apart from "something went wrong".
|
/// "still catching up" apart from "something went wrong".
|
||||||
@ -138,6 +116,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stalled_status_serializes_with_stall_reason() {
|
fn stalled_status_serializes_with_stall_reason() {
|
||||||
|
use chain_state::{BlockIngestError, StallReason};
|
||||||
use logos_blockchain_zone_sdk::Slot;
|
use logos_blockchain_zone_sdk::Slot;
|
||||||
|
|
||||||
let status = IndexerStatus {
|
let status = IndexerStatus {
|
||||||
|
|||||||
@ -9,8 +9,11 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lee.workspace = true
|
lee.workspace = true
|
||||||
pyo3.workspace = true
|
keycard-rs.workspace = true
|
||||||
|
bip39.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
pcsc.workspace = true
|
||||||
serde_json.workspace = true
|
rand.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
zeroize.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 lee::{AccountId, PublicKey, Signature};
|
||||||
use pyo3::{prelude::*, types::PyAny};
|
use rand::Rng as _;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use zeroize::Zeroizing;
|
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
|
/// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte
|
||||||
/// arrays.
|
/// arrays.
|
||||||
type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>);
|
type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>);
|
||||||
|
|
||||||
// TODO: encrypt at rest alongside broader wallet storage encryption work.
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[derive(Serialize, Deserialize)]
|
pub enum KeycardWalletError {
|
||||||
pub struct KeycardPairingData {
|
#[error(transparent)]
|
||||||
pub index: u8,
|
Keycard(#[from] keycard_rs::Error),
|
||||||
pub key: Vec<u8>,
|
#[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 {
|
/// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC.
|
||||||
const fn is_valid(&self) -> bool {
|
/// Only Secure Channel V2 cards are supported — see `require_secure_channel_v2`.
|
||||||
self.key.len() == 32 && self.index <= 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rust wrapper around the Python `KeycardWallet` class.
|
|
||||||
pub struct KeycardWallet {
|
pub struct KeycardWallet {
|
||||||
instance: Py<PyAny>,
|
command_set: KeycardCommandSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeycardWallet {
|
impl KeycardWallet {
|
||||||
/// Create a new Python `KeycardWallet` instance.
|
/// Connects to the first available PC/SC reader. Does not select the applet yet — callers
|
||||||
pub fn new(py: Python) -> PyResult<Self> {
|
/// that need application info (`initialize`, `connect`, ...) do that themselves.
|
||||||
let module = py.import("keycard_wallet")?;
|
///
|
||||||
let class = module.getattr("KeycardWallet")?;
|
/// 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`.
|
||||||
let instance = class.call0()?;
|
pub fn new() -> Result<Self, KeycardWalletError> {
|
||||||
|
let channel = PcscChannel::connect()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
instance: instance.into(),
|
command_set: Self::build_command_set(channel)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult<bool> {
|
fn build_command_set(channel: PcscChannel) -> Result<KeycardCommandSet, KeycardWalletError> {
|
||||||
self.instance
|
Ok(match ca_public_key_override()? {
|
||||||
.bind(py)
|
Some(ca) => KeycardCommandSet::new_with_ca(channel, ca),
|
||||||
.call_method0("is_unpaired_keycard_available")?
|
None => KeycardCommandSet::new(channel),
|
||||||
.extract()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult<bool> {
|
/// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both
|
||||||
self.instance
|
/// present.
|
||||||
.bind(py)
|
#[must_use]
|
||||||
.call_method1("initialize", (pin,))?
|
pub fn is_keycard_available() -> bool {
|
||||||
.extract()
|
let Ok(channel) = PcscChannel::connect() else {
|
||||||
}
|
return false;
|
||||||
|
};
|
||||||
pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec<u8>)> {
|
let Ok(mut command_set) = Self::build_command_set(channel) else {
|
||||||
self.instance
|
return false;
|
||||||
.bind(py)
|
};
|
||||||
.call_method1("pair", (pin,))?
|
if !command_set.select().is_ok_and(|resp| resp.is_ok()) {
|
||||||
.extract()
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
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(());
|
|
||||||
}
|
}
|
||||||
let (index, key) = self.pair(py, pin)?;
|
command_set.secure_channel_version() == Some(SecureChannelVersion::V2)
|
||||||
save_pairing(&KeycardPairingData { index, key });
|
}
|
||||||
|
|
||||||
|
fn select(&mut self) -> Result<(), KeycardWalletError> {
|
||||||
|
self.command_set.select()?.check_ok()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn disconnect(&self, py: Python) -> PyResult<bool> {
|
/// Rejects any card that isn't running Secure Channel V2 (older applets, or a card that
|
||||||
self.instance.bind(py).call_method0("disconnect")?.extract()
|
/// 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> {
|
/// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a
|
||||||
let public_key: Vec<u8> = self
|
/// transport-level error (e.g. the card lost power mid-session).
|
||||||
.instance
|
fn with_reconnect_on_transport_error<T>(
|
||||||
.bind(py)
|
&mut self,
|
||||||
.call_method1("get_public_key_for_path", (path,))?
|
op: impl Fn(&mut Self) -> Result<T, KeycardWalletError>,
|
||||||
.extract()?;
|
) -> Result<T, KeycardWalletError> {
|
||||||
|
match op(self) {
|
||||||
let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec<u8>| {
|
Err(KeycardWalletError::Keycard(keycard_rs::Error::Io(io_err))) => {
|
||||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
|
log::warn!(
|
||||||
"expected 32-byte public key from keycard, got {} bytes",
|
"transport error during keycard operation ({io_err}), reconnecting and retrying once"
|
||||||
vec.len()
|
);
|
||||||
))
|
*self = Self::new()?;
|
||||||
})?;
|
op(self)
|
||||||
|
}
|
||||||
PublicKey::try_new(public_key)
|
result => result,
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult<PublicKey> {
|
/// Initializes an uninitialized card, returning the generated PUK. The caller is responsible
|
||||||
Python::attach(|py| {
|
/// for surfacing the PUK to the operator — it cannot be recovered afterward.
|
||||||
python_path::add_python_path(py)?;
|
pub fn initialize(&mut self, pin: &str) -> Result<String, KeycardWalletError> {
|
||||||
let wallet = Self::new(py)?;
|
self.select()?;
|
||||||
wallet.connect(py, pin)?;
|
self.require_secure_channel_v2()?;
|
||||||
let pub_key = wallet.get_public_key_for_path(py, path);
|
let already_initialized = self
|
||||||
drop(wallet.close_session(py));
|
.command_set
|
||||||
pub_key
|
.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(
|
pub fn sign_message_for_path(
|
||||||
&self,
|
&mut self,
|
||||||
py: Python,
|
|
||||||
path: &str,
|
path: &str,
|
||||||
message: &[u8; 32],
|
message: &[u8; 32],
|
||||||
) -> PyResult<(Signature, PublicKey)> {
|
) -> Result<(Signature, PublicKey), KeycardWalletError> {
|
||||||
let py_signature: Vec<u8> = self
|
let resp = self.command_set.sign_with_path_and_algo(
|
||||||
.instance
|
message,
|
||||||
.bind(py)
|
path,
|
||||||
.call_method1("sign_message_for_path", (message, path))?
|
sign_p2::BIP340_SCHNORR,
|
||||||
.extract()?;
|
false,
|
||||||
|
)?;
|
||||||
|
resp.check_ok()?;
|
||||||
|
|
||||||
let sig = Signature {
|
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) {
|
if !sig.is_valid_for(message, &pub_key) {
|
||||||
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
|
return Err(KeycardWalletError::SignatureVerificationFailed);
|
||||||
"keycard returned a signature that does not verify against its own public key",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok((sig, pub_key))
|
Ok((sig, pub_key))
|
||||||
}
|
}
|
||||||
@ -162,39 +233,40 @@ impl KeycardWallet {
|
|||||||
pin: &str,
|
pin: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
message: &[u8; 32],
|
message: &[u8; 32],
|
||||||
) -> PyResult<(Signature, PublicKey)> {
|
) -> Result<(Signature, PublicKey), KeycardWalletError> {
|
||||||
Python::attach(|py| {
|
let mut wallet = Self::new()?;
|
||||||
python_path::add_python_path(py)?;
|
wallet.connect(pin)?;
|
||||||
let wallet = Self::new(py)?;
|
wallet.sign_message_for_path(path, message)
|
||||||
wallet.connect(py, pin)?;
|
|
||||||
let result = wallet.sign_message_for_path(py, path, message);
|
|
||||||
drop(wallet.close_session(py));
|
|
||||||
result
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> {
|
pub fn load_mnemonic(&mut self, mnemonic: &str) -> Result<(), KeycardWalletError> {
|
||||||
self.instance
|
let mnemonic = bip39::Mnemonic::from_str(mnemonic)
|
||||||
.bind(py)
|
.map_err(|e| KeycardWalletError::InvalidMnemonic(e.to_string()))?;
|
||||||
.call_method1("load_mnemonic", (mnemonic,))?;
|
let seed = mnemonic.to_seed("");
|
||||||
|
self.command_set.load_lee_key(&seed)?.check_ok()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_public_account_id_for_path_with_connect(
|
pub fn get_public_account_id_for_path_with_connect(
|
||||||
pin: &str,
|
pin: &str,
|
||||||
key_path: &str,
|
key_path: &str,
|
||||||
) -> PyResult<String> {
|
) -> Result<String, KeycardWalletError> {
|
||||||
let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?;
|
let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?;
|
||||||
|
|
||||||
Ok(format!("Public/{}", AccountId::from(&public_key)))
|
Ok(format!("Public/{}", AccountId::from(&public_key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult<PrivateKeyPair> {
|
pub fn get_private_keys_for_path(
|
||||||
let (raw_nsk, raw_vsk): (Vec<u8>, Vec<u8>) = self
|
&mut self,
|
||||||
.instance
|
path: &str,
|
||||||
.bind(py)
|
) -> Result<PrivateKeyPair, KeycardWalletError> {
|
||||||
.call_method1("get_private_keys_for_path", (path,))?
|
let resp = self.command_set.export_lee_key(path)?;
|
||||||
.extract()?;
|
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 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))?;
|
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(
|
pub fn get_private_keys_for_path_with_connect(
|
||||||
pin: &str,
|
pin: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> PyResult<PrivateKeyPair> {
|
) -> Result<PrivateKeyPair, KeycardWalletError> {
|
||||||
Python::attach(|py| {
|
let mut wallet = Self::new()?;
|
||||||
python_path::add_python_path(py)?;
|
wallet.connect(pin)?;
|
||||||
let wallet = Self::new(py)?;
|
wallet.get_private_keys_for_path(path)
|
||||||
wallet.connect(py, pin)?;
|
|
||||||
let result = wallet.get_private_keys_for_path(py, path);
|
|
||||||
drop(wallet.disconnect(py));
|
|
||||||
result
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keycard Python library strips leading zeros from S when S < 2^(8k) for some k.
|
fn generate_puk() -> String {
|
||||||
/// Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S).
|
let mut rng = rand::rngs::OsRng;
|
||||||
#[expect(
|
std::iter::repeat_with(|| char::from(rng.gen_range(b'0'..=b'9')))
|
||||||
clippy::arithmetic_side_effects,
|
.take(12)
|
||||||
reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]"
|
.collect()
|
||||||
)]
|
}
|
||||||
fn normalize_keycard_signature(py_signature: Vec<u8>) -> PyResult<[u8; 64]> {
|
|
||||||
if py_signature.len() < 64 {
|
/// Optional override for the CA public key used to verify a card's identity certificate, read
|
||||||
if py_signature.len() < 32 {
|
/// from `KEYCARD_CA_PUBLIC_KEY` as 66 hex characters (a 33-byte compressed secp256k1 key).
|
||||||
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
|
/// Falls back to `keycard-rs`'s production default when unset.
|
||||||
"signature from keycard too short: {} bytes",
|
///
|
||||||
py_signature.len()
|
/// 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
|
||||||
let s_stripped = &py_signature[32..];
|
/// override at all.
|
||||||
let mut padded = [0_u8; 64];
|
fn ca_public_key_override() -> Result<Option<[u8; 33]>, KeycardWalletError> {
|
||||||
padded[..32].copy_from_slice(&py_signature[..32]);
|
let Ok(hex_str) = std::env::var("KEYCARD_CA_PUBLIC_KEY") else {
|
||||||
padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped);
|
return Ok(None);
|
||||||
Ok(padded)
|
};
|
||||||
} else {
|
let mut bytes = [0_u8; 33];
|
||||||
py_signature.try_into().map_err(|vec: Vec<u8>| {
|
hex::decode_to_slice(hex_str.trim(), &mut bytes)
|
||||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
|
.map_err(|e| KeycardWalletError::InvalidCaPublicKey(e.to_string()))?;
|
||||||
"Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})",
|
Ok(Some(bytes))
|
||||||
vec.len(),
|
}
|
||||||
vec
|
|
||||||
))
|
/// 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(
|
#[expect(
|
||||||
@ -254,9 +346,9 @@ fn normalize_keycard_signature(py_signature: Vec<u8>) -> PyResult<[u8; 64]> {
|
|||||||
fn zeroizing_fixed_bytes<const N: usize>(
|
fn zeroizing_fixed_bytes<const N: usize>(
|
||||||
label: &str,
|
label: &str,
|
||||||
raw: Zeroizing<Vec<u8>>,
|
raw: Zeroizing<Vec<u8>>,
|
||||||
) -> PyResult<Zeroizing<[u8; N]>> {
|
) -> Result<Zeroizing<[u8; N]>, KeycardWalletError> {
|
||||||
if raw.len() != N {
|
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",
|
"expected {N}-byte {label} from keycard, got {} bytes",
|
||||||
raw.len()
|
raw.len()
|
||||||
)));
|
)));
|
||||||
@ -265,35 +357,3 @@ fn zeroizing_fixed_bytes<const N: usize>(
|
|||||||
arr.copy_from_slice(&raw);
|
arr.copy_from_slice(&raw);
|
||||||
Ok(arr)
|
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
|
# Forces a card power cycle before each keycard-backed wallet command to verify
|
||||||
# commands survive mid-session power loss.
|
# commands survive mid-session power loss.
|
||||||
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
export KEYCARD_PIN=111111
|
export KEYCARD_PIN=111111
|
||||||
|
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
unpower() {
|
unpower() {
|
||||||
python "$SCRIPT_DIR/force_unpower.py"
|
cargo run -q --manifest-path "$SCRIPT_DIR/../Cargo.toml" --bin force_unpower
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Test: wallet keycard available"
|
echo "Test: wallet keycard available"
|
||||||
|
|||||||
@ -2,14 +2,12 @@
|
|||||||
# keycard_test_3.sh — tests for `wallet keycard get-private-keys`.
|
# keycard_test_3.sh — tests for `wallet keycard get-private-keys`.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# 1. Run wallet_with_keycard.sh once to install dependencies.
|
# 1. Keycard reader inserted with card loaded (wallet keycard load has been run).
|
||||||
# 2. Keycard reader inserted with card loaded (wallet keycard load has been run).
|
|
||||||
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
cargo install --path lez/wallet --force --features keycard-debug
|
cargo install --path lez/wallet --force --features keycard-debug
|
||||||
|
|
||||||
export KEYCARD_PIN=111111
|
export KEYCARD_PIN=111111
|
||||||
|
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
|
||||||
|
|
||||||
echo "=== Test: wallet keycard get-private-keys path 10 ==="
|
echo "=== Test: wallet keycard get-private-keys path 10 ==="
|
||||||
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal
|
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Run wallet_with_keycard.sh first
|
# Run `cargo install --path lez/wallet --force` first
|
||||||
|
|
||||||
source venv/bin/activate # Load the appropriate virtual environment
|
|
||||||
|
|
||||||
export KEYCARD_PIN=111111
|
export KEYCARD_PIN=111111
|
||||||
|
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
|
||||||
|
|
||||||
# Tests wallet keycard available
|
# Tests wallet keycard available
|
||||||
# - Checks whether smart reader and keycard are both available.
|
# - Checks whether smart reader and keycard are both available.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
# keycard_tests_2.sh — comprehensive token + AMM keycard integration tests.
|
# keycard_tests_2.sh — comprehensive token + AMM keycard integration tests.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# 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.
|
# 2. Reset the local chain so all accounts are uninitialized.
|
||||||
# 3. Keycard reader inserted with card loaded.
|
# 3. Keycard reader inserted with card loaded.
|
||||||
#
|
#
|
||||||
@ -23,8 +23,8 @@
|
|||||||
# amm-lee-fund → public LEE holding used to seed the AMM pool
|
# 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)
|
# (LP holding for amm new is created fresh each run — no persistent label)
|
||||||
|
|
||||||
source venv/bin/activate
|
|
||||||
export KEYCARD_PIN=111111
|
export KEYCARD_PIN=111111
|
||||||
|
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Keycard setup
|
# 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>> {
|
pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError<T>> {
|
||||||
self.sender.send(item).await
|
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)]
|
#[cfg(test)]
|
||||||
@ -123,6 +128,19 @@ mod tests {
|
|||||||
assert_eq!(pool.pop(), Some(2));
|
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]
|
#[test]
|
||||||
async fn push_front() {
|
async fn push_front() {
|
||||||
let (mut pool, handle) = MemPool::new(10);
|
let (mut pool, handle) = MemPool::new(10);
|
||||||
|
|||||||
@ -10,8 +10,8 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
lee.workspace = true
|
lee.workspace = true
|
||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
|
chain_state.workspace = true
|
||||||
common.workspace = true
|
common.workspace = true
|
||||||
chain_consistency.workspace = true
|
|
||||||
storage.workspace = true
|
storage.workspace = true
|
||||||
mempool.workspace = true
|
mempool.workspace = true
|
||||||
logos-blockchain-zone-sdk.workspace = true
|
logos-blockchain-zone-sdk.workspace = true
|
||||||
@ -34,6 +34,7 @@ tempfile.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
tokio-util.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
borsh.workspace = true
|
borsh.workspace = true
|
||||||
bytesize.workspace = true
|
bytesize.workspace = true
|
||||||
|
|||||||
@ -1,23 +1,44 @@
|
|||||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
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 common::block::Block;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
|
pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId};
|
||||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription};
|
use logos_blockchain_core::{
|
||||||
pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey};
|
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;
|
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
|
||||||
use logos_blockchain_zone_sdk::{
|
use logos_blockchain_zone_sdk::{
|
||||||
CommonHttpClient, Slot, ZoneMessage,
|
CommonHttpClient, Slot, ZoneMessage,
|
||||||
adapter::{Node as _, NodeHttpClient},
|
adapter::{Node as _, NodeHttpClient},
|
||||||
indexer::ZoneIndexer,
|
indexer::ZoneIndexer,
|
||||||
sequencer::{
|
sequencer::{
|
||||||
DepositInfo, Event, FinalizedOp, InscriptionInfo,
|
DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx,
|
||||||
SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer,
|
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;
|
use crate::config::BedrockConfig;
|
||||||
|
|
||||||
@ -43,6 +64,36 @@ pub type OnDepositEventSink =
|
|||||||
pub type OnWithdrawEventSink =
|
pub type OnWithdrawEventSink =
|
||||||
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
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")]
|
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||||
pub trait BlockPublisherTrait: Sized {
|
pub trait BlockPublisherTrait: Sized {
|
||||||
#[expect(
|
#[expect(
|
||||||
@ -58,14 +109,23 @@ pub trait BlockPublisherTrait: Sized {
|
|||||||
on_finalized_block: FinalizedBlockSink,
|
on_finalized_block: FinalizedBlockSink,
|
||||||
on_deposit_event: OnDepositEventSink,
|
on_deposit_event: OnDepositEventSink,
|
||||||
on_withdraw_event: OnWithdrawEventSink,
|
on_withdraw_event: OnWithdrawEventSink,
|
||||||
|
on_follow: OnFollowSink,
|
||||||
) -> Result<Self>;
|
) -> Result<Self>;
|
||||||
|
|
||||||
/// Fire-and-forget publish. Zone-sdk drives the actual submission and
|
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
|
||||||
/// retries internally; this just hands the payload off.
|
/// Zone-sdk drives the actual submission and retries internally.
|
||||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()>;
|
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
|
||||||
|
|
||||||
fn channel_id(&self) -> ChannelId;
|
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
|
/// Current channel frontier slot on the connected chain, or `None` if the
|
||||||
/// channel does not exist there. Drives the startup frontier check.
|
/// channel does not exist there. Drives the startup frontier check.
|
||||||
async fn channel_tip_slot(&self) -> Result<Option<Slot>>;
|
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
|
/// Direct node handle retained for channel reads (startup consistency check
|
||||||
/// and reconstruction); the sequencer itself lives in the drive task.
|
/// and reconstruction); the sequencer itself lives in the drive task.
|
||||||
node: NodeHttpClient,
|
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.
|
// Aborts the drive task when the last clone is dropped.
|
||||||
_drive_task: Arc<DriveTaskGuard>,
|
_drive_task: Arc<DriveTaskGuard>,
|
||||||
indexer: ZoneIndexer<NodeHttpClient>,
|
indexer: ZoneIndexer<NodeHttpClient>,
|
||||||
@ -109,6 +172,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
on_finalized_block: FinalizedBlockSink,
|
on_finalized_block: FinalizedBlockSink,
|
||||||
on_deposit_event: OnDepositEventSink,
|
on_deposit_event: OnDepositEventSink,
|
||||||
on_withdraw_event: OnWithdrawEventSink,
|
on_withdraw_event: OnWithdrawEventSink,
|
||||||
|
on_follow: OnFollowSink,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let basic_auth = config.auth.clone().map(Into::into);
|
let basic_auth = config.auth.clone().map(Into::into);
|
||||||
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
|
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
|
// Grab readiness receiver before moving the sequencer into the drive
|
||||||
// task so we can await cold-start completion below.
|
// task so we can await cold-start completion below.
|
||||||
let mut ready_rx = sequencer.subscribe_ready();
|
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) =
|
let (command_tx, mut command_rx): (CommandSender, _) =
|
||||||
mpsc::channel::<(Inscription, Vec<WithdrawArg>)>(PUBLISH_INBOX_CAPACITY);
|
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 {
|
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 {
|
loop {
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::integer_division_remainder_used,
|
clippy::integer_division_remainder_used,
|
||||||
@ -141,32 +212,38 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
)]
|
)]
|
||||||
{
|
{
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Drain external publish requests by calling the
|
// Drain external commands by calling the borrowing
|
||||||
// borrowing handle — `&mut sequencer` is only
|
// handle — `&mut sequencer` is only available here.
|
||||||
// available here.
|
Some(command) = command_rx.recv() => match command {
|
||||||
Some((data_bounded, withdrawals)) = publish_rx.recv() => {
|
Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => {
|
||||||
let data_byte_size = data_bounded.len();
|
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 {
|
|
||||||
let withdraw_count = withdrawals.len();
|
let withdraw_count = withdrawals.len();
|
||||||
if let Err(e) = sequencer.handle()
|
let published = if withdrawals.is_empty() {
|
||||||
.publish_atomic_withdraw(data_bounded, withdrawals)
|
sequencer.handle()
|
||||||
.context("Failed to publish block with withdrawals") {
|
.publish(data_bounded)
|
||||||
warn!("zone-sdk publish failed: {e:?}");
|
.context("Failed to publish block")
|
||||||
}
|
} else {
|
||||||
|
sequencer.handle()
|
||||||
|
.publish_atomic_withdraw(data_bounded, withdrawals)
|
||||||
|
.context("Failed to publish block with withdrawals")
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
let msg_result = published
|
||||||
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
|
.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() => {
|
event = sequencer.next_event() => {
|
||||||
let Some(event) = event else {
|
let Some(event) = event else {
|
||||||
continue;
|
continue;
|
||||||
@ -174,17 +251,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
match event {
|
match event {
|
||||||
Event::BlocksProcessed {
|
Event::BlocksProcessed {
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channel_update: _,
|
channel_update,
|
||||||
finalized,
|
finalized,
|
||||||
} => {
|
} => {
|
||||||
on_checkpoint(checkpoint);
|
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) {
|
for op in finalized.into_iter().flat_map(|item| item.ops) {
|
||||||
match op {
|
match op {
|
||||||
FinalizedOp::Inscription(inscription) => {
|
FinalizedOp::Inscription(inscription) => {
|
||||||
if let Some(block_id) =
|
if let Some((msg, block)) =
|
||||||
block_id_from_inscription(&inscription)
|
block_from_inscription(&inscription)
|
||||||
{
|
{
|
||||||
on_finalized_block(block_id);
|
on_finalized_block(block.header.block_id);
|
||||||
|
finalized_blocks.push((msg, block));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FinalizedOp::Deposit(deposit) => {
|
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,
|
channel_id: config.channel_id,
|
||||||
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
|
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
|
||||||
node,
|
node,
|
||||||
publish_tx,
|
command_tx,
|
||||||
|
turn_rx,
|
||||||
|
driver_cancellation,
|
||||||
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
|
_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 = borsh::to_vec(block).context("Failed to serialize block")?;
|
||||||
let data_bounded: Inscription = data
|
let data_bounded: Inscription = data
|
||||||
.try_into()
|
.try_into()
|
||||||
.context("Block data exceeds maximum allowed size")?;
|
.context("Block data exceeds maximum allowed size")?;
|
||||||
|
|
||||||
self.publish_tx
|
let (resp_tx, resp_rx) = oneshot::channel();
|
||||||
.send((data_bounded, withdrawals))
|
self.command_tx
|
||||||
|
.send(Command::Publish {
|
||||||
|
inscription: data_bounded,
|
||||||
|
withdrawals,
|
||||||
|
resp: resp_tx,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
.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 {
|
fn channel_id(&self) -> ChannelId {
|
||||||
self.channel_id
|
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>> {
|
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.node
|
.node
|
||||||
@ -260,13 +383,87 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deserialize inscription payload as a `Block` and return it's`block_id`.
|
/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are
|
||||||
/// Bad payloads are logged and skipped.
|
/// logged and skipped.
|
||||||
fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option<u64> {
|
fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> {
|
||||||
borsh::from_slice::<Block>(&inscription.payload)
|
borsh::from_slice::<Block>(&inscription.payload)
|
||||||
.inspect_err(|err| {
|
.inspect_err(|err| {
|
||||||
warn!("Failed to deserialize block from inscription: {err:?}");
|
warn!("Failed to deserialize block from inscription: {err:?}");
|
||||||
})
|
})
|
||||||
.ok()
|
.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> {
|
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
|
||||||
self.dbio.is_deposit_event_submitted(deposit_op_id)
|
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> {
|
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 anyhow::{Context as _, Result, anyhow};
|
||||||
use borsh::BorshDeserialize;
|
use borsh::BorshDeserialize;
|
||||||
use chain_consistency::{Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, Tip};
|
use chain_state::{
|
||||||
|
AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip,
|
||||||
|
};
|
||||||
use common::{
|
use common::{
|
||||||
HashType,
|
HashType,
|
||||||
block::{BedrockStatus, Block, HashableBlockData},
|
block::{BedrockStatus, Block, BlockMeta, HashableBlockData},
|
||||||
transaction::{LeeTransaction, clock_invocation},
|
transaction::{LeeTransaction, clock_invocation},
|
||||||
};
|
};
|
||||||
use config::{GenesisAction, SequencerConfig};
|
use config::{GenesisAction, SequencerConfig};
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
use itertools::Itertools 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 lee_core::GENESIS_BLOCK_ID;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||||
@ -30,7 +36,7 @@ use storage::sequencer::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
|
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
|
||||||
block_store::SequencerStore,
|
block_store::SequencerStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,18 +62,13 @@ struct DepositMetadata {
|
|||||||
recipient_id: lee::AccountId,
|
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> {
|
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,
|
store: SequencerStore,
|
||||||
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
|
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
|
||||||
sequencer_config: SequencerConfig,
|
sequencer_config: SequencerConfig,
|
||||||
chain_height: u64,
|
|
||||||
block_publisher: BP,
|
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(
|
pub async fn start_from_config(
|
||||||
config: SequencerConfig,
|
config: SequencerConfig,
|
||||||
) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) {
|
) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) {
|
||||||
let bedrock_signing_key =
|
let bedrock_signing_key =
|
||||||
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
|
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
|
||||||
.expect("Failed to load or create 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
|
let initial_checkpoint = store
|
||||||
.get_zone_checkpoint()
|
.get_zone_checkpoint()
|
||||||
@ -146,6 +199,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
Self::on_finalized_block(store.dbio()),
|
Self::on_finalized_block(store.dbio()),
|
||||||
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
|
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
|
||||||
Self::on_withdraw_event(store.dbio()),
|
Self::on_withdraw_event(store.dbio()),
|
||||||
|
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to initialize Block Publisher");
|
.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
|
// Before producing, verify our local state still belongs to the chain
|
||||||
// the channel serves and replay any channel blocks we are missing
|
// the channel serves and replay any channel blocks we are missing
|
||||||
// (e.g. from other sequencers).
|
// (e.g. from other sequencers).
|
||||||
let chanel_was_empty =
|
let channel_was_empty =
|
||||||
Self::verify_and_reconstruct(&block_publisher, &mut store, &mut state, is_fresh_start)
|
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
|
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
|
||||||
|
|
||||||
// Publish our blocks only when bootstrapping an empty channel. If the
|
// Publish our blocks only when bootstrapping an empty channel. If the
|
||||||
// channel already has blocks (another sequencer bootstrapped it), we
|
// channel already has blocks (another sequencer bootstrapped it), we
|
||||||
// adopted them during reconstruction instead.
|
// adopted them during reconstruction instead.
|
||||||
if is_fresh_start && chanel_was_empty {
|
if is_fresh_start && channel_was_empty {
|
||||||
let mut pending_blocks = store
|
let mut pending_blocks = store
|
||||||
.get_all_blocks()
|
.get_all_blocks()
|
||||||
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
|
.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 {
|
let sequencer_core = Self {
|
||||||
state,
|
chain,
|
||||||
store,
|
store,
|
||||||
mempool,
|
mempool,
|
||||||
chain_height: latest_block_meta.id,
|
|
||||||
sequencer_config: config,
|
sequencer_config: config,
|
||||||
block_publisher,
|
block_publisher,
|
||||||
};
|
};
|
||||||
@ -224,8 +272,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
/// Returns whatever channel was empty or not.
|
/// Returns whatever channel was empty or not.
|
||||||
async fn verify_and_reconstruct(
|
async fn verify_and_reconstruct(
|
||||||
publisher: &BP,
|
publisher: &BP,
|
||||||
store: &mut SequencerStore,
|
store: &SequencerStore,
|
||||||
state: &mut V03State,
|
chain: &Mutex<ChainState>,
|
||||||
is_fresh_start: bool,
|
is_fresh_start: bool,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let anchor_record = store
|
let anchor_record = store
|
||||||
@ -322,7 +370,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
channel_is_empty = false;
|
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)
|
Ok(channel_is_empty)
|
||||||
@ -332,8 +384,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
/// blocks we already hold (verifying their hash), a validated continuation
|
/// blocks we already hold (verifying their hash), a validated continuation
|
||||||
/// for new ones. Advances the persisted anchor to the block's slot.
|
/// for new ones. Advances the persisted anchor to the block's slot.
|
||||||
fn apply_reconstructed_block(
|
fn apply_reconstructed_block(
|
||||||
store: &mut SequencerStore,
|
store: &SequencerStore,
|
||||||
state: &mut V03State,
|
chain: &mut ChainState,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
slot: Slot,
|
slot: Slot,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@ -379,21 +431,31 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New continuation: validate it chains onto the tip, then apply.
|
// New continuation: channel history is finalized, so it goes through
|
||||||
let cc_tip = tip.as_ref().map(|tip| Tip {
|
// the final tier — validation happens inside `apply_finalized`.
|
||||||
block_id: tip.id,
|
match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) {
|
||||||
hash: tip.hash,
|
AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {}
|
||||||
});
|
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
|
||||||
chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| {
|
return Err(anyhow!(
|
||||||
anyhow!(
|
"Channel block {block_id} does not extend local tip {:?}: {err}",
|
||||||
"Channel block {block_id} does not extend local tip {:?}: {err}",
|
tip.map(|tip| tip.id)
|
||||||
tip.map(|tip| tip.id)
|
));
|
||||||
)
|
}
|
||||||
})?;
|
}
|
||||||
|
|
||||||
let mut scratch = state.clone();
|
// Persist like the follow path: the tip meta stays pinned to the head
|
||||||
chain_consistency::apply_block(block, &mut scratch)
|
// tip even when the reconstructed block lands below it.
|
||||||
.map_err(|err| anyhow!("Failed to apply channel block {block_id}: {err}"))?;
|
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
|
// Mark the deposits' pending records submitted so the production-time
|
||||||
// guard drops the mints cold-start backfill re-queued for them. Withdraw
|
// guard drops the mints cold-start backfill re-queued for them. Withdraw
|
||||||
@ -406,11 +468,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(extract_bridge_deposit_id)
|
.filter_map(extract_bridge_deposit_id)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
store
|
store
|
||||||
.update(block, &deposit_event_ids, Vec::new(), &scratch)
|
.mark_deposit_events_submitted(&deposit_event_ids, block_id)
|
||||||
.context("Failed to persist reconstructed block")?;
|
.context("Failed to mark reconstructed deposits submitted")?;
|
||||||
*state = scratch;
|
|
||||||
store
|
store
|
||||||
.set_zone_anchor(&record)
|
.set_zone_anchor(&record)
|
||||||
.context("Failed to persist zone anchor")?;
|
.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.
|
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
|
||||||
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
pub async fn produce_new_block(&mut self) -> Result<u64> {
|
||||||
let BlockWithMeta {
|
let BlockWithMeta {
|
||||||
@ -563,26 +635,77 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
.collect::<Result<_>>()
|
.collect::<Result<_>>()
|
||||||
.context("Failed to build reconciliation keys for block withdrawals")?;
|
.context("Failed to build reconciliation keys for block withdrawals")?;
|
||||||
|
|
||||||
self.block_publisher
|
let this_msg = self
|
||||||
|
.block_publisher
|
||||||
.publish_block(&block, withdrawals)
|
.publish_block(&block, withdrawals)
|
||||||
.await
|
.await
|
||||||
.context("Failed to publish block to Bedrock")?;
|
.context("Failed to publish block to Bedrock")?;
|
||||||
|
|
||||||
self.store.update(
|
self.record_produced_block(
|
||||||
|
this_msg,
|
||||||
&block,
|
&block,
|
||||||
&deposit_event_ids,
|
&deposit_event_ids,
|
||||||
withdrawal_reconciliation_keys,
|
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.
|
/// Validates and applies a single mempool transaction to the current state.
|
||||||
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
|
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
|
||||||
/// it was skipped due to validation failure.
|
/// 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(
|
fn apply_mempool_transaction(
|
||||||
&mut self,
|
store: &SequencerStore,
|
||||||
|
state: &mut lee::V03State,
|
||||||
origin: TransactionOrigin,
|
origin: TransactionOrigin,
|
||||||
tx: &LeeTransaction,
|
tx: &LeeTransaction,
|
||||||
block_height: u64,
|
block_height: u64,
|
||||||
@ -593,11 +716,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let tx_hash = tx.hash();
|
let tx_hash = tx.hash();
|
||||||
match origin {
|
match origin {
|
||||||
TransactionOrigin::User => {
|
TransactionOrigin::User => {
|
||||||
let validated_diff = match tx.validate_on_state(
|
let validated_diff = match tx.validate_on_state(state, block_height, timestamp) {
|
||||||
&self.state,
|
|
||||||
block_height,
|
|
||||||
timestamp,
|
|
||||||
) {
|
|
||||||
Ok(diff) => diff,
|
Ok(diff) => diff,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(
|
error!(
|
||||||
@ -611,7 +730,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
withdrawals.push(withdraw_data);
|
withdrawals.push(withdraw_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state.apply_state_diff(validated_diff);
|
state.apply_state_diff(validated_diff);
|
||||||
}
|
}
|
||||||
TransactionOrigin::Sequencer => {
|
TransactionOrigin::Sequencer => {
|
||||||
let LeeTransaction::Public(public_tx) = tx else {
|
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 let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
|
||||||
if self
|
if store
|
||||||
.store
|
|
||||||
.is_deposit_event_submitted(deposit_op_id)
|
.is_deposit_event_submitted(deposit_op_id)
|
||||||
.context("Failed to check whether deposit was already submitted")?
|
.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);
|
deposit_event_ids.push(deposit_op_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state
|
state
|
||||||
.transition_from_public_transaction(public_tx, block_height, timestamp)
|
.transition_from_public_transaction(public_tx, block_height, timestamp)
|
||||||
.context("Failed to execute sequencer-generated transaction")?;
|
.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> {
|
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
|
||||||
let now = Instant::now();
|
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 valid_transactions = Vec::new();
|
||||||
let mut deposit_event_ids = 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())
|
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
|
||||||
.expect("`max_block_size` should fit into usize");
|
.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())
|
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
|
||||||
.expect("Timestamp must be positive");
|
.expect("Timestamp must be positive");
|
||||||
|
|
||||||
@ -676,7 +799,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let temp_hashable_data = HashableBlockData {
|
let temp_hashable_data = HashableBlockData {
|
||||||
block_id: new_block_height,
|
block_id: new_block_height,
|
||||||
transactions: temp_valid_transactions,
|
transactions: temp_valid_transactions,
|
||||||
prev_block_hash: latest_block_meta.hash,
|
prev_block_hash,
|
||||||
timestamp: new_block_timestamp,
|
timestamp: new_block_timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -693,7 +816,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.apply_mempool_transaction(
|
if Self::apply_mempool_transaction(
|
||||||
|
&self.store,
|
||||||
|
&mut working_state,
|
||||||
origin,
|
origin,
|
||||||
&tx,
|
&tx,
|
||||||
new_block_height,
|
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)
|
.transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp)
|
||||||
.context("Clock transaction failed. Aborting block production.")?;
|
.context("Clock transaction failed. Aborting block production.")?;
|
||||||
valid_transactions.push(clock_lee_tx);
|
valid_transactions.push(clock_lee_tx);
|
||||||
@ -717,7 +842,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
let hashable_data = HashableBlockData {
|
let hashable_data = HashableBlockData {
|
||||||
block_id: new_block_height,
|
block_id: new_block_height,
|
||||||
transactions: valid_transactions,
|
transactions: valid_transactions,
|
||||||
prev_block_hash: latest_block_meta.hash,
|
prev_block_hash,
|
||||||
timestamp: new_block_timestamp,
|
timestamp: new_block_timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -725,8 +850,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
.clone()
|
.clone()
|
||||||
.into_pending_block(self.store.signing_key());
|
.into_pending_block(self.store.signing_key());
|
||||||
|
|
||||||
self.chain_height = new_block_height;
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created block with {} transactions in {} seconds",
|
"Created block with {} transactions in {} seconds",
|
||||||
hashable_data.transactions.len(),
|
hashable_data.transactions.len(),
|
||||||
@ -740,16 +863,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn state(&self) -> &lee::V03State {
|
/// Reads the current head state under the lock without cloning it, so callers
|
||||||
&self.state
|
/// 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 {
|
pub const fn block_store(&self) -> &SequencerStore {
|
||||||
&self.store
|
&self.store
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn chain_height(&self) -> u64 {
|
#[must_use]
|
||||||
self.chain_height
|
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 {
|
pub const fn sequencer_config(&self) -> &SequencerConfig {
|
||||||
@ -786,10 +920,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
|||||||
&self.block_publisher
|
&self.block_publisher
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_block_id(&self) -> u64 {
|
/// Whether this sequencer is currently authorized to write to the channel.
|
||||||
self.chain_height
|
#[must_use]
|
||||||
.checked_add(1)
|
pub fn is_our_turn(&self) -> bool {
|
||||||
.unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height))
|
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>,
|
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
|
/// 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
|
/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next
|
||||||
/// block.
|
/// block.
|
||||||
@ -848,21 +1088,30 @@ fn replay_unfulfilled_deposit_events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis
|
/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings,
|
||||||
/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be
|
/// the only accounts seeded outside any transaction. Cross-zone config is seeded
|
||||||
/// committed to the genesis block so external observers can replay them.
|
/// by genesis InitConfig transactions and reconstructed by replaying them.
|
||||||
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
|
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
|
||||||
#[cfg(not(feature = "testnet"))]
|
#[cfg(not(feature = "testnet"))]
|
||||||
let base = testnet_initial_state::initial_state();
|
let base = testnet_initial_state::initial_state();
|
||||||
|
|
||||||
#[cfg(feature = "testnet")]
|
#[cfg(feature = "testnet")]
|
||||||
let base = testnet_initial_state::initial_state_testnet();
|
let base = testnet_initial_state::initial_state_testnet();
|
||||||
|
|
||||||
// Bridge-lock holdings belong to the source side, seeded regardless of receiving config.
|
// Bridge-lock holder balances belong to the source side and are not produced by
|
||||||
let holdings: Vec<_> = bridge_lock_holdings(&config.genesis)
|
// any transaction, so seed them directly. Cross-zone config is seeded by genesis
|
||||||
.map(|(holder, amount)| cross_zone::build_holding_account(holder, amount))
|
// InitConfig transactions in `build_genesis_state`, not here.
|
||||||
.collect();
|
let holdings = bridge_lock_holdings(&config.genesis)
|
||||||
let mut state = base.with_public_accounts(holdings);
|
.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.
|
// Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's.
|
||||||
info!(
|
info!(
|
||||||
@ -892,12 +1141,11 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
|||||||
GenesisAction::SupplyBridgeAccount { balance } => {
|
GenesisAction::SupplyBridgeAccount { balance } => {
|
||||||
Some(build_supply_bridge_account_genesis_transaction(*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,
|
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
|
let genesis_txs = wrapped_token_config_tx
|
||||||
.chain(inbox_config_tx)
|
.chain(inbox_config_tx)
|
||||||
.chain(supply_txs)
|
.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> {
|
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")?;
|
.context("Failed to decode finalized Bedrock deposit metadata")?;
|
||||||
|
|
||||||
let bridge_program_id = programs::bridge().id();
|
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]
|
#[must_use]
|
||||||
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
|
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
|
||||||
let LeeTransaction::Public(tx) = tx else {
|
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.
|
/// 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() {
|
if path.exists() {
|
||||||
let key_bytes = std::fs::read(path)?;
|
let key_bytes = std::fs::read(path)?;
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,14 @@ use std::time::Duration;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::block::Block;
|
use common::block::Block;
|
||||||
use futures::Stream;
|
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_key_management_system_service::keys::Ed25519Key;
|
||||||
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
|
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
block_publisher::{
|
block_publisher::{
|
||||||
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
|
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
|
||||||
OnWithdrawEventSink, SequencerCheckpoint,
|
OnWithdrawEventSink, SequencerCheckpoint,
|
||||||
},
|
},
|
||||||
config::BedrockConfig,
|
config::BedrockConfig,
|
||||||
@ -20,6 +21,8 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MockBlockPublisher {
|
pub struct MockBlockPublisher {
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
|
// Never cancelled: the mock driver never dies.
|
||||||
|
driver_cancellation: CancellationToken,
|
||||||
/// Canned channel frontier returned by [`Self::channel_tip_slot`].
|
/// Canned channel frontier returned by [`Self::channel_tip_slot`].
|
||||||
tip_slot: Option<Slot>,
|
tip_slot: Option<Slot>,
|
||||||
/// Canned finalized channel history returned by [`Self::read_channel_after`].
|
/// Canned finalized channel history returned by [`Self::read_channel_after`].
|
||||||
@ -31,13 +34,14 @@ impl MockBlockPublisher {
|
|||||||
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
|
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
|
||||||
/// serves an empty channel.
|
/// serves an empty channel.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn with_canned_channel(
|
pub fn with_canned_channel(
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
tip_slot: Option<Slot>,
|
tip_slot: Option<Slot>,
|
||||||
messages: Vec<(ZoneMessage, Slot)>,
|
messages: Vec<(ZoneMessage, Slot)>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
driver_cancellation: CancellationToken::new(),
|
||||||
tip_slot,
|
tip_slot,
|
||||||
messages,
|
messages,
|
||||||
}
|
}
|
||||||
@ -54,9 +58,11 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
|||||||
_on_finalized_block: FinalizedBlockSink,
|
_on_finalized_block: FinalizedBlockSink,
|
||||||
_on_deposit_event: OnDepositEventSink,
|
_on_deposit_event: OnDepositEventSink,
|
||||||
_on_withdraw_event: OnWithdrawEventSink,
|
_on_withdraw_event: OnWithdrawEventSink,
|
||||||
|
_on_follow: OnFollowSink,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
channel_id: config.channel_id,
|
channel_id: config.channel_id,
|
||||||
|
driver_cancellation: CancellationToken::new(),
|
||||||
tip_slot: None,
|
tip_slot: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
})
|
})
|
||||||
@ -64,16 +70,27 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
|||||||
|
|
||||||
async fn publish_block(
|
async fn publish_block(
|
||||||
&self,
|
&self,
|
||||||
_block: &Block,
|
block: &Block,
|
||||||
_bridge_withdrawals: Vec<WithdrawArg>,
|
_bridge_withdrawals: Vec<WithdrawArg>,
|
||||||
) -> Result<()> {
|
) -> Result<MsgId> {
|
||||||
Ok(())
|
// 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 {
|
fn channel_id(&self) -> ChannelId {
|
||||||
self.channel_id
|
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>> {
|
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
|
||||||
Ok(self.tip_slot)
|
Ok(self.tip_slot)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration};
|
|||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
HashType,
|
HashType,
|
||||||
block::HashableBlockData,
|
block::{BedrockStatus, HashableBlockData},
|
||||||
test_utils::sequencer_sign_key_for_testing,
|
test_utils::sequencer_sign_key_for_testing,
|
||||||
transaction::{LeeTransaction, clock_invocation},
|
transaction::{LeeTransaction, clock_invocation},
|
||||||
};
|
};
|
||||||
@ -22,19 +22,21 @@ use lee_core::{
|
|||||||
account::{AccountWithMetadata, Nonce},
|
account::{AccountWithMetadata, Nonce},
|
||||||
program::PdaSeed,
|
program::PdaSeed,
|
||||||
};
|
};
|
||||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
||||||
use mempool::MemPoolHandle;
|
use mempool::MemPoolHandle;
|
||||||
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
TransactionOrigin,
|
TransactionOrigin, apply_follow_update,
|
||||||
|
block_publisher::FollowUpdate,
|
||||||
block_store::SequencerStore,
|
block_store::SequencerStore,
|
||||||
build_genesis_state,
|
build_bridge_deposit_tx_from_event, build_genesis_state,
|
||||||
config::{BedrockConfig, SequencerConfig},
|
config::{BedrockConfig, SequencerConfig},
|
||||||
is_sequencer_only_program,
|
is_sequencer_only_program,
|
||||||
mock::SequencerCoreWithMockClients,
|
mock::SequencerCoreWithMockClients,
|
||||||
|
resubmittable_txs,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod reconstruction;
|
mod reconstruction;
|
||||||
@ -148,14 +150,14 @@ async fn start_from_config() {
|
|||||||
let (sequencer, _mempool_handle) =
|
let (sequencer, _mempool_handle) =
|
||||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
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);
|
assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10);
|
||||||
|
|
||||||
let acc1_account_id = initial_public_user_accounts()[0].account_id;
|
let acc1_account_id = initial_public_user_accounts()[0].account_id;
|
||||||
let acc2_account_id = initial_public_user_accounts()[1].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_1 = sequencer.with_state(|s| s.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_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
|
||||||
|
|
||||||
assert_eq!(10000, balance_acc_1);
|
assert_eq!(10000, balance_acc_1);
|
||||||
assert_eq!(20000, balance_acc_2);
|
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) =
|
let (sequencer, _mempool_handle) =
|
||||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
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());
|
assert!(sequencer.store.latest_block_meta().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,7 +299,7 @@ async fn transaction_pre_check_native_transfer_valid() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn transaction_pre_check_native_transfer_other_signature() {
|
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 acc1 = initial_public_user_accounts()[0].account_id;
|
||||||
let acc2 = initial_public_user_accounts()[1].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();
|
let tx = tx.transaction_stateless_check().unwrap();
|
||||||
|
|
||||||
// Signature is not from sender. Execution fails
|
// 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!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
@ -321,7 +331,7 @@ async fn transaction_pre_check_native_transfer_other_signature() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn transaction_pre_check_native_transfer_sent_too_much() {
|
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 acc1 = initial_public_user_accounts()[0].account_id;
|
||||||
let acc2 = initial_public_user_accounts()[1].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
|
// Passed pre-check
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let result = result
|
let result = result.unwrap().execute_check_on_state(
|
||||||
.unwrap()
|
sequencer
|
||||||
.execute_check_on_state(&mut sequencer.state, 0, 0);
|
.chain()
|
||||||
|
.lock()
|
||||||
|
.expect("chain mutex poisoned")
|
||||||
|
.head_state_mut(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
let is_failed_at_balance_mismatch = matches!(
|
let is_failed_at_balance_mismatch = matches!(
|
||||||
result.err().unwrap(),
|
result.err().unwrap(),
|
||||||
lee::error::LeeError::ProgramExecutionFailed(_)
|
lee::error::LeeError::ProgramExecutionFailed(_)
|
||||||
@ -350,7 +366,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn transaction_execute_native_transfer() {
|
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 acc1 = initial_public_user_accounts()[0].account_id;
|
||||||
let acc2 = initial_public_user_accounts()[1].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,
|
acc1, 0, acc2, 100, &sign_key1,
|
||||||
);
|
);
|
||||||
|
|
||||||
tx.execute_check_on_state(&mut sequencer.state, 0, 0)
|
tx.execute_check_on_state(
|
||||||
.unwrap();
|
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_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance);
|
||||||
let bal_to = sequencer.state.get_account_by_id(acc2).balance;
|
let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance);
|
||||||
|
|
||||||
assert_eq!(bal_from, 9900);
|
assert_eq!(bal_from, 9900);
|
||||||
assert_eq!(bal_to, 20100);
|
assert_eq!(bal_to, 20100);
|
||||||
@ -402,7 +426,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn build_block_from_mempool() {
|
async fn build_block_from_mempool() {
|
||||||
let (mut sequencer, mempool_handle) = common_setup().await;
|
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();
|
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||||
mempool_handle
|
mempool_handle
|
||||||
@ -412,7 +436,8 @@ async fn build_block_from_mempool() {
|
|||||||
|
|
||||||
let result = sequencer.build_block_from_mempool();
|
let result = sequencer.build_block_from_mempool();
|
||||||
assert!(result.is_ok());
|
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]
|
#[tokio::test]
|
||||||
@ -444,7 +469,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() {
|
|||||||
sequencer.produce_new_block().await.unwrap();
|
sequencer.produce_new_block().await.unwrap();
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -479,7 +504,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
|
|||||||
sequencer.produce_new_block().await.unwrap();
|
sequencer.produce_new_block().await.unwrap();
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -498,7 +523,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
|
|||||||
sequencer.produce_new_block().await.unwrap();
|
sequencer.produce_new_block().await.unwrap();
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// The replay is rejected, so only the clock tx is in the block.
|
// 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();
|
sequencer.produce_new_block().await.unwrap();
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -556,8 +581,8 @@ async fn restart_from_storage() {
|
|||||||
// with the above transaction and update the state to reflect that.
|
// with the above transaction and update the state to reflect that.
|
||||||
let (sequencer, _mempool_handle) =
|
let (sequencer, _mempool_handle) =
|
||||||
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
|
||||||
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_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.state.get_account_by_id(acc2_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
|
// Balances should be consistent with the stored block
|
||||||
assert_eq!(
|
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
|
// Step 5: Verify the new block has correct previous block metadata
|
||||||
let new_block = sequencer
|
let new_block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -707,7 +732,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() {
|
|||||||
|
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -762,7 +787,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() {
|
|||||||
|
|
||||||
let block = sequencer
|
let block = sequencer
|
||||||
.store
|
.store
|
||||||
.get_block_at_id(sequencer.chain_height)
|
.get_block_at_id(sequencer.chain_height())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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.
|
// Corrupt the clock 01 account data so the clock program panics on deserialization.
|
||||||
let clock_account_id = system_accounts::clock_account_ids()[0];
|
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();
|
corrupted.data = vec![0xff; 3].try_into().unwrap();
|
||||||
sequencer
|
sequencer
|
||||||
.state
|
.chain()
|
||||||
|
.lock()
|
||||||
|
.expect("chain mutex poisoned")
|
||||||
|
.head_state_mut()
|
||||||
.force_insert_account(clock_account_id, corrupted);
|
.force_insert_account(clock_account_id, corrupted);
|
||||||
|
|
||||||
// Push a dummy transaction so the mempool is non-empty.
|
// Push a dummy transaction so the mempool is non-empty.
|
||||||
@ -864,6 +892,7 @@ fn private_bridge_withdraw_invocation_is_dropped() {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: sender_keys.viewing_public_key.clone(),
|
vpk: sender_keys.viewing_public_key.clone(),
|
||||||
random_seed: [0; 32],
|
random_seed: [0; 32],
|
||||||
|
view_tag: 0,
|
||||||
nsk: sender_keys.private_key_holder.nullifier_secret_key,
|
nsk: sender_keys.private_key_holder.nullifier_secret_key,
|
||||||
membership_proof: state
|
membership_proof: state
|
||||||
.get_proof_for_commitment(&sender_commitment)
|
.get_proof_for_commitment(&sender_commitment)
|
||||||
@ -1247,3 +1276,509 @@ fn pda_mechanism_with_pinata_token_program() {
|
|||||||
expected_winner_token_holding_post
|
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"
|
reason = "We don't care about it in tests"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use chain_state::ChainState;
|
||||||
use common::block::Block;
|
use common::block::Block;
|
||||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||||
use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage};
|
use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage};
|
||||||
@ -14,6 +17,16 @@ use crate::{
|
|||||||
SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher,
|
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) {
|
fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
|
||||||
let bytes = borsh::to_vec(block).expect("serialize block");
|
let bytes = borsh::to_vec(block).expect("serialize block");
|
||||||
let message = ZoneMessage::Block(ZoneBlock {
|
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.
|
// Sequencer B starts from a fresh store and reconstructs A's chain.
|
||||||
let config_b = setup_sequencer_config();
|
let config_b = setup_sequencer_config();
|
||||||
let (mut store_b, mut state_b) =
|
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
|
||||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||||
|
|
||||||
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||||
&mock_b,
|
&mock_b, &store_b, &chain_b, true,
|
||||||
&mut store_b,
|
|
||||||
&mut state_b,
|
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("reconstruct");
|
.expect("reconstruct");
|
||||||
@ -72,10 +81,12 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
|||||||
assert_eq!(tip_b.hash, tip_a.hash);
|
assert_eq!(tip_b.hash, tip_a.hash);
|
||||||
|
|
||||||
// State matches: initial account balances agree with sequencer A.
|
// 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() {
|
for account in initial_public_user_accounts() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state_b.get_account_by_id(account.account_id).balance,
|
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.
|
// Re-running is idempotent: everything is already applied, no error.
|
||||||
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||||
&mock_b,
|
&mock_b, &store_b, &chain_b, true,
|
||||||
&mut store_b,
|
|
||||||
&mut state_b,
|
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("reconstruct idempotent");
|
.expect("reconstruct idempotent");
|
||||||
@ -99,7 +107,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_channel_serves_a_divergent_block() {
|
async fn fails_when_channel_serves_a_divergent_block() {
|
||||||
let config = setup_sequencer_config();
|
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.
|
// Anchor on the local genesis at some slot.
|
||||||
let genesis_id = store.genesis_id();
|
let genesis_id = store.genesis_id();
|
||||||
@ -123,17 +131,16 @@ async fn fails_when_channel_serves_a_divergent_block() {
|
|||||||
messages,
|
messages,
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, true,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(result.is_err(), "divergent channel must abort startup");
|
assert!(result.is_err(), "divergent channel must abort startup");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_channel_is_missing() {
|
async fn fails_when_channel_is_missing() {
|
||||||
let config = setup_sequencer_config();
|
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_id = store.genesis_id();
|
||||||
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
|
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
|
||||||
store
|
store
|
||||||
@ -147,10 +154,9 @@ async fn fails_when_channel_is_missing() {
|
|||||||
// Anchor present, but the channel does not exist on the connected chain.
|
// Anchor present, but the channel does not exist on the connected chain.
|
||||||
let mock =
|
let mock =
|
||||||
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, true,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(result.is_err(), "missing channel must abort startup");
|
assert!(result.is_err(), "missing channel must abort startup");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +167,7 @@ async fn fails_when_channel_is_missing() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
|
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
|
||||||
let config = setup_sequencer_config();
|
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
|
// Fresh store, no anchor. The channel serves a genesis at the same id but a
|
||||||
// different hash — a foreign chain reinscribing genesis.
|
// 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)),
|
Some(Slot::from(10)),
|
||||||
messages,
|
messages,
|
||||||
);
|
);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, true,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"a reinscribed genesis with a different hash must abort startup"
|
"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,
|
messages,
|
||||||
);
|
);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||||
&mock,
|
&mock, &seq.store, &seq.chain, true,
|
||||||
&mut seq.store,
|
|
||||||
&mut seq.state,
|
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
@ -224,7 +226,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_a_channel_block_is_missing_locally() {
|
async fn fails_when_a_channel_block_is_missing_locally() {
|
||||||
let config = setup_sequencer_config();
|
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
|
// A block numbered below our genesis is at/below the local tip yet absent from
|
||||||
// the store — a foreign chain with a lower numbering.
|
// 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)),
|
Some(Slot::from(10)),
|
||||||
messages,
|
messages,
|
||||||
);
|
);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, true,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"a channel block below the local range must abort startup"
|
"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]
|
#[tokio::test]
|
||||||
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
|
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
|
||||||
let config = setup_sequencer_config();
|
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.
|
// 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();
|
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)),
|
Some(Slot::from(10)),
|
||||||
messages,
|
messages,
|
||||||
);
|
);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, true,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"a non-contiguous channel block must abort startup"
|
"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);
|
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
||||||
&mock_b,
|
&mock_b,
|
||||||
&mut seq_b.store,
|
&seq_b.store,
|
||||||
&mut seq_b.state,
|
&seq_b.chain,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("reconstruct");
|
.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();
|
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
|
||||||
assert_eq!(tip_b.id, tip_a.id);
|
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 vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||||
let bridge_id = system_accounts::bridge_account_id();
|
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] {
|
for account in [vault_id, bridge_id, recipient] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seq_b.state().get_account_by_id(account).balance,
|
state_b.get_account_by_id(account).balance,
|
||||||
seq_a.state().get_account_by_id(account).balance,
|
state_a.get_account_by_id(account).balance,
|
||||||
"reconstructed balance mismatch for {account:?}",
|
"reconstructed balance mismatch for {account:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
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),
|
u128::from(deposit_amount),
|
||||||
"deposit must mint into the recipient vault exactly once, not twice"
|
"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.
|
// Sequencer B reconstructs A's chain from a fresh store.
|
||||||
let config_b = bridge_funded_config();
|
let config_b = bridge_funded_config();
|
||||||
let (mut store_b, mut state_b) =
|
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
|
||||||
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
|
||||||
&mock_b,
|
.await
|
||||||
&mut store_b,
|
.expect("reconstruct");
|
||||||
&mut state_b,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("reconstruct");
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!store_b.dbio().consume_unseen_withdraw_count(key).unwrap(),
|
!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
|
// pre-seeded, as the cold-start backfill would when it re-observes this
|
||||||
// already-finalized deposit.
|
// already-finalized deposit.
|
||||||
let config_b = bridge_funded_config();
|
let config_b = bridge_funded_config();
|
||||||
let (mut store_b, mut state_b) =
|
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
|
||||||
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
|
|
||||||
assert!(
|
assert!(
|
||||||
store_b
|
store_b
|
||||||
.dbio()
|
.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);
|
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
|
||||||
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
|
||||||
&mock_b,
|
.await
|
||||||
&mut store_b,
|
.expect("reconstruct");
|
||||||
&mut state_b,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("reconstruct");
|
|
||||||
|
|
||||||
// The mint was applied exactly once.
|
// The mint was applied exactly once.
|
||||||
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
|
||||||
assert_eq!(
|
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),
|
u128::from(deposit_amount),
|
||||||
"already-finished deposit must be applied exactly once"
|
"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
|
// 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).
|
// 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.get_zone_anchor().unwrap().is_none());
|
||||||
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
|
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
|
||||||
|
|
||||||
// The channel is gone: no tip, no messages.
|
// The channel is gone: no tip, no messages.
|
||||||
let mock =
|
let mock =
|
||||||
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
|
||||||
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
|
let result =
|
||||||
&mock, &mut store, &mut state, false,
|
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, false)
|
||||||
)
|
.await;
|
||||||
.await;
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"committed blocks against a missing channel must abort startup"
|
"committed blocks against a missing channel must abort startup"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sequencer_service"
|
name = "sequencer_service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
default-run = "sequencer_service"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = { workspace = true }
|
license = { workspace = true }
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ programs.workspace = true
|
|||||||
clap = { workspace = true, features = ["derive", "env"] }
|
clap = { workspace = true, features = ["derive", "env"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-util.workspace = true
|
tokio-util.workspace = true
|
||||||
|
|||||||
@ -90,6 +90,4 @@ pub trait Rpc {
|
|||||||
|
|
||||||
#[method(name = "getChannelId")]
|
#[method(name = "getChannelId")]
|
||||||
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
|
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;
|
use sequencer_core::SequencerCore;
|
||||||
#[cfg(feature = "standalone")]
|
#[cfg(feature = "standalone")]
|
||||||
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
|
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
|
||||||
use sequencer_core::TransactionOrigin;
|
|
||||||
pub use sequencer_core::config::*;
|
pub use sequencer_core::config::*;
|
||||||
|
use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _};
|
||||||
use sequencer_service_rpc::RpcServer as _;
|
use sequencer_service_rpc::RpcServer as _;
|
||||||
use tokio::{sync::Mutex, task::JoinHandle};
|
use tokio::{sync::Mutex, task::JoinHandle};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
||||||
@ -28,6 +29,9 @@ pub struct SequencerHandle {
|
|||||||
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
|
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
|
||||||
server_handle: Option<ServerHandle>,
|
server_handle: Option<ServerHandle>,
|
||||||
main_loop_handle: JoinHandle<Result<Never>>,
|
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 {
|
impl SequencerHandle {
|
||||||
@ -35,11 +39,13 @@ impl SequencerHandle {
|
|||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
server_handle: ServerHandle,
|
server_handle: ServerHandle,
|
||||||
main_loop_handle: JoinHandle<Result<Never>>,
|
main_loop_handle: JoinHandle<Result<Never>>,
|
||||||
|
driver_cancellation: CancellationToken,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
addr,
|
addr,
|
||||||
server_handle: Some(server_handle),
|
server_handle: Some(server_handle),
|
||||||
main_loop_handle,
|
main_loop_handle,
|
||||||
|
driver_cancellation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +59,7 @@ impl SequencerHandle {
|
|||||||
addr: _,
|
addr: _,
|
||||||
server_handle,
|
server_handle,
|
||||||
main_loop_handle,
|
main_loop_handle,
|
||||||
|
driver_cancellation,
|
||||||
} = &mut self;
|
} = &mut self;
|
||||||
|
|
||||||
let server_handle = server_handle.take().expect("Server handle is set");
|
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 task panicked")?
|
||||||
.context("Main loop exited unexpectedly")
|
.context("Main loop exited unexpectedly")
|
||||||
}
|
}
|
||||||
|
() = driver_cancellation.cancelled() => {
|
||||||
|
Err(anyhow!("Publisher drive task terminated"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +88,12 @@ impl SequencerHandle {
|
|||||||
addr: _,
|
addr: _,
|
||||||
server_handle,
|
server_handle,
|
||||||
main_loop_handle,
|
main_loop_handle,
|
||||||
|
driver_cancellation,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
|
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
|
!stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +109,7 @@ impl Drop for SequencerHandle {
|
|||||||
addr: _,
|
addr: _,
|
||||||
server_handle,
|
server_handle,
|
||||||
main_loop_handle,
|
main_loop_handle,
|
||||||
|
driver_cancellation: _,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
main_loop_handle.abort();
|
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 block_timeout = config.block_create_timeout;
|
||||||
let max_block_size = config.max_block_size;
|
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");
|
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 seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
|
||||||
let mempool_handle_for_server = mempool_handle.clone();
|
let mempool_handle_for_server = mempool_handle.clone();
|
||||||
|
|
||||||
let (server_handle, addr) = run_server(
|
let (server_handle, addr) = run_server(
|
||||||
Arc::clone(&seq_core_wrapped),
|
Arc::clone(&seq_core_wrapped),
|
||||||
mempool_handle_for_server,
|
mempool_handle_for_server,
|
||||||
port,
|
listen_addr,
|
||||||
max_block_size.as_u64(),
|
max_block_size.as_u64(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle>
|
|||||||
|
|
||||||
let _ = mempool_handle;
|
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(
|
async fn run_server(
|
||||||
sequencer: Arc<Mutex<SequencerCore>>,
|
sequencer: Arc<Mutex<SequencerCore>>,
|
||||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||||
port: u16,
|
listen_addr: SocketAddr,
|
||||||
max_block_size: u64,
|
max_block_size: u64,
|
||||||
) -> Result<(ServerHandle, SocketAddr)> {
|
) -> Result<(ServerHandle, SocketAddr)> {
|
||||||
let server = jsonrpsee::server::ServerBuilder::with_config(
|
let server = jsonrpsee::server::ServerBuilder::with_config(
|
||||||
@ -153,7 +173,7 @@ async fn run_server(
|
|||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.build(SocketAddr::from(([0, 0, 0, 0], port)))
|
.build(listen_addr)
|
||||||
.await
|
.await
|
||||||
.context("Failed to build RPC server")?;
|
.context("Failed to build RPC server")?;
|
||||||
|
|
||||||
@ -172,16 +192,15 @@ async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration)
|
|||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(block_timeout).await;
|
tokio::time::sleep(block_timeout).await;
|
||||||
|
|
||||||
info!("Collecting transactions from mempool, block creation");
|
let mut state = seq_core.lock().await;
|
||||||
|
|
||||||
let id = {
|
// Only produce on our turn.
|
||||||
let mut state = seq_core.lock().await;
|
if !state.is_our_turn() {
|
||||||
|
continue;
|
||||||
state.produce_new_block().await?
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
info!("Our turn: collecting transactions from mempool, creating block");
|
||||||
|
let id = state.produce_new_block().await?;
|
||||||
info!("Block with id {id} created");
|
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 anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@ -12,6 +15,14 @@ struct Args {
|
|||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
#[clap(short, long, default_value = "3040")]
|
#[clap(short, long, default_value = "3040")]
|
||||||
port: u16,
|
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]
|
#[tokio::main]
|
||||||
@ -22,14 +33,23 @@ struct Args {
|
|||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
env_logger::init();
|
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
|
// TODO: handle this cancellation token more gracefully within Sequencer service
|
||||||
// similar to how we do in Indexer
|
// similar to how we do in Indexer
|
||||||
let cancellation_token = listen_for_shutdown_signal();
|
let cancellation_token = listen_for_shutdown_signal();
|
||||||
|
|
||||||
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
|
let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?;
|
||||||
let sequencer_handle = sequencer_service::run(config, port).await?;
|
if let Some(home) = home {
|
||||||
|
config.home = home;
|
||||||
|
}
|
||||||
|
let sequencer_handle =
|
||||||
|
sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?;
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
() = cancellation_token.cancelled() => {
|
() = cancellation_token.cancelled() => {
|
||||||
|
|||||||
@ -40,7 +40,7 @@ impl<BC: BlockPublisherTrait> SequencerService<BC> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
impl<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::RpcServer
|
||||||
for SequencerService<BC>
|
for SequencerService<BC>
|
||||||
{
|
{
|
||||||
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
|
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> {
|
async fn get_account_balance(&self, account_id: AccountId) -> Result<u128, ErrorObjectOwned> {
|
||||||
let sequencer = self.sequencer.lock().await;
|
let sequencer = self.sequencer.lock().await;
|
||||||
let account = sequencer.state().get_account_by_id(account_id);
|
let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance);
|
||||||
Ok(account.balance)
|
Ok(balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_transaction(
|
async fn get_transaction(
|
||||||
@ -155,10 +155,12 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
|||||||
account_ids: Vec<AccountId>,
|
account_ids: Vec<AccountId>,
|
||||||
) -> Result<Vec<Nonce>, ErrorObjectOwned> {
|
) -> Result<Vec<Nonce>, ErrorObjectOwned> {
|
||||||
let sequencer = self.sequencer.lock().await;
|
let sequencer = self.sequencer.lock().await;
|
||||||
let nonces = account_ids
|
let nonces = sequencer.with_state(|state| {
|
||||||
.into_iter()
|
account_ids
|
||||||
.map(|account_id| sequencer.state().get_account_by_id(account_id).nonce)
|
.into_iter()
|
||||||
.collect();
|
.map(|account_id| state.get_account_by_id(account_id).nonce)
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
Ok(nonces)
|
Ok(nonces)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,17 +169,18 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
|||||||
commitments: Vec<Commitment>,
|
commitments: Vec<Commitment>,
|
||||||
) -> Result<(Vec<Option<MembershipProof>>, CommitmentSetDigest), ErrorObjectOwned> {
|
) -> Result<(Vec<Option<MembershipProof>>, CommitmentSetDigest), ErrorObjectOwned> {
|
||||||
let sequencer = self.sequencer.lock().await;
|
let sequencer = self.sequencer.lock().await;
|
||||||
let state = sequencer.state();
|
Ok(sequencer.with_state(|state| {
|
||||||
let proofs = commitments
|
let proofs = commitments
|
||||||
.iter()
|
.iter()
|
||||||
.map(|commitment| state.get_proof_for_commitment(commitment))
|
.map(|commitment| state.get_proof_for_commitment(commitment))
|
||||||
.collect();
|
.collect();
|
||||||
Ok((proofs, state.commitment_root()))
|
(proofs, state.commitment_root())
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
|
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
|
||||||
let sequencer = self.sequencer.lock().await;
|
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> {
|
async fn get_program_ids(&self) -> Result<BTreeMap<String, ProgramId>, ErrorObjectOwned> {
|
||||||
|
|||||||
@ -19,10 +19,12 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::DbError,
|
error::DbError,
|
||||||
sequencer::sequencer_cells::{
|
sequencer::sequencer_cells::{
|
||||||
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
|
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
|
||||||
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
|
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
|
||||||
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
|
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
|
||||||
ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
|
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.
|
/// Key base for storing the LEE state.
|
||||||
pub const DB_LEE_STATE_KEY: &str = "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.
|
/// Name of state column family.
|
||||||
pub const CF_LEE_STATE_NAME: &str = "cf_lee_state";
|
pub const CF_LEE_STATE_NAME: &str = "cf_lee_state";
|
||||||
@ -403,6 +409,25 @@ impl RocksDBIO {
|
|||||||
Ok(true)
|
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(
|
fn mark_pending_deposit_events_submitted(
|
||||||
&self,
|
&self,
|
||||||
deposit_op_ids: &[HashType],
|
deposit_op_ids: &[HashType],
|
||||||
@ -503,23 +528,36 @@ impl RocksDBIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
|
||||||
let cf_block = self.block_column();
|
|
||||||
|
|
||||||
if !first {
|
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()?;
|
let last_curr_block = self.get_meta_last_block_in_db()?;
|
||||||
|
for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block {
|
||||||
if block.header.block_id > 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 {
|
|
||||||
id: block.header.block_id,
|
|
||||||
hash: block.header.hash,
|
|
||||||
},
|
|
||||||
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(
|
batch.put_cf(
|
||||||
&cf_block,
|
&cf_block,
|
||||||
borsh::to_vec(&block.header.block_id).map_err(|err| {
|
borsh::to_vec(&block.header.block_id).map_err(|err| {
|
||||||
@ -537,6 +575,26 @@ impl RocksDBIO {
|
|||||||
.map(|opt| opt.map(|val| val.0))
|
.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> {
|
pub fn get_lee_state(&self) -> DbResult<V03State> {
|
||||||
self.get::<LEEStateCellOwned>(()).map(|val| val.0)
|
self.get::<LEEStateCellOwned>(()).map(|val| val.0)
|
||||||
}
|
}
|
||||||
@ -633,6 +691,97 @@ impl RocksDBIO {
|
|||||||
Ok(())
|
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>> {
|
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
|
||||||
let cf_block = self.block_column();
|
let cf_block = self.block_column();
|
||||||
self.db
|
self.db
|
||||||
@ -682,3 +831,6 @@ impl RocksDBIO {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@ -7,10 +7,10 @@ use crate::{
|
|||||||
cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell},
|
cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell},
|
||||||
error::DbError,
|
error::DbError,
|
||||||
sequencer::{
|
sequencer::{
|
||||||
CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
|
CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY,
|
||||||
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY,
|
DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY,
|
||||||
DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY,
|
DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY,
|
||||||
DB_META_ZONE_SDK_CHECKPOINT_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)]
|
#[derive(Debug, BorshSerialize, BorshDeserialize)]
|
||||||
pub struct LastFinalizedBlockIdCell(pub Option<u64>);
|
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
|
associated_token_account_core.workspace = true
|
||||||
|
|
||||||
bip39.workspace = true
|
bip39.workspace = true
|
||||||
pyo3.workspace = true
|
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use keycard_wallet::{KeycardWallet, python_path};
|
use keycard_wallet::KeycardWallet;
|
||||||
use lee::{AccountId, PrivateKey, PublicKey, Signature};
|
use lee::{AccountId, PrivateKey, PublicKey, Signature};
|
||||||
use lee_core::{
|
use lee_core::{
|
||||||
Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof,
|
Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof,
|
||||||
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
|
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
|
||||||
account::{AccountWithMetadata, Nonce},
|
account::{AccountWithMetadata, Nonce},
|
||||||
compute_digest_for_path,
|
compute_digest_for_path,
|
||||||
encryption::ViewingPublicKey,
|
encryption::{ViewTag, ViewingPublicKey},
|
||||||
};
|
};
|
||||||
use rand::{RngCore as _, rngs::OsRng};
|
use rand::{RngCore as _, rngs::OsRng};
|
||||||
|
|
||||||
@ -237,14 +237,7 @@ impl AccountManager {
|
|||||||
if pin.is_none() {
|
if pin.is_none() {
|
||||||
pin = Some(
|
pin = Some(
|
||||||
crate::helperfunctions::read_pin()
|
crate::helperfunctions::read_pin()
|
||||||
.map_err(|e| {
|
.map_err(ExecutionFailureKind::SignError)?
|
||||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<
|
|
||||||
pyo3::exceptions::PyRuntimeError,
|
|
||||||
_,
|
|
||||||
>(
|
|
||||||
e.to_string()
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
);
|
);
|
||||||
@ -411,6 +404,7 @@ impl AccountManager {
|
|||||||
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
|
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
|
||||||
vpk: pre.vpk.clone(),
|
vpk: pre.vpk.clone(),
|
||||||
random_seed: pre.random_seed,
|
random_seed: pre.random_seed,
|
||||||
|
view_tag: random_view_tag(),
|
||||||
nsk,
|
nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier: pre.identifier,
|
identifier: pre.identifier,
|
||||||
@ -430,6 +424,7 @@ impl AccountManager {
|
|||||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||||
vpk: pre.vpk.clone(),
|
vpk: pre.vpk.clone(),
|
||||||
random_seed: pre.random_seed,
|
random_seed: pre.random_seed,
|
||||||
|
view_tag: random_view_tag(),
|
||||||
nsk,
|
nsk,
|
||||||
membership_proof,
|
membership_proof,
|
||||||
identifier: pre.identifier,
|
identifier: pre.identifier,
|
||||||
@ -498,17 +493,11 @@ impl AccountManager {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Some(pin) = self.pin.clone() {
|
if let Some(pin) = self.pin.clone() {
|
||||||
pyo3::Python::attach(|py| -> pyo3::PyResult<()> {
|
let mut wallet = KeycardWallet::new()?;
|
||||||
python_path::add_python_path(py)?;
|
wallet.connect(&pin)?;
|
||||||
let wallet = KeycardWallet::new(py)?;
|
for path in keycard_paths {
|
||||||
wallet.connect(py, &pin)?;
|
sigs.push(wallet.sign_message_for_path(path, &message_hash)?);
|
||||||
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)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(sigs)
|
Ok(sigs)
|
||||||
@ -650,6 +639,13 @@ fn validate_proofs_against_root(
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -111,8 +111,9 @@ pub enum NewSubcommand {
|
|||||||
#[arg(long, requires = "pda")]
|
#[arg(long, requires = "pda")]
|
||||||
/// Program ID as hex string.
|
/// Program ID as hex string.
|
||||||
program_id: Option<String>,
|
program_id: Option<String>,
|
||||||
#[arg(long, requires = "pda")]
|
#[arg(long)]
|
||||||
/// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family.
|
/// 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.
|
/// Defaults to a random value if not specified.
|
||||||
identifier: Option<u128>,
|
identifier: Option<u128>,
|
||||||
},
|
},
|
||||||
@ -197,7 +198,7 @@ impl NewSubcommand {
|
|||||||
Ok(SubcommandReturnValue::RegisterAccount { account_id })
|
Ok(SubcommandReturnValue::RegisterAccount { account_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_private_gms(
|
async fn handle_private_gms(
|
||||||
group: &Label,
|
group: &Label,
|
||||||
label: Option<Label>,
|
label: Option<Label>,
|
||||||
pda: bool,
|
pda: bool,
|
||||||
@ -229,14 +230,22 @@ impl NewSubcommand {
|
|||||||
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
|
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet_core.create_shared_pda_account(
|
wallet_core
|
||||||
group.clone(),
|
.create_shared_pda_account(
|
||||||
pda_seed,
|
group.clone(),
|
||||||
pid,
|
pda_seed,
|
||||||
identifier.unwrap_or_else(rand::random),
|
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 {
|
} else {
|
||||||
wallet_core.create_shared_regular_account(group.clone())?
|
wallet_core
|
||||||
|
.create_shared_regular_account(group.clone())
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(label) = label {
|
if let Some(label) = label {
|
||||||
@ -295,15 +304,18 @@ impl WalletSubcommand for NewSubcommand {
|
|||||||
seed,
|
seed,
|
||||||
program_id,
|
program_id,
|
||||||
identifier,
|
identifier,
|
||||||
} => Self::handle_private_gms(
|
} => {
|
||||||
&group,
|
Self::handle_private_gms(
|
||||||
label,
|
&group,
|
||||||
pda,
|
label,
|
||||||
seed,
|
pda,
|
||||||
program_id,
|
seed,
|
||||||
identifier,
|
program_id,
|
||||||
wallet_core,
|
identifier,
|
||||||
),
|
wallet_core,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
Self::PrivateAccountsKey { cci } => Self::handle_private_accounts_key(cci, wallet_core),
|
Self::PrivateAccountsKey { cci } => Self::handle_private_accounts_key(cci, wallet_core),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,7 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use keycard_wallet::{KeycardWallet, clear_pairing, python_path};
|
use keycard_wallet::KeycardWallet;
|
||||||
use pyo3::prelude::*;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
WalletCore,
|
WalletCore,
|
||||||
@ -18,9 +17,16 @@ use crate::{
|
|||||||
pub enum KeycardSubcommand {
|
pub enum KeycardSubcommand {
|
||||||
Available,
|
Available,
|
||||||
Connect,
|
Connect,
|
||||||
Disconnect,
|
|
||||||
Init,
|
Init,
|
||||||
Load,
|
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.
|
/// Retrieve the private keys (NSK, VSK) for a given BIP-32 key path.
|
||||||
///
|
///
|
||||||
/// Prints raw key material to stdout — intended for debugging only.
|
/// Prints raw key material to stdout — intended for debugging only.
|
||||||
@ -39,22 +45,11 @@ pub enum KeycardSubcommand {
|
|||||||
|
|
||||||
impl KeycardSubcommand {
|
impl KeycardSubcommand {
|
||||||
fn handle_available(_wallet_core: &mut WalletCore) -> SubcommandReturnValue {
|
fn handle_available(_wallet_core: &mut WalletCore) -> SubcommandReturnValue {
|
||||||
Python::attach(|py| {
|
if KeycardWallet::is_keycard_available() {
|
||||||
python_path::add_python_path(py)
|
println!("\u{2705} Keycard is available.");
|
||||||
.expect("`wallet::keycard::available`: unable to setup python path");
|
} else {
|
||||||
|
println!("\u{274c} Keycard is not available.");
|
||||||
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.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
SubcommandReturnValue::Empty
|
SubcommandReturnValue::Empty
|
||||||
}
|
}
|
||||||
@ -62,45 +57,9 @@ impl KeycardSubcommand {
|
|||||||
fn handle_connect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
|
fn handle_connect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
|
||||||
let pin = read_pin()?;
|
let pin = read_pin()?;
|
||||||
|
|
||||||
Python::attach(|py| {
|
let mut wallet = KeycardWallet::new()?;
|
||||||
python_path::add_python_path(py)
|
wallet.connect(&pin)?;
|
||||||
.expect("`wallet::keycard::connect`: unable to setup python path");
|
println!("\u{2705} Keycard connected and PIN verified.");
|
||||||
|
|
||||||
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.");
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(SubcommandReturnValue::Empty)
|
Ok(SubcommandReturnValue::Empty)
|
||||||
}
|
}
|
||||||
@ -108,22 +67,12 @@ impl KeycardSubcommand {
|
|||||||
fn handle_init(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
|
fn handle_init(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
|
||||||
let pin = read_pin()?;
|
let pin = read_pin()?;
|
||||||
|
|
||||||
Python::attach(|py| {
|
let mut wallet = KeycardWallet::new()?;
|
||||||
python_path::add_python_path(py)
|
let puk = wallet.initialize(&pin)?;
|
||||||
.expect("`wallet::keycard::init`: unable to setup python path");
|
|
||||||
|
|
||||||
let wallet = KeycardWallet::new(py)
|
println!("Keycard PUK: {puk}");
|
||||||
.expect("`wallet::keycard::init`: invalid keycard wallet provided");
|
println!("Record this PUK and store it somewhere safe. It cannot be recovered.");
|
||||||
|
println!("\u{2705} Keycard initialized successfully.");
|
||||||
let initialized = wallet
|
|
||||||
.initialize(py, &pin)
|
|
||||||
.expect("`wallet::keycard::init`: failed to initialize keycard");
|
|
||||||
|
|
||||||
if initialized {
|
|
||||||
clear_pairing();
|
|
||||||
println!("\u{2705} Keycard initialized successfully.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(SubcommandReturnValue::Empty)
|
Ok(SubcommandReturnValue::Empty)
|
||||||
}
|
}
|
||||||
@ -132,25 +81,31 @@ impl KeycardSubcommand {
|
|||||||
let pin = read_pin()?;
|
let pin = read_pin()?;
|
||||||
let mnemonic = read_mnemonic()?;
|
let mnemonic = read_mnemonic()?;
|
||||||
|
|
||||||
Python::attach(|py| {
|
let mut wallet = KeycardWallet::new()?;
|
||||||
python_path::add_python_path(py)
|
wallet.connect(&pin)?;
|
||||||
.expect("`wallet::keycard::load`: unable to setup python path");
|
println!("\u{2705} Keycard is now connected to wallet.");
|
||||||
|
|
||||||
let wallet = KeycardWallet::new(py)
|
wallet.load_mnemonic(&mnemonic)?;
|
||||||
.expect("`wallet::keycard::load`: invalid keycard wallet provided");
|
println!("\u{2705} Mnemonic phrase loaded successfully.");
|
||||||
|
|
||||||
wallet
|
Ok(SubcommandReturnValue::Empty)
|
||||||
.connect(py, &pin)
|
}
|
||||||
.expect("`wallet::keycard::load`: failed to connect to keycard");
|
|
||||||
|
|
||||||
println!("\u{2705} Keycard is now connected to wallet.");
|
fn handle_factory_reset(
|
||||||
if wallet.load_mnemonic(py, &mnemonic).is_ok() {
|
confirm: bool,
|
||||||
println!("\u{2705} Mnemonic phrase loaded successfully.");
|
_wallet_core: &mut WalletCore,
|
||||||
} else {
|
) -> Result<SubcommandReturnValue> {
|
||||||
println!("\u{274c} Failed to load mnemonic phrase.");
|
if !confirm {
|
||||||
}
|
eprintln!(
|
||||||
drop(wallet.close_session(py));
|
"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)
|
Ok(SubcommandReturnValue::Empty)
|
||||||
}
|
}
|
||||||
@ -189,9 +144,9 @@ impl WalletSubcommand for KeycardSubcommand {
|
|||||||
match self {
|
match self {
|
||||||
Self::Available => Ok(Self::handle_available(wallet_core)),
|
Self::Available => Ok(Self::handle_available(wallet_core)),
|
||||||
Self::Connect => Self::handle_connect(wallet_core),
|
Self::Connect => Self::handle_connect(wallet_core),
|
||||||
Self::Disconnect => Self::handle_disconnect(wallet_core),
|
|
||||||
Self::Init => Self::handle_init(wallet_core),
|
Self::Init => Self::handle_init(wallet_core),
|
||||||
Self::Load => Self::handle_load(wallet_core),
|
Self::Load => Self::handle_load(wallet_core),
|
||||||
|
Self::FactoryReset { confirm } => Self::handle_factory_reset(confirm, wallet_core),
|
||||||
#[cfg(feature = "keycard-debug")]
|
#[cfg(feature = "keycard-debug")]
|
||||||
Self::GetPrivateKeys { key_path, reveal } => {
|
Self::GetPrivateKeys { key_path, reveal } => {
|
||||||
Self::handle_get_private_keys(&key_path, reveal, wallet_core)
|
Self::handle_get_private_keys(&key_path, reveal, wallet_core)
|
||||||
|
|||||||
@ -51,7 +51,6 @@ pub mod helperfunctions;
|
|||||||
pub mod multi_client;
|
pub mod multi_client;
|
||||||
pub mod poller;
|
pub mod poller;
|
||||||
pub mod program_facades;
|
pub mod program_facades;
|
||||||
pub mod signing;
|
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
pub const HOME_DIR_ENV_VAR: &str = "LEE_WALLET_HOME_DIR";
|
pub const HOME_DIR_ENV_VAR: &str = "LEE_WALLET_HOME_DIR";
|
||||||
@ -86,8 +85,6 @@ pub enum ExecutionFailureKind {
|
|||||||
TransactionBuildError(#[from] lee::error::LeeError),
|
TransactionBuildError(#[from] lee::error::LeeError),
|
||||||
#[error("Failed to sign transaction: {0}")]
|
#[error("Failed to sign transaction: {0}")]
|
||||||
SignError(anyhow::Error),
|
SignError(anyhow::Error),
|
||||||
#[error(transparent)]
|
|
||||||
KeycardError(#[from] pyo3::PyErr),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WalletCore {
|
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.
|
/// 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,
|
&mut self,
|
||||||
group_name: Label,
|
group_name: Label,
|
||||||
pda_seed: lee_core::program::PdaSeed,
|
pda_seed: lee_core::program::PdaSeed,
|
||||||
@ -441,6 +490,7 @@ impl WalletCore {
|
|||||||
Some(pda_seed),
|
Some(pda_seed),
|
||||||
Some(program_id),
|
Some(program_id),
|
||||||
);
|
);
|
||||||
|
self.catch_up_shared_account(account_id).await?;
|
||||||
|
|
||||||
Ok(SharedAccountInfo {
|
Ok(SharedAccountInfo {
|
||||||
account_id,
|
account_id,
|
||||||
@ -449,14 +499,21 @@ impl WalletCore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a shared regular private account from a group's GMS. Returns the `AccountId` and
|
/// Create a shared regular private account from a group's GMS with a random identifier.
|
||||||
/// derived keys. The derivation seed is computed deterministically from a random identifier.
|
pub async fn create_shared_regular_account(
|
||||||
pub fn create_shared_regular_account(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
group_name: Label,
|
group_name: Label,
|
||||||
) -> Result<SharedAccountInfo> {
|
) -> 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
|
let holder = self
|
||||||
.storage
|
.storage
|
||||||
.key_chain()
|
.key_chain()
|
||||||
@ -469,6 +526,7 @@ impl WalletCore {
|
|||||||
let account_id = AccountId::from((&npk, &vpk, identifier));
|
let account_id = AccountId::from((&npk, &vpk, identifier));
|
||||||
|
|
||||||
self.register_shared_account(account_id, group_name, identifier, None, None);
|
self.register_shared_account(account_id, group_name, identifier, None, None);
|
||||||
|
self.catch_up_shared_account(account_id).await?;
|
||||||
|
|
||||||
Ok(SharedAccountInfo {
|
Ok(SharedAccountInfo {
|
||||||
account_id,
|
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.
|
/// Replaces a spent nullifier with the account's `next` one.
|
||||||
pub fn update(&mut self, spent: &Nullifier, next: Nullifier, account_id: AccountId) {
|
pub fn update(&mut self, spent: &Nullifier, next: Nullifier, account_id: AccountId) {
|
||||||
self.0.remove(spent);
|
self.0.remove(spent);
|
||||||
@ -987,6 +995,93 @@ mod tests {
|
|||||||
assert!(index.account_for(&old_nullifier).is_none());
|
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]
|
#[test]
|
||||||
fn nullifier_sync_ignores_unindexed_nullifier() {
|
fn nullifier_sync_ignores_unindexed_nullifier() {
|
||||||
let mut kc = UserKeyChain::default();
|
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