chore: remove TUI Zone code (#3439)

This commit is contained in:
Hansie Odendaal
2026-08-28 15:06:02 +00:00
committed by GitHub
parent 7e71cb454c
commit ecb2cc640a
23 changed files with 0 additions and 3595 deletions
Generated
-26
View File
@@ -5102,7 +5102,6 @@ dependencies = [
"logos-blockchain-mmr",
"logos-blockchain-node",
"logos-blockchain-storage-service",
"logos-blockchain-tui-zone",
"logos-blockchain-tx-service",
"logos-blockchain-utils",
"logos-blockchain-utxotree",
@@ -5220,30 +5219,6 @@ dependencies = [
"tracing-subscriber 0.3.23",
]
[[package]]
name = "logos-blockchain-tui-zone"
version = "0.0.0"
dependencies = [
"bincode",
"chrono",
"clap",
"hex",
"logos-blockchain-codec",
"logos-blockchain-core",
"logos-blockchain-groth16",
"logos-blockchain-key-management-system-service",
"logos-blockchain-zone-sdk",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber 0.3.23",
"uuid",
]
[[package]]
name = "logos-blockchain-tx-service"
version = "0.0.0"
@@ -9383,7 +9358,6 @@ checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
-2
View File
@@ -44,7 +44,6 @@ members = [
"consensus/cryptarchia-sync",
"core",
"deployment/faucet",
"deployment/tui-zone",
"kms/keys",
"kms/macros",
"kms/operators",
@@ -158,7 +157,6 @@ lb-testing-framework = { default-features = false, package = "test
lb-time-service = { default-features = false, package = "logos-blockchain-time-service", path = "./services/time" }
lb-tracing = { default-features = false, package = "logos-blockchain-tracing", path = "./tracing" }
lb-tracing-service = { default-features = false, package = "logos-blockchain-tracing-service", path = "./services/tracing" }
lb-tui-zone = { default-features = false, package = "logos-blockchain-tui-zone", path = "./deployment/tui-zone" }
lb-tx-service = { default-features = false, package = "logos-blockchain-tx-service", path = "./services/tx-service" }
lb-utils = { default-features = false, package = "logos-blockchain-utils", path = "./utils" }
lb-utxotree = { default-features = false, package = "logos-blockchain-utxotree", path = "merkle/utxotree" }
-41
View File
@@ -1,41 +0,0 @@
[package]
categories = { workspace = true }
description = "Terminal UI zone sequencer - prompts for messages and publishes them as zone blocks"
edition = { workspace = true }
keywords = { workspace = true }
license = { workspace = true }
name = "logos-blockchain-tui-zone"
readme = { workspace = true }
repository = { workspace = true }
version = { workspace = true }
[lib]
name = "logos_blockchain_tui_zone"
path = "src/lib.rs"
[[bin]]
name = "tui-sequencer"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
bincode = { workspace = true }
chrono = { features = ["alloc"], workspace = true }
clap = { features = ["derive", "env", "error-context", "help", "std", "suggestions", "usage"], workspace = true }
hex = { workspace = true }
lb-codec = { workspace = true }
lb-core = { workspace = true }
lb-groth16 = { workspace = true }
lb-key-management-system-service = { workspace = true }
lb-zone-sdk = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
serde = { features = ["derive"], workspace = true }
serde_json = { workspace = true }
tokio = { features = ["macros", "rt-multi-thread", "signal", "sync", "time"], workspace = true }
tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { features = ["env-filter", "registry"], workspace = true }
uuid = { features = ["serde", "v4"], workspace = true }
-332
View File
@@ -1,332 +0,0 @@
# Manual TUI Zone Deposit/Withdrawal Demo
This demo uses the existing cucumber `Transactions manual control` scenario as
the chain and wallet bootstrapper, then uses `tui-sequencer` subcommands for
file-based Zone deposit and withdrawal.
The wallet export format is demo-only. `include_secret true` writes private
wallet material to disk so the TUI command can spend exported cucumber wallet
UTXOs into a Zone deposit.
## Start from a clean slate
Remove prior demo files and create the working directories:
```sh
rm -rf /tmp/tui-zone
mkdir -p /tmp/tui-zone/keys /tmp/tui-zone/artifacts
```
## [Cucumber] Start cucumber manual control
```sh
export CUCUMBER_MANUAL_COMMAND_FILE=/tmp/cucumber-manual-commands.txt
export CUCUMBER_LOG_LEVEL=trace
export CUCUMBER_VERBOSE_CONSOLE=true
cargo test -p logos-blockchain-tests --features cucumber --test cucumber -- --name "Transactions manual control"
```
Wait until the scenario reaches:
```text
When I perform manual control of transactions for all wallets no time-out
```
## [Cucumber] Export wallet funds
Append commands to `/tmp/cucumber-manual-commands.txt`:
```text
EXPORT_FUNDS, wallet 'WALLET_1A', value 1000, output '/tmp/tui-zone/artifacts/funds-wallet-1a.json', include_secret true
EXPORT_FUNDS, wallet 'WALLET_2A', value 1000, output '/tmp/tui-zone/artifacts/funds-wallet-2a.json', include_secret true
```
Processed commands are marked with `---->`. Invalid commands are marked with
`== ERROR == >`.
The exported funds are not committed on-chain yet, so cucumber wallet balances
will not change until after the corresponding deposit transaction is mined.
Use the `node_url` field from the exported funds JSON files if your node is not
available at `http://localhost:<PORT>`.
## [Sequencer] Create and validate a new channel
Start the sequencer with the channel admin key:
```sh
cargo run -p logos-blockchain-tui-zone -- run \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key
```
Post one inscription and wait until it is shown as adopted/published:
```text
a1
```
Then stop the sequencer with `CTRL-C`.
Create the second, third, and fourth signer keys:
```sh
cargo run -p logos-blockchain-tui-zone -- keygen \
--key-path /tmp/tui-zone/keys/seq-b.key
cargo run -p logos-blockchain-tui-zone -- keygen \
--key-path /tmp/tui-zone/keys/seq-c.key
cargo run -p logos-blockchain-tui-zone -- keygen \
--key-path /tmp/tui-zone/keys/seq-d.key
```
`seq-a.key` is the channel admin key. `seq-b.key`, `seq-c.key`, and
`seq-d.key` are only accredited later by channel config commands.
Print the channel balance at any point:
```sh
cargo run -p logos-blockchain-tui-zone -- state balance \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key
```
## [Sequencer] Deposit
Deposit the first exported wallet funds:
```sh
cargo run -p logos-blockchain-tui-zone -- deposit \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--funds /tmp/tui-zone/artifacts/funds-wallet-1a.json \
--amount 1000 \
--metadata "demo deposit" \
--message "deposit wallet 1a"
```
Deposit the second exported wallet funds:
```sh
cargo run -p logos-blockchain-tui-zone -- deposit \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--funds /tmp/tui-zone/artifacts/funds-wallet-2a.json \
--amount 1000 \
--metadata "demo deposit" \
--message "deposit wallet 2a"
```
## [Cucumber] Check deposit balances
Append:
```text
BALANCE, wallet 'WALLET_1A'
BALANCE, wallet 'WALLET_2A'
```
If a balance does not reflect the deposit yet, wait for the deposit transaction
to be mined and observed by the cucumber wallet before proceeding.
## [Sequencer] Single-signer withdrawal
Prepare the withdrawal intent:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw prepare \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--amount 500 \
--recipient-funds /tmp/tui-zone/artifacts/funds-wallet-1a.json \
--message "withdraw wallet 1a" \
--out /tmp/tui-zone/artifacts/withdraw.intent.json
```
Sign it with the channel admin key:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw sign \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/withdraw.intent.json \
--out /tmp/tui-zone/artifacts/sig-a.json
```
Combine the intent and signature:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw combine \
--in /tmp/tui-zone/artifacts/withdraw.intent.json \
--sig /tmp/tui-zone/artifacts/sig-a.json \
--out /tmp/tui-zone/artifacts/withdraw.signed.json
```
Submit the signed withdrawal:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw submit \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/withdraw.signed.json
```
## [Cucumber] Check single-signer withdrawal balance
Append:
```text
BALANCE, wallet 'WALLET_1A'
```
## [Sequencer] Configure multi-signers
Configure the Zone channel created by `seq-a.key` so the accredited withdrawal
keys contain the first three local sequencer keys, the withdrawal threshold is
`2`, and future configuration changes require `2` signatures:
```sh
cargo run -p logos-blockchain-tui-zone -- config apply \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--authorized-key-path /tmp/tui-zone/keys/seq-a.key \
--authorized-key-path /tmp/tui-zone/keys/seq-b.key \
--authorized-key-path /tmp/tui-zone/keys/seq-c.key \
--configuration-threshold 2 \
--withdraw-threshold 2 \
--posting-timeframe 30 \
--posting-timeout 30
```
The `--key-path` key signs this threshold-1 update and is kept at authorized key
index `0`; duplicate `--authorized-key-path` entries are ignored.
## [Sequencer] Multi-signer withdrawal
Prepare the 2-of-3 withdrawal intent:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw prepare \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--amount 500 \
--recipient-funds /tmp/tui-zone/artifacts/funds-wallet-1a.json \
--message "withdraw wallet 1a multisig" \
--out /tmp/tui-zone/artifacts/withdraw-2of3.intent.json
```
Sign the same intent with two different authorized signer keys:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw sign \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/withdraw-2of3.intent.json \
--out /tmp/tui-zone/artifacts/sig-a.json
cargo run -p logos-blockchain-tui-zone -- withdraw sign \
--key-path /tmp/tui-zone/keys/seq-b.key \
--in /tmp/tui-zone/artifacts/withdraw-2of3.intent.json \
--out /tmp/tui-zone/artifacts/sig-b.json
```
Combine both signatures:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw combine \
--in /tmp/tui-zone/artifacts/withdraw-2of3.intent.json \
--sig /tmp/tui-zone/artifacts/sig-a.json \
--sig /tmp/tui-zone/artifacts/sig-b.json \
--out /tmp/tui-zone/artifacts/withdraw-2of3.signed.json
```
Submit the signed withdrawal:
```sh
cargo run -p logos-blockchain-tui-zone -- withdraw submit \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/withdraw-2of3.signed.json
```
Each signer keeps its private key local. The only exchanged files are the intent
JSON and signature JSON files.
## [Cucumber] Check multi-signer withdrawal balance
Append:
```text
BALANCE, wallet 'WALLET_1A'
```
The withdrawn funds are normal chain notes addressed to the exported cucumber
wallet public key, so they are observed through the existing wallet scan path.
## [Sequencer] Multi-signer config update
Prepare a 2-of-3 configuration update that adds the fourth key and raises both
thresholds to `3`:
```sh
cargo run -p logos-blockchain-tui-zone -- config prepare \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--authorized-key-path /tmp/tui-zone/keys/seq-a.key \
--authorized-key-path /tmp/tui-zone/keys/seq-b.key \
--authorized-key-path /tmp/tui-zone/keys/seq-c.key \
--authorized-key-path /tmp/tui-zone/keys/seq-d.key \
--configuration-threshold 3 \
--withdraw-threshold 3 \
--posting-timeframe 30 \
--posting-timeout 30 \
--out /tmp/tui-zone/artifacts/config-3of4.intent.json
```
Sign the config intent with two currently authorized keys:
```sh
cargo run -p logos-blockchain-tui-zone -- config sign \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/config-3of4.intent.json \
--out /tmp/tui-zone/artifacts/config-sig-a.json
cargo run -p logos-blockchain-tui-zone -- config sign \
--key-path /tmp/tui-zone/keys/seq-b.key \
--in /tmp/tui-zone/artifacts/config-3of4.intent.json \
--out /tmp/tui-zone/artifacts/config-sig-b.json
```
Combine the signatures:
```sh
cargo run -p logos-blockchain-tui-zone -- config combine \
--in /tmp/tui-zone/artifacts/config-3of4.intent.json \
--sig /tmp/tui-zone/artifacts/config-sig-a.json \
--sig /tmp/tui-zone/artifacts/config-sig-b.json \
--out /tmp/tui-zone/artifacts/config-3of4.signed.json
```
Submit the signed config update:
```sh
cargo run -p logos-blockchain-tui-zone -- config submit \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key \
--in /tmp/tui-zone/artifacts/config-3of4.signed.json
```
Check the full channel state:
```sh
cargo run -p logos-blockchain-tui-zone -- state full \
--node-url http://localhost:<PORT> \
--key-path /tmp/tui-zone/keys/seq-a.key
```
## [Cucumber] Stop cucumber scenario
Append:
```text
STOP
```
-364
View File
@@ -1,364 +0,0 @@
use std::{error::Error, path::PathBuf};
use clap::{Args, Parser, Subcommand};
use crate::run_commands::{
run_balance::run_state_full,
run_config::{
run_config, run_config_combine, run_config_prepare, run_config_sign, run_config_submit,
},
run_deposit::run_deposit,
run_keygen::run_keygen,
run_withdraw::{
run_withdraw_combine, run_withdraw_prepare, run_withdraw_sign, run_withdraw_submit,
},
};
pub(crate) type RunResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
#[derive(Parser, Debug)]
#[command(about = "Terminal UI zone sequencer", version)]
/// Top-level command-line parser for the TUI zone sequencer.
pub struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
run_args: NodeKeyArgs,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Run the interactive inscription TUI.
Run(NodeKeyArgs),
/// Apply, prepare, sign, combine, or submit zone channel configuration
/// updates.
Config {
#[command(subcommand)]
command: ConfigCommand,
},
/// Print zone channel state.
State {
#[command(subcommand)]
command: StateCommand,
},
/// Build and optionally submit a zone deposit.
Deposit(DepositArgs),
/// Create or inspect a local sequencer signing key.
Keygen(KeygenArgs),
/// Prepare, sign, combine, or submit zone withdrawals.
Withdraw {
#[command(subcommand)]
command: WithdrawCommand,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommand {
/// Apply a single-signer channel configuration update.
Apply(ConfigArgs),
/// Prepare an unsigned channel configuration intent file.
Prepare(ConfigPrepareArgs),
/// Sign a channel configuration intent with one authorized key.
Sign(ConfigSignArgs),
/// Combine configuration signature files into a signed transaction file.
Combine(ConfigCombineArgs),
/// Submit a signed channel configuration transaction file.
Submit(ConfigSubmitArgs),
}
#[derive(Subcommand, Debug)]
enum StateCommand {
/// Print the channel configuration state.
Full(StateArgs),
// TODO: support channel note tracking, which restores the
// channel balance command.
// /// Print the channel balance.
// Balance(StateArgs),
}
#[derive(Parser, Debug)]
#[command(about = "Terminal UI zone sequencer - publish text inscriptions")]
/// Shared node endpoint and channel signing key arguments.
pub struct NodeKeyArgs {
/// Logos blockchain node HTTP endpoint
#[arg(long, default_value = "http://localhost:8080", env = "NODE_URL")]
pub node_url: String,
/// Zone channel ID to use instead of deriving one from the signing key.
#[arg(long, env = "CHANNEL_ID")]
pub channel_id: Option<String>,
/// Path to the signing key file (created if it doesn't exist)
#[arg(long, default_value = "sequencer.key", env = "KEY_PATH")]
pub key_path: String,
/// Node wallet public key (hex, 32 bytes) used to pay transaction fees.
/// The value comes from the node's own configuration.
#[arg(long, env = "FUNDING_PK")]
pub funding_pk: String,
/// Cap on a single transaction's fee (in gas units) when funding via
/// `--funding-pk`.
#[arg(long, default_value_t = 1_000_000, env = "MAX_TX_FEE")]
pub max_tx_fee: u64,
/// Percentage of the final mandatory fee reserved when funding via
/// `--funding-pk`. The percentage covers execution plus storage cost; only
/// unused reserve becomes an effective priority tip. The 12% default is a
/// practical reserve for normal fee movement, including approximately one
/// storage-market epoch at normal price levels, not a protocol guarantee
/// at very low prices or when execution fees also rise materially. Storage
/// prices use integer arithmetic, so 1 can become 2. Capped in total by
/// `--max-tx-fee`.
#[arg(
long,
default_value_t = lb_zone_sdk::sequencer::FundingConfig::DEFAULT_PRIORITY_FEE_PERCENT,
env = "PRIORITY_FEE_PERCENT"
)]
pub priority_fee_percent: u64,
}
#[derive(Args, Debug)]
/// Arguments for building and optionally submitting a zone deposit.
pub struct DepositArgs {
#[command(flatten)]
/// Node endpoint and channel signing key used by the sequencer.
pub node_key: NodeKeyArgs,
#[arg(long)]
/// Path to a cucumber `EXPORT_FUNDS` wallet funds JSON file.
pub funds: PathBuf,
#[arg(long)]
/// Amount to deposit into the zone channel.
pub amount: u64,
#[arg(long)]
/// Deposit metadata stored in the channel deposit op.
pub metadata: String,
#[arg(long)]
/// Inscription message paired with the deposit transaction.
pub message: String,
#[arg(long)]
/// Wait for finality instead of returning once the tx is observed on chain.
pub wait_finalized: bool,
}
#[derive(Args, Debug)]
/// Arguments for updating zone channel configuration.
pub struct ConfigArgs {
#[command(flatten)]
/// Node endpoint and channel admin signing key.
pub node_key: NodeKeyArgs,
#[arg(long = "authorized-key-path", required = true)]
/// Paths to signing keys that should be accredited for the channel.
pub authorized_key_paths: Vec<String>,
#[arg(long, default_value_t = 1)]
/// Number of accredited signatures required for future config updates.
pub configuration_threshold: u16,
#[arg(long)]
/// Number of accredited signatures required to transfer or withdraw the
/// channel's notes.
pub transfer_threshold: u16,
#[arg(long, default_value_t = 0)]
/// Number of slots assigned to an accredited poster.
pub posting_timeframe: u32,
#[arg(long, default_value_t = 0)]
/// Number of slots after which a poster is considered timed out.
pub posting_timeout: u32,
#[arg(long)]
/// Wait for finality instead of returning once the tx is observed on chain.
pub wait_finalized: bool,
}
#[derive(Args, Debug)]
/// Arguments for printing zone channel state.
pub struct StateArgs {
#[command(flatten)]
/// Node endpoint and channel key used to resolve the channel.
pub node_key: NodeKeyArgs,
}
#[derive(Args, Debug)]
/// Arguments for preparing an unsigned channel configuration intent file.
pub struct ConfigPrepareArgs {
#[command(flatten)]
/// Node endpoint and channel signing key.
pub node_key: NodeKeyArgs,
#[arg(long = "authorized-key-path", required = true)]
/// Paths to signing keys that should be accredited for the channel.
pub authorized_key_paths: Vec<String>,
#[arg(long)]
/// Number of accredited signatures required for future config updates.
pub configuration_threshold: u16,
#[arg(long)]
/// Number of accredited signatures required to transfer or withdraw the
/// channel's notes.
pub transfer_threshold: u16,
#[arg(long, default_value_t = 0)]
/// Number of slots assigned to an accredited poster.
pub posting_timeframe: u32,
#[arg(long, default_value_t = 0)]
/// Number of slots after which a poster is considered timed out.
pub posting_timeout: u32,
#[arg(long)]
/// Path where the configuration intent JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for signing a channel configuration intent with one key.
pub struct ConfigSignArgs {
#[arg(long)]
/// Path to the signer key file.
pub key_path: String,
#[arg(long = "in")]
/// Path to the configuration intent JSON file.
pub input: PathBuf,
#[arg(long)]
/// Path where the signature JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for combining configuration signature files into a signed tx file.
pub struct ConfigCombineArgs {
#[arg(long = "in")]
/// Path to the configuration intent JSON file.
pub input: PathBuf,
#[arg(long)]
/// Signature JSON file paths to include.
pub sig: Vec<PathBuf>,
#[arg(long)]
/// Path where the signed configuration transaction JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for submitting a signed channel configuration transaction file.
pub struct ConfigSubmitArgs {
#[command(flatten)]
/// Node endpoint and channel signing key used to submit the transaction.
pub node_key: NodeKeyArgs,
#[arg(long = "in")]
/// Path to the signed configuration transaction JSON file.
pub input: PathBuf,
#[arg(long)]
/// Wait for finality instead of returning once the tx is observed on chain.
pub wait_finalized: bool,
}
#[derive(Args, Debug)]
/// Arguments for creating or inspecting a local sequencer signing key.
pub struct KeygenArgs {
#[arg(long, default_value = "sequencer.key", env = "KEY_PATH")]
/// Path to the signing key file to create or inspect.
pub key_path: String,
}
#[derive(Subcommand, Debug)]
enum WithdrawCommand {
/// Prepare an unsigned withdrawal intent file.
Prepare(WithdrawPrepareArgs),
/// Sign a withdrawal intent with one authorized key.
Sign(WithdrawSignArgs),
/// Combine withdrawal signature files into a signed transaction file.
Combine(WithdrawCombineArgs),
/// Submit a signed withdrawal transaction file.
Submit(WithdrawSubmitArgs),
}
#[derive(Args, Debug)]
/// Arguments for preparing an unsigned withdrawal intent file.
pub struct WithdrawPrepareArgs {
#[command(flatten)]
/// Node endpoint and channel signing key used to prepare the intent.
pub node_key: NodeKeyArgs,
#[arg(long)]
/// Amount to withdraw from the zone channel.
pub amount: u64,
#[arg(long)]
/// Path to a recipient cucumber `EXPORT_FUNDS` JSON file.
pub recipient_funds: PathBuf,
#[arg(long)]
/// Inscription message paired with the withdrawal transaction.
pub message: String,
#[arg(long)]
/// Path where the withdrawal intent JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for signing a withdrawal intent with one authorized key.
pub struct WithdrawSignArgs {
#[arg(long)]
/// Path to the signer key file.
pub key_path: String,
#[arg(long = "in")]
/// Path to the withdrawal intent JSON file.
pub input: PathBuf,
#[arg(long)]
/// Path where the signature JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for combining withdrawal signature files into a signed tx file.
pub struct WithdrawCombineArgs {
#[arg(long = "in")]
/// Path to the withdrawal intent JSON file.
pub input: PathBuf,
#[arg(long)]
/// Signature JSON file paths to include.
pub sig: Vec<PathBuf>,
#[arg(long)]
/// Path where the signed withdrawal transaction JSON is written.
pub out: PathBuf,
}
#[derive(Args, Debug)]
/// Arguments for submitting a signed withdrawal transaction file.
pub struct WithdrawSubmitArgs {
#[command(flatten)]
/// Node endpoint and channel signing key used to submit the transaction.
pub node_key: NodeKeyArgs,
#[arg(long = "in")]
/// Path to the signed withdrawal transaction JSON file.
pub input: PathBuf,
#[arg(long)]
/// Wait for finality instead of returning once the tx is observed on chain.
pub wait_finalized: bool,
}
/// Dispatch a parsed CLI command to the matching command runner.
pub async fn run_cli(cli: Cli) -> RunResult<()> {
match cli.command {
Some(Command::Run(args)) => {
crate::run_commands::run_inscribe::run_inscribe(args).await;
Ok(())
}
Some(Command::Config { command }) => match command {
ConfigCommand::Apply(args) => run_config(args).await,
ConfigCommand::Prepare(args) => run_config_prepare(args).await,
ConfigCommand::Sign(args) => run_config_sign(&args),
ConfigCommand::Combine(args) => run_config_combine(args),
ConfigCommand::Submit(args) => run_config_submit(args).await,
},
Some(Command::State { command }) => match command {
StateCommand::Full(args) => run_state_full(args).await,
},
Some(Command::Deposit(args)) => run_deposit(args).await,
Some(Command::Keygen(args)) => {
run_keygen(&args);
Ok(())
}
Some(Command::Withdraw { command }) => match command {
WithdrawCommand::Prepare(args) => run_withdraw_prepare(args).await,
WithdrawCommand::Sign(args) => run_withdraw_sign(&args),
WithdrawCommand::Combine(args) => run_withdraw_combine(args),
WithdrawCommand::Submit(args) => run_withdraw_submit(args).await,
},
None => {
crate::run_commands::run_inscribe::run_inscribe(cli.run_args).await;
Ok(())
}
}
}
-10
View File
@@ -1,10 +0,0 @@
/// Command-line argument parsing and dispatch.
pub mod cli;
/// Message payload types rendered by the TUI.
pub mod message;
/// Manual command implementations used by the CLI.
pub mod run_commands;
/// In-memory TUI state.
pub mod state;
/// Terminal rendering helpers.
pub mod ui;
-32
View File
@@ -1,32 +0,0 @@
use clap::Parser as _;
use logos_blockchain_tui_zone::cli::{Cli, run_cli};
use tracing_subscriber::{
Layer as _, filter::LevelFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _,
};
#[tokio::main]
async fn main() {
let file_appender = tracing_appender::rolling::daily("logs", "tui-zone.log");
let (log_writer, _log_guard) = tracing_appender::non_blocking(file_appender);
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout)
.with_ansi(true)
.with_filter(LevelFilter::WARN);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(log_writer)
.with_ansi(false)
.with_filter(LevelFilter::DEBUG);
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.init();
let args = Cli::parse();
if let Err(error) = run_cli(args).await {
eprintln!("tui-zone command failed: {error}");
std::process::exit(1);
}
}
-61
View File
@@ -1,61 +0,0 @@
use lb_core::mantle::ops::channel::MsgId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Application-level message wrapper. `tx_uuid` ensures unique payload to
/// avoid mempool deduplication even with same signing keys in decentralized
/// scenarios.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppMessage {
/// Random transaction UUID used to make otherwise identical payloads
/// unique.
pub tx_uuid: Uuid,
/// User-entered message text.
pub text: String,
}
impl AppMessage {
/// Create a new application message with a fresh UUID.
#[must_use]
pub fn new(text: String) -> Self {
Self {
tx_uuid: Uuid::new_v4(),
text,
}
}
/// Serialize the message to bytes for inscription payloads.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("AppMessage serialization should not fail")
}
/// Deserialize an application message from inscription payload bytes.
#[must_use]
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
serde_json::from_slice(bytes).ok()
}
}
/// A single message tracked by the TUI.
///
/// `msg_id` is SDK-provided lineage anchor; `text` is the app-level text
/// extracted from the JSON-encoded payload (falls back to raw UTF-8 for
/// payloads that didn't come from this TUI).
#[derive(Debug, Clone)]
pub struct Msg {
/// SDK message lineage identifier.
pub msg_id: MsgId,
/// Text rendered in the TUI.
pub text: String,
}
impl Msg {
/// Build a rendered message from an inscription payload.
#[must_use]
pub fn from_payload(msg_id: MsgId, payload: &[u8]) -> Self {
let text = AppMessage::from_bytes(payload)
.map_or_else(|| String::from_utf8_lossy(payload).into_owned(), |m| m.text);
Self { msg_id, text }
}
}
@@ -1,177 +0,0 @@
use std::time::Duration;
use lb_core::mantle::{
Value,
ledger::Inputs,
ops::channel::{ChannelId, deposit::Metadata, withdraw::ChannelWithdrawOp},
transactions::hash::TxHash,
};
use lb_zone_sdk::{
adapter::NodeHttpClient,
sequencer::{Event, FinalizedOp, FinalizedTx, TxStatus, TxStatusUpdate, ZoneSequencer},
};
use tokio::{select, sync::broadcast, time::sleep};
use crate::{
cli::RunResult,
run_commands::utils::{save_cli_checkpoint, timestamp},
};
const COMMAND_FINALITY_TIMEOUT: Duration = Duration::from_mins(5);
/// Command verification depth.
#[derive(Clone, Copy)]
pub enum WaitFor {
/// Return once the transaction is observed on the local canonical chain.
OnChain,
/// Return only once the command goal is finalized.
Finalized,
}
/// Finalization target for a non-interactive sequencer command.
pub enum CommandGoal {
/// Wait until the transaction hash appears in finalized chain history.
Tx { tx_hash: TxHash },
/// Wait until the expected deposit op appears in finalized channel ops.
Deposit {
/// Signed transaction hash that carried the deposit.
tx_hash: TxHash,
/// Deposit note inputs.
inputs: Inputs,
/// Deposited amount.
amount: Value,
/// Deposit metadata.
metadata: Metadata,
},
/// Wait until all expected withdraw ops appear in the finalized tx.
Withdraw {
/// Signed transaction hash that carried the withdraw ops.
tx_hash: TxHash,
/// Expected withdraw ops.
withdraws: Vec<ChannelWithdrawOp>,
},
}
impl CommandGoal {
const fn tx_hash(&self) -> TxHash {
match self {
Self::Tx { tx_hash }
| Self::Deposit { tx_hash, .. }
| Self::Withdraw { tx_hash, .. } => *tx_hash,
}
}
}
/// Drive a sequencer until the requested command goal reaches the wait target.
pub async fn drive_until_observed(
channel_id: &ChannelId,
sequencer: &mut ZoneSequencer<NodeHttpClient>,
mut status_rx: broadcast::Receiver<TxStatusUpdate>,
goal: CommandGoal,
wait_for: WaitFor,
label: &str,
) -> RunResult<()> {
let tx_hash = goal.tx_hash();
let timeout = sleep(COMMAND_FINALITY_TIMEOUT);
tokio::pin!(timeout);
let mut observed_on_chain = false;
loop {
select! {
() = &mut timeout => {
return Err(format!(
"{} {label}: verification timeout tx_hash={}",
timestamp(),
hex::encode(tx_hash.as_ref())
).into());
}
update = status_rx.recv() => match update {
Ok(update) if update.tx_hash == tx_hash => {
print_status(label, update);
if matches!(update.status, TxStatus::Orphaned(_)) {
return Err(format!(
"{} {label}: orphaned tx_hash={}",
timestamp(),
hex::encode(tx_hash.as_ref())
).into());
}
if matches!(update.status, TxStatus::OnChain(_))
&& matches!(wait_for, WaitFor::OnChain)
{
observed_on_chain = true;
}
if matches!(update.status, TxStatus::Finalized(_))
&& matches!(goal, CommandGoal::Tx { .. })
{
return Ok(());
}
}
Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
println!(
"{} {label}: verification lagged skipped_updates={skipped}",
timestamp()
);
}
Err(broadcast::error::RecvError::Closed) => {
return Err("sequencer tx-status stream closed".into());
}
},
event = sequencer.next_event() => {
if let Event::BlocksProcessed { checkpoint, finalized, .. } = event {
save_cli_checkpoint(channel_id, &checkpoint)?;
if observed_on_chain {
return Ok(());
}
if finalized_goal_matches(&goal, &finalized) {
println!(
"{} {label}: finalized tx_hash={}",
timestamp(),
hex::encode(tx_hash.as_ref())
);
return Ok(());
}
}
}
}
}
}
fn print_status(label: &str, update: TxStatusUpdate) {
println!(
"{} {label}: verification tx_hash={} status={:?}",
timestamp(),
hex::encode(update.tx_hash.as_ref()),
update.status
);
}
fn finalized_goal_matches(goal: &CommandGoal, finalized: &[FinalizedTx]) -> bool {
finalized.iter().any(|tx| match goal {
CommandGoal::Tx { tx_hash } => tx.tx_hash == *tx_hash,
CommandGoal::Deposit {
tx_hash,
inputs,
amount,
metadata,
} => {
tx.tx_hash == *tx_hash
&& tx.ops.iter().any(|op| {
matches!(op, FinalizedOp::Deposit(deposit)
if deposit.inputs == *inputs
&& deposit.amount == *amount
&& deposit.metadata == *metadata)
})
}
CommandGoal::Withdraw { tx_hash, withdraws } => {
tx.tx_hash == *tx_hash
&& withdraws.iter().all(|expected| {
tx.ops.iter().any(|op| {
matches!(op, FinalizedOp::Withdraw(withdraw)
if withdraw.op.channel_id == expected.channel_id
&& withdraw.op.inputs == expected.inputs)
})
})
}
})
}
@@ -1,48 +0,0 @@
/// The TUI sequencer command runner module.
mod driver;
/// Channel balance query command runner.
pub mod run_balance;
/// Channel configuration command runner.
pub mod run_config;
/// Deposit command runner.
pub mod run_deposit;
/// Interactive inscription command runner.
pub mod run_inscribe;
/// Local sequencer key generation command runner.
pub mod run_keygen;
/// Withdrawal command runners.
pub mod run_withdraw;
/// Transaction signing command runner.
pub(crate) mod types;
/// Utility functions for the TUI sequencer command runners.
mod utils;
#[cfg(test)]
mod unit_tests;
/// The TUI sequencer prefix for all config intent files to ensure they are
/// easily identifiable.
pub const ZONE_CONFIG_INTENT: &str = "zone_config_intent";
/// The TUI sequencer prefix for all config signature files to ensure they are
/// easily identifiable.
pub const ZONE_CONFIG_SIGNATURE: &str = "zone_config_signature";
/// The TUI sequencer prefix for all signed config transaction files to ensure
/// they are easily identifiable.
pub const ZONE_SIGNED_CONFIG: &str = "zone_signed_config";
/// The TUI sequencer prefix for all withdraw intent files to ensure they are
/// easily identifiable.
pub const ZONE_WITHDRAW_INTENT: &str = "zone_withdraw_intent";
/// The TUI sequencer prefix for all withdraw signature files to ensure they are
/// easily identifiable.
pub const ZONE_WITHDRAW_SIGNATURE: &str = "zone_withdraw_signature";
/// The TUI sequencer prefix for all signed transaction files to ensure they are
/// easily identifiable.
pub const ZONE_SIGNED_TRANSACTION: &str = "zone_signed_transaction";
/// The TUI sequencer prefix for all funds export files to ensure they are
/// easily identifiable.
pub const ZONE_WALLET_FUNDS_EXPORT: &str = "zone_wallet_funds_export";
/// The TUI sequencer file transfer version to ensure compatibility of transfer
/// files.
pub const ZONE_FILE_TRANSFER_VERSION: u8 = 1;
@@ -1,24 +0,0 @@
use crate::{
cli::{RunResult, StateArgs},
run_commands::utils::{
node_client, print_channel_state, query_channel_state, resolve_channel_id,
},
};
// TODO: support channel note tracking in the TUI. A channel's balance is now
// the sum of its channel notes rather than a field on `ChannelState`.
// pub(crate) async fn run_state_balance(args: StateArgs) -> RunResult<()> {
// let channel_id = resolve_channel_id(&args.node_key)?;
// let node = node_client(&args.node_key.node_url)?;
// let channel_state = query_channel_state(&node, channel_id).await;
// print_channel_balance("balance", &channel_id, channel_state.as_ref());
// Ok(())
// }
pub(crate) async fn run_state_full(args: StateArgs) -> RunResult<()> {
let channel_id = resolve_channel_id(&args.node_key)?;
let node = node_client(&args.node_key.node_url)?;
let channel_state = query_channel_state(&node, channel_id).await;
print_channel_state("state", &channel_id, channel_state.as_ref());
Ok(())
}
@@ -1,409 +0,0 @@
use std::path::PathBuf;
use lb_codec::BinaryEncode as _;
use lb_core::{
mantle::{
Op, OpProof, SignedMantleTx,
ops::channel::{
ChannelId, ChannelKeyIndex, MsgId,
config::{ChannelConfigOp, Keys},
},
traits::Hashable as _,
transactions::{codec::encode_signed_mantle_tx, mantle_tx::MantleTx as _},
},
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
};
use lb_key_management_system_service::keys::{Ed25519PublicKey, Ed25519Signature};
use crate::{
cli::{
ConfigArgs, ConfigCombineArgs, ConfigPrepareArgs, ConfigSignArgs, ConfigSubmitArgs,
RunResult,
},
run_commands::{
ZONE_CONFIG_INTENT, ZONE_CONFIG_SIGNATURE, ZONE_FILE_TRANSFER_VERSION, ZONE_SIGNED_CONFIG,
driver::{CommandGoal, WaitFor, drive_until_observed},
types::{
AuthorizedSigner, ConfigIntent, ConfigSignatureFile, SignedConfigFile,
WithdrawSignatureEntry,
},
utils::{
decode_ed25519_public_key_hex, decode_hex_bincode, decode_mantle_tx_hex,
decode_signed_mantle_tx_hex, encode_hex_bincode, ensure_tx_hash, fixed_bytes,
load_or_create_signing_key, node_client, print_channel_state, query_channel_state,
read_json, resolve_channel_id, start_cli_sequencer_with_channel_state, timestamp,
validate_kind, write_json,
},
},
};
pub(crate) async fn run_config(args: ConfigArgs) -> RunResult<()> {
let authorized_keys =
authorized_keys_for_paths(&args.node_key.key_path, &args.authorized_key_paths);
validate_config_thresholds(
args.configuration_threshold,
args.transfer_threshold,
authorized_keys.len(),
)?;
let channel_id = resolve_channel_id(&args.node_key)?;
let (mut sequencer, channel_state) =
start_cli_sequencer_with_channel_state(&args.node_key).await?;
print_channel_state("zone_config before", &channel_id, channel_state.as_ref());
let status_rx = sequencer.subscribe_tx_status();
let (_receipt, signed_tx) = sequencer
.handle()
.channel_config(
Keys::try_from(authorized_keys)?,
args.posting_timeframe.into(),
args.posting_timeout.into(),
args.configuration_threshold,
args.transfer_threshold,
)
.await?;
let tx_hash = signed_tx.hash();
let goal = CommandGoal::Tx { tx_hash };
let wait_for = if args.wait_finalized {
WaitFor::Finalized
} else {
WaitFor::OnChain
};
drive_until_observed(
&channel_id,
&mut sequencer,
status_rx,
goal,
wait_for,
"zone_config",
)
.await?;
let node = node_client(&args.node_key.node_url)?;
let channel_state = query_channel_state(&node, channel_id).await;
print_channel_state("zone_config after", &channel_id, channel_state.as_ref());
Ok(())
}
pub(crate) async fn run_config_prepare(args: ConfigPrepareArgs) -> RunResult<()> {
let authorized_keys =
authorized_keys_for_paths(&args.node_key.key_path, &args.authorized_key_paths);
validate_config_thresholds(
args.configuration_threshold,
args.transfer_threshold,
authorized_keys.len(),
)?;
let channel_id = resolve_channel_id(&args.node_key)?;
let (_sequencer, channel_state) =
start_cli_sequencer_with_channel_state(&args.node_key).await?;
let channel_state =
channel_state.ok_or_else(|| format!("channel state not found for {channel_id}"))?;
let config_op = build_config_op(
channel_id,
channel_state.config_tip_hash,
authorized_keys.clone(),
args.posting_timeframe,
args.posting_timeout,
args.configuration_threshold,
args.transfer_threshold,
)?;
let config_id = config_op.id();
let tx = lb_core::mantle::RawMantleTx([Op::ChannelConfig(config_op)].into());
let tx_hash = tx.hash();
let intent = ConfigIntent {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_CONFIG_INTENT.to_owned(),
channel_id: hex::encode(channel_id.as_ref()),
tx_hash: hex::encode(tx_hash.as_ref()),
config_id: hex::encode(config_id.as_ref()),
required_threshold: channel_state.configuration_threshold,
mantle_tx: hex::encode(tx.encode()),
new_authorized_keys: authorized_keys
.iter()
.map(|key| hex::encode(key.to_bytes()))
.collect(),
configuration_threshold: args.configuration_threshold,
transfer_threshold: args.transfer_threshold,
posting_timeframe: args.posting_timeframe,
posting_timeout: args.posting_timeout,
authorized_signers: channel_state
.accredited_keys
.iter()
.enumerate()
.map(|(index, key)| AuthorizedSigner {
key_index: index as ChannelKeyIndex,
public_key: hex::encode(key.to_bytes()),
})
.collect(),
signatures: Vec::new(),
};
write_json(&args.out, &intent)?;
println!(
"{} zone_config: intent tx_hash={} config_id={} required_threshold={}",
timestamp(),
intent.tx_hash,
intent.config_id,
intent.required_threshold
);
Ok(())
}
pub(crate) fn run_config_sign(args: &ConfigSignArgs) -> RunResult<()> {
let intent = read_json::<ConfigIntent>(&args.input)?;
validate_kind(&intent.kind, ZONE_CONFIG_INTENT, intent.version)?;
let tx = decode_mantle_tx_hex(&intent.mantle_tx)?;
let tx_hash = tx.hash();
ensure_tx_hash(&intent.tx_hash, tx_hash)?;
let signing_key = load_or_create_signing_key(PathBuf::from(&args.key_path).as_path());
let public_key = signing_key.public_key();
let signer = intent
.authorized_signers
.iter()
.find(|signer| signer.public_key == hex::encode(public_key.to_bytes()))
.ok_or_else(|| {
"signing key is not listed in config intent authorized_signers".to_owned()
})?;
let signature = signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref());
let signature_file = ConfigSignatureFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_CONFIG_SIGNATURE.to_owned(),
channel_id: intent.channel_id,
tx_hash: intent.tx_hash,
signer_public_key: signer.public_key.clone(),
signer_key_index: signer.key_index,
signature: encode_hex_bincode(&signature)?,
};
write_json(&args.out, &signature_file)?;
println!(
"{} zone_config: signature tx_hash={} signer_key_index={}",
timestamp(),
signature_file.tx_hash,
signature_file.signer_key_index
);
Ok(())
}
pub(crate) fn run_config_combine(args: ConfigCombineArgs) -> RunResult<()> {
let intent = read_json::<ConfigIntent>(&args.input)?;
validate_kind(&intent.kind, ZONE_CONFIG_INTENT, intent.version)?;
let tx = decode_mantle_tx_hex(&intent.mantle_tx)?;
let tx_hash = tx.hash();
ensure_tx_hash(&intent.tx_hash, tx_hash)?;
validate_config_tx(&tx, &intent)?;
let mut signature_entries = intent.signatures.clone();
for path in args.sig {
let sig = read_json::<ConfigSignatureFile>(&path)?;
validate_kind(&sig.kind, ZONE_CONFIG_SIGNATURE, sig.version)?;
if sig.channel_id != intent.channel_id {
return Err(format!(
"signature '{}' channel_id {} does not match intent {}",
path.display(),
sig.channel_id,
intent.channel_id
)
.into());
}
if sig.tx_hash != intent.tx_hash {
return Err(format!(
"signature '{}' tx_hash {} does not match intent {}",
path.display(),
sig.tx_hash,
intent.tx_hash
)
.into());
}
let public_key = decode_ed25519_public_key_hex(&sig.signer_public_key)?;
validate_authorized_signer(&intent, sig.signer_key_index, &sig.signer_public_key)?;
let signature = decode_hex_bincode::<Ed25519Signature>(&sig.signature)?;
public_key.verify(tx_hash.as_signing_bytes().as_ref(), &signature)?;
signature_entries.push(WithdrawSignatureEntry {
signer_key_index: sig.signer_key_index,
signer_public_key: sig.signer_public_key,
signature: sig.signature,
});
}
let proof = ChannelMultiSigProof::try_new(
signature_entries
.iter()
.map(|sig| {
validate_authorized_signer(&intent, sig.signer_key_index, &sig.signer_public_key)?;
decode_hex_bincode::<Ed25519Signature>(&sig.signature)
.map(|signature| IndexedSignature::new(sig.signer_key_index, signature))
})
.collect::<RunResult<Vec<_>>>()?
.try_into()?,
)?;
if proof.signatures().len() != intent.required_threshold as usize {
return Err(format!(
"config combine requires exactly {} unique authorized signature(s), got {}",
intent.required_threshold,
proof.signatures().len()
)
.into());
}
let signed_tx = SignedMantleTx::new(tx, [OpProof::ChannelMultiSigProof(proof)].into());
let signed = SignedConfigFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_SIGNED_CONFIG.to_owned(),
channel_id: intent.channel_id,
tx_hash: intent.tx_hash,
config_id: intent.config_id,
signed_mantle_tx: hex::encode(encode_signed_mantle_tx(&signed_tx)),
signatures: signature_entries,
};
write_json(&args.out, &signed)?;
println!(
"{} zone_config: signed tx_hash={} config_id={}",
timestamp(),
signed.tx_hash,
signed.config_id
);
Ok(())
}
pub(crate) async fn run_config_submit(args: ConfigSubmitArgs) -> RunResult<()> {
let signed = read_json::<SignedConfigFile>(&args.input)?;
validate_kind(&signed.kind, ZONE_SIGNED_CONFIG, signed.version)?;
let signed_tx = decode_signed_mantle_tx_hex(&signed.signed_mantle_tx)?;
let tx_hash = signed_tx.hash();
ensure_tx_hash(&signed.tx_hash, tx_hash)?;
let channel_id = ChannelId::from(fixed_bytes::<32>(&signed.channel_id)?);
if let Some(requested_channel_id) = args.node_key.channel_id.as_deref()
&& signed.channel_id != requested_channel_id
{
return Err(format!(
"signed config channel_id {} does not match requested channel_id {}",
signed.channel_id, requested_channel_id
)
.into());
}
let mut node_key = args.node_key;
node_key.channel_id = Some(signed.channel_id.clone());
let (mut sequencer, channel_state) = start_cli_sequencer_with_channel_state(&node_key).await?;
print_channel_state("zone_config before", &channel_id, channel_state.as_ref());
let status_rx = sequencer.subscribe_tx_status();
let goal = CommandGoal::Tx { tx_hash };
let tip_message = sequencer
.checkpoint()
.map_or_else(MsgId::root, |checkpoint| checkpoint.last_msg_id);
let (_result, _checkpoint) = sequencer
.handle()
.submit_signed_tx(signed_tx, tip_message)?;
println!(
"{} zone_config: submitted tx_hash={} config_id={}",
timestamp(),
signed.tx_hash,
signed.config_id
);
let wait_for = if args.wait_finalized {
WaitFor::Finalized
} else {
WaitFor::OnChain
};
drive_until_observed(
&channel_id,
&mut sequencer,
status_rx,
goal,
wait_for,
"zone_config",
)
.await?;
let node = node_client(&node_key.node_url)?;
let channel_state = query_channel_state(&node, channel_id).await;
print_channel_state("zone_config after", &channel_id, channel_state.as_ref());
Ok(())
}
fn validate_config_thresholds(
configuration_threshold: u16,
transfer_threshold: u16,
authorized_key_count: usize,
) -> RunResult<()> {
if transfer_threshold == 0 {
return Err("transfer_threshold must be greater than 0".into());
}
if configuration_threshold == 0 {
return Err("configuration_threshold must be greater than 0".into());
}
if transfer_threshold as usize > authorized_key_count {
return Err(format!(
"transfer_threshold {transfer_threshold} exceeds authorized key count {authorized_key_count}"
)
.into());
}
if configuration_threshold as usize > authorized_key_count {
return Err(format!(
"configuration_threshold {configuration_threshold} exceeds authorized key count {authorized_key_count}"
)
.into());
}
Ok(())
}
fn authorized_keys_for_paths(
admin_key_path: &str,
authorized_key_paths: &[String],
) -> Vec<Ed25519PublicKey> {
let admin_key = load_or_create_signing_key(PathBuf::from(admin_key_path).as_path());
let mut authorized_keys = vec![admin_key.public_key()];
for key_path in authorized_key_paths {
let public_key = load_or_create_signing_key(PathBuf::from(key_path).as_path()).public_key();
if !authorized_keys.contains(&public_key) {
authorized_keys.push(public_key);
}
}
authorized_keys
}
fn build_config_op(
channel_id: ChannelId,
parent: MsgId,
authorized_keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
transfer_threshold: u16,
) -> RunResult<ChannelConfigOp> {
Ok(ChannelConfigOp {
channel: channel_id,
parent,
keys: Keys::try_from(authorized_keys)?,
posting_timeframe: posting_timeframe.into(),
posting_timeout: posting_timeout.into(),
configuration_threshold,
transfer_threshold,
})
}
fn validate_authorized_signer(
intent: &ConfigIntent,
signer_key_index: ChannelKeyIndex,
signer_public_key: &str,
) -> RunResult<()> {
if intent.authorized_signers.iter().any(|signer| {
signer.key_index == signer_key_index && signer.public_key == signer_public_key
}) {
return Ok(());
}
Err(format!(
"signature signer key_index={signer_key_index} public_key={signer_public_key} is not listed in config intent authorized_signers"
)
.into())
}
fn validate_config_tx(tx: &lb_core::mantle::RawMantleTx, intent: &ConfigIntent) -> RunResult<()> {
let config = tx
.ops()
.iter()
.find_map(|op| match op {
Op::ChannelConfig(config) => Some(config),
_ => None,
})
.ok_or("config intent transaction has no ChannelConfig op")?;
if hex::encode(config.id().as_ref()) != intent.config_id {
return Err("config intent config_id does not match ChannelConfig op id".into());
}
if tx.ops().len() != 1 {
return Err("config intent transaction must contain exactly one op".into());
}
Ok(())
}
@@ -1,94 +0,0 @@
use lb_core::mantle::{
Op, OpProof, SignedMantleTx, ops::channel::inscribe::Inscription, traits::Hashable as _,
};
use lb_key_management_system_service::keys::ZkKey;
use crate::{
cli::{DepositArgs, RunResult},
run_commands::{
ZONE_WALLET_FUNDS_EXPORT,
driver::{CommandGoal, WaitFor, drive_until_observed},
types::WalletFundsExport,
utils::{
build_deposit_op, build_deposit_transfer, decode_exported_utxos,
decode_required_hex_bincode, read_json, resolve_channel_id,
start_cli_sequencer_with_channel_state, timestamp, validate_kind,
},
},
};
pub(crate) async fn run_deposit(args: DepositArgs) -> RunResult<()> {
let funds = read_json::<WalletFundsExport>(&args.funds)?;
validate_kind(&funds.kind, ZONE_WALLET_FUNDS_EXPORT, funds.version)?;
let wallet_key = decode_required_hex_bincode::<ZkKey>(
funds.secret_key.as_deref(),
"funds JSON is missing secret_key; rerun EXPORT_FUNDS with include_secret true",
)?;
let funding_public_key = wallet_key.to_public_key();
let available_utxos = decode_exported_utxos(&funds)?;
let (transfer, _reserved_inputs) =
build_deposit_transfer(available_utxos, funding_public_key, args.amount)?;
let channel_id = resolve_channel_id(&args.node_key)?;
let deposit = build_deposit_op(channel_id, &transfer, &args.metadata)?;
let inscription = Inscription::try_from(args.message.into_bytes())?;
// TODO: report the channel balance again once channel notes are tracked.
let (mut sequencer, _channel_state) =
start_cli_sequencer_with_channel_state(&args.node_key).await?;
let status_rx = sequencer.subscribe_tx_status();
let goal_inputs = deposit.inputs.clone();
let goal_metadata = deposit.metadata.clone();
let (tx, msg_id, sequencer_sig) = sequencer.handle().prepare_tx(
[Op::Transfer(transfer), Op::ChannelDeposit(deposit)].into(),
inscription,
)?;
let user_sig = ZkKey::multi_sign(&[wallet_key], &tx.hash().to_fr())?;
let signed_tx = SignedMantleTx::new(
tx,
[
OpProof::ZkSig(user_sig.clone()),
OpProof::ZkSig(user_sig),
OpProof::Ed25519Sig(sequencer_sig),
]
.into(),
);
let tx_hash = signed_tx.hash();
let goal = CommandGoal::Deposit {
tx_hash,
inputs: goal_inputs,
amount: args.amount,
metadata: goal_metadata,
};
let _receipt = sequencer.handle().submit_signed_tx(signed_tx, msg_id)?;
println!(
"{} deposit: submitted tx_hash={} msg_id={}",
timestamp(),
hex::encode(tx_hash.as_ref()),
hex::encode(msg_id.as_ref())
);
let wait_for = if args.wait_finalized {
WaitFor::Finalized
} else {
WaitFor::OnChain
};
drive_until_observed(
&channel_id,
&mut sequencer,
status_rx,
goal,
wait_for,
"deposit",
)
.await?;
// TODO: report the channel balance again once channel notes are tracked. It
// is now the sum of the channel's notes rather than a field on
// `ChannelState`.
println!(
"{} deposit: tx_hash={} msg_id={}",
timestamp(),
hex::encode(tx_hash.as_ref()),
hex::encode(msg_id.as_ref())
);
Ok(())
}
@@ -1,253 +0,0 @@
use std::{collections::HashSet, path::Path};
use lb_core::mantle::ops::channel::inscribe::Inscription;
use lb_zone_sdk::{
CommonHttpClient,
adapter::NodeHttpClient,
sequencer::{
ChannelUpdate, ChannelUpdateTx, Event, FinalizedOp, FinalizedTx, InscriptionInfo,
ZoneSequencer,
},
};
use reqwest::Url;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::{
cli::NodeKeyArgs,
message::AppMessage,
run_commands::utils,
state::{InMemoryZoneState, ZoneState as _},
ui,
};
#[expect(
clippy::cognitive_complexity,
reason = "TODO: address this in a dedicated refactor"
)]
/// Run the interactive inscription TUI.
pub async fn run_inscribe(args: NodeKeyArgs) {
let node_url: Url = args.node_url.parse().expect("invalid node URL");
let signing_key = utils::load_or_create_signing_key(Path::new(&args.key_path));
let channel_id = utils::resolve_channel_id(&args).expect("invalid channel ID");
println!("TUI Zone Sequencer");
println!(" Node: {node_url}");
println!(" Key: {}", args.key_path);
println!(" Channel ID: {}", hex::encode(channel_id.as_ref()));
println!();
let node = NodeHttpClient::new(CommonHttpClient::new(None), node_url);
let channel_exists = utils::query_channel_exists(&node, channel_id).await;
let mut state =
InMemoryZoneState::for_channel(channel_id, channel_exists).expect("invalid checkpoint");
let checkpoint = state.load_checkpoint().cloned();
let sequencer_config = utils::cli_sequencer_config(&args).expect("invalid funding pk");
let mut sequencer = ZoneSequencer::init_with_config(
channel_id,
signing_key,
node,
sequencer_config,
checkpoint,
);
let view_rx = sequencer.subscribe_channel_view();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let mut stdin_rx = spawn_stdin_reader(ready_rx);
let mut ready_tx = Some(ready_tx);
println!("Bootstrapping sequencer...");
loop {
tokio::select! {
event = sequencer.next_event() => {
state.set_channel_view(view_rx.borrow().clone());
handle_event(event, &mut state, &mut sequencer, &mut ready_tx).await;
}
input = stdin_rx.recv() => {
let Some(text) = input else {
println!();
break;
};
let msg = AppMessage::new(text);
debug!(tx_uuid = %msg.tx_uuid, text = %msg.text, "Publishing message");
let Ok(inscription) = Inscription::try_from(msg.to_bytes()) else {
error!("Message is too large to fit in an inscription");
continue;
};
match sequencer.handle().publish(inscription).await {
Ok((result, checkpoint)) => {
let info = result.tx.inscription();
debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "Published");
state.on_published(info);
state.save_checkpoint(checkpoint);
ui::render_state(&state);
eprintln!(" \x1b[90mpending...\x1b[0m");
ui::prompt();
}
Err(lb_zone_sdk::sequencer::Error::Unavailable { reason }) => {
warn!("publish rejected: {reason}");
eprintln!(
" \x1b[33msequencer is still starting up, try again in a moment\x1b[0m"
);
ui::prompt();
}
Err(e) => {
error!("failed to publish: {e}");
break;
}
}
}
_ = tokio::signal::ctrl_c() => {
println!();
break;
}
}
}
println!("Goodbye!");
}
async fn handle_event(
event: Event,
state: &mut InMemoryZoneState,
sequencer: &mut ZoneSequencer<NodeHttpClient>,
ready_tx: &mut Option<tokio::sync::oneshot::Sender<()>>,
) {
match event {
Event::Ready => handle_ready(state, ready_tx),
Event::BlocksProcessed {
checkpoint,
channel_update,
finalized,
} => {
// Pin finalized payloads before the re-publish dedup below.
apply_finalized(&finalized, state);
apply_channel_update(channel_update, state, sequencer).await;
state.save_checkpoint(checkpoint);
}
Event::MempoolPending(..) | Event::TurnNotification { .. } => {}
}
}
fn apply_finalized(items: &[FinalizedTx], state: &mut InMemoryZoneState) {
// TUI only cares about inscriptions for rendering; deposit / withdraw
// ops have no inscription payload.
let inscriptions: Vec<InscriptionInfo> = items
.iter()
.flat_map(|t| t.ops.iter())
.filter_map(|op| match op {
FinalizedOp::Inscription(i) => Some(i.clone()),
FinalizedOp::Deposit(_)
| FinalizedOp::Withdraw(_)
| FinalizedOp::Config(_)
| FinalizedOp::ChannelTransfer(_) => None,
})
.collect();
if inscriptions.is_empty() {
return;
}
state.on_finalized(&inscriptions);
ui::render_state(state);
ui::prompt();
}
async fn apply_channel_update(
update: ChannelUpdate,
state: &mut InMemoryZoneState,
sequencer: &mut ZoneSequencer<NodeHttpClient>,
) {
let ChannelUpdate { orphaned, adopted } = update;
if orphaned.is_empty() {
return;
}
// Dedup by payload (carries a unique tx_uuid): an orphan already back on
// the channel reappears in `adopted`, so don't republish it.
let adopted_payloads: HashSet<&[u8]> = adopted
.iter()
.filter_map(|tx| tx.inscription().map(|i| i.payload.as_slice()))
.collect();
for entry in &orphaned {
handle_orphan(state, sequencer, entry, &adopted_payloads).await;
}
}
async fn handle_orphan(
state: &mut InMemoryZoneState,
sequencer: &mut ZoneSequencer<NodeHttpClient>,
entry: &ChannelUpdateTx,
adopted_payloads: &HashSet<&[u8]>,
) {
let ChannelUpdateTx::Inscription(info) = entry else {
debug!("ignoring atomic-withdraw orphan; TUI does not publish bundles");
return;
};
if adopted_payloads.contains(info.payload.as_slice()) {
debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "orphan already on channel; not republishing");
return;
}
if state.is_finalized(info.payload.as_slice()) {
debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "orphan already finalized; not republishing");
return;
}
republish_orphan(state, sequencer, info).await;
}
async fn republish_orphan(
state: &mut InMemoryZoneState,
sequencer: &mut ZoneSequencer<NodeHttpClient>,
info: &InscriptionInfo,
) {
debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "Auto-republishing orphan");
match sequencer.handle().publish(info.payload.clone()).await {
Ok((_, checkpoint)) => state.save_checkpoint(checkpoint),
Err(e) => error!("failed to auto-republish: {e}"),
}
}
fn handle_ready(
state: &InMemoryZoneState,
ready_tx: &mut Option<tokio::sync::oneshot::Sender<()>>,
) {
info!("Sequencer ready");
if let Some(tx) = ready_tx.take() {
let _ = tx.send(());
}
println!("Ready.");
println!();
println!("Type a message and press Enter to publish.");
println!("Press Ctrl-D or type an empty line to exit.");
println!();
ui::render_state(state);
ui::prompt();
}
fn spawn_stdin_reader(ready: tokio::sync::oneshot::Receiver<()>) -> mpsc::Receiver<String> {
let (tx, rx) = mpsc::channel(16);
std::thread::spawn(move || {
// Wait until the sequencer is ready before accepting input
if ready.blocking_recv().is_err() {
return;
}
let stdin = std::io::stdin();
let mut line = String::new();
loop {
line.clear();
match stdin.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
let text = line.trim_end().to_owned();
if text.is_empty() || tx.blocking_send(text).is_err() {
break;
}
}
}
}
});
rx
}
@@ -1,10 +0,0 @@
use std::path::Path;
use crate::{cli::KeygenArgs, run_commands::utils::load_or_create_signing_key};
/// Create or load a local sequencer signing key and print its public key.
pub(crate) fn run_keygen(args: &KeygenArgs) {
let key = load_or_create_signing_key(Path::new(&args.key_path));
println!("key_path={}", args.key_path);
println!("public_key={}", hex::encode(key.public_key().to_bytes()));
}
@@ -1,318 +0,0 @@
use std::path::PathBuf;
use lb_core::{
mantle::{
Op, OpProof, SignedMantleTx,
ops::channel::{ChannelId, ChannelKeyIndex},
traits::Hashable as _,
transactions::{codec::encode_signed_mantle_tx, mantle_tx::MantleTx as _},
},
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
};
use lb_key_management_system_service::keys::Ed25519Signature;
pub(crate) use crate::{
cli::{
RunResult, WithdrawCombineArgs, WithdrawPrepareArgs, WithdrawSignArgs, WithdrawSubmitArgs,
},
run_commands::{
ZONE_FILE_TRANSFER_VERSION, ZONE_SIGNED_TRANSACTION, ZONE_WITHDRAW_INTENT,
ZONE_WITHDRAW_SIGNATURE,
driver::{CommandGoal, WaitFor, drive_until_observed},
types::{
SignedWithdrawFile, WithdrawIntent, WithdrawSignatureEntry, WithdrawSignatureFile,
},
utils::{
decode_ed25519_public_key_hex, decode_hex_bincode, decode_mantle_tx_hex,
decode_msg_id_hex, decode_signed_mantle_tx_hex, encode_hex_bincode, ensure_tx_hash,
fixed_bytes, load_or_create_signing_key, read_json, start_cli_sequencer, timestamp,
validate_kind, write_json,
},
},
};
// TODO: rebuild withdraw prepare on CHANNEL_TRANSFER + CHANNEL_WITHDRAW. A
// withdraw now only releases an existing channel note to the key it already
// carries, so paying a recipient an arbitrary amount first requires
// transferring a channel note to their key. That needs channel note tracking.
pub(crate) async fn run_withdraw_prepare(_args: WithdrawPrepareArgs) -> RunResult<()> {
Err("withdraw prepare is unsupported until channel notes are tracked".into())
}
// pub(crate) async fn run_withdraw_prepare(args: WithdrawPrepareArgs) ->
// RunResult<()> { let funds =
// read_json::<WalletFundsExport>(&args.recipient_funds)?; validate_kind(&
// funds.kind, ZONE_WALLET_FUNDS_EXPORT, funds.version)?; let recipient =
// decode_zk_public_key_hex(&funds.public_key)?; let channel_id =
// resolve_channel_id(&args.node_key)?; let (mut sequencer, channel_state) =
// start_cli_sequencer_with_channel_state(&args.node_key).await?;
// let channel_state =
// channel_state.ok_or_else(|| format!("channel state not found for
// {channel_id}"))?; print_channel_balance("withdraw before", &channel_id,
// Some(&channel_state)); if args.amount > channel_state.balance {
// return Err(format!(
// "insufficient channel balance for withdraw: requested {},
// available {}", args.amount, channel_state.balance
// )
// .into());
// }
// let withdraw_nonce = channel_state.withdrawal_nonce;
// let withdraw = ChannelWithdrawOp {
// channel_id,
// outputs: Outputs::new([Note::new(args.amount, recipient)]),
// withdraw_nonce,
// };
// let inscription = Inscription::try_from(args.message.into_bytes())?;
// let (tx, msg_id, inscription_signature) = sequencer
// .handle()
// .prepare_tx([Op::ChannelWithdraw(withdraw)].into(), inscription)?;
// let tx_hash = tx.hash();
// let intent = WithdrawIntent {
// version: ZONE_FILE_TRANSFER_VERSION,
// kind: ZONE_WITHDRAW_INTENT.to_owned(),
// channel_id: hex::encode(channel_id.as_ref()),
// tx_hash: hex::encode(tx_hash.as_ref()),
// msg_id: hex::encode(msg_id.as_ref()),
// required_threshold: channel_state.transfer_threshold,
// mantle_tx: hex::encode(tx.encode()),
// inscription_signature: encode_hex_bincode(&inscription_signature)?,
// withdraws: vec![WithdrawFileEntry {
// amount: args.amount,
// recipient_public_key: funds.public_key,
// withdraw_nonce,
// }],
// authorized_signers: channel_state
// .accredited_keys
// .iter()
// .enumerate()
// .map(|(index, key)| AuthorizedSigner {
// key_index: index as ChannelKeyIndex,
// public_key: hex::encode(key.to_bytes()),
// })
// .collect(),
// signatures: Vec::new(),
// };
// write_json(&args.out, &intent)?;
// println!(
// "{} withdraw: intent tx_hash={} msg_id={}",
// timestamp(),
// intent.tx_hash,
// intent.msg_id
// );
// Ok(())
// }
pub(crate) fn run_withdraw_sign(args: &WithdrawSignArgs) -> RunResult<()> {
let intent = read_json::<WithdrawIntent>(&args.input)?;
validate_kind(&intent.kind, ZONE_WITHDRAW_INTENT, intent.version)?;
let tx = decode_mantle_tx_hex(&intent.mantle_tx)?;
let tx_hash = tx.hash();
ensure_tx_hash(&intent.tx_hash, tx_hash)?;
let signing_key = load_or_create_signing_key(PathBuf::from(&args.key_path).as_path());
let public_key = signing_key.public_key();
let signer = intent
.authorized_signers
.iter()
.find(|signer| signer.public_key == hex::encode(public_key.to_bytes()))
.ok_or_else(|| {
"signing key is not listed in withdraw intent authorized_signers".to_owned()
})?;
let signature = signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref());
let signature_file = WithdrawSignatureFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_SIGNATURE.to_owned(),
channel_id: intent.channel_id,
tx_hash: intent.tx_hash,
signer_public_key: signer.public_key.clone(),
signer_key_index: signer.key_index,
signature: encode_hex_bincode(&signature)?,
};
write_json(&args.out, &signature_file)?;
println!(
"{} withdraw: signature tx_hash={} signer_key_index={}",
timestamp(),
signature_file.tx_hash,
signature_file.signer_key_index
);
Ok(())
}
pub(crate) fn run_withdraw_combine(args: WithdrawCombineArgs) -> RunResult<()> {
let intent = read_json::<WithdrawIntent>(&args.input)?;
validate_kind(&intent.kind, ZONE_WITHDRAW_INTENT, intent.version)?;
let tx = decode_mantle_tx_hex(&intent.mantle_tx)?;
let tx_hash = tx.hash();
ensure_tx_hash(&intent.tx_hash, tx_hash)?;
let mut signature_entries = intent.signatures.clone();
for path in args.sig {
let sig = read_json::<WithdrawSignatureFile>(&path)?;
validate_kind(&sig.kind, ZONE_WITHDRAW_SIGNATURE, sig.version)?;
if sig.channel_id != intent.channel_id {
return Err(format!(
"signature '{}' channel_id {} does not match intent {}",
path.display(),
sig.channel_id,
intent.channel_id
)
.into());
}
if sig.tx_hash != intent.tx_hash {
return Err(format!(
"signature '{}' tx_hash {} does not match intent {}",
path.display(),
sig.tx_hash,
intent.tx_hash
)
.into());
}
let public_key = decode_ed25519_public_key_hex(&sig.signer_public_key)?;
validate_authorized_signer(&intent, sig.signer_key_index, &sig.signer_public_key)?;
let signature = decode_hex_bincode::<Ed25519Signature>(&sig.signature)?;
public_key.verify(tx_hash.as_signing_bytes().as_ref(), &signature)?;
signature_entries.push(WithdrawSignatureEntry {
signer_key_index: sig.signer_key_index,
signer_public_key: sig.signer_public_key,
signature: sig.signature,
});
}
let proof = ChannelMultiSigProof::try_new(
signature_entries
.iter()
.map(|sig| {
validate_authorized_signer(&intent, sig.signer_key_index, &sig.signer_public_key)?;
decode_hex_bincode::<Ed25519Signature>(&sig.signature)
.map(|signature| IndexedSignature::new(sig.signer_key_index, signature))
})
.collect::<RunResult<Vec<_>>>()?
.try_into()?,
)?;
if proof.signatures().len() < intent.required_threshold as usize {
return Err(format!(
"withdraw combine requires {} unique authorized signature(s), got {}",
intent.required_threshold,
proof.signatures().len()
)
.into());
}
let inscription_signature =
decode_hex_bincode::<Ed25519Signature>(&intent.inscription_signature)?;
let op_proofs = tx
.ops()
.iter()
.map(|op| match op {
Op::ChannelWithdraw(_) => Ok(OpProof::ChannelMultiSigProof(proof.clone())),
Op::ChannelInscribe(_) => Ok(OpProof::Ed25519Sig(inscription_signature)),
other => Err(format!("unexpected op in withdraw intent: {other:?}").into()),
})
.collect::<RunResult<Vec<_>>>()?
.try_into()?;
let signed_tx = SignedMantleTx::new(tx, op_proofs);
let signed = SignedWithdrawFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_SIGNED_TRANSACTION.to_owned(),
channel_id: intent.channel_id,
tx_hash: intent.tx_hash,
msg_id: intent.msg_id,
signed_mantle_tx: hex::encode(encode_signed_mantle_tx(&signed_tx)),
signatures: signature_entries,
};
write_json(&args.out, &signed)?;
println!(
"{} withdraw: signed tx_hash={} msg_id={}",
timestamp(),
signed.tx_hash,
signed.msg_id
);
Ok(())
}
fn validate_authorized_signer(
intent: &WithdrawIntent,
signer_key_index: ChannelKeyIndex,
signer_public_key: &str,
) -> RunResult<()> {
if intent.authorized_signers.iter().any(|signer| {
signer.key_index == signer_key_index && signer.public_key == signer_public_key
}) {
return Ok(());
}
Err(format!(
"signature signer key_index={signer_key_index} public_key={signer_public_key} is not listed in withdraw intent authorized_signers"
)
.into())
}
fn decode_channel_id_hex(channel_id: &str) -> RunResult<ChannelId> {
Ok(ChannelId::from(fixed_bytes::<32>(channel_id)?))
}
pub(crate) async fn run_withdraw_submit(args: WithdrawSubmitArgs) -> RunResult<()> {
let signed = read_json::<SignedWithdrawFile>(&args.input)?;
validate_kind(&signed.kind, ZONE_SIGNED_TRANSACTION, signed.version)?;
let signed_tx = decode_signed_mantle_tx_hex(&signed.signed_mantle_tx)?;
let tx_hash = signed_tx.hash();
ensure_tx_hash(&signed.tx_hash, tx_hash)?;
let channel_id = decode_channel_id_hex(&signed.channel_id)?;
if let Some(requested_channel_id) = args.node_key.channel_id.as_deref()
&& signed.channel_id != requested_channel_id
{
return Err(format!(
"signed withdraw channel_id {} does not match requested channel_id {}",
signed.channel_id, requested_channel_id
)
.into());
}
let mut node_key = args.node_key;
node_key.channel_id = Some(signed.channel_id.clone());
let withdraws = signed_tx
.mantle_tx()
.ops()
.iter()
.filter_map(|op| match op {
Op::ChannelWithdraw(withdraw) => Some(withdraw.clone()),
_ => None,
})
.collect::<Vec<_>>();
if withdraws.is_empty() {
return Err("signed withdraw transaction has no withdraw ops".into());
}
if withdraws
.iter()
.any(|withdraw| withdraw.channel_id != channel_id)
{
return Err(
"signed withdraw transaction channel_id does not match requested channel".into(),
);
}
let goal = CommandGoal::Withdraw { tx_hash, withdraws };
let mut sequencer = start_cli_sequencer(&node_key).await?;
let status_rx = sequencer.subscribe_tx_status();
let (_result, _checkpoint) = sequencer
.handle()
.submit_signed_tx(signed_tx, decode_msg_id_hex(&signed.msg_id)?)?;
println!(
"{} withdraw: submitted tx_hash={} msg_id={}",
timestamp(),
signed.tx_hash,
signed.msg_id
);
let wait_for = if args.wait_finalized {
WaitFor::Finalized
} else {
WaitFor::OnChain
};
drive_until_observed(
&channel_id,
&mut sequencer,
status_rx,
goal,
wait_for,
"withdraw",
)
.await?;
// TODO: report the channel balance again once channel notes are tracked.
// let node = node_client(&node_key.node_url)?;
// let channel_state = query_channel_state(&node, channel_id).await;
// print_channel_balance("withdraw after", &channel_id, channel_state.as_ref());
Ok(())
}
@@ -1,205 +0,0 @@
use lb_core::mantle::{Value, ops::channel::ChannelKeyIndex};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
/// Wallet funds exported by the cucumber manual `EXPORT_FUNDS` command.
pub struct WalletFundsExport {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Name of the cucumber wallet that exported the funds.
pub wallet: String,
/// HTTP endpoint of the node associated with the exporting wallet.
#[serde(alias = "node")]
pub node_url: String,
/// Hex-encoded recipient wallet public key.
pub public_key: String,
/// Optional hex-encoded wallet secret key for spending exported funds.
pub secret_key: Option<String>,
/// Amount requested by the export command.
pub requested_value: u64,
/// Total value selected by the export command.
pub selected_value: u64,
/// Exported spendable UTXOs.
pub utxos: Vec<ExportedUtxo>,
}
#[derive(Serialize, Deserialize)]
/// One UTXO entry in a wallet funds export.
pub struct ExportedUtxo {
/// Hex-encoded UTXO identifier.
pub utxo_id: String,
/// Value carried by the UTXO.
pub value: u64,
/// Hex-encoded bincode representation of the UTXO.
pub encoded_utxo: String,
}
#[derive(Serialize, Deserialize)]
/// Unsigned withdrawal transaction plus metadata required for offline signing.
pub struct WithdrawIntent {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded transaction hash that signers must sign.
pub tx_hash: String,
/// Hex-encoded inscription message identifier.
pub msg_id: String,
/// Number of unique authorized signatures required.
pub required_threshold: u16,
/// Hex-encoded mantle transaction bytes.
pub mantle_tx: String,
/// Hex-encoded sequencer signature for the inscription op.
pub inscription_signature: String,
/// Withdrawal output metadata included for operators.
pub withdraws: Vec<WithdrawFileEntry>,
/// Authorized channel signers indexed by channel key index.
pub authorized_signers: Vec<AuthorizedSigner>,
/// Signatures already attached to the intent.
pub signatures: Vec<WithdrawSignatureEntry>,
}
#[derive(Serialize, Deserialize)]
/// Human-readable withdrawal output metadata.
pub struct WithdrawFileEntry {
/// Withdrawn value.
pub amount: Value,
/// Hex-encoded recipient public key.
pub recipient_public_key: String,
/// Channel withdrawal nonce used by the transaction.
pub withdraw_nonce: u32,
}
#[derive(Serialize, Deserialize, Clone)]
/// Authorized channel signer metadata.
pub struct AuthorizedSigner {
/// Index of the key in the channel's accredited key list.
pub key_index: ChannelKeyIndex,
/// Hex-encoded Ed25519 public key.
pub public_key: String,
}
#[derive(Serialize, Deserialize, Clone)]
/// Signature entry embedded in a withdrawal intent or signed withdrawal file.
pub struct WithdrawSignatureEntry {
/// Index of the signer in the channel's accredited key list.
pub signer_key_index: ChannelKeyIndex,
/// Hex-encoded Ed25519 public key for the signer.
pub signer_public_key: String,
/// Hex-encoded bincode Ed25519 signature over the transaction hash.
pub signature: String,
}
#[derive(Serialize, Deserialize)]
/// Standalone signature file produced by `withdraw sign`.
pub struct WithdrawSignatureFile {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded transaction hash this signature covers.
pub tx_hash: String,
/// Hex-encoded Ed25519 public key for the signer.
pub signer_public_key: String,
/// Index of the signer in the channel's accredited key list.
pub signer_key_index: ChannelKeyIndex,
/// Hex-encoded bincode Ed25519 signature over the transaction hash.
pub signature: String,
}
#[derive(Serialize, Deserialize)]
/// Signed withdrawal transaction file ready for submission.
pub struct SignedWithdrawFile {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded signed transaction hash.
pub tx_hash: String,
/// Hex-encoded inscription message identifier.
pub msg_id: String,
/// Hex-encoded signed mantle transaction bytes.
pub signed_mantle_tx: String,
/// Signature entries used to build the signed transaction.
pub signatures: Vec<WithdrawSignatureEntry>,
}
#[derive(Serialize, Deserialize)]
/// Unsigned channel configuration transaction plus metadata for offline
/// signing.
pub struct ConfigIntent {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded transaction hash that signers must sign.
pub tx_hash: String,
/// Hex-encoded channel configuration identifier (config lineage).
pub config_id: String,
/// Number of current authorized signatures required.
pub required_threshold: u16,
/// Hex-encoded mantle transaction bytes.
pub mantle_tx: String,
/// New authorized signer public keys in channel order.
pub new_authorized_keys: Vec<String>,
/// New configuration threshold.
pub configuration_threshold: u16,
/// New transfer threshold, gating transfers and withdrawals.
pub transfer_threshold: u16,
/// New posting timeframe in slots.
pub posting_timeframe: u32,
/// New posting timeout in slots.
pub posting_timeout: u32,
/// Current authorized channel signers indexed by channel key index.
pub authorized_signers: Vec<AuthorizedSigner>,
/// Signatures already attached to the intent.
pub signatures: Vec<WithdrawSignatureEntry>,
}
#[derive(Serialize, Deserialize)]
/// Standalone signature file produced by `config sign`.
pub struct ConfigSignatureFile {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded transaction hash this signature covers.
pub tx_hash: String,
/// Hex-encoded Ed25519 public key for the signer.
pub signer_public_key: String,
/// Index of the signer in the channel's current accredited key list.
pub signer_key_index: ChannelKeyIndex,
/// Hex-encoded bincode Ed25519 signature over the transaction hash.
pub signature: String,
}
#[derive(Serialize, Deserialize)]
/// Signed channel configuration transaction file ready for submission.
pub struct SignedConfigFile {
/// File format version.
pub version: u8,
/// File kind discriminator.
pub kind: String,
/// Hex-encoded zone channel identifier.
pub channel_id: String,
/// Hex-encoded signed transaction hash.
pub tx_hash: String,
/// Hex-encoded channel configuration identifier (config lineage).
pub config_id: String,
/// Hex-encoded signed mantle transaction bytes.
pub signed_mantle_tx: String,
/// Signature entries used to build the signed transaction.
pub signatures: Vec<WithdrawSignatureEntry>,
}
@@ -1,491 +0,0 @@
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use lb_codec::BinaryEncode as _;
use lb_core::mantle::{
Note, NoteId, Op, RawMantleTx, SignedMantleTx, Utxo, Value,
ledger::Inputs,
ops::channel::{
ChannelId, MsgId,
inscribe::{Inscription, InscriptionOp},
withdraw::ChannelWithdrawOp,
},
traits::Hashable as _,
transactions::{Ops, OpsProofs, codec::encode_signed_mantle_tx},
};
use lb_groth16::{Fr, fr_to_bytes};
use lb_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey};
use crate::{
cli::{WithdrawCombineArgs, WithdrawSignArgs},
run_commands::{
ZONE_FILE_TRANSFER_VERSION, ZONE_WALLET_FUNDS_EXPORT, ZONE_WITHDRAW_INTENT,
ZONE_WITHDRAW_SIGNATURE,
run_withdraw::{run_withdraw_combine, run_withdraw_sign},
types::{
AuthorizedSigner, ExportedUtxo, SignedWithdrawFile, WalletFundsExport,
WithdrawFileEntry, WithdrawIntent, WithdrawSignatureFile,
},
utils::{
build_deposit_op, build_deposit_transfer, decode_ed25519_public_key_hex,
decode_exported_utxos, decode_hex, decode_hex_bincode, decode_mantle_tx_hex,
decode_signed_mantle_tx_hex, decode_zk_public_key_hex, encode_hex_bincode,
ensure_tx_hash, fixed_bytes, read_json, validate_kind, write_json,
},
},
};
fn test_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time must be after UNIX_EPOCH")
.as_nanos();
std::env::temp_dir().join(format!(
"tui-zone-manual-demo-test-{}-{nanos}-{name}",
std::process::id()
))
}
fn test_zk_key() -> ZkKey {
ZkKey::new(Fr::from(1u64))
}
fn test_utxo(value: Value, output_index: usize) -> Utxo {
Utxo::new(
[output_index as u8; 32],
output_index,
Note::new(value, test_zk_key().to_public_key()),
)
}
fn test_signing_key(byte: u8) -> Ed25519Key {
Ed25519Key::from_bytes(&[byte; ED25519_SECRET_KEY_SIZE])
}
fn empty_mantle_tx() -> RawMantleTx {
RawMantleTx(Ops::try_from(Vec::new()).expect("empty ops must be valid"))
}
#[test]
fn validate_kind_accepts_expected_versioned_kind() {
validate_kind(ZONE_WALLET_FUNDS_EXPORT, ZONE_WALLET_FUNDS_EXPORT, 1).unwrap();
}
#[test]
fn validate_kind_rejects_wrong_version_or_kind() {
assert!(validate_kind(ZONE_WALLET_FUNDS_EXPORT, ZONE_WALLET_FUNDS_EXPORT, 2).is_err());
assert!(validate_kind("other", ZONE_WALLET_FUNDS_EXPORT, 1).is_err());
}
#[test]
fn hex_helpers_accept_prefixed_and_plain_values() {
assert_eq!(decode_hex("0x0a0b").unwrap(), vec![10, 11]);
assert_eq!(decode_hex("0a0b").unwrap(), vec![10, 11]);
assert_eq!(fixed_bytes::<2>("0x0a0b").unwrap(), [10, 11]);
assert!(fixed_bytes::<3>("0x0a0b").is_err());
}
#[test]
fn bincode_hex_roundtrips_exported_utxo() {
let utxo = test_utxo(42, 0);
let encoded = encode_hex_bincode(&utxo).unwrap();
let decoded = decode_hex_bincode::<Utxo>(&encoded).unwrap();
assert_eq!(decoded, utxo);
}
#[test]
fn public_key_decoders_accept_cli_json_hex_shapes() {
let zk_public_key = test_zk_key().to_public_key();
let zk_hex = hex::encode(fr_to_bytes(zk_public_key.as_fr()));
assert_eq!(decode_zk_public_key_hex(&zk_hex).unwrap(), zk_public_key);
let ed25519_public_key = test_signing_key(9).public_key();
let ed25519_hex = hex::encode(ed25519_public_key.to_bytes());
assert_eq!(
decode_ed25519_public_key_hex(&ed25519_hex).unwrap(),
ed25519_public_key
);
}
#[test]
fn mantle_tx_decoders_reject_trailing_bytes_and_hash_mismatches() {
let tx = empty_mantle_tx();
let encoded = hex::encode(tx.encode());
assert_eq!(decode_mantle_tx_hex(&encoded).unwrap(), tx);
assert!(decode_mantle_tx_hex(&format!("{encoded}00")).is_err());
ensure_tx_hash(&hex::encode(tx.hash().as_ref()), tx.hash()).unwrap();
assert!(ensure_tx_hash(&hex::encode([1u8; 32]), tx.hash()).is_err());
let signed_tx = SignedMantleTx::new(tx, OpsProofs::empty());
let signed_encoded = hex::encode(encode_signed_mantle_tx(&signed_tx));
assert_eq!(
decode_signed_mantle_tx_hex(&signed_encoded).unwrap(),
signed_tx
);
assert!(decode_signed_mantle_tx_hex(&format!("{signed_encoded}00")).is_err());
}
#[test]
fn decode_exported_utxos_reads_cucumber_wallet_funds_json_entries() {
let utxos = vec![test_utxo(100, 0), test_utxo(200, 1)];
let funds = WalletFundsExport {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WALLET_FUNDS_EXPORT.to_owned(),
wallet: "WALLET_1A".to_owned(),
node_url: "http://localhost:8080".to_owned(),
public_key: hex::encode(fr_to_bytes(test_zk_key().to_public_key().as_fr())),
secret_key: Some(encode_hex_bincode(&test_zk_key()).unwrap()),
requested_value: 100,
selected_value: 300,
utxos: utxos
.iter()
.map(|utxo| ExportedUtxo {
utxo_id: hex::encode(utxo.id().as_bytes()),
value: utxo.note.value,
encoded_utxo: encode_hex_bincode(utxo).unwrap(),
})
.collect(),
};
assert_eq!(decode_exported_utxos(&funds).unwrap(), utxos);
}
#[test]
fn build_deposit_transfer_selects_largest_inputs_and_returns_change() {
let public_key = test_zk_key().to_public_key();
let (transfer, selected) =
build_deposit_transfer(vec![test_utxo(4, 0), test_utxo(10, 1)], public_key, 7).unwrap();
assert_eq!(selected, vec![test_utxo(10, 1)]);
let outputs = transfer.utxos().collect::<Vec<_>>();
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0].note.value, 7);
assert_eq!(outputs[1].note.value, 3);
}
#[test]
fn build_deposit_transfer_rejects_insufficient_funds() {
assert!(
build_deposit_transfer(vec![test_utxo(4, 0)], test_zk_key().to_public_key(), 7)
.is_err()
);
}
#[test]
fn build_deposit_op_consumes_first_transfer_output() {
let (transfer, _) =
build_deposit_transfer(vec![test_utxo(10, 1)], test_zk_key().to_public_key(), 7)
.unwrap();
let channel_id = ChannelId::from([5; 32]);
let deposit = build_deposit_op(channel_id, &transfer, "demo metadata").unwrap();
assert_eq!(deposit.channel_id, channel_id);
assert_eq!(
deposit.inputs,
Inputs::new([transfer.outputs.utxo_by_index(0, &transfer).unwrap().id()])
);
}
#[test]
fn json_helpers_roundtrip_and_create_parent_directories() {
let path = test_path("nested/funds.json");
let funds = WalletFundsExport {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WALLET_FUNDS_EXPORT.to_owned(),
wallet: "WALLET_1A".to_owned(),
node_url: "http://localhost:8080".to_owned(),
public_key: hex::encode(fr_to_bytes(test_zk_key().to_public_key().as_fr())),
secret_key: None,
requested_value: 0,
selected_value: 0,
utxos: Vec::new(),
};
write_json(&path, &funds).unwrap();
let decoded = read_json::<WalletFundsExport>(&path).unwrap();
assert_eq!(decoded.kind, funds.kind);
assert_eq!(decoded.wallet, funds.wallet);
drop(fs::remove_file(&path));
if let Some(parent) = path.parent() {
drop(fs::remove_dir_all(parent));
}
}
#[test]
fn withdraw_sign_and_combine_build_signed_withdraw_file_offline() {
let channel_id = ChannelId::from([7; 32]);
let signer = test_signing_key(2);
let inscriber = test_signing_key(3);
let recipient = test_zk_key().to_public_key();
let withdraw = ChannelWithdrawOp {
channel_id,
inputs: Inputs::new([NoteId::from(Fr::from(500u64))]),
};
let inscribe = InscriptionOp {
channel_id,
inscription: Inscription::try_from(b"withdraw wallet 1a".to_vec()).unwrap(),
parent: MsgId::root(),
signer: inscriber.public_key(),
};
let tx = RawMantleTx(
Ops::try_from(vec![
Op::ChannelWithdraw(withdraw),
Op::ChannelInscribe(inscribe),
])
.unwrap(),
);
let tx_hash = tx.hash();
let msg_id = MsgId::from([8; 32]);
let intent = WithdrawIntent {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_INTENT.to_owned(),
channel_id: hex::encode(channel_id.as_ref()),
tx_hash: hex::encode(tx_hash.as_ref()),
msg_id: hex::encode(msg_id.as_ref()),
required_threshold: 1,
mantle_tx: hex::encode(tx.encode()),
inscription_signature: encode_hex_bincode(
&inscriber.sign_payload(tx_hash.as_signing_bytes().as_ref()),
)
.unwrap(),
withdraws: vec![WithdrawFileEntry {
amount: 500,
recipient_public_key: hex::encode(fr_to_bytes(recipient.as_fr())),
withdraw_nonce: 0,
}],
authorized_signers: vec![AuthorizedSigner {
key_index: 0,
public_key: hex::encode(signer.public_key().to_bytes()),
}],
signatures: Vec::new(),
};
let key_path = test_path("signer.key");
let intent_path = test_path("withdraw.intent.json");
let sig_path = test_path("sig-a.json");
let signed_path = test_path("withdraw.signed.json");
fs::write(&key_path, [2u8; ED25519_SECRET_KEY_SIZE]).unwrap();
write_json(&intent_path, &intent).unwrap();
run_withdraw_sign(&WithdrawSignArgs {
key_path: key_path.to_string_lossy().to_string(),
input: intent_path.clone(),
out: sig_path.clone(),
})
.unwrap();
run_withdraw_combine(WithdrawCombineArgs {
input: intent_path.clone(),
sig: vec![sig_path.clone()],
out: signed_path.clone(),
})
.unwrap();
let sig_file = read_json::<WithdrawSignatureFile>(&sig_path).unwrap();
assert_eq!(sig_file.signer_key_index, 0);
assert_eq!(sig_file.tx_hash, intent.tx_hash);
let signed_file = read_json::<SignedWithdrawFile>(&signed_path).unwrap();
let signed_tx = decode_signed_mantle_tx_hex(&signed_file.signed_mantle_tx).unwrap();
assert_eq!(signed_tx.hash(), tx_hash);
assert_eq!(signed_file.signatures.len(), 1);
drop(fs::remove_file(key_path));
drop(fs::remove_file(intent_path));
drop(fs::remove_file(sig_path));
drop(fs::remove_file(signed_path));
}
#[test]
fn withdraw_combine_rejects_too_few_signatures() {
let tx = RawMantleTx(
Ops::try_from(vec![Op::ChannelWithdraw(ChannelWithdrawOp {
channel_id: ChannelId::from([9; 32]),
inputs: Inputs::new([NoteId::from(Fr::from(1u64))]),
})])
.unwrap(),
);
let intent = WithdrawIntent {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_INTENT.to_owned(),
channel_id: hex::encode([9; 32]),
tx_hash: hex::encode(tx.hash().as_ref()),
msg_id: hex::encode(MsgId::root().as_ref()),
required_threshold: 1,
mantle_tx: hex::encode(tx.encode()),
inscription_signature: encode_hex_bincode(
&test_signing_key(4).sign_payload(tx.hash().as_signing_bytes().as_ref()),
)
.unwrap(),
withdraws: Vec::new(),
authorized_signers: Vec::new(),
signatures: Vec::new(),
};
let intent_path = test_path("withdraw-too-few.intent.json");
let signed_path = test_path("withdraw-too-few.signed.json");
write_json(&intent_path, &intent).unwrap();
let error = run_withdraw_combine(WithdrawCombineArgs {
input: intent_path.clone(),
sig: Vec::new(),
out: signed_path.clone(),
})
.unwrap_err()
.to_string();
assert!(error.contains("requires 1 unique authorized signature(s), got 0"));
drop(fs::remove_file(intent_path));
drop(fs::remove_file(signed_path));
}
#[test]
fn withdraw_combine_counts_unique_signatures_after_proof_normalization() {
let channel_id = ChannelId::from([10; 32]);
let signer = test_signing_key(2);
let second_signer = test_signing_key(3);
let tx = RawMantleTx(
Ops::try_from(vec![Op::ChannelWithdraw(ChannelWithdrawOp {
channel_id,
inputs: Inputs::new([NoteId::from(Fr::from(1u64))]),
})])
.unwrap(),
);
let tx_hash = tx.hash();
let intent = WithdrawIntent {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_INTENT.to_owned(),
channel_id: hex::encode(channel_id.as_ref()),
tx_hash: hex::encode(tx_hash.as_ref()),
msg_id: hex::encode(MsgId::root().as_ref()),
required_threshold: 2,
mantle_tx: hex::encode(tx.encode()),
inscription_signature: encode_hex_bincode(
&signer.sign_payload(tx_hash.as_signing_bytes().as_ref()),
)
.unwrap(),
withdraws: Vec::new(),
authorized_signers: vec![
AuthorizedSigner {
key_index: 0,
public_key: hex::encode(signer.public_key().to_bytes()),
},
AuthorizedSigner {
key_index: 1,
public_key: hex::encode(second_signer.public_key().to_bytes()),
},
],
signatures: Vec::new(),
};
let sig = WithdrawSignatureFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_SIGNATURE.to_owned(),
channel_id: intent.channel_id.clone(),
tx_hash: intent.tx_hash.clone(),
signer_public_key: hex::encode(signer.public_key().to_bytes()),
signer_key_index: 0,
signature: encode_hex_bincode(
&signer.sign_payload(tx_hash.as_signing_bytes().as_ref()),
)
.unwrap(),
};
let intent_path = test_path("withdraw-duplicate.intent.json");
let sig_path = test_path("withdraw-duplicate.sig.json");
let signed_path = test_path("withdraw-duplicate.signed.json");
write_json(&intent_path, &intent).unwrap();
write_json(&sig_path, &sig).unwrap();
let error = run_withdraw_combine(WithdrawCombineArgs {
input: intent_path.clone(),
sig: vec![sig_path.clone(), sig_path.clone()],
out: signed_path.clone(),
})
.unwrap_err()
.to_string();
assert!(
error.contains("Signature indices are not strictly increasing"),
"Error is: {error:?}",
);
drop(fs::remove_file(intent_path));
drop(fs::remove_file(sig_path));
drop(fs::remove_file(signed_path));
}
#[test]
fn withdraw_combine_rejects_signature_with_mismatched_authorized_index() {
let channel_id = ChannelId::from([11; 32]);
let signer = test_signing_key(2);
let second_signer = test_signing_key(3);
let tx = RawMantleTx(
Ops::try_from(vec![Op::ChannelWithdraw(ChannelWithdrawOp {
channel_id,
inputs: Inputs::new([NoteId::from(Fr::from(1u64))]),
})])
.unwrap(),
);
let tx_hash = tx.hash();
let intent = WithdrawIntent {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_INTENT.to_owned(),
channel_id: hex::encode(channel_id.as_ref()),
tx_hash: hex::encode(tx_hash.as_ref()),
msg_id: hex::encode(MsgId::root().as_ref()),
required_threshold: 1,
mantle_tx: hex::encode(tx.encode()),
inscription_signature: encode_hex_bincode(
&signer.sign_payload(tx_hash.as_signing_bytes().as_ref()),
)
.unwrap(),
withdraws: Vec::new(),
authorized_signers: vec![
AuthorizedSigner {
key_index: 0,
public_key: hex::encode(signer.public_key().to_bytes()),
},
AuthorizedSigner {
key_index: 1,
public_key: hex::encode(second_signer.public_key().to_bytes()),
},
],
signatures: Vec::new(),
};
let sig = WithdrawSignatureFile {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WITHDRAW_SIGNATURE.to_owned(),
channel_id: intent.channel_id.clone(),
tx_hash: intent.tx_hash.clone(),
signer_public_key: hex::encode(signer.public_key().to_bytes()),
signer_key_index: 1,
signature: encode_hex_bincode(
&signer.sign_payload(tx_hash.as_signing_bytes().as_ref()),
)
.unwrap(),
};
let intent_path = test_path("withdraw-mismatched-index.intent.json");
let sig_path = test_path("withdraw-mismatched-index.sig.json");
let signed_path = test_path("withdraw-mismatched-index.signed.json");
write_json(&intent_path, &intent).unwrap();
write_json(&sig_path, &sig).unwrap();
let error = run_withdraw_combine(WithdrawCombineArgs {
input: intent_path.clone(),
sig: vec![sig_path.clone()],
out: signed_path.clone(),
})
.unwrap_err()
.to_string();
assert!(error.contains("is not listed in withdraw intent authorized_signers"));
drop(fs::remove_file(intent_path));
drop(fs::remove_file(sig_path));
drop(fs::remove_file(signed_path));
}
}
@@ -1,417 +0,0 @@
use std::{
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use chrono::{DateTime, Utc};
use lb_codec::BinaryDecodeExt as _;
use lb_core::mantle::{
Note, SignedMantleTx, Utxo, Value,
channel::ChannelState,
ledger::{Inputs, Outputs},
ops::{
channel::{
ChannelId, MsgId,
deposit::{DepositOp, Metadata},
},
transfer::TransferOp,
},
transactions::{
codec::decode_signed_mantle_tx, hash::TxHash, mantle_tx::RawMantleTx, states::Unverified,
},
};
use lb_key_management_system_service::keys::{
ED25519_SECRET_KEY_SIZE, Ed25519Key, Ed25519PublicKey, ZkPublicKey,
};
use lb_zone_sdk::{
CommonHttpClient,
adapter::{Node as _, NodeHttpClient},
sequencer::{Event, FundingConfig, SequencerCheckpoint, SequencerConfig, ZoneSequencer},
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use tracing::warn;
use crate::{
cli::{NodeKeyArgs, RunResult},
run_commands::types::WalletFundsExport,
state::{load_or_discard_persisted_checkpoint_for_channel, save_persisted_checkpoint},
};
const CHANNEL_STATE_QUERY_RETRY: Duration = Duration::from_secs(5);
/// Format the current wall-clock time as RFC 3339 / ISO-8601 UTC timestamp with
/// microseconds, example `2026-06-19T05:43:07.036408Z`.
pub fn timestamp() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let micros = now.subsec_micros();
let datetime = DateTime::<Utc>::from_timestamp(secs as i64, micros * 1_000)
.unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).unwrap());
datetime.format("%Y-%m-%dT%H:%M:%S.%6fZ").to_string()
}
/// Load an Ed25519 signing key from disk or create a new one at `path`.
pub fn load_or_create_signing_key(path: &Path) -> Ed25519Key {
if path.exists() {
let key_bytes = fs::read(path).expect("failed to read key file");
assert_eq!(
key_bytes.len(),
ED25519_SECRET_KEY_SIZE,
"invalid key file: expected {} bytes, got {}",
ED25519_SECRET_KEY_SIZE,
key_bytes.len()
);
let key_array: [u8; ED25519_SECRET_KEY_SIZE] =
key_bytes.try_into().expect("length already checked");
Ed25519Key::from_bytes(&key_array)
} else {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).expect("failed to create key file parent directory");
}
let mut key_bytes = [0u8; ED25519_SECRET_KEY_SIZE];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key_bytes);
fs::write(path, key_bytes).expect("failed to write key file");
Ed25519Key::from_bytes(&key_bytes)
}
}
/// Build a node HTTP client from a URL string.
pub fn node_client(node_url: &str) -> RunResult<NodeHttpClient> {
Ok(NodeHttpClient::new(
CommonHttpClient::new(None),
Url::parse(node_url)?,
))
}
/// Validate a versioned JSON file kind discriminator.
pub fn validate_kind(actual: &str, expected: &str, version: u8) -> RunResult<()> {
if version != 1 {
return Err(format!("unsupported {actual} version {version}; expected version 1").into());
}
if actual != expected {
return Err(format!("unsupported JSON kind '{actual}'; expected '{expected}'").into());
}
Ok(())
}
/// Read and deserialize a JSON file.
pub fn read_json<T: for<'de> Deserialize<'de>>(path: &PathBuf) -> RunResult<T> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
/// Serialize and write a JSON file, creating parent directories when needed.
pub fn write_json<T: Serialize>(path: &PathBuf, value: &T) -> RunResult<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec_pretty(value)?)?;
Ok(())
}
/// Decode a required hex-encoded bincode value.
pub fn decode_required_hex_bincode<T: for<'de> Deserialize<'de>>(
value: Option<&str>,
missing: &str,
) -> RunResult<T> {
let value = value.ok_or_else(|| missing.to_owned())?;
decode_hex_bincode(value)
}
/// Encode a serializable value as hex-encoded bincode.
pub fn encode_hex_bincode<T: Serialize>(value: &T) -> RunResult<String> {
Ok(hex::encode(bincode::serialize(value)?))
}
/// Decode a hex-encoded bincode value.
pub fn decode_hex_bincode<T: for<'de> Deserialize<'de>>(value: &str) -> RunResult<T> {
Ok(bincode::deserialize(&decode_hex(value)?)?)
}
/// Decode a plain or `0x`-prefixed hex string.
pub fn decode_hex(value: &str) -> RunResult<Vec<u8>> {
Ok(hex::decode(value.strip_prefix("0x").unwrap_or(value))?)
}
/// Derive the zone channel ID from a channel signing key path.
pub fn channel_id_for_key_path(path: &str) -> ChannelId {
let signing_key = load_or_create_signing_key(PathBuf::from(path).as_path());
ChannelId::from(signing_key.public_key().to_bytes())
}
/// Resolve the target channel ID from CLI args or the signing key path.
pub fn resolve_channel_id(args: &NodeKeyArgs) -> RunResult<ChannelId> {
args.channel_id.as_ref().map_or_else(
|| Ok(channel_id_for_key_path(&args.key_path)),
|channel_id| Ok(ChannelId::from(fixed_bytes::<32>(channel_id)?)),
)
}
/// Query whether the node has channel state, retrying until the node responds.
pub async fn query_channel_exists(node: &NodeHttpClient, channel_id: ChannelId) -> bool {
query_channel_state(node, channel_id).await.is_some()
}
/// Query channel state, retrying until the node responds.
pub async fn query_channel_state(
node: &NodeHttpClient,
channel_id: ChannelId,
) -> Option<ChannelState> {
loop {
match node.channel_state(channel_id).await {
Ok(channel_state) => return channel_state,
Err(error) => {
warn!("failed to query channel state before sequencer init: {error}");
sleep(CHANNEL_STATE_QUERY_RETRY).await;
}
}
}
}
// TODO: support channel note tracking in the TUI. A channel's balance is now
// the sum of its channel notes rather than a field on `ChannelState`.
// /// Print the channel's current balance.
// pub fn print_channel_balance(label: &str, channel_id: &ChannelId, state:
// Option<&ChannelState>) { match state {
// Some(state) => println!(
// "{} {label}: channel_id={} balance={}",
// timestamp(),
// hex::encode(channel_id.as_ref()),
// state.balance,
// ),
// None => println!(
// "{} {label}: channel_id={} balance=unknown
// channel_state=missing", timestamp(),
// hex::encode(channel_id.as_ref())
// ),
// }
// }
/// Print the channel's current configuration state.
pub fn print_channel_state(label: &str, channel_id: &ChannelId, state: Option<&ChannelState>) {
match state {
Some(state) => println!(
"{} {label}: channel_id={} accredited_keys={} configuration_threshold={} transfer_threshold={} tip_message={} config_tip_hash={}",
timestamp(),
hex::encode(channel_id.as_ref()),
state.accredited_keys.len(),
state.configuration_threshold,
state.transfer_threshold,
hex::encode(state.tip_message.as_ref()),
hex::encode(state.config_tip_hash.as_ref())
),
None => println!(
"{} {label}: channel_id={} channel_state=missing",
timestamp(),
hex::encode(channel_id.as_ref())
),
}
}
/// Decode all UTXOs embedded in a wallet funds export.
pub fn decode_exported_utxos(funds: &WalletFundsExport) -> RunResult<Vec<Utxo>> {
funds
.utxos
.iter()
.map(|utxo| decode_hex_bincode::<Utxo>(&utxo.encoded_utxo))
.collect()
}
/// Decode a hex-encoded mantle transaction and reject trailing bytes.
pub fn decode_mantle_tx_hex(value: &str) -> RunResult<RawMantleTx> {
let bytes = decode_hex(value)?;
let (remaining, tx) = RawMantleTx::decode(&bytes).map_err(|error| format!("{error:?}"))?;
if !remaining.is_empty() {
return Err("mantle tx has trailing bytes".into());
}
Ok(tx)
}
/// Decode a hex-encoded signed mantle transaction and reject trailing bytes.
pub fn decode_signed_mantle_tx_hex(value: &str) -> RunResult<SignedMantleTx<Unverified>> {
let bytes = decode_hex(value)?;
let (remaining, tx) = decode_signed_mantle_tx(&bytes).map_err(|error| format!("{error:?}"))?;
if !remaining.is_empty() {
return Err("signed mantle tx has trailing bytes".into());
}
Ok(tx)
}
/// Decode a hex-encoded ZK public key.
pub fn decode_zk_public_key_hex(value: &str) -> RunResult<ZkPublicKey> {
let bytes = fixed_bytes::<32>(value)?;
Ok(ZkPublicKey::new(lb_groth16::fr_from_bytes(&bytes)?))
}
/// Decode a hex-encoded Ed25519 public key.
pub fn decode_ed25519_public_key_hex(value: &str) -> RunResult<Ed25519PublicKey> {
let bytes = fixed_bytes::<32>(value)?;
Ok(Ed25519PublicKey::from_bytes(&bytes)?)
}
/// Decode a hex-encoded zone message ID.
pub fn decode_msg_id_hex(value: &str) -> RunResult<MsgId> {
Ok(MsgId::from(fixed_bytes(value)?))
}
/// Decode a hex string into exactly `N` bytes.
pub fn fixed_bytes<const N: usize>(value: &str) -> RunResult<[u8; N]> {
let bytes = decode_hex(value)?;
bytes
.try_into()
.map_err(|bytes: Vec<u8>| format!("expected {N} bytes, got {}", bytes.len()).into())
}
/// Ensure a decoded transaction hash matches the expected hex string.
pub fn ensure_tx_hash(expected_hex: &str, actual: TxHash) -> RunResult<()> {
let actual_hex = hex::encode(actual.as_ref());
if expected_hex != actual_hex {
return Err(format!(
"tx_hash mismatch: JSON has {expected_hex}, decoded tx has {actual_hex}"
)
.into());
}
Ok(())
}
/// Build a transfer op that selects exported UTXOs and returns change.
pub fn build_deposit_transfer(
mut available_utxos: Vec<Utxo>,
funding_public_key: ZkPublicKey,
amount: Value,
) -> RunResult<(TransferOp, Vec<Utxo>)> {
available_utxos.sort_by_key(|utxo| std::cmp::Reverse(utxo.note.value));
let mut selected = Vec::new();
let mut selected_value = 0u64;
for utxo in available_utxos {
selected_value = selected_value.saturating_add(utxo.note.value);
selected.push(utxo);
if selected_value >= amount {
break;
}
}
if selected_value < amount {
return Err(format!(
"insufficient exported funds: requested {amount}, selected {selected_value}"
)
.into());
}
let mut outputs = vec![Note::new(amount, funding_public_key)];
let change = selected_value - amount;
if change > 0 {
outputs.push(Note::new(change, funding_public_key));
}
let transfer = TransferOp {
inputs: Inputs::try_new(selected.iter().map(Utxo::id).collect::<Vec<_>>())?,
outputs: Outputs::try_new(outputs)?,
};
Ok((transfer, selected))
}
/// Build a channel deposit op that consumes the first transfer output.
pub fn build_deposit_op(
channel_id: ChannelId,
transfer: &TransferOp,
metadata: &str,
) -> RunResult<DepositOp> {
let deposit_note_id = transfer
.outputs
.utxo_by_index(0, transfer)
.expect("deposit transfer always has an output")
.id();
Ok(DepositOp {
channel_id,
inputs: Inputs::new([deposit_note_id]),
metadata: Metadata::try_from(metadata.as_bytes().to_vec())?,
})
}
/// Build the sequencer funding config from CLI args.
pub fn funding_config(args: &NodeKeyArgs) -> RunResult<FundingConfig> {
Ok(FundingConfig {
funding_pk: decode_zk_public_key_hex(&args.funding_pk)?,
change_pk: None,
max_tx_fee: args.max_tx_fee.into(),
priority_fee_percent: args.priority_fee_percent,
})
}
/// Sequencer config for CLI commands, with funding taken from the args.
pub fn cli_sequencer_config(args: &NodeKeyArgs) -> RunResult<SequencerConfig> {
Ok(SequencerConfig::new(funding_config(args)?))
}
/// Start a zone sequencer for non-interactive CLI commands and wait for
/// readiness.
pub async fn start_cli_sequencer(args: &NodeKeyArgs) -> RunResult<ZoneSequencer<NodeHttpClient>> {
let (sequencer, _channel_state) = start_cli_sequencer_with_channel_state(args).await?;
Ok(sequencer)
}
/// Start a zone sequencer for non-interactive CLI commands and wait until the
/// post-ready channel view is backed by freshly queried node channel state.
pub async fn start_cli_sequencer_with_channel_state(
args: &NodeKeyArgs,
) -> RunResult<(ZoneSequencer<NodeHttpClient>, Option<ChannelState>)> {
let signing_key = load_or_create_signing_key(PathBuf::from(&args.key_path).as_path());
let channel_id = resolve_channel_id(args)?;
let node = node_client(&args.node_url)?;
let channel_exists = query_channel_exists(&node, channel_id).await;
let checkpoint = load_cli_checkpoint(&channel_id, channel_exists)?;
let mut sequencer = ZoneSequencer::init_with_config(
channel_id,
signing_key,
node.clone(),
cli_sequencer_config(args)?,
checkpoint,
);
while !sequencer.is_ready() {
drop(sequencer.next_event().await);
}
let channel_state = wait_for_fresh_channel_state(&mut sequencer, &node, channel_id).await?;
Ok((sequencer, channel_state))
}
async fn wait_for_fresh_channel_state(
sequencer: &mut ZoneSequencer<NodeHttpClient>,
node: &NodeHttpClient,
channel_id: ChannelId,
) -> RunResult<Option<ChannelState>> {
let fresh_channel_state = query_channel_state(node, channel_id).await;
let view_rx = sequencer.subscribe_channel_view();
loop {
if view_rx.borrow().channel == fresh_channel_state {
return Ok(fresh_channel_state);
}
if let Event::BlocksProcessed { checkpoint, .. } = sequencer.next_event().await {
save_cli_checkpoint(&channel_id, &checkpoint)?;
}
}
}
/// Load the persisted non-interactive sequencer checkpoint, if present.
pub fn load_cli_checkpoint(
channel_id: &ChannelId,
channel_exists: bool,
) -> RunResult<Option<SequencerCheckpoint>> {
load_or_discard_persisted_checkpoint_for_channel(channel_id, channel_exists)
}
/// Persist a non-interactive sequencer checkpoint in the runtime directory.
pub fn save_cli_checkpoint(
channel_id: &ChannelId,
checkpoint: &SequencerCheckpoint,
) -> RunResult<()> {
save_persisted_checkpoint(channel_id, checkpoint)
}
-205
View File
@@ -1,205 +0,0 @@
use std::{collections::HashSet, error::Error, fs};
use lb_core::mantle::ops::channel::ChannelId;
use lb_zone_sdk::sequencer::{InscriptionInfo, SequencerChannelView, SequencerCheckpoint};
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use crate::message::Msg;
const CHECKPOINT_FILE: &str = "sequencer.checkpoint.json";
const CHECKPOINT_KIND: &str = "zone_sequencer_checkpoint";
const CHECKPOINT_VERSION: u8 = 1;
#[derive(Serialize, Deserialize)]
struct CheckpointFile {
version: u8,
kind: String,
channel_id: Option<String>,
checkpoint: String,
}
/// Trait for the TUI's view of zone state.
///
/// The TUI feeds SDK events into this trait; the trait owns persistence.
/// `InMemoryZoneState` is the demo implementation.
///
/// Tracks two lists:
/// - `pending`: messages we published that haven't finalized yet.
/// - `finalized`: inscriptions below LIB, delivered on `BlocksProcessed`.
///
/// The SDK manages the outbox (resubmit and shed across reorgs); this state
/// renders published and finalized messages and does not consume the channel
/// delta.
pub trait ZoneState: Send {
/// Record a message we just published as pending.
fn on_published(&mut self, info: &InscriptionInfo);
/// Move finalized inscriptions from `pending` into `finalized`.
fn on_finalized(&mut self, inscriptions: &[InscriptionInfo]);
/// Locally published inscriptions that are not finalized yet.
fn pending(&self) -> &[Msg];
/// Finalized inscriptions below LIB.
fn finalized(&self) -> &[Msg];
/// Persist the sequencer checkpoint for later resume.
fn save_checkpoint(&mut self, checkpoint: SequencerCheckpoint);
/// Load the last persisted sequencer checkpoint, if any.
fn load_checkpoint(&self) -> Option<&SequencerCheckpoint>;
}
/// In-memory implementation of [`ZoneState`].
#[derive(Default)]
pub struct InMemoryZoneState {
pending: Vec<Msg>,
finalized: Vec<Msg>,
finalized_payloads: HashSet<Vec<u8>>,
checkpoint: Option<SequencerCheckpoint>,
channel_id: Option<ChannelId>,
channel_view: Option<SequencerChannelView>,
}
impl ZoneState for InMemoryZoneState {
fn on_published(&mut self, info: &InscriptionInfo) {
if !self.pending.iter().any(|m| m.msg_id == info.this_msg) {
self.pending
.push(Msg::from_payload(info.this_msg, &info.payload));
}
}
fn on_finalized(&mut self, inscriptions: &[InscriptionInfo]) {
for info in inscriptions {
if let Some(i) = self.pending.iter().position(|m| m.msg_id == info.this_msg) {
self.pending.remove(i);
}
if !self.finalized.iter().any(|m| m.msg_id == info.this_msg) {
self.finalized
.push(Msg::from_payload(info.this_msg, &info.payload));
}
self.finalized_payloads
.insert(info.payload.as_slice().to_vec());
}
}
fn pending(&self) -> &[Msg] {
&self.pending
}
fn finalized(&self) -> &[Msg] {
&self.finalized
}
fn save_checkpoint(&mut self, checkpoint: SequencerCheckpoint) {
self.checkpoint = Some(checkpoint.clone());
if let Some(channel_id) = self.channel_id
&& let Err(error) = save_persisted_checkpoint(&channel_id, &checkpoint)
{
error!("failed to save sequencer checkpoint: {error}");
}
}
fn load_checkpoint(&self) -> Option<&SequencerCheckpoint> {
self.checkpoint.as_ref()
}
}
/// Load the persisted sequencer checkpoint after validating the target channel.
pub fn load_or_discard_persisted_checkpoint_for_channel(
channel_id: &ChannelId,
channel_exists: bool,
) -> Result<Option<SequencerCheckpoint>, Box<dyn Error + Send + Sync>> {
let Ok(bytes) = fs::read(CHECKPOINT_FILE) else {
return Ok(None);
};
let file = serde_json::from_slice::<CheckpointFile>(&bytes)?;
if file.version != CHECKPOINT_VERSION {
return Err(format!(
"unsupported {CHECKPOINT_KIND} version {}; expected {CHECKPOINT_VERSION}",
file.version
)
.into());
}
if file.kind != CHECKPOINT_KIND {
return Err(format!(
"unsupported checkpoint kind '{}'; expected '{CHECKPOINT_KIND}'",
file.kind
)
.into());
}
let expected_channel_id = hex::encode(channel_id.as_ref());
let checkpoint_channel_id = file.channel_id.ok_or_else(|| {
format!("checkpoint '{CHECKPOINT_FILE}' is missing channel_id; remove it or recreate it")
})?;
if checkpoint_channel_id != expected_channel_id {
discard_stale_checkpoint(&format!(
"checkpoint channel_id {checkpoint_channel_id} does not match requested channel_id {expected_channel_id}"
))?;
return Ok(None);
}
if !channel_exists {
discard_stale_checkpoint(&format!(
"checkpoint channel_id {expected_channel_id} was accepted by file validation, but the node has no channel state for it"
))?;
return Ok(None);
}
Ok(Some(bincode::deserialize(&hex::decode(file.checkpoint)?)?))
}
fn discard_stale_checkpoint(reason: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
warn!("discarding stale sequencer checkpoint '{CHECKPOINT_FILE}': {reason}");
fs::remove_file(CHECKPOINT_FILE)?;
Ok(())
}
/// Persist the sequencer checkpoint shared by all TUI zone commands.
pub fn save_persisted_checkpoint(
channel_id: &ChannelId,
checkpoint: &SequencerCheckpoint,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let file = CheckpointFile {
version: CHECKPOINT_VERSION,
kind: CHECKPOINT_KIND.to_owned(),
channel_id: Some(hex::encode(channel_id.as_ref())),
checkpoint: hex::encode(bincode::serialize(checkpoint)?),
};
fs::write(CHECKPOINT_FILE, serde_json::to_vec_pretty(&file)?)?;
Ok(())
}
impl InMemoryZoneState {
/// Create in-memory TUI state with a channel-validated runtime checkpoint.
pub fn for_channel(
channel_id: ChannelId,
channel_exists: bool,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
Ok(Self {
pending: Vec::new(),
finalized: Vec::new(),
finalized_payloads: HashSet::new(),
checkpoint: load_or_discard_persisted_checkpoint_for_channel(
&channel_id,
channel_exists,
)?,
channel_id: Some(channel_id),
channel_view: None,
})
}
/// Store the latest sequencer channel view for rendering.
pub fn set_channel_view(&mut self, channel_view: SequencerChannelView) {
self.channel_view = Some(channel_view);
}
/// Return the latest sequencer channel view, if one has been observed.
#[must_use]
pub const fn channel_view(&self) -> Option<&SequencerChannelView> {
self.channel_view.as_ref()
}
/// True if this exact payload has finalized — used to skip re-publishing an
/// orphan whose payload is already permanently on chain.
#[must_use]
pub fn is_finalized(&self, payload: &[u8]) -> bool {
self.finalized_payloads.contains(payload)
}
}
-70
View File
@@ -1,70 +0,0 @@
use std::io::Write as _;
use crate::{
message::Msg,
state::{InMemoryZoneState, ZoneState as _},
};
/// Print current state as two sections: Pending, Finalized.
pub fn render_state(state: &InMemoryZoneState) {
eprintln!();
if let Some(view) = state.channel_view() {
eprintln!("=== Sequencer ===");
eprintln!(" Channel: {}", hex::encode(view.channel_id.as_ref()));
eprintln!(" Slot: {}", view.current_slot.into_inner());
eprintln!(
" Accredited keys: {}",
view.accredited_key_count.unwrap_or_default()
);
eprintln!(
" This sequencer: {}",
view.own_key_index
.map_or_else(|| "not accredited".to_owned(), |idx| format!("index {idx}"))
);
eprintln!(
" Authorized sequencer: {}",
view.authorized_key_index
.map_or_else(|| "unknown".to_owned(), |idx| format!("index {idx}"))
);
eprintln!(
" Status: {}",
if view.our_turn_to_write {
"our turn"
} else {
"waiting for turn"
}
);
eprintln!(
" Posting timeframe: {}",
view.turn_to_write_slots
.map_or_else(|| "unknown".to_owned(), |slots| format!("{slots} slots"))
);
eprintln!(
" Posting timeout: {}",
view.posting_timeout_slots
.map_or_else(|| "unknown".to_owned(), |slots| format!("{slots} slots"))
);
eprintln!(" Queued messages: {}", view.queued_messages);
eprintln!(" Tip message: {}", hex::encode(view.tip_message.as_ref()));
eprintln!();
}
print_section("Pending", state.pending());
print_section("Finalized", state.finalized());
}
fn print_section(label: &str, msgs: &[Msg]) {
if msgs.is_empty() {
return;
}
eprintln!("=== {label} ===");
for m in msgs {
eprintln!(" {}", m.text);
}
eprintln!();
}
/// Print the prompt character.
pub fn prompt() {
eprint!("> ");
std::io::stderr().flush().expect("flush stderr");
}
-1
View File
@@ -43,7 +43,6 @@ lb-mmr = { workspace = true }
lb-node = { features = ["testing"], workspace = true }
lb-storage-service = { features = ["rocksdb-backend"], workspace = true }
lb-testing-framework = { workspace = true }
lb-tui-zone = { workspace = true }
lb-tx-service = { workspace = true }
lb-utils = { workspace = true }
lb-wallet = { workspace = true }
@@ -51,7 +51,6 @@ use lb_core::mantle::{
transactions::{GasPrices, hash::TxHash},
};
use lb_key_management_system_service::keys::ZkPublicKey;
use lb_tui_zone::run_commands::{ZONE_FILE_TRANSFER_VERSION, ZONE_WALLET_FUNDS_EXPORT};
use lb_wallet::WalletError;
use serde::Serialize;
use tokio::time::{Instant, sleep};
@@ -759,8 +758,6 @@ fn log_wallet_state_balance(wallet_name: &str, public_key_hex: &str, state: &Wal
#[derive(Serialize)]
struct WalletFundsExport {
version: u8,
kind: &'static str,
wallet: String,
node_url: String,
public_key: String,
@@ -799,8 +796,6 @@ async fn export_funds(
let selected = select_utxos_covering(available_utxos.clone(), value)?;
let selected_value = selected.iter().map(|utxo| utxo.note.value).sum();
let export = WalletFundsExport {
version: ZONE_FILE_TRANSFER_VERSION,
kind: ZONE_WALLET_FUNDS_EXPORT,
wallet: wallet.wallet_name.clone(),
node_url: format!(
"{}",