diff --git a/.gitignore b/.gitignore index 3c598d9..1393327 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /data .idea/ logs/ +/config .DS_Store diff --git a/Cargo.lock b/Cargo.lock index b344e1e..e437454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3766,6 +3766,7 @@ dependencies = [ "tari_common_types 5.3.0-pre.3 (registry+https://github.com/rust-lang/crates.io-index)", "tari_crypto", "tari_script 5.3.0-pre.3 (registry+https://github.com/rust-lang/crates.io-index)", + "tari_sidechain 5.3.0-pre.3 (registry+https://github.com/rust-lang/crates.io-index)", "tari_transaction_components 5.3.0-pre.3 (registry+https://github.com/rust-lang/crates.io-index)", "tari_utilities", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 6e73182..ab5cf94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ tari_common_types = { version = "5.3.0-pre.3" } tari_crypto = { version = "0.22.1", features = ["borsh"] } tari_utilities = { version = "0.8", features = ["std"] } tari_script = { version = "5.3.0-pre.3" } +tari_sidechain = { version = "5.3.0-pre.3" } tari_transaction_components = { version = "5.3.0-pre.3" } minotari_app_grpc = { version = "5.3.0-pre.3", default-features = false } tari_node_components = {version = "5.3.0-pre.3" } diff --git a/minotari/Cargo.toml b/minotari/Cargo.toml index 61e497d..f46e4d1 100644 --- a/minotari/Cargo.toml +++ b/minotari/Cargo.toml @@ -49,6 +49,7 @@ tari_common = { workspace = true } tari_common_types = { workspace = true } tari_crypto = { version = "0.22.1", features = ["borsh"] } tari_script = { workspace = true } +tari_sidechain = { workspace = true } tari_transaction_components = { workspace = true } tari_utilities = { version = "0.8", features = ["std"] } #tari_common = { path = "../tari/common" } diff --git a/minotari/src/cli.rs b/minotari/src/cli.rs index a5549a7..2538530 100644 --- a/minotari/src/cli.rs +++ b/minotari/src/cli.rs @@ -444,6 +444,193 @@ pub enum Commands { )] seconds_to_lock_utxos: Option, }, + /// Register a validator node on the Tari base layer. + /// + /// Creates, signs, and broadcasts a pay-to-self transaction that embeds the validator + /// node's public key and pre-computed signature into the output features, locking the + /// consensus-required minimum deposit back to the wallet. + /// + /// # Prerequisites + /// + /// - The account must be a full SeedWords wallet (view-only wallets are not supported) + /// - The caller must pre-compute the validator node signature using the VN's private key + /// via `ValidatorNodeSignature::sign_for_registration` on the validator node side + /// + /// # Signature Format + /// + /// The signature is a Schnorr signature split into two 32-byte hex-encoded components: + /// - `--vn-sig-nonce`: the public nonce (compressed Ristretto point) + /// - `--vn-sig`: the signature scalar + RegisterValidatorNode { + #[command(flatten)] + security: SecurityArgs, + #[command(flatten)] + node: NodeArgs, + #[command(flatten)] + db: DatabaseArgs, + #[command(flatten)] + tx: TransactionArgs, + + /// Name of the account to fund the registration deposit from. + #[arg(short, long, help = "Name of the account to fund the deposit from")] + account_name: String, + + /// Validator node public key (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node public key (hex)")] + vn_public_key: String, + + /// Public nonce component of the validator node signature (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node signature public nonce (hex)")] + vn_sig_nonce: String, + + /// Scalar component of the validator node signature (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node signature scalar (hex)")] + vn_sig: String, + + /// Claim public key for validator node rewards (hex-encoded, 32 bytes). + #[arg(long, help = "Claim public key for VN rewards (hex)")] + claim_public_key: String, + + /// Maximum epoch for replay protection. + #[arg(long, help = "Max epoch for replay protection")] + max_epoch: u64, + + /// Fee rate in MicroMinotari per gram (default: 5). + #[arg(long, default_value_t = 5)] + fee_per_gram: u64, + + /// Optional payment ID or memo attached to the transaction. + #[arg(long, help = "Optional payment ID or memo")] + payment_id: Option, + + /// Optional sidechain deployment key (hex-encoded private key, 32 bytes). + /// If provided, proves ownership of the sidechain and is included in the signature. + #[arg(long, help = "Optional sidechain deployment private key (hex)")] + sidechain_deployment_key: Option, + + /// Duration in seconds to lock input UTXOs (default: 24 hours). + #[arg(long, help = "Seconds to lock UTXOs", default_value_t = 86400)] + seconds_to_lock: u64, + }, + + /// Submit a validator node exit transaction on the Tari base layer. + /// + /// Creates, signs, and broadcasts a pay-to-self transaction that signals the validator + /// node's intention to leave the active set. The transaction embeds the validator node's + /// public key and pre-computed exit signature into the output features. + /// + /// # Prerequisites + /// + /// - The account must be a full SeedWords wallet (view-only wallets are not supported) + /// - The caller must pre-compute the validator node signature using the VN's private key + /// via `ValidatorNodeSignature::sign_for_exit` on the validator node side + /// + /// # Signature Format + /// + /// The signature is a Schnorr signature split into two 32-byte hex-encoded components: + /// - `--vn-sig-nonce`: the public nonce (compressed Ristretto point) + /// - `--vn-sig`: the signature scalar + /// + /// The exit signature does NOT include a claim public key (unlike registration). + SubmitValidatorNodeExit { + #[command(flatten)] + security: SecurityArgs, + #[command(flatten)] + node: NodeArgs, + #[command(flatten)] + db: DatabaseArgs, + #[command(flatten)] + tx: TransactionArgs, + + /// Name of the account to fund the exit deposit from. + #[arg(short, long, help = "Name of the account to fund the deposit from")] + account_name: String, + + /// Validator node public key (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node public key (hex)")] + vn_public_key: String, + + /// Public nonce component of the validator node signature (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node signature public nonce (hex)")] + vn_sig_nonce: String, + + /// Scalar component of the validator node signature (hex-encoded, 32 bytes). + #[arg(long, help = "Validator node signature scalar (hex)")] + vn_sig: String, + + /// Maximum epoch for replay protection. + #[arg(long, help = "Max epoch for replay protection")] + max_epoch: u64, + + /// Fee rate in MicroMinotari per gram (default: 5). + #[arg(long, default_value_t = 5)] + fee_per_gram: u64, + + /// Optional payment ID or memo attached to the transaction. + #[arg(long, help = "Optional payment ID or memo")] + payment_id: Option, + + /// Optional sidechain deployment key (hex-encoded private key, 32 bytes). + /// If provided, proves ownership of the sidechain and is included in the signature. + #[arg(long, help = "Optional sidechain deployment private key (hex)")] + sidechain_deployment_key: Option, + + /// Duration in seconds to lock input UTXOs (default: 24 hours). + #[arg(long, help = "Seconds to lock UTXOs", default_value_t = 86400)] + seconds_to_lock: u64, + }, + + /// Submit a validator node eviction proof transaction on the Tari base layer. + /// + /// Creates, signs, and broadcasts a pay-to-self transaction that embeds a self-validating + /// eviction proof into the output features. The proof contains sidechain quorum certificates + /// and a Merkle inclusion proof — no additional wallet signature is required. + /// + /// # Prerequisites + /// + /// - The account must be a full SeedWords wallet (view-only wallets are not supported) + /// - The eviction proof must be provided as a JSON file (e.g., generated by the sidechain node) + /// + /// # Proof File Format + /// + /// The proof file must contain a JSON-serialized [`EvictionProof`] as produced by the + /// sidechain node. The proof is self-validating via embedded quorum certificates. + SubmitValidatorEvictionProof { + #[command(flatten)] + security: SecurityArgs, + #[command(flatten)] + node: NodeArgs, + #[command(flatten)] + db: DatabaseArgs, + #[command(flatten)] + tx: TransactionArgs, + + /// Name of the account to fund the eviction deposit from. + #[arg(short, long, help = "Name of the account to fund the deposit from")] + account_name: String, + + /// Path to a JSON file containing the serialized EvictionProof. + #[arg(long, help = "Path to the eviction proof JSON file")] + proof_file: std::path::PathBuf, + + /// Fee rate in MicroMinotari per gram (default: 5). + #[arg(long, default_value_t = 5)] + fee_per_gram: u64, + + /// Optional payment ID or memo attached to the transaction. + #[arg(long, help = "Optional payment ID or memo")] + payment_id: Option, + + /// Optional sidechain deployment key (hex-encoded private key, 32 bytes). + /// If provided, creates a knowledge proof signed over the evicted node's public key. + #[arg(long, help = "Optional sidechain deployment private key (hex)")] + sidechain_deployment_key: Option, + + /// Duration in seconds to lock input UTXOs (default: 24 hours). + #[arg(long, help = "Seconds to lock UTXOs", default_value_t = 86400)] + seconds_to_lock: u64, + }, + /// Delete a wallet account and all associated data. /// /// This permanently removes the account, transaction history, and keys from the database. diff --git a/minotari/src/commands/mod.rs b/minotari/src/commands/mod.rs new file mode 100644 index 0000000..8b9e2b7 --- /dev/null +++ b/minotari/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod validator_nodes; diff --git a/minotari/src/commands/validator_nodes.rs b/minotari/src/commands/validator_nodes.rs new file mode 100644 index 0000000..5f1a898 --- /dev/null +++ b/minotari/src/commands/validator_nodes.rs @@ -0,0 +1,363 @@ +//! CLI handlers for validator node commands. +//! +//! Each handler parses its CLI inputs, builds the appropriate params struct, +//! calls the transaction constructor, then signs, persists, and broadcasts the result. + +use std::{fs, path::PathBuf}; + +use anyhow::anyhow; +use log::info; +use minotari::{ + db::{self, AccountRow, init_db}, + http::WalletHttpClient, + models::PendingTransactionStatus, + transactions::validator_node::{ + eviction::{ValidatorNodeEvictionParams, create_validator_node_eviction_tx}, + exit::{ValidatorNodeExitParams, create_validator_node_exit_tx}, + registration::{ValidatorNodeRegistrationParams, create_validator_node_registration_tx}, + }, +}; +use rusqlite::Connection; +use tari_common::configuration::Network; +use tari_common_types::{ + epoch::VnEpoch, + types::{CompressedPublicKey, CompressedSignature, PrivateKey}, +}; +use tari_transaction_components::{ + consensus::ConsensusConstantsBuilder, + offline_signing::{models::PrepareOneSidedTransactionForSigningResult, sign_locked_transaction}, + tari_amount::MicroMinotari, +}; +use tari_utilities::byte_array::ByteArray; + +// ── Parsing helpers ────────────────────────────────────────────────────────── + +fn parse_compressed_public_key(hex_str: &str, field_name: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|e| anyhow!("Invalid {} hex: {}", field_name, e))?; + CompressedPublicKey::from_canonical_bytes(&bytes).map_err(|e| anyhow!("Invalid {}: {}", field_name, e)) +} + +/// Parses the three hex strings that describe a VN Schnorr signature. +/// +/// Returns `(vn_public_key, signature)` where the signature bundles the public nonce +/// and the scalar component. +fn parse_vn_signature( + pk_hex: &str, + nonce_hex: &str, + sig_hex: &str, +) -> Result<(CompressedPublicKey, CompressedSignature), anyhow::Error> { + let vn_public_key = parse_compressed_public_key(pk_hex, "vn-public-key")?; + let nonce = parse_compressed_public_key(nonce_hex, "vn-sig-nonce")?; + let sig_bytes = hex::decode(sig_hex).map_err(|e| anyhow!("Invalid vn-sig hex: {}", e))?; + let sig_scalar = + PrivateKey::from_canonical_bytes(&sig_bytes).map_err(|e| anyhow!("Invalid vn-sig scalar: {}", e))?; + Ok((vn_public_key, CompressedSignature::new(nonce, sig_scalar))) +} + +fn parse_sidechain_deployment_key(key: Option) -> Result, anyhow::Error> { + key.map(|k| { + let bytes = hex::decode(&k).map_err(|e| anyhow!("Invalid sidechain-deployment-key hex: {}", e))?; + PrivateKey::from_canonical_bytes(&bytes).map_err(|e| anyhow!("Invalid sidechain-deployment-key: {}", e)) + }) + .transpose() +} + +// ── Sign + persist + broadcast ──────────────────────────────────────────────── + +/// Signs an unsigned VN transaction, saves it to the DB, and broadcasts it. +/// +/// This is the common post-processing step shared by all three VN commands. +/// `tx_kind` is used in log/error messages (e.g. `"VN registration"`). +#[allow(clippy::too_many_arguments)] +async fn sign_save_and_broadcast( + unsigned_result: PrepareOneSidedTransactionForSigningResult, + account: &AccountRow, + conn: &Connection, + password: &str, + network: Network, + idempotency_key: &str, + base_url: &str, + tx_kind: &str, +) -> Result<(), anyhow::Error> { + let key_manager = account.get_key_manager(password)?; + let consensus_constants = ConsensusConstantsBuilder::new(network).build(); + let signed_result = sign_locked_transaction(&key_manager, consensus_constants, network, unsigned_result) + .map_err(|e| anyhow!("Failed to sign {} transaction: {}", tx_kind, e))?; + + let completed_tx_id = signed_result.signed_transaction.tx_id; + let kernel_excess = signed_result + .signed_transaction + .transaction + .body() + .kernels() + .first() + .map(|k| k.excess.as_bytes().to_vec()) + .unwrap_or_default(); + let serialized_tx = serde_json::to_vec(&signed_result.signed_transaction.transaction) + .map_err(|e| anyhow!("Failed to serialize transaction: {}", e))?; + let sent_output_hash = signed_result.signed_transaction.sent_hashes.first().map(hex::encode); + + if let Some(pending_tx) = db::find_pending_transaction_by_idempotency_key(conn, idempotency_key, account.id)? { + let pending_tx_id = pending_tx.id.to_string(); + db::update_pending_transaction_status(conn, &pending_tx_id, PendingTransactionStatus::Completed)?; + db::create_completed_transaction( + conn, + account.id, + &pending_tx_id, + &kernel_excess, + &serialized_tx, + sent_output_hash, + completed_tx_id, + )?; + } + + let client = WalletHttpClient::new(base_url.parse()?)?; + let response = client + .submit_transaction(signed_result.signed_transaction.transaction) + .await; + + match response { + Ok(r) if r.accepted => { + db::mark_completed_transaction_as_broadcasted(conn, completed_tx_id, 1)?; + info!(target: "audit", tx_id = completed_tx_id.to_string().as_str(); "{} broadcasted", tx_kind); + println!("{} transaction broadcasted. tx_id={}", tx_kind, completed_tx_id); + }, + Ok(r) => return Err(anyhow!("Transaction rejected by network: {}", r.rejection_reason)), + Err(e) => return Err(anyhow!("Broadcast failed: {}", e)), + } + + Ok(()) +} + +// ── Shared command runner ───────────────────────────────────────────────────── + +/// Handles the shared boilerplate for all three VN commands: initialise the +/// database, fetch the account, call the operation-specific transaction +/// constructor, then sign, persist, and broadcast the result. +/// +/// `create_tx` is a closure that captures the operation-specific params and +/// returns an unsigned transaction ready for signing. +#[allow(clippy::too_many_arguments)] +async fn run_vn_command( + database_file: PathBuf, + account_name: &str, + network: Network, + password: &str, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, + base_url: &str, + tx_kind: &str, + create_tx: F, +) -> Result<(), anyhow::Error> +where + F: FnOnce( + &AccountRow, + db::SqlitePool, + Network, + &str, + Option, + u64, + u64, + ) -> Result, +{ + let idempotency_key = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let pool = init_db(database_file)?; + let conn = pool.get()?; + let account = + db::get_account_by_name(&conn, account_name)?.ok_or_else(|| anyhow!("Account not found: {}", account_name))?; + + let unsigned_result = create_tx( + &account, + pool.clone(), + network, + password, + Some(idempotency_key.clone()), + seconds_to_lock, + confirmation_window, + )?; + + sign_save_and_broadcast( + unsigned_result, + &account, + &conn, + password, + network, + &idempotency_key, + base_url, + tx_kind, + ) + .await +} + +// ── Public handlers ─────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +pub async fn handle_register_validator_node( + vn_public_key: String, + vn_sig_nonce: String, + vn_sig: String, + claim_public_key: String, + max_epoch: u64, + fee_per_gram: u64, + payment_id: Option, + sidechain_deployment_key: Option, + database_file: PathBuf, + account_name: String, + network: Network, + password: String, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, + base_url: String, +) -> Result<(), anyhow::Error> { + let (vn_public_key, vn_signature) = parse_vn_signature(&vn_public_key, &vn_sig_nonce, &vn_sig)?; + let claim_public_key = parse_compressed_public_key(&claim_public_key, "claim-public-key")?; + let sidechain_deployment_key = parse_sidechain_deployment_key(sidechain_deployment_key)?; + + let params = ValidatorNodeRegistrationParams { + validator_node_public_key: vn_public_key, + validator_node_signature: vn_signature, + claim_public_key, + max_epoch: VnEpoch(max_epoch), + fee_per_gram: MicroMinotari(fee_per_gram), + payment_id, + sidechain_deployment_key, + }; + + run_vn_command( + database_file, + &account_name, + network, + &password, + idempotency_key, + seconds_to_lock, + confirmation_window, + &base_url, + "VN registration", + |account, pool, network, password, idempotency_key, seconds_to_lock, confirmation_window| { + create_validator_node_registration_tx( + account, + params, + pool, + network, + password, + idempotency_key, + seconds_to_lock, + confirmation_window, + ) + }, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn handle_submit_validator_node_exit( + vn_public_key: String, + vn_sig_nonce: String, + vn_sig: String, + max_epoch: u64, + fee_per_gram: u64, + payment_id: Option, + sidechain_deployment_key: Option, + database_file: PathBuf, + account_name: String, + network: Network, + password: String, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, + base_url: String, +) -> Result<(), anyhow::Error> { + let (vn_public_key, vn_signature) = parse_vn_signature(&vn_public_key, &vn_sig_nonce, &vn_sig)?; + let sidechain_deployment_key = parse_sidechain_deployment_key(sidechain_deployment_key)?; + + let params = ValidatorNodeExitParams { + validator_node_public_key: vn_public_key, + validator_node_signature: vn_signature, + max_epoch: VnEpoch(max_epoch), + fee_per_gram: MicroMinotari(fee_per_gram), + payment_id, + sidechain_deployment_key, + }; + + run_vn_command( + database_file, + &account_name, + network, + &password, + idempotency_key, + seconds_to_lock, + confirmation_window, + &base_url, + "VN exit", + |account, pool, network, password, idempotency_key, seconds_to_lock, confirmation_window| { + create_validator_node_exit_tx( + account, + params, + pool, + network, + password, + idempotency_key, + seconds_to_lock, + confirmation_window, + ) + }, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn handle_submit_validator_eviction_proof( + proof_file: PathBuf, + fee_per_gram: u64, + payment_id: Option, + sidechain_deployment_key: Option, + database_file: PathBuf, + account_name: String, + network: Network, + password: String, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, + base_url: String, +) -> Result<(), anyhow::Error> { + let proof_json = fs::read_to_string(&proof_file) + .map_err(|e| anyhow!("Failed to read proof file '{}': {}", proof_file.display(), e))?; + let eviction_proof: tari_sidechain::EvictionProof = + serde_json::from_str(&proof_json).map_err(|e| anyhow!("Failed to parse eviction proof JSON: {}", e))?; + + let sidechain_deployment_key = parse_sidechain_deployment_key(sidechain_deployment_key)?; + + let params = ValidatorNodeEvictionParams { + eviction_proof, + fee_per_gram: MicroMinotari(fee_per_gram), + payment_id, + sidechain_deployment_key, + }; + + run_vn_command( + database_file, + &account_name, + network, + &password, + idempotency_key, + seconds_to_lock, + confirmation_window, + &base_url, + "VN eviction proof", + |account, pool, network, password, idempotency_key, seconds_to_lock, confirmation_window| { + create_validator_node_eviction_tx( + account, + params, + pool, + network, + password, + idempotency_key, + seconds_to_lock, + confirmation_window, + ) + }, + ) + .await +} diff --git a/minotari/src/main.rs b/minotari/src/main.rs index a08bf0c..9dae795 100644 --- a/minotari/src/main.rs +++ b/minotari/src/main.rs @@ -45,6 +45,8 @@ //! - Transaction and balance data is stored in a SQLite database //! - Default data directory is `./data/` +mod commands; + use std::{ fs::{self, create_dir_all}, path::{Path, PathBuf}, @@ -434,6 +436,122 @@ async fn main() -> Result<(), anyhow::Error> { }; handle_lock_funds(wallet_config.database_path.clone(), account_name, output_file, request) }, + Commands::RegisterValidatorNode { + security, + node, + db, + tx, + account_name, + vn_public_key, + vn_sig_nonce, + vn_sig, + claim_public_key, + max_epoch, + fee_per_gram, + payment_id, + sidechain_deployment_key, + seconds_to_lock, + } => { + info!(target: "audit", account = account_name.as_str(); "Registering validator node..."); + + wallet_config.apply_node(&node); + wallet_config.apply_database(&db); + wallet_config.apply_transaction(&tx); + + commands::validator_nodes::handle_register_validator_node( + vn_public_key, + vn_sig_nonce, + vn_sig, + claim_public_key, + max_epoch, + fee_per_gram, + payment_id, + sidechain_deployment_key, + wallet_config.database_path.clone(), + account_name, + wallet_config.network, + security.password, + tx.idempotency_key, + seconds_to_lock, + wallet_config.confirmation_window, + wallet_config.base_url, + ) + .await + }, + Commands::SubmitValidatorNodeExit { + security, + node, + db, + tx, + account_name, + vn_public_key, + vn_sig_nonce, + vn_sig, + max_epoch, + fee_per_gram, + payment_id, + sidechain_deployment_key, + seconds_to_lock, + } => { + info!(target: "audit", account = account_name.as_str(); "Submitting validator node exit..."); + + wallet_config.apply_node(&node); + wallet_config.apply_database(&db); + wallet_config.apply_transaction(&tx); + + commands::validator_nodes::handle_submit_validator_node_exit( + vn_public_key, + vn_sig_nonce, + vn_sig, + max_epoch, + fee_per_gram, + payment_id, + sidechain_deployment_key, + wallet_config.database_path.clone(), + account_name, + wallet_config.network, + security.password, + tx.idempotency_key, + seconds_to_lock, + wallet_config.confirmation_window, + wallet_config.base_url, + ) + .await + }, + Commands::SubmitValidatorEvictionProof { + security, + node, + db, + tx, + account_name, + proof_file, + fee_per_gram, + payment_id, + sidechain_deployment_key, + seconds_to_lock, + } => { + info!(target: "audit", account = account_name.as_str(); "Submitting validator node eviction proof..."); + + wallet_config.apply_node(&node); + wallet_config.apply_database(&db); + wallet_config.apply_transaction(&tx); + + commands::validator_nodes::handle_submit_validator_eviction_proof( + proof_file, + fee_per_gram, + payment_id, + sidechain_deployment_key, + wallet_config.database_path.clone(), + account_name, + wallet_config.network, + security.password, + tx.idempotency_key, + seconds_to_lock, + wallet_config.confirmation_window, + wallet_config.base_url, + ) + .await + }, Commands::Delete { db, account } => { let name = account.account_name.as_deref().unwrap_or("default"); info!(target: "audit", account = name; "Deleting wallet..."); diff --git a/minotari/src/transactions/mod.rs b/minotari/src/transactions/mod.rs index 65ec4e8..57f4082 100644 --- a/minotari/src/transactions/mod.rs +++ b/minotari/src/transactions/mod.rs @@ -77,6 +77,7 @@ pub mod manager; pub mod monitor; pub mod one_sided_transaction; pub mod transaction_history; +pub mod validator_node; pub use displayed_transaction_processor::{ BlockchainInfo, CounterpartyInfo, DisplayedTransaction, DisplayedTransactionBuilder, DisplayedTransactionProcessor, diff --git a/minotari/src/transactions/validator_node/common.rs b/minotari/src/transactions/validator_node/common.rs new file mode 100644 index 0000000..e802b00 --- /dev/null +++ b/minotari/src/transactions/validator_node/common.rs @@ -0,0 +1,88 @@ +//! Shared helpers for validator node pay-to-self transaction construction. +//! +//! All three VN operations (registration, exit, eviction) lock the consensus-required +//! minimum deposit and build a single pay-to-self output with operation-specific +//! [`OutputFeatures`]. This module extracts that common pattern. + +use log::info; +use tari_common::configuration::Network; +use tari_common_types::transaction::TxId; +use tari_transaction_components::{ + TransactionBuilder, + consensus::ConsensusConstantsBuilder, + offline_signing::{ + models::{PaymentRecipient, PrepareOneSidedTransactionForSigningResult}, + prepare_one_sided_transaction_for_signing, + }, + tari_amount::MicroMinotari, + transaction_components::{MemoField, OutputFeatures, memo_field::TxType}, +}; + +use crate::{ + db::{AccountRow, SqlitePool}, + transactions::fund_locker::FundLocker, +}; + +/// Locks the VN registration deposit and prepares a pay-to-self transaction for signing. +/// +/// Used by all three VN operations (registration, exit, eviction) which share the same +/// transaction shape: one output sent back to the sender, carrying operation-specific +/// `output_features`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_vn_pay_to_self_tx( + account: &AccountRow, + db_pool: SqlitePool, + network: Network, + password: &str, + output_features: OutputFeatures, + fee_per_gram: MicroMinotari, + payment_id: Option<&str>, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, + tx_description: &str, +) -> Result { + let consensus_constants = ConsensusConstantsBuilder::new(network).build(); + let deposit_amount = consensus_constants.validator_node_registration_min_deposit_amount(); + + info!( + target: "audit", + deposit_amount = deposit_amount.as_u64(); + "Creating {} transaction", tx_description + ); + + let sender_address = account.get_address(network, password)?; + let fund_locker = FundLocker::new(db_pool); + let locked_funds = fund_locker.lock( + account.id, + deposit_amount, + 1, + fee_per_gram, + None, + idempotency_key, + seconds_to_lock, + confirmation_window, + )?; + + let key_manager = account.get_key_manager(password)?; + let mut tx_builder = TransactionBuilder::new(consensus_constants, key_manager, network)?; + tx_builder.with_fee_per_gram(fee_per_gram); + for utxo in &locked_funds.utxos { + tx_builder.with_input(utxo.clone())?; + } + + let tx_id = TxId::new_random(); + let memo = payment_id + .and_then(|s| MemoField::new_open_from_string(s, TxType::PaymentToOther).ok()) + .unwrap_or_default(); + + let payment_recipient = PaymentRecipient { + amount: deposit_amount, + output_features, + address: sender_address.clone(), + payment_id: memo.clone(), + }; + + prepare_one_sided_transaction_for_signing(tx_id, tx_builder, &[payment_recipient], memo, sender_address) + .map_err(Into::into) +} diff --git a/minotari/src/transactions/validator_node/eviction.rs b/minotari/src/transactions/validator_node/eviction.rs new file mode 100644 index 0000000..336297a --- /dev/null +++ b/minotari/src/transactions/validator_node/eviction.rs @@ -0,0 +1,90 @@ +//! Validator node eviction proof transaction construction. +//! +//! This module provides functionality for creating validator node eviction proof +//! transactions on the Tari base layer. The eviction embeds a self-validating +//! [`EvictionProof`] (containing sidechain quorum certificates and a Merkle inclusion +//! proof) into a pay-to-self transaction with special [`OutputFeatures`]. +//! +//! Unlike registration and exit, no wallet-side signature validation is performed — +//! the proof is self-validating via embedded quorum certificates. +//! Only [`WalletType::SeedWords`] wallets are supported. +//! +//! # Flow +//! +//! 1. Build [`OutputFeatures::for_validator_node_eviction`] from the proof +//! 2. Lock the deposit UTXOs via [`FundLocker`] and prepare the transaction for signing + +use anyhow::anyhow; +use tari_common::configuration::Network; +use tari_common_types::types::PrivateKey; +use tari_sidechain::EvictionProof; +use tari_transaction_components::{ + key_manager::wallet_types::WalletType, offline_signing::models::PrepareOneSidedTransactionForSigningResult, + tari_amount::MicroMinotari, transaction_components::OutputFeatures, +}; + +use super::common::build_vn_pay_to_self_tx; +use crate::db::{AccountRow, SqlitePool}; + +/// Parameters for validator node eviction +/// +/// The `eviction_proof` is self-validating via embedded quorum certificates and a +/// Merkle inclusion proof — no additional wallet signature is required. +pub struct ValidatorNodeEvictionParams { + /// The self-validating eviction proof containing sidechain block commit data. + pub eviction_proof: EvictionProof, + /// Fee rate in MicroMinotari per gram. + pub fee_per_gram: MicroMinotari, + /// Optional payment memo attached to the transaction. + pub payment_id: Option, + /// Optional sidechain deployment private key. If provided, creates a [`SideChainId`] + /// knowledge proof signed over the evicted node's public key. + pub sidechain_deployment_key: Option, +} + +/// Creates an unsigned validator node eviction proof transaction. +/// +/// Unlike registration and exit, no signature validation is performed — the +/// [`EvictionProof`] is self-validating via embedded quorum certificates. +/// +/// # Errors +/// +/// Returns an error if: +/// - The account is not a [`WalletType::SeedWords`] wallet +/// - There are insufficient funds for the consensus-required deposit +/// - Transaction construction fails +#[allow(clippy::too_many_arguments)] +pub fn create_validator_node_eviction_tx( + account: &AccountRow, + params: ValidatorNodeEvictionParams, + db_pool: SqlitePool, + network: Network, + password: &str, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, +) -> Result { + let wallet_type = account.decrypt_wallet_type(password)?; + if !matches!(wallet_type, WalletType::SeedWords(_)) { + return Err(anyhow!( + "Validator node eviction requires a SeedWords wallet, not a view-only wallet" + )); + } + + let output_features = + OutputFeatures::for_validator_node_eviction(params.eviction_proof, params.sidechain_deployment_key.as_ref()); + + build_vn_pay_to_self_tx( + account, + db_pool, + network, + password, + output_features, + params.fee_per_gram, + params.payment_id.as_deref(), + idempotency_key, + seconds_to_lock, + confirmation_window, + "validator node eviction proof", + ) +} diff --git a/minotari/src/transactions/validator_node/exit.rs b/minotari/src/transactions/validator_node/exit.rs new file mode 100644 index 0000000..fce24cb --- /dev/null +++ b/minotari/src/transactions/validator_node/exit.rs @@ -0,0 +1,111 @@ +//! Validator node exit transaction construction. +//! +//! This module provides functionality for creating validator node exit +//! transactions on the Tari base layer. The exit embeds the validator +//! node's public key and signature into a pay-to-self transaction with special +//! [`OutputFeatures`], locking the consensus-required minimum deposit. +//! +//! # Pattern +//! +//! The caller pre-computes the [`ValidatorNodeSignature`] on the validator node +//! side, then passes the public key and signature to the wallet for transaction +//! construction. Only [`WalletType::SeedWords`] wallets are supported. +//! +//! # Flow +//! +//! 1. Validate the pre-computed [`ValidatorNodeSignature`] +//! 2. Build [`OutputFeatures::for_validator_node_exit`] +//! 3. Lock the deposit UTXOs via [`FundLocker`] and prepare the transaction for signing + +use anyhow::anyhow; +use tari_common::configuration::Network; +use tari_common_types::{ + epoch::VnEpoch, + types::{CompressedPublicKey, CompressedSignature, PrivateKey}, +}; +use tari_transaction_components::{ + key_manager::wallet_types::WalletType, + offline_signing::models::PrepareOneSidedTransactionForSigningResult, + tari_amount::MicroMinotari, + transaction_components::{OutputFeatures, ValidatorNodeSignature}, +}; + +use super::common::build_vn_pay_to_self_tx; +use crate::db::{AccountRow, SqlitePool}; + +/// Parameters for validator node exit +/// +/// The `validator_node_signature` must be pre-computed by the validator node using +/// [`ValidatorNodeSignature::sign_for_exit`] with the node's private key. +pub struct ValidatorNodeExitParams { + /// The validator node's public key (the signing key used to create `validator_node_signature`). + pub validator_node_public_key: CompressedPublicKey, + /// Pre-computed Schnorr signature over `(vn_pk | nonce | sidechain_pk? | epoch)`. + pub validator_node_signature: CompressedSignature, + /// Maximum epoch for replay protection. + pub max_epoch: VnEpoch, + /// Fee rate in MicroMinotari per gram. + pub fee_per_gram: MicroMinotari, + /// Optional payment memo attached to the transaction. + pub payment_id: Option, + /// Optional sidechain deployment private key. If provided, derives the sidechain public key + /// included in the signature message and creates a [`SideChainId`] proof. + pub sidechain_deployment_key: Option, +} + +/// Creates an unsigned validator node exit transaction. +/// +/// The returned [`PrepareOneSidedTransactionForSigningResult`] is signed and broadcast +/// by the caller, following the same flow as [`create_validator_node_registration_tx`]. +/// +/// # Errors +/// +/// Returns an error if: +/// - The account is not a [`WalletType::SeedWords`] wallet +/// - The validator node signature is invalid +/// - There are insufficient funds for the consensus-required deposit +/// - Transaction construction fails +#[allow(clippy::too_many_arguments)] +pub fn create_validator_node_exit_tx( + account: &AccountRow, + params: ValidatorNodeExitParams, + db_pool: SqlitePool, + network: Network, + password: &str, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, +) -> Result { + let wallet_type = account.decrypt_wallet_type(password)?; + if !matches!(wallet_type, WalletType::SeedWords(_)) { + return Err(anyhow!( + "Validator node exit requires a SeedWords wallet, not a view-only wallet" + )); + } + + let vn_sig = ValidatorNodeSignature::new(params.validator_node_public_key, params.validator_node_signature); + let sidechain_pk = params + .sidechain_deployment_key + .as_ref() + .map(CompressedPublicKey::from_secret_key); + if !vn_sig.is_valid_exit_signature_for(sidechain_pk.as_ref(), params.max_epoch) { + return Err(anyhow!("Invalid validator node exit signature")); + } + + let output_features = + OutputFeatures::for_validator_node_exit(vn_sig, params.sidechain_deployment_key.as_ref(), params.max_epoch); + + build_vn_pay_to_self_tx( + account, + db_pool, + network, + password, + output_features, + params.fee_per_gram, + params.payment_id.as_deref(), + idempotency_key, + seconds_to_lock, + confirmation_window, + "validator node exit", + ) +} diff --git a/minotari/src/transactions/validator_node/mod.rs b/minotari/src/transactions/validator_node/mod.rs new file mode 100644 index 0000000..bc3365a --- /dev/null +++ b/minotari/src/transactions/validator_node/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod common; +pub mod eviction; +pub mod exit; +pub mod registration; diff --git a/minotari/src/transactions/validator_node/registration.rs b/minotari/src/transactions/validator_node/registration.rs new file mode 100644 index 0000000..de63e40 --- /dev/null +++ b/minotari/src/transactions/validator_node/registration.rs @@ -0,0 +1,117 @@ +//! Validator node registration transaction construction. +//! +//! This module provides functionality for creating validator node registration +//! transactions on the Tari base layer. The registration embeds the validator +//! node's public key and signature into a pay-to-self transaction with special +//! [`OutputFeatures`], locking a minimum deposit defined by consensus constants. +//! +//! # Pattern +//! +//! The caller pre-computes the [`ValidatorNodeSignature`] on the validator node +//! side, then passes the public key and signature to the wallet for transaction +//! construction. Only [`WalletType::SeedWords`] wallets are supported. +//! +//! # Flow +//! +//! 1. Validate the pre-computed [`ValidatorNodeSignature`] +//! 2. Build [`OutputFeatures::for_validator_node_registration`] +//! 3. Lock the deposit UTXOs via [`FundLocker`] and prepare the transaction for signing + +use anyhow::anyhow; +use tari_common::configuration::Network; +use tari_common_types::{ + epoch::VnEpoch, + types::{CompressedPublicKey, CompressedSignature, PrivateKey}, +}; +use tari_transaction_components::{ + key_manager::wallet_types::WalletType, + offline_signing::models::PrepareOneSidedTransactionForSigningResult, + tari_amount::MicroMinotari, + transaction_components::{OutputFeatures, ValidatorNodeSignature}, +}; + +use super::common::build_vn_pay_to_self_tx; +use crate::db::{AccountRow, SqlitePool}; + +/// Parameters for validator node registration +/// +/// The `validator_node_signature` must be pre-computed by the validator node using +/// [`ValidatorNodeSignature::sign_for_registration`] with the node's private key. +pub struct ValidatorNodeRegistrationParams { + /// The validator node's public key (the signing key used to create `validator_node_signature`). + pub validator_node_public_key: CompressedPublicKey, + /// Pre-computed Schnorr signature over `(vn_pk | nonce | sidechain_pk? | claim_pk | epoch)`. + pub validator_node_signature: CompressedSignature, + /// Public key used to claim validator node rewards. + pub claim_public_key: CompressedPublicKey, + /// Maximum epoch for replay protection. + pub max_epoch: VnEpoch, + /// Fee rate in MicroMinotari per gram. + pub fee_per_gram: MicroMinotari, + /// Optional payment memo attached to the transaction. + pub payment_id: Option, + /// Optional sidechain deployment private key. If provided, derives the sidechain public key + /// included in the signature message and creates a [`SideChainId`] proof. + pub sidechain_deployment_key: Option, +} + +/// Creates an unsigned validator node registration transaction. +/// +/// The returned [`PrepareOneSidedTransactionForSigningResult`] can be written to a +/// file for external signing, following the same flow as [`CreateUnsignedTransaction`]. +/// +/// # Errors +/// +/// Returns an error if: +/// - The account is not a [`WalletType::SeedWords`] wallet +/// - The validator node signature is invalid +/// - There are insufficient funds for the consensus-required deposit +/// - Transaction construction fails +#[allow(clippy::too_many_arguments)] +pub fn create_validator_node_registration_tx( + account: &AccountRow, + params: ValidatorNodeRegistrationParams, + db_pool: SqlitePool, + network: Network, + password: &str, + idempotency_key: Option, + seconds_to_lock: u64, + confirmation_window: u64, +) -> Result { + let wallet_type = account.decrypt_wallet_type(password)?; + if !matches!(wallet_type, WalletType::SeedWords(_)) { + return Err(anyhow!( + "Validator node registration requires a SeedWords wallet, not a view-only wallet" + )); + } + + let vn_sig = ValidatorNodeSignature::new(params.validator_node_public_key, params.validator_node_signature); + let sidechain_pk = params + .sidechain_deployment_key + .as_ref() + .map(CompressedPublicKey::from_secret_key); + if !vn_sig.is_valid_registration_signature_for(sidechain_pk.as_ref(), ¶ms.claim_public_key, params.max_epoch) { + return Err(anyhow!("Invalid validator node registration signature")); + } + + let output_features = OutputFeatures::for_validator_node_registration( + vn_sig, + params.claim_public_key, + params.sidechain_deployment_key.as_ref(), + params.max_epoch, + ); + + build_vn_pay_to_self_tx( + account, + db_pool, + network, + password, + output_features, + params.fee_per_gram, + params.payment_id.as_deref(), + idempotency_key, + seconds_to_lock, + confirmation_window, + "validator node registration", + ) +}