mirror of
https://github.com/logos-co/nomos-node.git
synced 2026-08-31 03:21:15 +00:00
chore: remove TUI Zone code (#3439)
This commit is contained in:
Generated
-26
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
```
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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!(
|
||||
"{}",
|
||||
|
||||
Reference in New Issue
Block a user