From 5328eb3917369064154c52b1f5a11f9868b1bc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 29 Jul 2026 15:27:20 -0400 Subject: [PATCH 1/6] feat(validator): add private decryption share RPC --- bin/validator/src/commands/mod.rs | 33 +- bin/validator/src/commands/start.rs | 30 +- bin/validator/src/lib.rs | 2 +- bin/validator/src/private_record.rs | 2 +- bin/validator/src/server/admin_service.rs | 408 +++++++++++++++++++++ bin/validator/src/server/mod.rs | 56 ++- bin/validator/src/storage_key.rs | 44 ++- compose/validator.yml | 1 + crates/proto/build.rs | 2 + proto/proto/README.md | 3 +- proto/proto/internal/validator_admin.proto | 27 ++ 11 files changed, 583 insertions(+), 25 deletions(-) create mode 100644 bin/validator/src/server/admin_service.rs create mode 100644 proto/proto/internal/validator_admin.proto diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index bc7ca059d7..429ec10c8c 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -24,7 +24,6 @@ use miden_validator::{ GoldenOperatorKey, LOG_TARGET, LocalX25519TransactionInputDecrypter, - PrivateRecordSealer, StorageKeyEpoch, TransactionInputDecrypter, ValidatorSigner, @@ -32,6 +31,7 @@ use miden_validator::{ const ENV_DATA_DIRECTORY: &str = "MIDEN_VALIDATOR_DATA_DIRECTORY"; const ENV_LISTEN: &str = "MIDEN_VALIDATOR_LISTEN"; +const ENV_ADMIN_LISTEN: &str = "MIDEN_VALIDATOR_ADMIN_LISTEN"; const ENV_SIGNING_KEY: &str = "MIDEN_VALIDATOR_SIGNING_KEY"; const ENV_SIGNING_KEY_KMS_ID: &str = "MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID"; const ENV_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY"; @@ -170,6 +170,10 @@ pub enum ValidatorCommand { #[arg(long = "listen", env = ENV_LISTEN, value_name = "LISTEN")] listen: std::net::SocketAddr, + /// Socket address at which to serve the private administration API. + #[arg(long = "admin-listen", env = ENV_ADMIN_LISTEN, value_name = "LISTEN")] + admin_listen: Option, + #[command(flatten)] grpc_options: GrpcOptionsInternal, @@ -292,6 +296,7 @@ impl ValidatorCommand { Self::ExportPrivateRecord(options) => export_private_record::export(options).await, Self::Start { listen, + admin_listen, grpc_options, signing_key, data_directory, @@ -303,14 +308,17 @@ impl ValidatorCommand { .. } => { let address = listen; - let private_record_sealer = - PrivateRecordSealer::from_operator_key(&storage_key.load()?); + let operator_key = storage_key.load()?; tracing::info!( target: miden_validator::LOG_TARGET, { service.name = "miden-validator", service.version = env!("CARGO_PKG_VERSION"), validator.listen = %address, + validator.admin_listen = admin_listen.map_or_else( + || "disabled".to_owned(), + |address| address.to_string(), + ), data.directory = %data_directory.display(), validator.signer = if signing_key_kms_id.is_some() { "kms" } else { "local" }, sqlite.connection_pool_size = sqlite_connection_pool_size.get(), @@ -326,8 +334,9 @@ impl ValidatorCommand { start::start( address, + admin_listen, grpc_options, - start::ValidatorKeys { signer, decrypter, private_record_sealer }, + start::ValidatorKeys { signer, decrypter, operator_key }, data_directory, sqlite_connection_pool_size, shutdown, @@ -675,6 +684,22 @@ mod tests { assert_eq!(encryption_key_kms_ciphertext, None); } + #[test] + fn admin_listener_is_opt_in() { + let command = parse_start(&[]).expect("start without an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, None); + + let command = parse_start(&["--admin-listen", "127.0.0.1:50102"]) + .expect("start with an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, Some("127.0.0.1:50102".parse().unwrap())); + } + #[test] fn encryption_key_kms_ciphertext_parses_alone() { let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index ade165a757..a53ca3fdd7 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -6,10 +6,13 @@ use std::sync::Arc; use anyhow::Context; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::shutdown::CancellationToken; +use miden_node_utils::tasks::Tasks; use miden_validator::{ DataDirectory, + GoldenOperatorKey, PrivateRecordSealer, TransactionInputDecrypter, + ValidatorAdminServer, ValidatorServer, ValidatorSigner, }; @@ -17,12 +20,13 @@ use miden_validator::{ pub(crate) struct ValidatorKeys { pub(crate) signer: ValidatorSigner, pub(crate) decrypter: Arc, - pub(crate) private_record_sealer: PrivateRecordSealer, + pub(crate) operator_key: GoldenOperatorKey, } // Starts the validator component. pub async fn start( address: SocketAddr, + admin_address: Option, grpc_options: GrpcOptionsInternal, keys: ValidatorKeys, data_directory: PathBuf, @@ -31,16 +35,30 @@ pub async fn start( ) -> anyhow::Result<()> { let data_directory = DataDirectory::load(data_directory).context("failed to load validator data directory")?; - ValidatorServer { + let private_record_sealer = PrivateRecordSealer::from_operator_key(&keys.operator_key); + let public_server = ValidatorServer { address, grpc_options, signer: keys.signer, decrypter: keys.decrypter, - private_record_sealer: keys.private_record_sealer, + private_record_sealer, data_directory, sqlite_connection_pool_size, + }; + + let mut tasks = Tasks::new(); + tasks.spawn("validator public API", public_server.serve(shutdown.clone())); + if let Some(address) = admin_address { + let admin_server = ValidatorAdminServer { + address, + grpc_options, + operator_key: keys.operator_key, + }; + tasks.spawn("validator admin API", admin_server.serve(shutdown.clone())); } - .serve(shutdown) - .await - .context("failed while serving validator component") + + tasks + .join_next_or_cancelled(shutdown) + .await + .context("failed while serving validator component") } diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 4c0defaee5..93ac76c532 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -20,7 +20,7 @@ pub use private_record::{ PrivateRecordStorageFields, StoredPrivateRecord, }; -pub use server::ValidatorServer; +pub use server::{ValidatorAdminServer, ValidatorServer}; pub use signers::{ KmsSigner, LocalX25519TransactionInputDecrypter, diff --git a/bin/validator/src/private_record.rs b/bin/validator/src/private_record.rs index 0810ec6efa..441c3385d2 100644 --- a/bin/validator/src/private_record.rs +++ b/bin/validator/src/private_record.rs @@ -22,7 +22,7 @@ pub const PRIVATE_RECORD_FORMAT_V1: u32 = 1; const CONTEXT_DOMAIN_V1: &[u8] = b"miden-private-record-context-v1"; const PRIVATE_RECORD_BUNDLE_MAGIC: &[u8] = b"miden-private-record-bundle-v1"; -const CONTENT_KEY_BYTES: usize = 32; +pub(crate) const CONTENT_KEY_BYTES: usize = 32; const NONCE_BYTES: usize = 24; const TAG_BYTES: usize = 16; const VALIDATOR_ID_BYTES: usize = 33; diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs new file mode 100644 index 0000000000..6da71633c8 --- /dev/null +++ b/bin/validator/src/server/admin_service.rs @@ -0,0 +1,408 @@ +use miden_node_proto::generated as proto; +use miden_node_proto::generated::server::validator_admin_api; +use rand_core_06::OsRng; +use tonic::Status; + +use crate::{GoldenOperatorKey, PrivateRecordError}; + +/// Implements the private validator administration API. +pub(crate) struct ValidatorAdminService { + operator_key: GoldenOperatorKey, +} + +impl ValidatorAdminService { + /// Creates an admin service that owns this validator's Golden secret share. + pub(crate) const fn new(operator_key: GoldenOperatorKey) -> Self { + Self { operator_key } + } +} + +#[tonic::async_trait] +impl validator_admin_api::IssueDecryptionShare for ValidatorAdminService { + type Input = proto::validator_admin::IssueDecryptionShareRequest; + type Output = proto::validator_admin::IssueDecryptionShareResponse; + + fn decode(request: Self::Input) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + request: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + let decryption_share = self + .operator_key + .issue_decryption_share(&mut OsRng, &request.ciphertext, &request.decryption_context) + .map_err(|error| map_share_error(&error))?; + Ok(Self::Output { decryption_share }) + } +} + +fn map_share_error(error: &PrivateRecordError) -> Status { + match error { + PrivateRecordError::InvalidGoldenEncoding(_) + | PrivateRecordError::InvalidEncryptedRecordKey + | PrivateRecordError::DecryptionContextMismatch => { + Status::invalid_argument(error.to_string()) + }, + _ => Status::internal("failed to issue Golden decryption share"), + } +} + +#[cfg(test)] +mod tests { + use golden_ehtdh1::wire::to_wire_bytes; + use miden_node_proto::generated::server::validator_api; + use miden_node_utils::clap::GrpcOptionsInternal; + use miden_node_utils::shutdown::CancellationToken; + use miden_protocol::Word; + use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + use miden_protocol::transaction::TransactionId; + use miden_protocol::utils::serde::Deserializable; + use rand_chacha_03::ChaCha20Rng; + use rand_chacha_03::rand_core::SeedableRng; + use tokio::net::TcpListener; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::Code; + + use super::*; + use crate::storage_key::tests::operator_keys; + use crate::{ + PrivateRecordChainId, + PrivateRecordCombiner, + PrivateRecordContext, + PrivateRecordId, + PrivateRecordSealer, + PrivateRecordShareRequest, + StoredPrivateRecord, + }; + + fn target_record( + operator_key: &GoldenOperatorKey, + seed: u8, + plaintext: &[u8], + ) -> StoredPrivateRecord { + let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let record_id = PrivateRecordId::new(transaction_id, &signer.public_key()); + let context = PrivateRecordContext::new( + PrivateRecordChainId::new([7; 32]), + operator_key.key_epoch(), + transaction_id, + ); + PrivateRecordSealer::from_operator_key(operator_key) + .seal(&mut ChaCha20Rng::from_seed([seed; 32]), record_id, context, plaintext) + .unwrap() + } + + fn rpc_request( + record: &StoredPrivateRecord, + ) -> proto::validator_admin::IssueDecryptionShareRequest { + proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: record.encrypted_record_key().to_vec(), + decryption_context: record.context().to_bytes(), + } + } + + async fn issue( + operator_key: GoldenOperatorKey, + request: proto::validator_admin::IssueDecryptionShareRequest, + ) -> tonic::Result { + let service = ValidatorAdminService::new(operator_key); + validator_admin_api::IssueDecryptionShare::full(&service, tonic::Request::new(request)) + .await + } + + #[tokio::test] + async fn two_validators_issue_shares_for_third_validator_ciphertext() { + let mut operator_keys = operator_keys(); + let third = operator_keys.pop().unwrap(); + let second = operator_keys.pop().unwrap(); + let first = operator_keys.pop().unwrap(); + let plaintext = b"private transaction inputs"; + let record = target_record(&third, 1, plaintext); + let request = PrivateRecordShareRequest::for_record(&record); + let rpc_request = rpc_request(&record); + + let shares = [ + issue(first, rpc_request.clone()).await.unwrap().decryption_share, + issue(second, rpc_request).await.unwrap().decryption_share, + ]; + + let opened = PrivateRecordCombiner::from_operator_key(&third) + .unwrap() + .open(&request, &record, &shares) + .unwrap(); + assert_eq!(opened.as_slice(), plaintext); + } + + #[tokio::test] + async fn shares_for_different_ciphertexts_are_not_reusable_with_the_same_context() { + let mut operator_keys = operator_keys(); + let third = operator_keys.pop().unwrap(); + let second = operator_keys.pop().unwrap(); + let first = operator_keys.pop().unwrap(); + let first_record = target_record(&third, 2, b"same plaintext"); + let second_record = target_record(&third, 3, b"same plaintext"); + assert_eq!(first_record.context(), second_record.context()); + assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key(),); + + let shares = [ + issue(first, rpc_request(&first_record)).await.unwrap().decryption_share, + issue(second, rpc_request(&second_record)).await.unwrap().decryption_share, + ]; + let request = PrivateRecordShareRequest::for_record(&first_record); + let result = PrivateRecordCombiner::from_operator_key(&third).unwrap().open( + &request, + &first_record, + &shares, + ); + + assert!(matches!(result, Err(PrivateRecordError::ShareCombination(_)))); + } + + #[tokio::test] + async fn malformed_ciphertext_wrong_payload_size_and_context_mismatch_are_rejected() { + let mut keys = operator_keys(); + let record = target_record(&keys[0], 4, b"record"); + let context = record.context().to_bytes(); + + let malformed = proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: vec![0], + decryption_context: context.clone(), + }; + assert_eq!( + issue(keys.remove(0), malformed).await.unwrap_err().code(), + Code::InvalidArgument, + ); + + let mut non_canonical = record.encrypted_record_key().to_vec(); + non_canonical.push(0); + let non_canonical = proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: non_canonical, + decryption_context: context.clone(), + }; + assert_eq!( + issue(keys.remove(0), non_canonical).await.unwrap_err().code(), + Code::InvalidArgument, + ); + + let mut short_rng = ChaCha20Rng::from_seed([5; 32]); + let short_ciphertext = keys[0] + .sealing_key() + .seal_bytes_with_associated_data(&mut short_rng, &[0; 31], &context) + .unwrap(); + let wrong_size = proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: to_wire_bytes(&short_ciphertext), + decryption_context: context.clone(), + }; + assert_eq!( + issue(keys.remove(0), wrong_size).await.unwrap_err().code(), + Code::InvalidArgument, + ); + + let wrong_context = proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: record.encrypted_record_key().to_vec(), + decryption_context: b"wrong context".to_vec(), + }; + assert_eq!( + issue(operator_keys().remove(0), wrong_context).await.unwrap_err().code(), + Code::InvalidArgument, + ); + } + + #[derive(Clone, Copy)] + struct PublicValidatorStub; + + #[tonic::async_trait] + impl validator_api::GetTransactionEncryptionKey for PublicValidatorStub { + type Input = (); + type Output = proto::transaction::TransactionEncryptionKey; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + Err(Status::unimplemented("stub")) + } + } + + #[tonic::async_trait] + impl validator_api::Status for PublicValidatorStub { + type Input = (); + type Output = proto::validator::ValidatorStatus; + + fn decode(request: ()) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + Err(Status::unimplemented("stub")) + } + } + + #[tonic::async_trait] + impl validator_api::SubmitProvenTransaction for PublicValidatorStub { + type Input = (); + type Output = (); + + fn decode(_request: proto::transaction::ProvenTransaction) -> tonic::Result { + Ok(()) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + Err(Status::unimplemented("stub")) + } + } + + #[tonic::async_trait] + impl validator_api::SignBlock for PublicValidatorStub { + type Input = (); + type Output = proto::blockchain::SignBlockResponse; + + fn decode(_request: proto::blockchain::ProposedBlock) -> tonic::Result { + Ok(()) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + Err(Status::unimplemented("stub")) + } + } + + #[tonic::async_trait] + impl validator_api::BlockSubscription for PublicValidatorStub { + type Input = (); + type Item = proto::validator::BlockSubscriptionResponse; + type ItemStream = tokio_stream::Empty>; + + fn decode( + _request: proto::validator::BlockSubscriptionRequest, + ) -> tonic::Result { + Ok(()) + } + + fn encode(item: Self::Item) -> tonic::Result { + Ok(item) + } + + async fn handle( + &self, + _input: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + Err(Status::unimplemented("stub")) + } + } + + #[tokio::test] + async fn admin_method_is_registered_only_on_admin_listener() { + let mut operator_keys = operator_keys(); + let record = target_record(&operator_keys[0], 6, b"record"); + let request = rpc_request(&record); + let admin_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let admin_address = admin_listener.local_addr().unwrap(); + let public_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let public_address = public_listener.local_addr().unwrap(); + let shutdown = CancellationToken::new(); + + let admin_server = super::super::ValidatorAdminServer { + address: admin_address, + grpc_options: GrpcOptionsInternal::test(), + operator_key: operator_keys.remove(0), + }; + let admin_shutdown = shutdown.clone(); + let admin_task = tokio::spawn(async move { + admin_server.serve_on(admin_listener, admin_shutdown).await.unwrap(); + }); + let public_shutdown = shutdown.clone(); + let public_task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(validator_api::service(PublicValidatorStub)) + .serve_with_incoming_shutdown( + TcpListenerStream::new(public_listener), + public_shutdown.cancelled_owned(), + ) + .await + .unwrap(); + }); + + let mut admin_client = proto::validator_admin::api_client::ApiClient::connect(format!( + "http://{admin_address}", + )) + .await + .unwrap(); + assert!( + !admin_client + .issue_decryption_share(request.clone()) + .await + .unwrap() + .into_inner() + .decryption_share + .is_empty() + ); + + let mut admin_on_public = proto::validator_admin::api_client::ApiClient::connect(format!( + "http://{public_address}", + )) + .await + .unwrap(); + assert_eq!( + admin_on_public.issue_decryption_share(request).await.unwrap_err().code(), + Code::Unimplemented, + ); + + let mut public_on_admin = + proto::validator::api_client::ApiClient::connect(format!("http://{admin_address}")) + .await + .unwrap(); + assert_eq!(public_on_admin.status(()).await.unwrap_err().code(), Code::Unimplemented); + + shutdown.cancel(); + admin_task.await.unwrap(); + public_task.await.unwrap(); + } +} diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index fd71f347b6..1ce9e70138 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use std::num::NonZeroUsize; use anyhow::Context; -use miden_node_proto::server::validator_api; +use miden_node_proto::server::{validator_admin_api, validator_api}; use miden_node_proto_build::validator_api_descriptor; use miden_node_store::BlockStore; use miden_node_utils::clap::GrpcOptionsInternal; @@ -22,14 +22,17 @@ use crate::db::{ }; use crate::{ DataDirectory, + GoldenOperatorKey, LOG_TARGET, PrivateRecordSealer, TransactionInputDecrypter, ValidatorSigner, }; +mod admin_service; mod validator_service; +use admin_service::ValidatorAdminService; use validator_service::{InitialMetrics, ValidatorService}; // VALIDATOR SERVER @@ -63,6 +66,57 @@ pub struct ValidatorServer { pub sqlite_connection_pool_size: NonZeroUsize, } +/// Serves the private validator administration API on a network-isolated listener. +pub struct ValidatorAdminServer { + /// Address of the private administration listener. + pub address: SocketAddr, + /// gRPC request timeout. + pub grpc_options: GrpcOptionsInternal, + /// Golden key material used to issue this validator's decryption shares. + pub operator_key: GoldenOperatorKey, +} + +impl ValidatorAdminServer { + /// Serves the private validator administration API. + pub async fn serve(self, shutdown: CancellationToken) -> anyhow::Result<()> { + let listener = TcpListener::bind(self.address) + .await + .context("failed to bind validator admin address")?; + self.serve_on(listener, shutdown).await + } + + async fn serve_on( + self, + listener: TcpListener, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let endpoint = + listener.local_addr().context("failed to read validator admin listen address")?; + tracing::info!( + target: LOG_TARGET, + { + service.name = "miden-validator-admin", + validator.admin_listen = %endpoint, + }, + "Validator admin server ready", + ); + + tonic::transport::Server::builder() + .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) + .layer(TraceLayer::new_for_grpc().make_span_with(grpc_trace_fn)) + .timeout(self.grpc_options.request_timeout) + .add_service(validator_admin_api::service(ValidatorAdminService::new( + self.operator_key, + ))) + .serve_with_incoming_shutdown( + TcpListenerStream::new(listener), + shutdown.cancelled_owned(), + ) + .await + .context("failed to serve validator admin API") + } +} + impl ValidatorServer { /// Serves the validator RPC API. /// diff --git a/bin/validator/src/storage_key.rs b/bin/validator/src/storage_key.rs index 378e3108bd..efeff218f1 100644 --- a/bin/validator/src/storage_key.rs +++ b/bin/validator/src/storage_key.rs @@ -3,6 +3,7 @@ use std::fmt; use golden_core::{GoldenGroup, ParticipantIndex}; use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; use golden_ehtdh1::{ + Ciphertext, PublicKeySet, SealingKey, SecretShare, @@ -14,6 +15,7 @@ use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use rand_core_06::{CryptoRng, RngCore}; use zeroize::Zeroizing; +use crate::private_record::CONTENT_KEY_BYTES; use crate::{ PrivateRecordError, PrivateRecordSharePolicy, @@ -223,25 +225,25 @@ impl GoldenOperatorKey { self.secret_share.participant } - /// Checks one private-record request and returns a canonical decryption share. - pub fn issue_private_record_share( + /// Issues a canonical decryption share for one encrypted content key and exact context. + pub fn issue_decryption_share( &self, rng: &mut R, - request: &PrivateRecordShareRequest, - record: &StoredPrivateRecord, - policy: &P, + ciphertext_bytes: &[u8], + context: &[u8], ) -> Result, PrivateRecordError> where R: RngCore + CryptoRng, - P: PrivateRecordSharePolicy + ?Sized, { - record.validate_share_request(request, self.key_epoch, self.setup_context_id())?; - if !policy.allows(request, record) { - return Err(PrivateRecordError::ShareDenied); + let ciphertext: Ciphertext = + from_wire_bytes(ciphertext_bytes).map_err(PrivateRecordError::InvalidGoldenEncoding)?; + if ciphertext.encrypted_payload.len() != CONTENT_KEY_BYTES { + return Err(PrivateRecordError::InvalidEncryptedRecordKey); } + ciphertext + .verify_with_associated_data(context) + .map_err(PrivateRecordError::InvalidGoldenEncoding)?; - let ciphertext = record.decode_encrypted_record_key()?; - let context = request.context(); let share = UnsealingShare::new(self.secret_share.clone()) .decrypt_share_with_associated_data( rng, @@ -253,6 +255,26 @@ impl GoldenOperatorKey { .map_err(PrivateRecordError::ShareGeneration)?; Ok(to_wire_bytes(&share)) } + + /// Checks one private-record request and returns a canonical decryption share. + pub fn issue_private_record_share( + &self, + rng: &mut R, + request: &PrivateRecordShareRequest, + record: &StoredPrivateRecord, + policy: &P, + ) -> Result, PrivateRecordError> + where + R: RngCore + CryptoRng, + P: PrivateRecordSharePolicy + ?Sized, + { + record.validate_share_request(request, self.key_epoch, self.setup_context_id())?; + if !policy.allows(request, record) { + return Err(PrivateRecordError::ShareDenied); + } + + self.issue_decryption_share(rng, record.encrypted_record_key(), request.context()) + } } /// Error raised while loading a Golden operator key. diff --git a/compose/validator.yml b/compose/validator.yml index cbe4f1b45b..10d41a2993 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -16,6 +16,7 @@ x-validator: &validator - miden-validator - start - --listen=0.0.0.0:50101 + - --admin-listen=0.0.0.0:50102 services: validator-1: diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 3f7ed1e059..18aba08fdd 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -9,6 +9,7 @@ use miden_node_proto_build::{ remote_prover_api_descriptor, rpc_api_descriptor, sequencer_api_descriptor, + validator_admin_api_descriptor, validator_api_descriptor, }; use miette::{Context, IntoDiagnostic}; @@ -29,6 +30,7 @@ fn main() -> miette::Result<()> { rpc_api_descriptor(), remote_prover_api_descriptor(), validator_api_descriptor(), + validator_admin_api_descriptor(), ntx_builder_api_descriptor(), sequencer_api_descriptor(), ]; diff --git a/proto/proto/README.md b/proto/proto/README.md index fed23cbfd4..b1bd0a087c 100644 --- a/proto/proto/README.md +++ b/proto/proto/README.md @@ -16,7 +16,8 @@ types/ └── xxx.proto internal/ ├── ntx_builder.proto -└── validator.proto +├── validator.proto +└── validator_admin.proto ``` The public-facing files should only allow the usage of the `types` directory, to avoid service reflection to internal diff --git a/proto/proto/internal/validator_admin.proto b/proto/proto/internal/validator_admin.proto new file mode 100644 index 0000000000..2f50f8e132 --- /dev/null +++ b/proto/proto/internal/validator_admin.proto @@ -0,0 +1,27 @@ +// Specification of the private Validator administration API. +// +// This service is served on a private, network-isolated listener. +syntax = "proto3"; +package validator_admin; + +// INTERNAL VALIDATOR ADMIN API +// ================================================================================================ + +service Api { + // Issues this validator's Golden decryption share for one encrypted content key and context. + rpc IssueDecryptionShare(IssueDecryptionShareRequest) + returns (IssueDecryptionShareResponse) {} +} + +message IssueDecryptionShareRequest { + // Canonical Golden EHTDH1 ciphertext for a private record content key. + bytes ciphertext = 1; + + // Exact context used to encrypt the content key. + bytes decryption_context = 2; +} + +message IssueDecryptionShareResponse { + // Canonical Golden EHTDH1 decryption share. + bytes decryption_share = 1; +} From a9225d7d1328790c14ab2f8eda1edd803a1826a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 29 Jul 2026 16:07:44 -0400 Subject: [PATCH 2/6] feat(validator): list private records over admin RPC --- bin/validator/src/commands/start.rs | 9 +- bin/validator/src/db/migrations.rs | 7 +- ..._validated_transaction_insertion_order.sql | 14 + bin/validator/src/db/mod.rs | 143 ++++++ .../src/db/sql/insert_transaction.sql | 16 +- ...ad_validated_private_transactions_page.sql | 15 + bin/validator/src/server/admin_service.rs | 412 +++++++++++++++++- bin/validator/src/server/mod.rs | 24 +- proto/proto/internal/validator_admin.proto | 36 ++ 9 files changed, 638 insertions(+), 38 deletions(-) create mode 100644 bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql create mode 100644 bin/validator/src/db/sql/load_validated_private_transactions_page.sql diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index a53ca3fdd7..2bfe47479e 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -35,6 +35,12 @@ pub async fn start( ) -> anyhow::Result<()> { let data_directory = DataDirectory::load(data_directory).context("failed to load validator data directory")?; + let database = miden_validator::db::load_with_pool_size( + data_directory.database_path(), + sqlite_connection_pool_size, + ) + .await + .context("failed to initialize validator database")?; let private_record_sealer = PrivateRecordSealer::from_operator_key(&keys.operator_key); let public_server = ValidatorServer { address, @@ -43,7 +49,7 @@ pub async fn start( decrypter: keys.decrypter, private_record_sealer, data_directory, - sqlite_connection_pool_size, + database: database.clone(), }; let mut tasks = Tasks::new(); @@ -53,6 +59,7 @@ pub async fn start( address, grpc_options, operator_key: keys.operator_key, + database, }; tasks.spawn("validator admin API", admin_server.serve(shutdown.clone())); } diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 34d9109a10..7434f92723 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -70,9 +70,10 @@ mod tests { use super::*; - const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "384a849131983267a3a8b61d170dae7bcbec535eb4716fea7812024073f569cf", - )]; + const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ + SchemaHash::from_hex("384a849131983267a3a8b61d170dae7bcbec535eb4716fea7812024073f569cf"), + SchemaHash::from_hex("a97252fab76d168d0999e59d4ed17fcb6f33d9676c8612fd4eee65ed97a2043e"), + ]; #[test] fn migration_schema_hashes_are_stable() -> Result<()> { diff --git a/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql b/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql new file mode 100644 index 0000000000..e8b62af66b --- /dev/null +++ b/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql @@ -0,0 +1,14 @@ +ALTER TABLE validated_transactions +ADD COLUMN insertion_sequence INTEGER NOT NULL DEFAULT 0; + +-- The original insertion order is unavailable for existing rows. Assign a stable order by +-- transaction ID so every migrated database has the same result. +UPDATE validated_transactions AS current +SET insertion_sequence = ( + SELECT COUNT(*) + FROM validated_transactions AS preceding + WHERE preceding.id <= current.id +); + +CREATE UNIQUE INDEX idx_validated_transactions_insertion_sequence +ON validated_transactions(insertion_sequence); diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index bbe50f743d..0635646763 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -30,6 +30,8 @@ mod sql { include_str!("sql/load_private_records_by_key_epoch.sql"); pub(super) const LOAD_PRIVATE_RECORDS_BY_SETUP_CONTEXT: &str = include_str!("sql/load_private_records_by_setup_context.sql"); + pub(super) const LOAD_VALIDATED_PRIVATE_TRANSACTIONS_PAGE: &str = + include_str!("sql/load_validated_private_transactions_page.sql"); pub(super) const TRANSACTION_EXISTS: &str = include_str!("sql/transaction_exists.sql"); pub(super) const UPSERT_BLOCK_HEADER: &str = include_str!("sql/upsert_block_header.sql"); pub(super) const LOAD_CHAIN_TIP: &str = include_str!("sql/load_chain_tip.sql"); @@ -39,6 +41,15 @@ mod sql { pub(super) const COUNT_SIGNED_BLOCKS: &str = include_str!("sql/count_signed_blocks.sql"); } +/// One insertion-ordered page of validated private transactions. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ValidatedPrivateTransactionsPage { + /// Stored private records in insertion order. + pub(crate) records: Vec, + /// Last insertion sequence in this page when another page exists. + pub(crate) next_cursor: Option, +} + /// Open a connection to the DB after verifying that it is at the latest schema version. #[miden_instrument( target = COMPONENT, @@ -188,6 +199,34 @@ pub fn load_private_records_by_setup_context( ) } +/// Loads validated private transactions after an insertion-sequence cursor. +pub(crate) fn load_validated_private_transactions_page( + tx: &ReadTx<'_>, + page_size: u32, + after_sequence: i64, +) -> Result { + let limit = i64::from(page_size) + 1; + let mut rows = tx.query( + sql::LOAD_VALIDATED_PRIVATE_TRANSACTIONS_PAGE, + &[&after_sequence, &limit], + |row| { + let record = private_record_from_row(row)?; + let insertion_sequence = row.get::(9)?; + Ok((insertion_sequence, record)) + }, + )?; + + let next_cursor = if rows.len() > page_size as usize { + rows.pop(); + rows.last().map(|(sequence, _record)| *sequence) + } else { + None + }; + let records = rows.into_iter().map(|(_sequence, record)| record).collect(); + + Ok(ValidatedPrivateTransactionsPage { records, next_cursor }) +} + fn private_record_from_row(row: &Row<'_>) -> Result { let chain_id = fixed_32(row.get(0)?, "private record chain id")?; let key_epoch = fixed_32(row.get(1)?, "private record key epoch")?; @@ -483,6 +522,109 @@ mod tests { assert_eq!(by_setup, vec![expected.clone()]); } + #[tokio::test] + async fn validated_private_transactions_are_paginated_in_insertion_order() { + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); + let transaction_ids = [ + TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), + TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), + TransactionId::from_raw(Word::from([5u32, 0, 0, 0])), + ]; + let records = transaction_ids + .into_iter() + .zip([1u8, 2, 3]) + .map(|(transaction_id, seed)| private_record(transaction_id, seed)) + .collect::>(); + + for record in records.clone() { + db.write("insert paginated private record", move |tx| { + insert_validated_private_transaction(tx, &record) + }) + .await + .unwrap(); + } + + let first_page = db + .read("load first private record page", |tx| { + load_validated_private_transactions_page(tx, 2, 0) + }) + .await + .unwrap(); + assert_eq!(first_page.records, records[..2]); + let cursor = first_page.next_cursor.expect("another page must exist"); + + let second_page = db + .read("load second private record page", move |tx| { + load_validated_private_transactions_page(tx, 2, cursor) + }) + .await + .unwrap(); + assert_eq!(second_page.records, records[2..]); + assert_eq!(second_page.next_cursor, None); + } + + #[tokio::test] + async fn migration_assigns_existing_records_a_stable_order() { + const LEGACY_INSERT: &str = "\ + INSERT INTO validated_transactions (\ + id, validator_id, chain_id, key_epoch, setup_context_id, format_version,\ + cipher_nonce, encrypted_record, encrypted_record_key\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"; + + let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); + let database_path = temp_dir.path().join("validator.sqlite3"); + miden_node_db::migration::Migrator::builder() + .unwrap() + .push_sql("001_initial", include_str!("migrations/001_initial.sql")) + .unwrap() + .build() + .unwrap() + .bootstrap(&database_path) + .unwrap(); + let legacy_database = Database::new(&database_path).unwrap(); + let mut records = [ + private_record(TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), 1), + private_record(TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), 2), + ]; + for record in records.clone() { + legacy_database + .write("insert legacy private record", move |tx| { + let context = record.context(); + tx.execute( + LEGACY_INSERT, + &[ + &context.transaction_id().to_bytes(), + &record.record_id().validator_id().to_vec(), + &context.chain_id().as_bytes().to_vec(), + &context.key_epoch().as_bytes().to_vec(), + &record.setup_context_id().to_vec(), + &i64::from(context.format_version()), + &record.nonce().to_vec(), + &record.encrypted_record().to_vec(), + &record.encrypted_record_key().to_vec(), + ], + ) + }) + .await + .unwrap(); + } + drop(legacy_database); + + migrate(&database_path).unwrap(); + let database = load(database_path).await.unwrap(); + records.sort_by_key(|record| record.context().transaction_id().to_bytes()); + let page = database + .read("load migrated private records", |tx| { + load_validated_private_transactions_page(tx, 10, 0) + }) + .await + .unwrap(); + + assert_eq!(page.records, records); + assert_eq!(page.next_cursor, None); + } + #[tokio::test] async fn stored_private_record_opens_with_threshold_shares() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); @@ -548,5 +690,6 @@ mod tests { assert!(schema.contains("PRIMARY KEY (id)")); assert!(schema.contains("idx_validated_transactions_key_epoch")); assert!(schema.contains("idx_validated_transactions_setup_context_id")); + assert!(schema.contains("idx_validated_transactions_insertion_sequence")); } } diff --git a/bin/validator/src/db/sql/insert_transaction.sql b/bin/validator/src/db/sql/insert_transaction.sql index 278160acdf..c0a1e41d61 100644 --- a/bin/validator/src/db/sql/insert_transaction.sql +++ b/bin/validator/src/db/sql/insert_transaction.sql @@ -7,7 +7,19 @@ INSERT INTO validated_transactions ( format_version, cipher_nonce, encrypted_record, - encrypted_record_key + encrypted_record_key, + insertion_sequence +) +VALUES ( + ?1, + ?2, + ?3, + ?4, + ?5, + ?6, + ?7, + ?8, + ?9, + (SELECT COALESCE(MAX(insertion_sequence), 0) + 1 FROM validated_transactions) ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT DO NOTHING; diff --git a/bin/validator/src/db/sql/load_validated_private_transactions_page.sql b/bin/validator/src/db/sql/load_validated_private_transactions_page.sql new file mode 100644 index 0000000000..9d7abb7b35 --- /dev/null +++ b/bin/validator/src/db/sql/load_validated_private_transactions_page.sql @@ -0,0 +1,15 @@ +SELECT + chain_id, + key_epoch, + id, + validator_id, + setup_context_id, + format_version, + cipher_nonce, + encrypted_record, + encrypted_record_key, + insertion_sequence +FROM validated_transactions +WHERE insertion_sequence > ?1 +ORDER BY insertion_sequence +LIMIT ?2; diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs index 6da71633c8..fd00e60418 100644 --- a/bin/validator/src/server/admin_service.rs +++ b/bin/validator/src/server/admin_service.rs @@ -1,19 +1,81 @@ +use miden_node_db::sqlite::Database; use miden_node_proto::generated as proto; use miden_node_proto::generated::server::validator_admin_api; +use miden_protocol::utils::serde::Serializable; use rand_core_06::OsRng; use tonic::Status; +use crate::db::load_validated_private_transactions_page; use crate::{GoldenOperatorKey, PrivateRecordError}; +const DEFAULT_PAGE_SIZE: u32 = 50; +const MAX_PAGE_SIZE: u32 = 200; +const PAGE_TOKEN_BYTES: usize = size_of::(); + /// Implements the private validator administration API. pub(crate) struct ValidatorAdminService { operator_key: GoldenOperatorKey, + database: Database, } impl ValidatorAdminService { /// Creates an admin service that owns this validator's Golden secret share. - pub(crate) const fn new(operator_key: GoldenOperatorKey) -> Self { - Self { operator_key } + pub(crate) const fn new(operator_key: GoldenOperatorKey, database: Database) -> Self { + Self { operator_key, database } + } +} + +#[tonic::async_trait] +impl validator_admin_api::ListValidatedPrivateTransactions for ValidatorAdminService { + type Input = proto::validator_admin::ListValidatedPrivateTransactionsRequest; + type Output = proto::validator_admin::ListValidatedPrivateTransactionsResponse; + + fn decode(request: Self::Input) -> tonic::Result { + Ok(request) + } + + fn encode(output: Self::Output) -> tonic::Result { + Ok(output) + } + + async fn handle( + &self, + request: Self::Input, + _metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + let page_size = match request.page_size { + 0 => DEFAULT_PAGE_SIZE, + page_size if page_size <= MAX_PAGE_SIZE => page_size, + page_size => { + return Err(Status::invalid_argument(format!( + "page size {page_size} exceeds maximum {MAX_PAGE_SIZE}", + ))); + }, + }; + let after_sequence = decode_page_token(&request.page_token)?; + let page = self + .database + .read("list validated private transactions", move |tx| { + load_validated_private_transactions_page(tx, page_size, after_sequence) + }) + .await + .map_err(|_error| Status::internal("failed to list validated private transactions"))?; + + let transactions = page + .records + .into_iter() + .map(|record| proto::validator_admin::ValidatedPrivateTransaction { + transaction_id: record.context().transaction_id().to_bytes(), + final_ciphertext: record.encrypted_record().to_vec(), + cipher_nonce: record.nonce().to_vec(), + encrypted_record_key: record.encrypted_record_key().to_vec(), + decryption_context: record.context().to_bytes(), + }) + .collect(); + let next_page_token = page.next_cursor.map_or_else(Vec::new, encode_page_token); + + Ok(Self::Output { transactions, next_page_token }) } } @@ -44,6 +106,24 @@ impl validator_admin_api::IssueDecryptionShare for ValidatorAdminService { } } +fn decode_page_token(page_token: &[u8]) -> tonic::Result { + if page_token.is_empty() { + return Ok(0); + } + let token: [u8; PAGE_TOKEN_BYTES] = page_token + .try_into() + .map_err(|_| Status::invalid_argument("invalid page token"))?; + let sequence = i64::from_be_bytes(token); + if sequence <= 0 { + return Err(Status::invalid_argument("invalid page token")); + } + Ok(sequence) +} + +fn encode_page_token(sequence: i64) -> Vec { + sequence.to_be_bytes().to_vec() +} + fn map_share_error(error: &PrivateRecordError) -> Status { match error { PrivateRecordError::InvalidGoldenEncoding(_) @@ -57,14 +137,20 @@ fn map_share_error(error: &PrivateRecordError) -> Status { #[cfg(test)] mod tests { - use golden_ehtdh1::wire::to_wire_bytes; + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{XChaCha20Poly1305, XNonce}; + use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; + use golden_ehtdh1::{Ciphertext, Combiner, DecryptionShare}; + use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use miden_node_proto::generated::server::validator_api; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::shutdown::CancellationToken; use miden_protocol::Word; + use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - use miden_protocol::transaction::TransactionId; - use miden_protocol::utils::serde::Deserializable; + use miden_protocol::transaction::{TransactionId, TransactionInputs}; + use miden_protocol::utils::serde::{Deserializable, Serializable}; + use miden_testing::{Auth, MockChainBuilder}; use rand_chacha_03::ChaCha20Rng; use rand_chacha_03::rand_core::SeedableRng; use tokio::net::TcpListener; @@ -72,6 +158,7 @@ mod tests { use tonic::Code; use super::*; + use crate::db::insert_validated_private_transaction; use crate::storage_key::tests::operator_keys; use crate::{ PrivateRecordChainId, @@ -89,6 +176,15 @@ mod tests { plaintext: &[u8], ) -> StoredPrivateRecord { let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + target_record_for_transaction(operator_key, transaction_id, seed, plaintext) + } + + fn target_record_for_transaction( + operator_key: &GoldenOperatorKey, + transaction_id: TransactionId, + seed: u8, + plaintext: &[u8], + ) -> StoredPrivateRecord { let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); let record_id = PrivateRecordId::new(transaction_id, &signer.public_key()); let context = PrivateRecordContext::new( @@ -101,6 +197,22 @@ mod tests { .unwrap() } + fn transaction_inputs() -> TransactionInputs { + let mut builder = MockChainBuilder::new(); + let account = builder + .add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }) + .unwrap(); + builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap() + } + + async fn test_database() -> (tempfile::TempDir, Database) { + let directory = tempfile::tempdir().unwrap(); + let database = crate::db::setup(directory.path().join("validator.sqlite3")).await.unwrap(); + (directory, database) + } + fn rpc_request( record: &StoredPrivateRecord, ) -> proto::validator_admin::IssueDecryptionShareRequest { @@ -111,12 +223,226 @@ mod tests { } async fn issue( - operator_key: GoldenOperatorKey, + service: &ValidatorAdminService, request: proto::validator_admin::IssueDecryptionShareRequest, ) -> tonic::Result { - let service = ValidatorAdminService::new(operator_key); - validator_admin_api::IssueDecryptionShare::full(&service, tonic::Request::new(request)) + validator_admin_api::IssueDecryptionShare::full(service, tonic::Request::new(request)).await + } + + async fn list( + service: &ValidatorAdminService, + request: proto::validator_admin::ListValidatedPrivateTransactionsRequest, + ) -> tonic::Result { + validator_admin_api::ListValidatedPrivateTransactions::full( + service, + tonic::Request::new(request), + ) + .await + } + + #[tokio::test] + async fn listed_record_drives_threshold_recovery() { + let mut operator_keys = operator_keys(); + let target = operator_keys.pop().unwrap(); + let second = operator_keys.pop().unwrap(); + let public_key_set = target.public_key_set().clone(); + let setup_context = target.setup_context().clone(); + let (_directory, database) = test_database().await; + let target_service = ValidatorAdminService::new(target, database.clone()); + let second_service = ValidatorAdminService::new(second, database.clone()); + let inputs = transaction_inputs(); + let transaction_id = TransactionId::from_raw(Word::from([8u32, 7, 6, 5])); + let record = target_record_for_transaction( + &operator_keys[0], + transaction_id, + 10, + &inputs.to_bytes(), + ); + let stored_record = record.clone(); + database + .write("store listed private transaction", move |tx| { + insert_validated_private_transaction(tx, &stored_record) + }) .await + .unwrap(); + + let response = list( + &target_service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 1, + page_token: Vec::new(), + }, + ) + .await + .unwrap(); + let [listed] = response.transactions.as_slice() else { + panic!("expected one listed transaction"); + }; + assert_eq!(listed.transaction_id, transaction_id.to_bytes()); + assert_eq!(listed.final_ciphertext, record.encrypted_record()); + assert_eq!(listed.cipher_nonce, record.nonce()); + assert_eq!(listed.encrypted_record_key, record.encrypted_record_key()); + assert_eq!(listed.decryption_context, record.context().to_bytes()); + + let request = proto::validator_admin::IssueDecryptionShareRequest { + ciphertext: listed.encrypted_record_key.clone(), + decryption_context: listed.decryption_context.clone(), + }; + let share_bytes = [ + issue(&target_service, request.clone()).await.unwrap().decryption_share, + issue(&second_service, request).await.unwrap().decryption_share, + ]; + let ciphertext: Ciphertext = + from_wire_bytes(&listed.encrypted_record_key).unwrap(); + let shares = share_bytes + .iter() + .map(|share| from_wire_bytes::>(share).unwrap()) + .collect::>(); + let content_key = Combiner::new(public_key_set, setup_context) + .unwrap() + .combine_exact_with_associated_data( + &ciphertext, + &listed.decryption_context, + &listed.decryption_context, + &shares, + ) + .unwrap(); + let nonce: [u8; 24] = listed.cipher_nonce.as_slice().try_into().unwrap(); + let plaintext = XChaCha20Poly1305::new_from_slice(&content_key) + .unwrap() + .decrypt( + &XNonce::from(nonce), + Payload { + msg: &listed.final_ciphertext, + aad: &listed.decryption_context, + }, + ) + .unwrap(); + + assert_eq!(TransactionInputs::read_from_bytes(&plaintext).unwrap(), inputs); + } + + #[tokio::test] + async fn list_paginates_records_in_insertion_order() { + let mut keys = operator_keys(); + let (_directory, database) = test_database().await; + let transaction_ids = [ + TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), + TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), + TransactionId::from_raw(Word::from([5u32, 0, 0, 0])), + ]; + for (seed, transaction_id) in [11u8, 12, 13].into_iter().zip(transaction_ids) { + let record = target_record_for_transaction(&keys[0], transaction_id, seed, b"record"); + database + .write("store paginated private transaction", move |tx| { + insert_validated_private_transaction(tx, &record) + }) + .await + .unwrap(); + } + let service = ValidatorAdminService::new(keys.remove(0), database); + + let first = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 2, + page_token: Vec::new(), + }, + ) + .await + .unwrap(); + assert_eq!( + first + .transactions + .iter() + .map(|transaction| transaction.transaction_id.as_slice()) + .collect::>(), + transaction_ids[..2].iter().map(TransactionId::as_bytes).collect::>(), + ); + assert!(!first.next_page_token.is_empty()); + + let second = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 2, + page_token: first.next_page_token, + }, + ) + .await + .unwrap(); + assert_eq!(second.transactions.len(), 1); + assert_eq!(second.transactions[0].transaction_id, transaction_ids[2].to_bytes()); + assert!(second.next_page_token.is_empty()); + } + + #[tokio::test] + async fn list_applies_page_limits_and_validates_token() { + let mut keys = operator_keys(); + let (_directory, database) = test_database().await; + let records = (0..=MAX_PAGE_SIZE) + .map(|index| { + let transaction_id = TransactionId::from_raw(Word::from([index + 1, 0, 0, 0])); + target_record_for_transaction(&keys[0], transaction_id, 14, b"record") + }) + .collect::>(); + database + .write("store records for page limits", move |tx| { + for record in records { + insert_validated_private_transaction(tx, &record)?; + } + Ok::<_, miden_node_db::DatabaseError>(()) + }) + .await + .unwrap(); + let service = ValidatorAdminService::new(keys.remove(0), database); + + let default_page = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 0, + page_token: Vec::new(), + }, + ) + .await + .unwrap(); + assert_eq!(default_page.transactions.len(), DEFAULT_PAGE_SIZE as usize); + assert!(!default_page.next_page_token.is_empty()); + + let maximum_page = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: MAX_PAGE_SIZE, + page_token: Vec::new(), + }, + ) + .await + .unwrap(); + assert_eq!(maximum_page.transactions.len(), MAX_PAGE_SIZE as usize); + assert!(!maximum_page.next_page_token.is_empty()); + + let oversized = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: MAX_PAGE_SIZE + 1, + page_token: Vec::new(), + }, + ) + .await + .unwrap_err(); + assert_eq!(oversized.code(), Code::InvalidArgument); + + for page_token in [vec![0], 0i64.to_be_bytes().to_vec()] { + let invalid = list( + &service, + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 1, + page_token, + }, + ) + .await + .unwrap_err(); + assert_eq!(invalid.code(), Code::InvalidArgument); + } } #[tokio::test] @@ -125,14 +451,17 @@ mod tests { let third = operator_keys.pop().unwrap(); let second = operator_keys.pop().unwrap(); let first = operator_keys.pop().unwrap(); + let (_directory, database) = test_database().await; + let first_service = ValidatorAdminService::new(first, database.clone()); + let second_service = ValidatorAdminService::new(second, database); let plaintext = b"private transaction inputs"; let record = target_record(&third, 1, plaintext); let request = PrivateRecordShareRequest::for_record(&record); let rpc_request = rpc_request(&record); let shares = [ - issue(first, rpc_request.clone()).await.unwrap().decryption_share, - issue(second, rpc_request).await.unwrap().decryption_share, + issue(&first_service, rpc_request.clone()).await.unwrap().decryption_share, + issue(&second_service, rpc_request).await.unwrap().decryption_share, ]; let opened = PrivateRecordCombiner::from_operator_key(&third) @@ -148,14 +477,23 @@ mod tests { let third = operator_keys.pop().unwrap(); let second = operator_keys.pop().unwrap(); let first = operator_keys.pop().unwrap(); + let (_directory, database) = test_database().await; + let first_service = ValidatorAdminService::new(first, database.clone()); + let second_service = ValidatorAdminService::new(second, database); let first_record = target_record(&third, 2, b"same plaintext"); let second_record = target_record(&third, 3, b"same plaintext"); assert_eq!(first_record.context(), second_record.context()); assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key(),); let shares = [ - issue(first, rpc_request(&first_record)).await.unwrap().decryption_share, - issue(second, rpc_request(&second_record)).await.unwrap().decryption_share, + issue(&first_service, rpc_request(&first_record)) + .await + .unwrap() + .decryption_share, + issue(&second_service, rpc_request(&second_record)) + .await + .unwrap() + .decryption_share, ]; let request = PrivateRecordShareRequest::for_record(&first_record); let result = PrivateRecordCombiner::from_operator_key(&third).unwrap().open( @@ -170,6 +508,7 @@ mod tests { #[tokio::test] async fn malformed_ciphertext_wrong_payload_size_and_context_mismatch_are_rejected() { let mut keys = operator_keys(); + let (_directory, database) = test_database().await; let record = target_record(&keys[0], 4, b"record"); let context = record.context().to_bytes(); @@ -178,7 +517,10 @@ mod tests { decryption_context: context.clone(), }; assert_eq!( - issue(keys.remove(0), malformed).await.unwrap_err().code(), + issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), malformed,) + .await + .unwrap_err() + .code(), Code::InvalidArgument, ); @@ -189,7 +531,10 @@ mod tests { decryption_context: context.clone(), }; assert_eq!( - issue(keys.remove(0), non_canonical).await.unwrap_err().code(), + issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), non_canonical,) + .await + .unwrap_err() + .code(), Code::InvalidArgument, ); @@ -203,7 +548,10 @@ mod tests { decryption_context: context.clone(), }; assert_eq!( - issue(keys.remove(0), wrong_size).await.unwrap_err().code(), + issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), wrong_size,) + .await + .unwrap_err() + .code(), Code::InvalidArgument, ); @@ -212,7 +560,10 @@ mod tests { decryption_context: b"wrong context".to_vec(), }; assert_eq!( - issue(operator_keys().remove(0), wrong_context).await.unwrap_err().code(), + issue(&ValidatorAdminService::new(operator_keys().remove(0), database), wrong_context,) + .await + .unwrap_err() + .code(), Code::InvalidArgument, ); } @@ -341,6 +692,7 @@ mod tests { #[tokio::test] async fn admin_method_is_registered_only_on_admin_listener() { let mut operator_keys = operator_keys(); + let (_directory, database) = test_database().await; let record = target_record(&operator_keys[0], 6, b"record"); let request = rpc_request(&record); let admin_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -353,6 +705,7 @@ mod tests { address: admin_address, grpc_options: GrpcOptionsInternal::test(), operator_key: operator_keys.remove(0), + database, }; let admin_shutdown = shutdown.clone(); let admin_task = tokio::spawn(async move { @@ -384,6 +737,20 @@ mod tests { .decryption_share .is_empty() ); + assert!( + admin_client + .list_validated_private_transactions( + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 1, + page_token: Vec::new(), + }, + ) + .await + .unwrap() + .into_inner() + .transactions + .is_empty(), + ); let mut admin_on_public = proto::validator_admin::api_client::ApiClient::connect(format!( "http://{public_address}", @@ -394,6 +761,19 @@ mod tests { admin_on_public.issue_decryption_share(request).await.unwrap_err().code(), Code::Unimplemented, ); + assert_eq!( + admin_on_public + .list_validated_private_transactions( + proto::validator_admin::ListValidatedPrivateTransactionsRequest { + page_size: 1, + page_token: Vec::new(), + }, + ) + .await + .unwrap_err() + .code(), + Code::Unimplemented, + ); let mut public_on_admin = proto::validator::api_client::ApiClient::connect(format!("http://{admin_address}")) diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index 1ce9e70138..cdf00280eb 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; -use std::num::NonZeroUsize; use anyhow::Context; +use miden_node_db::sqlite::Database; use miden_node_proto::server::{validator_admin_api, validator_api}; use miden_node_proto_build::validator_api_descriptor; use miden_node_store::BlockStore; @@ -14,12 +14,7 @@ use tokio_stream::wrappers::TcpListenerStream; use tower_http::catch_panic::CatchPanicLayer; use tower_http::trace::TraceLayer; -use crate::db::{ - count_signed_blocks, - count_validated_transactions, - load_chain_tip, - load_with_pool_size, -}; +use crate::db::{count_signed_blocks, count_validated_transactions, load_chain_tip}; use crate::{ DataDirectory, GoldenOperatorKey, @@ -62,8 +57,8 @@ pub struct ValidatorServer { /// The data directory for the validator component's database files. pub data_directory: DataDirectory, - /// Maximum number of SQLite connections in the validator database connection pool. - pub sqlite_connection_pool_size: NonZeroUsize, + /// Shared validator database. + pub database: Database, } /// Serves the private validator administration API on a network-isolated listener. @@ -74,6 +69,8 @@ pub struct ValidatorAdminServer { pub grpc_options: GrpcOptionsInternal, /// Golden key material used to issue this validator's decryption shares. pub operator_key: GoldenOperatorKey, + /// Shared validator database. + pub database: Database, } impl ValidatorAdminServer { @@ -107,6 +104,7 @@ impl ValidatorAdminServer { .timeout(self.grpc_options.request_timeout) .add_service(validator_admin_api::service(ValidatorAdminService::new( self.operator_key, + self.database, ))) .serve_with_incoming_shutdown( TcpListenerStream::new(listener), @@ -123,13 +121,7 @@ impl ValidatorServer { /// Executes in place (i.e. not spawned) and will run indefinitely until a fatal error is /// encountered. pub async fn serve(self, shutdown: CancellationToken) -> anyhow::Result<()> { - // Initialize database connection. - let db = load_with_pool_size( - self.data_directory.database_path(), - self.sqlite_connection_pool_size, - ) - .await - .context("failed to initialize validator database")?; + let db = self.database; // Initialize block store. let block_store = BlockStore::load(self.data_directory.block_store_dir()) diff --git a/proto/proto/internal/validator_admin.proto b/proto/proto/internal/validator_admin.proto index 2f50f8e132..ce68c3df3d 100644 --- a/proto/proto/internal/validator_admin.proto +++ b/proto/proto/internal/validator_admin.proto @@ -8,11 +8,47 @@ package validator_admin; // ================================================================================================ service Api { + // Lists this validator's stored private transactions in insertion order. + rpc ListValidatedPrivateTransactions(ListValidatedPrivateTransactionsRequest) + returns (ListValidatedPrivateTransactionsResponse) {} + // Issues this validator's Golden decryption share for one encrypted content key and context. rpc IssueDecryptionShare(IssueDecryptionShareRequest) returns (IssueDecryptionShareResponse) {} } +message ListValidatedPrivateTransactionsRequest { + // Number of transactions to return. Zero uses the server default. + uint32 page_size = 1; + + // Opaque cursor returned by the prior page. Empty starts at the first transaction. + bytes page_token = 2; +} + +message ValidatedPrivateTransaction { + // Canonical Miden transaction ID. + bytes transaction_id = 1; + + // XChaCha20-Poly1305 ciphertext of the validated TransactionInputs. + bytes final_ciphertext = 2; + + // XChaCha20-Poly1305 nonce for final_ciphertext. + bytes cipher_nonce = 3; + + // Canonical Golden EHTDH1 ciphertext for the content key. + bytes encrypted_record_key = 4; + + // Exact associated data used by both encryption layers. + bytes decryption_context = 5; +} + +message ListValidatedPrivateTransactionsResponse { + repeated ValidatedPrivateTransaction transactions = 1; + + // Opaque cursor for the next page. Empty means this is the final page. + bytes next_page_token = 2; +} + message IssueDecryptionShareRequest { // Canonical Golden EHTDH1 ciphertext for a private record content key. bytes ciphertext = 1; From e6762375bf3a694fccba2e884f04dc25b542d25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 29 Jul 2026 16:10:53 -0400 Subject: [PATCH 3/6] fix(validator): rank legacy private records in one pass --- ..._validated_transaction_insertion_order.sql | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql b/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql index e8b62af66b..e47d332019 100644 --- a/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql +++ b/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql @@ -3,12 +3,23 @@ ADD COLUMN insertion_sequence INTEGER NOT NULL DEFAULT 0; -- The original insertion order is unavailable for existing rows. Assign a stable order by -- transaction ID so every migrated database has the same result. -UPDATE validated_transactions AS current +CREATE TEMP TABLE validated_transaction_sequences ( + id BLOB PRIMARY KEY, + insertion_sequence INTEGER NOT NULL +) WITHOUT ROWID; + +INSERT INTO validated_transaction_sequences (id, insertion_sequence) +SELECT id, ROW_NUMBER() OVER (ORDER BY id) +FROM validated_transactions; + +UPDATE validated_transactions SET insertion_sequence = ( - SELECT COUNT(*) - FROM validated_transactions AS preceding - WHERE preceding.id <= current.id + SELECT insertion_sequence + FROM validated_transaction_sequences + WHERE validated_transaction_sequences.id = validated_transactions.id ); +DROP TABLE validated_transaction_sequences; + CREATE UNIQUE INDEX idx_validated_transactions_insertion_sequence ON validated_transactions(insertion_sequence); From 9df8ae982755ac4a6b1260269f3ffbeb17f362a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 29 Jul 2026 20:24:00 -0400 Subject: [PATCH 4/6] refactor(validator): serve recovery API as JSON --- Cargo.lock | 4 + Cargo.toml | 2 + bin/validator/Cargo.toml | 4 + bin/validator/src/commands/start.rs | 1 - bin/validator/src/db/migrations.rs | 7 +- .../src/db/migrations/001_initial.sql | 7 +- ..._validated_transaction_insertion_order.sql | 25 - bin/validator/src/db/mod.rs | 128 +-- .../src/db/sql/insert_transaction.sql | 16 +- ...> load_validated_private_transactions.sql} | 7 +- bin/validator/src/server/admin_service.rs | 821 +++++------------- bin/validator/src/server/mod.rs | 19 +- crates/proto/build.rs | 2 - proto/proto/README.md | 3 +- proto/proto/internal/validator_admin.proto | 63 -- 15 files changed, 276 insertions(+), 833 deletions(-) delete mode 100644 bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql rename bin/validator/src/db/sql/{load_validated_private_transactions_page.sql => load_validated_private_transactions.sql} (59%) delete mode 100644 proto/proto/internal/validator_admin.proto diff --git a/Cargo.lock b/Cargo.lock index f835b0c7e8..1d0f2e9018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4051,6 +4051,7 @@ dependencies = [ "anyhow", "aws-config", "aws-sdk-kms", + "axum", "base64", "build-rs", "chacha20poly1305", @@ -4071,12 +4072,15 @@ dependencies = [ "rand 0.10.2", "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", + "serde_json", "tempfile", "thiserror 2.0.19", "tokio", "tokio-stream", "tonic", "tonic-reflection", + "tower", "tower-http", "tracing", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index dfb5d2e57d..26d14dedf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ miden-crypto = { version = "0.28" } # External dependencies anyhow = { version = "1.0" } assert_matches = { version = "1.5" } +axum = { version = "0.8" } backon = { version = "1.6" } build-rs = { version = "0.3" } chacha20poly1305 = { version = "0.11" } @@ -109,6 +110,7 @@ rstest = { version = "0.26" } rusqlite = { features = ["array", "bundled"], version = "0.37" } semver = { version = "1.0" } serde = { features = ["derive"], version = "1" } +serde_json = { version = "1" } serial_test = { version = "3.2" } sha2 = { version = "0.10" } syn = { version = "2.0" } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index 07121849f7..10fb8a488d 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -21,6 +21,7 @@ doctest = false anyhow = { workspace = true } aws-config = { version = "1.8.14" } aws-sdk-kms = { version = "1.100" } +axum = { workspace = true } base64 = { version = "0.22" } chacha20poly1305 = { workspace = true } clap = { features = ["env", "string"], workspace = true } @@ -37,6 +38,7 @@ miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { workspace = true } miden-tx = { features = ["concurrent"], workspace = true } rand_core_06 = { workspace = true } +serde = { workspace = true } thiserror = { workspace = true } tokio = { features = ["macros", "net", "rt-multi-thread"], workspace = true } tokio-stream = { features = ["net"], workspace = true } @@ -58,5 +60,7 @@ miden-testing = { workspace = true } miden-tx = { features = ["concurrent", "testing"], workspace = true } rand = { workspace = true } rand_chacha_03 = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "sync"], workspace = true } +tower = { features = ["util"], workspace = true } diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index 2bfe47479e..c135b7e70b 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -57,7 +57,6 @@ pub async fn start( if let Some(address) = admin_address { let admin_server = ValidatorAdminServer { address, - grpc_options, operator_key: keys.operator_key, database, }; diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 7434f92723..49557c13c4 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -70,10 +70,9 @@ mod tests { use super::*; - const EXPECTED_SCHEMA_HASHES: [SchemaHash; 2] = [ - SchemaHash::from_hex("384a849131983267a3a8b61d170dae7bcbec535eb4716fea7812024073f569cf"), - SchemaHash::from_hex("a97252fab76d168d0999e59d4ed17fcb6f33d9676c8612fd4eee65ed97a2043e"), - ]; + const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( + "5ae520fbd4e0ec05c983b77d9eabdc685e6820f97b41d1aabbf85c357bebc961", + )]; #[test] fn migration_schema_hashes_are_stable() -> Result<()> { diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index 2ef033c174..d5e1f92a5a 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -1,6 +1,8 @@ CREATE TABLE validated_transactions ( + -- Local insertion order used by the private administration API. + insertion_sequence INTEGER PRIMARY KEY AUTOINCREMENT, -- Transaction ID, unique within this validator's database. - id BLOB NOT NULL, + id BLOB NOT NULL UNIQUE, -- Signing public key of the validator that produced this record. validator_id BLOB NOT NULL, -- Genesis commitment of the network that produced the transaction. @@ -17,7 +19,6 @@ CREATE TABLE validated_transactions ( encrypted_record BLOB NOT NULL, -- Golden EHTDH1 encryption of the key for encrypted_record. encrypted_record_key BLOB NOT NULL, - PRIMARY KEY (id), CHECK (length(id) = 32), CHECK (length(validator_id) = 33), CHECK (length(chain_id) = 32), @@ -27,7 +28,7 @@ CREATE TABLE validated_transactions ( CHECK (length(cipher_nonce) = 24), CHECK (length(encrypted_record) >= 16), CHECK (length(encrypted_record_key) > 0) -) WITHOUT ROWID; +); CREATE INDEX idx_validated_transactions_key_epoch ON validated_transactions(key_epoch); diff --git a/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql b/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql deleted file mode 100644 index e47d332019..0000000000 --- a/bin/validator/src/db/migrations/002_validated_transaction_insertion_order.sql +++ /dev/null @@ -1,25 +0,0 @@ -ALTER TABLE validated_transactions -ADD COLUMN insertion_sequence INTEGER NOT NULL DEFAULT 0; - --- The original insertion order is unavailable for existing rows. Assign a stable order by --- transaction ID so every migrated database has the same result. -CREATE TEMP TABLE validated_transaction_sequences ( - id BLOB PRIMARY KEY, - insertion_sequence INTEGER NOT NULL -) WITHOUT ROWID; - -INSERT INTO validated_transaction_sequences (id, insertion_sequence) -SELECT id, ROW_NUMBER() OVER (ORDER BY id) -FROM validated_transactions; - -UPDATE validated_transactions -SET insertion_sequence = ( - SELECT insertion_sequence - FROM validated_transaction_sequences - WHERE validated_transaction_sequences.id = validated_transactions.id -); - -DROP TABLE validated_transaction_sequences; - -CREATE UNIQUE INDEX idx_validated_transactions_insertion_sequence -ON validated_transactions(insertion_sequence); diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 0635646763..052b97d890 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -30,8 +30,8 @@ mod sql { include_str!("sql/load_private_records_by_key_epoch.sql"); pub(super) const LOAD_PRIVATE_RECORDS_BY_SETUP_CONTEXT: &str = include_str!("sql/load_private_records_by_setup_context.sql"); - pub(super) const LOAD_VALIDATED_PRIVATE_TRANSACTIONS_PAGE: &str = - include_str!("sql/load_validated_private_transactions_page.sql"); + pub(super) const LOAD_VALIDATED_PRIVATE_TRANSACTIONS: &str = + include_str!("sql/load_validated_private_transactions.sql"); pub(super) const TRANSACTION_EXISTS: &str = include_str!("sql/transaction_exists.sql"); pub(super) const UPSERT_BLOCK_HEADER: &str = include_str!("sql/upsert_block_header.sql"); pub(super) const LOAD_CHAIN_TIP: &str = include_str!("sql/load_chain_tip.sql"); @@ -41,15 +41,6 @@ mod sql { pub(super) const COUNT_SIGNED_BLOCKS: &str = include_str!("sql/count_signed_blocks.sql"); } -/// One insertion-ordered page of validated private transactions. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ValidatedPrivateTransactionsPage { - /// Stored private records in insertion order. - pub(crate) records: Vec, - /// Last insertion sequence in this page when another page exists. - pub(crate) next_cursor: Option, -} - /// Open a connection to the DB after verifying that it is at the latest schema version. #[miden_instrument( target = COMPONENT, @@ -199,32 +190,11 @@ pub fn load_private_records_by_setup_context( ) } -/// Loads validated private transactions after an insertion-sequence cursor. -pub(crate) fn load_validated_private_transactions_page( +/// Loads all validated private transactions in insertion order. +pub(crate) fn load_validated_private_transactions( tx: &ReadTx<'_>, - page_size: u32, - after_sequence: i64, -) -> Result { - let limit = i64::from(page_size) + 1; - let mut rows = tx.query( - sql::LOAD_VALIDATED_PRIVATE_TRANSACTIONS_PAGE, - &[&after_sequence, &limit], - |row| { - let record = private_record_from_row(row)?; - let insertion_sequence = row.get::(9)?; - Ok((insertion_sequence, record)) - }, - )?; - - let next_cursor = if rows.len() > page_size as usize { - rows.pop(); - rows.last().map(|(sequence, _record)| *sequence) - } else { - None - }; - let records = rows.into_iter().map(|(_sequence, record)| record).collect(); - - Ok(ValidatedPrivateTransactionsPage { records, next_cursor }) +) -> Result, DatabaseError> { + tx.query(sql::LOAD_VALIDATED_PRIVATE_TRANSACTIONS, &[], private_record_from_row) } fn private_record_from_row(row: &Row<'_>) -> Result { @@ -523,7 +493,7 @@ mod tests { } #[tokio::test] - async fn validated_private_transactions_are_paginated_in_insertion_order() { + async fn validated_private_transactions_are_loaded_in_insertion_order() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); let transaction_ids = [ @@ -538,91 +508,19 @@ mod tests { .collect::>(); for record in records.clone() { - db.write("insert paginated private record", move |tx| { + db.write("insert private record", move |tx| { insert_validated_private_transaction(tx, &record) }) .await .unwrap(); } - let first_page = db - .read("load first private record page", |tx| { - load_validated_private_transactions_page(tx, 2, 0) - }) - .await - .unwrap(); - assert_eq!(first_page.records, records[..2]); - let cursor = first_page.next_cursor.expect("another page must exist"); - - let second_page = db - .read("load second private record page", move |tx| { - load_validated_private_transactions_page(tx, 2, cursor) - }) - .await - .unwrap(); - assert_eq!(second_page.records, records[2..]); - assert_eq!(second_page.next_cursor, None); - } - - #[tokio::test] - async fn migration_assigns_existing_records_a_stable_order() { - const LEGACY_INSERT: &str = "\ - INSERT INTO validated_transactions (\ - id, validator_id, chain_id, key_epoch, setup_context_id, format_version,\ - cipher_nonce, encrypted_record, encrypted_record_key\ - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"; - - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let database_path = temp_dir.path().join("validator.sqlite3"); - miden_node_db::migration::Migrator::builder() - .unwrap() - .push_sql("001_initial", include_str!("migrations/001_initial.sql")) - .unwrap() - .build() - .unwrap() - .bootstrap(&database_path) - .unwrap(); - let legacy_database = Database::new(&database_path).unwrap(); - let mut records = [ - private_record(TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), 1), - private_record(TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), 2), - ]; - for record in records.clone() { - legacy_database - .write("insert legacy private record", move |tx| { - let context = record.context(); - tx.execute( - LEGACY_INSERT, - &[ - &context.transaction_id().to_bytes(), - &record.record_id().validator_id().to_vec(), - &context.chain_id().as_bytes().to_vec(), - &context.key_epoch().as_bytes().to_vec(), - &record.setup_context_id().to_vec(), - &i64::from(context.format_version()), - &record.nonce().to_vec(), - &record.encrypted_record().to_vec(), - &record.encrypted_record_key().to_vec(), - ], - ) - }) - .await - .unwrap(); - } - drop(legacy_database); - - migrate(&database_path).unwrap(); - let database = load(database_path).await.unwrap(); - records.sort_by_key(|record| record.context().transaction_id().to_bytes()); - let page = database - .read("load migrated private records", |tx| { - load_validated_private_transactions_page(tx, 10, 0) - }) + let loaded = db + .read("load validated private transactions", load_validated_private_transactions) .await .unwrap(); - assert_eq!(page.records, records); - assert_eq!(page.next_cursor, None); + assert_eq!(loaded, records); } #[tokio::test] @@ -687,9 +585,9 @@ mod tests { .unwrap() .join("\n"); - assert!(schema.contains("PRIMARY KEY (id)")); + assert!(schema.contains("insertion_sequence INTEGER PRIMARY KEY AUTOINCREMENT")); + assert!(schema.contains("id BLOB NOT NULL UNIQUE")); assert!(schema.contains("idx_validated_transactions_key_epoch")); assert!(schema.contains("idx_validated_transactions_setup_context_id")); - assert!(schema.contains("idx_validated_transactions_insertion_sequence")); } } diff --git a/bin/validator/src/db/sql/insert_transaction.sql b/bin/validator/src/db/sql/insert_transaction.sql index c0a1e41d61..278160acdf 100644 --- a/bin/validator/src/db/sql/insert_transaction.sql +++ b/bin/validator/src/db/sql/insert_transaction.sql @@ -7,19 +7,7 @@ INSERT INTO validated_transactions ( format_version, cipher_nonce, encrypted_record, - encrypted_record_key, - insertion_sequence -) -VALUES ( - ?1, - ?2, - ?3, - ?4, - ?5, - ?6, - ?7, - ?8, - ?9, - (SELECT COALESCE(MAX(insertion_sequence), 0) + 1 FROM validated_transactions) + encrypted_record_key ) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT DO NOTHING; diff --git a/bin/validator/src/db/sql/load_validated_private_transactions_page.sql b/bin/validator/src/db/sql/load_validated_private_transactions.sql similarity index 59% rename from bin/validator/src/db/sql/load_validated_private_transactions_page.sql rename to bin/validator/src/db/sql/load_validated_private_transactions.sql index 9d7abb7b35..b6dc8ff24b 100644 --- a/bin/validator/src/db/sql/load_validated_private_transactions_page.sql +++ b/bin/validator/src/db/sql/load_validated_private_transactions.sql @@ -7,9 +7,6 @@ SELECT format_version, cipher_nonce, encrypted_record, - encrypted_record_key, - insertion_sequence + encrypted_record_key FROM validated_transactions -WHERE insertion_sequence > ?1 -ORDER BY insertion_sequence -LIMIT ?2; +ORDER BY insertion_sequence; diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs index fd00e60418..67ac39e9a6 100644 --- a/bin/validator/src/server/admin_service.rs +++ b/bin/validator/src/server/admin_service.rs @@ -1,150 +1,165 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; use miden_node_db::sqlite::Database; -use miden_node_proto::generated as proto; -use miden_node_proto::generated::server::validator_admin_api; use miden_protocol::utils::serde::Serializable; use rand_core_06::OsRng; -use tonic::Status; +use serde::{Deserialize, Serialize}; -use crate::db::load_validated_private_transactions_page; -use crate::{GoldenOperatorKey, PrivateRecordError}; +use crate::db::load_validated_private_transactions; +use crate::{GoldenOperatorKey, PrivateRecordError, StoredPrivateRecord}; -const DEFAULT_PAGE_SIZE: u32 = 50; -const MAX_PAGE_SIZE: u32 = 200; -const PAGE_TOKEN_BYTES: usize = size_of::(); +const LIST_TRANSACTIONS_PATH: &str = "/admin/transactions"; +const ISSUE_SHARE_PATH: &str = "/admin/decryption-share"; -/// Implements the private validator administration API. -pub(crate) struct ValidatorAdminService { - operator_key: GoldenOperatorKey, +#[derive(Clone)] +struct ValidatorAdminService { + operator_key: Arc, database: Database, } impl ValidatorAdminService { - /// Creates an admin service that owns this validator's Golden secret share. - pub(crate) const fn new(operator_key: GoldenOperatorKey, database: Database) -> Self { - Self { operator_key, database } + fn new(operator_key: GoldenOperatorKey, database: Database) -> Self { + Self { + operator_key: Arc::new(operator_key), + database, + } } } -#[tonic::async_trait] -impl validator_admin_api::ListValidatedPrivateTransactions for ValidatorAdminService { - type Input = proto::validator_admin::ListValidatedPrivateTransactionsRequest; - type Output = proto::validator_admin::ListValidatedPrivateTransactionsResponse; +pub(super) fn router(operator_key: GoldenOperatorKey, database: Database) -> Router { + Router::new() + .route(LIST_TRANSACTIONS_PATH, get(list_validated_private_transactions)) + .route(ISSUE_SHARE_PATH, post(issue_decryption_share)) + .with_state(ValidatorAdminService::new(operator_key, database)) +} - fn decode(request: Self::Input) -> tonic::Result { - Ok(request) - } +#[derive(Debug, Deserialize, Serialize)] +struct ValidatedPrivateTransaction { + transaction_id: String, + final_ciphertext: String, + cipher_nonce: String, + encrypted_record_key: String, + decryption_context: String, +} - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) +impl From for ValidatedPrivateTransaction { + fn from(record: StoredPrivateRecord) -> Self { + Self { + transaction_id: hex::encode(record.context().transaction_id().to_bytes()), + final_ciphertext: hex::encode(record.encrypted_record()), + cipher_nonce: hex::encode(record.nonce()), + encrypted_record_key: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(record.context().to_bytes()), + } } +} - async fn handle( - &self, - request: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - let page_size = match request.page_size { - 0 => DEFAULT_PAGE_SIZE, - page_size if page_size <= MAX_PAGE_SIZE => page_size, - page_size => { - return Err(Status::invalid_argument(format!( - "page size {page_size} exceeds maximum {MAX_PAGE_SIZE}", - ))); - }, - }; - let after_sequence = decode_page_token(&request.page_token)?; - let page = self - .database - .read("list validated private transactions", move |tx| { - load_validated_private_transactions_page(tx, page_size, after_sequence) - }) - .await - .map_err(|_error| Status::internal("failed to list validated private transactions"))?; - - let transactions = page - .records - .into_iter() - .map(|record| proto::validator_admin::ValidatedPrivateTransaction { - transaction_id: record.context().transaction_id().to_bytes(), - final_ciphertext: record.encrypted_record().to_vec(), - cipher_nonce: record.nonce().to_vec(), - encrypted_record_key: record.encrypted_record_key().to_vec(), - decryption_context: record.context().to_bytes(), - }) - .collect(); - let next_page_token = page.next_cursor.map_or_else(Vec::new, encode_page_token); +#[derive(Debug, Deserialize, Serialize)] +struct ListValidatedPrivateTransactionsResponse { + transactions: Vec, +} - Ok(Self::Output { transactions, next_page_token }) - } +async fn list_validated_private_transactions( + State(service): State, +) -> Result, ApiError> { + let records = service + .database + .read("list validated private transactions", load_validated_private_transactions) + .await + .map_err(|_error| ApiError::internal("failed to list validated private transactions"))?; + + Ok(Json(ListValidatedPrivateTransactionsResponse { + transactions: records.into_iter().map(Into::into).collect(), + })) } -#[tonic::async_trait] -impl validator_admin_api::IssueDecryptionShare for ValidatorAdminService { - type Input = proto::validator_admin::IssueDecryptionShareRequest; - type Output = proto::validator_admin::IssueDecryptionShareResponse; +#[derive(Clone, Debug, Deserialize, Serialize)] +struct IssueDecryptionShareRequest { + ciphertext: String, + decryption_context: String, +} - fn decode(request: Self::Input) -> tonic::Result { - Ok(request) - } +#[derive(Debug, Deserialize, Serialize)] +struct IssueDecryptionShareResponse { + decryption_share: String, +} - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } +async fn issue_decryption_share( + State(service): State, + Json(request): Json, +) -> Result, ApiError> { + let ciphertext = decode_hex("ciphertext", &request.ciphertext)?; + let decryption_context = decode_hex("decryption_context", &request.decryption_context)?; + let decryption_share = service + .operator_key + .issue_decryption_share(&mut OsRng, &ciphertext, &decryption_context) + .map_err(|error| map_share_error(&error))?; + + Ok(Json(IssueDecryptionShareResponse { + decryption_share: hex::encode(decryption_share), + })) +} + +fn decode_hex(field: &str, value: &str) -> Result, ApiError> { + hex::decode(value).map_err(|_error| ApiError::bad_request(format!("{field} must be valid hex"))) +} - async fn handle( - &self, - request: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - let decryption_share = self - .operator_key - .issue_decryption_share(&mut OsRng, &request.ciphertext, &request.decryption_context) - .map_err(|error| map_share_error(&error))?; - Ok(Self::Output { decryption_share }) +fn map_share_error(error: &PrivateRecordError) -> ApiError { + match error { + PrivateRecordError::InvalidGoldenEncoding(_) + | PrivateRecordError::InvalidEncryptedRecordKey + | PrivateRecordError::DecryptionContextMismatch => ApiError::bad_request(error.to_string()), + _ => ApiError::internal("failed to issue Golden decryption share"), } } -fn decode_page_token(page_token: &[u8]) -> tonic::Result { - if page_token.is_empty() { - return Ok(0); +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } } - let token: [u8; PAGE_TOKEN_BYTES] = page_token - .try_into() - .map_err(|_| Status::invalid_argument("invalid page token"))?; - let sequence = i64::from_be_bytes(token); - if sequence <= 0 { - return Err(Status::invalid_argument("invalid page token")); + + fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } } - Ok(sequence) } -fn encode_page_token(sequence: i64) -> Vec { - sequence.to_be_bytes().to_vec() +#[derive(Serialize)] +struct ErrorResponse { + error: String, } -fn map_share_error(error: &PrivateRecordError) -> Status { - match error { - PrivateRecordError::InvalidGoldenEncoding(_) - | PrivateRecordError::InvalidEncryptedRecordKey - | PrivateRecordError::DecryptionContextMismatch => { - Status::invalid_argument(error.to_string()) - }, - _ => Status::internal("failed to issue Golden decryption share"), +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(ErrorResponse { error: self.message })).into_response() } } #[cfg(test)] mod tests { + use axum::body::{Body, to_bytes}; + use axum::http::Request; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{XChaCha20Poly1305, XNonce}; use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; use golden_ehtdh1::{Ciphertext, Combiner, DecryptionShare}; use golden_halo2curves::golden_group::Secp256k1GoldenGroup; - use miden_node_proto::generated::server::validator_api; - use miden_node_utils::clap::GrpcOptionsInternal; - use miden_node_utils::shutdown::CancellationToken; use miden_protocol::Word; use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; @@ -153,9 +168,7 @@ mod tests { use miden_testing::{Auth, MockChainBuilder}; use rand_chacha_03::ChaCha20Rng; use rand_chacha_03::rand_core::SeedableRng; - use tokio::net::TcpListener; - use tokio_stream::wrappers::TcpListenerStream; - use tonic::Code; + use tower::ServiceExt; use super::*; use crate::db::insert_validated_private_transaction; @@ -171,15 +184,6 @@ mod tests { }; fn target_record( - operator_key: &GoldenOperatorKey, - seed: u8, - plaintext: &[u8], - ) -> StoredPrivateRecord { - let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); - target_record_for_transaction(operator_key, transaction_id, seed, plaintext) - } - - fn target_record_for_transaction( operator_key: &GoldenOperatorKey, transaction_id: TransactionId, seed: u8, @@ -213,51 +217,36 @@ mod tests { (directory, database) } - fn rpc_request( - record: &StoredPrivateRecord, - ) -> proto::validator_admin::IssueDecryptionShareRequest { - proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: record.encrypted_record_key().to_vec(), - decryption_context: record.context().to_bytes(), + fn share_request(record: &StoredPrivateRecord) -> IssueDecryptionShareRequest { + IssueDecryptionShareRequest { + ciphertext: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(record.context().to_bytes()), } } async fn issue( service: &ValidatorAdminService, - request: proto::validator_admin::IssueDecryptionShareRequest, - ) -> tonic::Result { - validator_admin_api::IssueDecryptionShare::full(service, tonic::Request::new(request)).await - } - - async fn list( - service: &ValidatorAdminService, - request: proto::validator_admin::ListValidatedPrivateTransactionsRequest, - ) -> tonic::Result { - validator_admin_api::ListValidatedPrivateTransactions::full( - service, - tonic::Request::new(request), - ) - .await + request: IssueDecryptionShareRequest, + ) -> Result { + issue_decryption_share(State(service.clone()), Json(request)) + .await + .map(|Json(response)| response) } #[tokio::test] async fn listed_record_drives_threshold_recovery() { - let mut operator_keys = operator_keys(); - let target = operator_keys.pop().unwrap(); - let second = operator_keys.pop().unwrap(); - let public_key_set = target.public_key_set().clone(); - let setup_context = target.setup_context().clone(); + let mut keys = operator_keys(); + let record_owner = keys.pop().unwrap(); + let second = keys.pop().unwrap(); + let first = keys.pop().unwrap(); + let public_key_set = record_owner.public_key_set().clone(); + let setup_context = record_owner.setup_context().clone(); let (_directory, database) = test_database().await; - let target_service = ValidatorAdminService::new(target, database.clone()); + let first_service = ValidatorAdminService::new(first, database.clone()); let second_service = ValidatorAdminService::new(second, database.clone()); let inputs = transaction_inputs(); let transaction_id = TransactionId::from_raw(Word::from([8u32, 7, 6, 5])); - let record = target_record_for_transaction( - &operator_keys[0], - transaction_id, - 10, - &inputs.to_bytes(), - ); + let record = target_record(&record_owner, transaction_id, 10, &inputs.to_bytes()); let stored_record = record.clone(); database .write("store listed private transaction", move |tx| { @@ -266,55 +255,43 @@ mod tests { .await .unwrap(); - let response = list( - &target_service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 1, - page_token: Vec::new(), - }, - ) - .await - .unwrap(); + let Json(response) = + list_validated_private_transactions(State(first_service.clone())).await.unwrap(); let [listed] = response.transactions.as_slice() else { panic!("expected one listed transaction"); }; - assert_eq!(listed.transaction_id, transaction_id.to_bytes()); - assert_eq!(listed.final_ciphertext, record.encrypted_record()); - assert_eq!(listed.cipher_nonce, record.nonce()); - assert_eq!(listed.encrypted_record_key, record.encrypted_record_key()); - assert_eq!(listed.decryption_context, record.context().to_bytes()); - - let request = proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: listed.encrypted_record_key.clone(), - decryption_context: listed.decryption_context.clone(), - }; + assert_eq!(listed.transaction_id, hex::encode(transaction_id.to_bytes())); + assert_eq!(listed.final_ciphertext, hex::encode(record.encrypted_record())); + assert_eq!(listed.cipher_nonce, hex::encode(record.nonce())); + assert_eq!(listed.encrypted_record_key, hex::encode(record.encrypted_record_key())); + assert_eq!(listed.decryption_context, hex::encode(record.context().to_bytes())); + + let request = share_request(&record); let share_bytes = [ - issue(&target_service, request.clone()).await.unwrap().decryption_share, + issue(&first_service, request.clone()).await.unwrap().decryption_share, issue(&second_service, request).await.unwrap().decryption_share, ]; let ciphertext: Ciphertext = - from_wire_bytes(&listed.encrypted_record_key).unwrap(); + from_wire_bytes(record.encrypted_record_key()).unwrap(); let shares = share_bytes .iter() - .map(|share| from_wire_bytes::>(share).unwrap()) + .map(|share| { + let bytes = hex::decode(share).unwrap(); + from_wire_bytes::>(&bytes).unwrap() + }) .collect::>(); + let context = record.context().to_bytes(); let content_key = Combiner::new(public_key_set, setup_context) .unwrap() - .combine_exact_with_associated_data( - &ciphertext, - &listed.decryption_context, - &listed.decryption_context, - &shares, - ) + .combine_exact_with_associated_data(&ciphertext, &context, &context, &shares) .unwrap(); - let nonce: [u8; 24] = listed.cipher_nonce.as_slice().try_into().unwrap(); let plaintext = XChaCha20Poly1305::new_from_slice(&content_key) .unwrap() .decrypt( - &XNonce::from(nonce), + &XNonce::from(*record.nonce()), Payload { - msg: &listed.final_ciphertext, - aad: &listed.decryption_context, + msg: record.encrypted_record(), + aad: &context, }, ) .unwrap(); @@ -323,7 +300,7 @@ mod tests { } #[tokio::test] - async fn list_paginates_records_in_insertion_order() { + async fn list_uses_insertion_order() { let mut keys = operator_keys(); let (_directory, database) = test_database().await; let transaction_ids = [ @@ -332,171 +309,49 @@ mod tests { TransactionId::from_raw(Word::from([5u32, 0, 0, 0])), ]; for (seed, transaction_id) in [11u8, 12, 13].into_iter().zip(transaction_ids) { - let record = target_record_for_transaction(&keys[0], transaction_id, seed, b"record"); + let record = target_record(&keys[0], transaction_id, seed, b"record"); database - .write("store paginated private transaction", move |tx| { + .write("store private transaction", move |tx| { insert_validated_private_transaction(tx, &record) }) .await .unwrap(); } - let service = ValidatorAdminService::new(keys.remove(0), database); - let first = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 2, - page_token: Vec::new(), - }, - ) - .await - .unwrap(); + let service = ValidatorAdminService::new(keys.remove(0), database); + let Json(response) = list_validated_private_transactions(State(service)).await.unwrap(); assert_eq!( - first + response .transactions .iter() - .map(|transaction| transaction.transaction_id.as_slice()) + .map(|transaction| transaction.transaction_id.as_str()) + .collect::>(), + transaction_ids + .iter() + .map(|transaction_id| hex::encode(transaction_id.to_bytes())) .collect::>(), - transaction_ids[..2].iter().map(TransactionId::as_bytes).collect::>(), ); - assert!(!first.next_page_token.is_empty()); - - let second = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 2, - page_token: first.next_page_token, - }, - ) - .await - .unwrap(); - assert_eq!(second.transactions.len(), 1); - assert_eq!(second.transactions[0].transaction_id, transaction_ids[2].to_bytes()); - assert!(second.next_page_token.is_empty()); } #[tokio::test] - async fn list_applies_page_limits_and_validates_token() { + async fn shares_for_different_ciphertexts_are_not_reusable() { let mut keys = operator_keys(); - let (_directory, database) = test_database().await; - let records = (0..=MAX_PAGE_SIZE) - .map(|index| { - let transaction_id = TransactionId::from_raw(Word::from([index + 1, 0, 0, 0])); - target_record_for_transaction(&keys[0], transaction_id, 14, b"record") - }) - .collect::>(); - database - .write("store records for page limits", move |tx| { - for record in records { - insert_validated_private_transaction(tx, &record)?; - } - Ok::<_, miden_node_db::DatabaseError>(()) - }) - .await - .unwrap(); - let service = ValidatorAdminService::new(keys.remove(0), database); - - let default_page = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 0, - page_token: Vec::new(), - }, - ) - .await - .unwrap(); - assert_eq!(default_page.transactions.len(), DEFAULT_PAGE_SIZE as usize); - assert!(!default_page.next_page_token.is_empty()); - - let maximum_page = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: MAX_PAGE_SIZE, - page_token: Vec::new(), - }, - ) - .await - .unwrap(); - assert_eq!(maximum_page.transactions.len(), MAX_PAGE_SIZE as usize); - assert!(!maximum_page.next_page_token.is_empty()); - - let oversized = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: MAX_PAGE_SIZE + 1, - page_token: Vec::new(), - }, - ) - .await - .unwrap_err(); - assert_eq!(oversized.code(), Code::InvalidArgument); - - for page_token in [vec![0], 0i64.to_be_bytes().to_vec()] { - let invalid = list( - &service, - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 1, - page_token, - }, - ) - .await - .unwrap_err(); - assert_eq!(invalid.code(), Code::InvalidArgument); - } - } - - #[tokio::test] - async fn two_validators_issue_shares_for_third_validator_ciphertext() { - let mut operator_keys = operator_keys(); - let third = operator_keys.pop().unwrap(); - let second = operator_keys.pop().unwrap(); - let first = operator_keys.pop().unwrap(); - let (_directory, database) = test_database().await; - let first_service = ValidatorAdminService::new(first, database.clone()); - let second_service = ValidatorAdminService::new(second, database); - let plaintext = b"private transaction inputs"; - let record = target_record(&third, 1, plaintext); - let request = PrivateRecordShareRequest::for_record(&record); - let rpc_request = rpc_request(&record); - - let shares = [ - issue(&first_service, rpc_request.clone()).await.unwrap().decryption_share, - issue(&second_service, rpc_request).await.unwrap().decryption_share, - ]; - - let opened = PrivateRecordCombiner::from_operator_key(&third) - .unwrap() - .open(&request, &record, &shares) - .unwrap(); - assert_eq!(opened.as_slice(), plaintext); - } - - #[tokio::test] - async fn shares_for_different_ciphertexts_are_not_reusable_with_the_same_context() { - let mut operator_keys = operator_keys(); - let third = operator_keys.pop().unwrap(); - let second = operator_keys.pop().unwrap(); - let first = operator_keys.pop().unwrap(); - let (_directory, database) = test_database().await; - let first_service = ValidatorAdminService::new(first, database.clone()); - let second_service = ValidatorAdminService::new(second, database); - let first_record = target_record(&third, 2, b"same plaintext"); - let second_record = target_record(&third, 3, b"same plaintext"); + let record_owner = keys.pop().unwrap(); + let second = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.1); + let first = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.1); + let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + let first_record = target_record(&record_owner, transaction_id, 2, b"same plaintext"); + let second_record = target_record(&record_owner, transaction_id, 3, b"same plaintext"); assert_eq!(first_record.context(), second_record.context()); - assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key(),); + assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key()); let shares = [ - issue(&first_service, rpc_request(&first_record)) - .await - .unwrap() - .decryption_share, - issue(&second_service, rpc_request(&second_record)) - .await - .unwrap() - .decryption_share, - ]; + issue(&first, share_request(&first_record)).await.unwrap().decryption_share, + issue(&second, share_request(&second_record)).await.unwrap().decryption_share, + ] + .map(|share| hex::decode(share).unwrap()); let request = PrivateRecordShareRequest::for_record(&first_record); - let result = PrivateRecordCombiner::from_operator_key(&third).unwrap().open( + let result = PrivateRecordCombiner::from_operator_key(&record_owner).unwrap().open( &request, &first_record, &shares, @@ -506,283 +361,83 @@ mod tests { } #[tokio::test] - async fn malformed_ciphertext_wrong_payload_size_and_context_mismatch_are_rejected() { + async fn invalid_share_requests_return_bad_request() { let mut keys = operator_keys(); - let (_directory, database) = test_database().await; - let record = target_record(&keys[0], 4, b"record"); - let context = record.context().to_bytes(); - - let malformed = proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: vec![0], - decryption_context: context.clone(), - }; - assert_eq!( - issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), malformed,) - .await - .unwrap_err() - .code(), - Code::InvalidArgument, + let record = target_record( + &keys[0], + TransactionId::from_raw(Word::from([1u32, 2, 3, 4])), + 4, + b"record", ); + let context = record.context().to_bytes(); + let (_directory, database) = test_database().await; - let mut non_canonical = record.encrypted_record_key().to_vec(); - non_canonical.push(0); - let non_canonical = proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: non_canonical, - decryption_context: context.clone(), + let invalid_hex = IssueDecryptionShareRequest { + ciphertext: "not hex".to_owned(), + decryption_context: hex::encode(&context), }; - assert_eq!( - issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), non_canonical,) + let error = + issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), invalid_hex) .await - .unwrap_err() - .code(), - Code::InvalidArgument, - ); + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); let mut short_rng = ChaCha20Rng::from_seed([5; 32]); let short_ciphertext = keys[0] .sealing_key() .seal_bytes_with_associated_data(&mut short_rng, &[0; 31], &context) .unwrap(); - let wrong_size = proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: to_wire_bytes(&short_ciphertext), - decryption_context: context.clone(), + let wrong_size = IssueDecryptionShareRequest { + ciphertext: hex::encode(to_wire_bytes(&short_ciphertext)), + decryption_context: hex::encode(context), }; - assert_eq!( - issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), wrong_size,) + let error = + issue(&ValidatorAdminService::new(keys.remove(0), database.clone()), wrong_size) .await - .unwrap_err() - .code(), - Code::InvalidArgument, - ); + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); - let wrong_context = proto::validator_admin::IssueDecryptionShareRequest { - ciphertext: record.encrypted_record_key().to_vec(), - decryption_context: b"wrong context".to_vec(), + let wrong_context = IssueDecryptionShareRequest { + ciphertext: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(b"wrong context"), }; - assert_eq!( - issue(&ValidatorAdminService::new(operator_keys().remove(0), database), wrong_context,) + let error = + issue(&ValidatorAdminService::new(operator_keys().remove(0), database), wrong_context) .await - .unwrap_err() - .code(), - Code::InvalidArgument, - ); - } - - #[derive(Clone, Copy)] - struct PublicValidatorStub; - - #[tonic::async_trait] - impl validator_api::GetTransactionEncryptionKey for PublicValidatorStub { - type Input = (); - type Output = proto::transaction::TransactionEncryptionKey; - - fn decode(request: ()) -> tonic::Result { - Ok(request) - } - - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } - - async fn handle( - &self, - _input: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - Err(Status::unimplemented("stub")) - } - } - - #[tonic::async_trait] - impl validator_api::Status for PublicValidatorStub { - type Input = (); - type Output = proto::validator::ValidatorStatus; - - fn decode(request: ()) -> tonic::Result { - Ok(request) - } - - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } - - async fn handle( - &self, - _input: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - Err(Status::unimplemented("stub")) - } - } - - #[tonic::async_trait] - impl validator_api::SubmitProvenTransaction for PublicValidatorStub { - type Input = (); - type Output = (); - - fn decode(_request: proto::transaction::ProvenTransaction) -> tonic::Result { - Ok(()) - } - - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } - - async fn handle( - &self, - _input: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - Err(Status::unimplemented("stub")) - } - } - - #[tonic::async_trait] - impl validator_api::SignBlock for PublicValidatorStub { - type Input = (); - type Output = proto::blockchain::SignBlockResponse; - - fn decode(_request: proto::blockchain::ProposedBlock) -> tonic::Result { - Ok(()) - } - - fn encode(output: Self::Output) -> tonic::Result { - Ok(output) - } - - async fn handle( - &self, - _input: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - Err(Status::unimplemented("stub")) - } - } - - #[tonic::async_trait] - impl validator_api::BlockSubscription for PublicValidatorStub { - type Input = (); - type Item = proto::validator::BlockSubscriptionResponse; - type ItemStream = tokio_stream::Empty>; - - fn decode( - _request: proto::validator::BlockSubscriptionRequest, - ) -> tonic::Result { - Ok(()) - } - - fn encode(item: Self::Item) -> tonic::Result { - Ok(item) - } - - async fn handle( - &self, - _input: Self::Input, - _metadata: &tonic::metadata::MetadataMap, - _extensions: &tonic::codegen::http::Extensions, - ) -> tonic::Result { - Err(Status::unimplemented("stub")) - } + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); } #[tokio::test] - async fn admin_method_is_registered_only_on_admin_listener() { - let mut operator_keys = operator_keys(); + async fn router_exposes_only_the_json_admin_routes() { let (_directory, database) = test_database().await; - let record = target_record(&operator_keys[0], 6, b"record"); - let request = rpc_request(&record); - let admin_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let admin_address = admin_listener.local_addr().unwrap(); - let public_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let public_address = public_listener.local_addr().unwrap(); - let shutdown = CancellationToken::new(); - - let admin_server = super::super::ValidatorAdminServer { - address: admin_address, - grpc_options: GrpcOptionsInternal::test(), - operator_key: operator_keys.remove(0), - database, - }; - let admin_shutdown = shutdown.clone(); - let admin_task = tokio::spawn(async move { - admin_server.serve_on(admin_listener, admin_shutdown).await.unwrap(); - }); - let public_shutdown = shutdown.clone(); - let public_task = tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(validator_api::service(PublicValidatorStub)) - .serve_with_incoming_shutdown( - TcpListenerStream::new(public_listener), - public_shutdown.cancelled_owned(), - ) - .await - .unwrap(); - }); - - let mut admin_client = proto::validator_admin::api_client::ApiClient::connect(format!( - "http://{admin_address}", - )) - .await - .unwrap(); - assert!( - !admin_client - .issue_decryption_share(request.clone()) - .await - .unwrap() - .into_inner() - .decryption_share - .is_empty() - ); - assert!( - admin_client - .list_validated_private_transactions( - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 1, - page_token: Vec::new(), - }, - ) - .await - .unwrap() - .into_inner() - .transactions - .is_empty(), - ); - - let mut admin_on_public = proto::validator_admin::api_client::ApiClient::connect(format!( - "http://{public_address}", - )) - .await - .unwrap(); - assert_eq!( - admin_on_public.issue_decryption_share(request).await.unwrap_err().code(), - Code::Unimplemented, - ); - assert_eq!( - admin_on_public - .list_validated_private_transactions( - proto::validator_admin::ListValidatedPrivateTransactionsRequest { - page_size: 1, - page_token: Vec::new(), - }, - ) - .await - .unwrap_err() - .code(), - Code::Unimplemented, - ); + let app = router(operator_keys().remove(0), database); - let mut public_on_admin = - proto::validator::api_client::ApiClient::connect(format!("http://{admin_address}")) - .await - .unwrap(); - assert_eq!(public_on_admin.status(()).await.unwrap_err().code(), Code::Unimplemented); + let response = app + .clone() + .oneshot(Request::get(LIST_TRANSACTIONS_PATH).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json",); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let response: ListValidatedPrivateTransactionsResponse = + serde_json::from_slice(&body).unwrap(); + assert!(response.transactions.is_empty()); + + let response = app + .clone() + .oneshot( + Request::post(ISSUE_SHARE_PATH) + .header("content-type", "application/json") + .body(Body::from(r#"{"ciphertext":"not hex","decryption_context":""}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); - shutdown.cancel(); - admin_task.await.unwrap(); - public_task.await.unwrap(); + let response = app.oneshot(Request::get("/").body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } } diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index cdf00280eb..9b211eac90 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use anyhow::Context; use miden_node_db::sqlite::Database; -use miden_node_proto::server::{validator_admin_api, validator_api}; +use miden_node_proto::server::validator_api; use miden_node_proto_build::validator_api_descriptor; use miden_node_store::BlockStore; use miden_node_utils::clap::GrpcOptionsInternal; @@ -27,7 +27,6 @@ use crate::{ mod admin_service; mod validator_service; -use admin_service::ValidatorAdminService; use validator_service::{InitialMetrics, ValidatorService}; // VALIDATOR SERVER @@ -65,8 +64,6 @@ pub struct ValidatorServer { pub struct ValidatorAdminServer { /// Address of the private administration listener. pub address: SocketAddr, - /// gRPC request timeout. - pub grpc_options: GrpcOptionsInternal, /// Golden key material used to issue this validator's decryption shares. pub operator_key: GoldenOperatorKey, /// Shared validator database. @@ -98,18 +95,8 @@ impl ValidatorAdminServer { "Validator admin server ready", ); - tonic::transport::Server::builder() - .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) - .layer(TraceLayer::new_for_grpc().make_span_with(grpc_trace_fn)) - .timeout(self.grpc_options.request_timeout) - .add_service(validator_admin_api::service(ValidatorAdminService::new( - self.operator_key, - self.database, - ))) - .serve_with_incoming_shutdown( - TcpListenerStream::new(listener), - shutdown.cancelled_owned(), - ) + axum::serve(listener, admin_service::router(self.operator_key, self.database)) + .with_graceful_shutdown(shutdown.cancelled_owned()) .await .context("failed to serve validator admin API") } diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 18aba08fdd..3f7ed1e059 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -9,7 +9,6 @@ use miden_node_proto_build::{ remote_prover_api_descriptor, rpc_api_descriptor, sequencer_api_descriptor, - validator_admin_api_descriptor, validator_api_descriptor, }; use miette::{Context, IntoDiagnostic}; @@ -30,7 +29,6 @@ fn main() -> miette::Result<()> { rpc_api_descriptor(), remote_prover_api_descriptor(), validator_api_descriptor(), - validator_admin_api_descriptor(), ntx_builder_api_descriptor(), sequencer_api_descriptor(), ]; diff --git a/proto/proto/README.md b/proto/proto/README.md index b1bd0a087c..fed23cbfd4 100644 --- a/proto/proto/README.md +++ b/proto/proto/README.md @@ -16,8 +16,7 @@ types/ └── xxx.proto internal/ ├── ntx_builder.proto -├── validator.proto -└── validator_admin.proto +└── validator.proto ``` The public-facing files should only allow the usage of the `types` directory, to avoid service reflection to internal diff --git a/proto/proto/internal/validator_admin.proto b/proto/proto/internal/validator_admin.proto deleted file mode 100644 index ce68c3df3d..0000000000 --- a/proto/proto/internal/validator_admin.proto +++ /dev/null @@ -1,63 +0,0 @@ -// Specification of the private Validator administration API. -// -// This service is served on a private, network-isolated listener. -syntax = "proto3"; -package validator_admin; - -// INTERNAL VALIDATOR ADMIN API -// ================================================================================================ - -service Api { - // Lists this validator's stored private transactions in insertion order. - rpc ListValidatedPrivateTransactions(ListValidatedPrivateTransactionsRequest) - returns (ListValidatedPrivateTransactionsResponse) {} - - // Issues this validator's Golden decryption share for one encrypted content key and context. - rpc IssueDecryptionShare(IssueDecryptionShareRequest) - returns (IssueDecryptionShareResponse) {} -} - -message ListValidatedPrivateTransactionsRequest { - // Number of transactions to return. Zero uses the server default. - uint32 page_size = 1; - - // Opaque cursor returned by the prior page. Empty starts at the first transaction. - bytes page_token = 2; -} - -message ValidatedPrivateTransaction { - // Canonical Miden transaction ID. - bytes transaction_id = 1; - - // XChaCha20-Poly1305 ciphertext of the validated TransactionInputs. - bytes final_ciphertext = 2; - - // XChaCha20-Poly1305 nonce for final_ciphertext. - bytes cipher_nonce = 3; - - // Canonical Golden EHTDH1 ciphertext for the content key. - bytes encrypted_record_key = 4; - - // Exact associated data used by both encryption layers. - bytes decryption_context = 5; -} - -message ListValidatedPrivateTransactionsResponse { - repeated ValidatedPrivateTransaction transactions = 1; - - // Opaque cursor for the next page. Empty means this is the final page. - bytes next_page_token = 2; -} - -message IssueDecryptionShareRequest { - // Canonical Golden EHTDH1 ciphertext for a private record content key. - bytes ciphertext = 1; - - // Exact context used to encrypt the content key. - bytes decryption_context = 2; -} - -message IssueDecryptionShareResponse { - // Canonical Golden EHTDH1 decryption share. - bytes decryption_share = 1; -} From 96465cf0b6001e58d7ea52e61ab5ee7439315746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 29 Jul 2026 20:27:00 -0400 Subject: [PATCH 5/6] fix(ci): inherit shared Axum dependency --- Cargo.lock | 1 - Cargo.toml | 1 - bin/network-monitor/Cargo.toml | 2 +- bin/validator/Cargo.toml | 1 - bin/validator/src/server/admin_service.rs | 4 +--- 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d0f2e9018..bf0af484b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4073,7 +4073,6 @@ dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", - "serde_json", "tempfile", "thiserror 2.0.19", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 26d14dedf9..b2b084c825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,6 @@ rstest = { version = "0.26" } rusqlite = { features = ["array", "bundled"], version = "0.37" } semver = { version = "1.0" } serde = { features = ["derive"], version = "1" } -serde_json = { version = "1" } serial_test = { version = "3.2" } sha2 = { version = "0.10" } syn = { version = "2.0" } diff --git a/bin/network-monitor/Cargo.toml b/bin/network-monitor/Cargo.toml index dd79d4aa79..659a2fea3c 100644 --- a/bin/network-monitor/Cargo.toml +++ b/bin/network-monitor/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -axum = { version = "0.8" } +axum = { workspace = true } backon = { workspace = true } clap = { features = ["env"], workspace = true } futures = { workspace = true } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index 10fb8a488d..fe90c274d0 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -60,7 +60,6 @@ miden-testing = { workspace = true } miden-tx = { features = ["concurrent", "testing"], workspace = true } rand = { workspace = true } rand_chacha_03 = { workspace = true } -serde_json = { workspace = true } tempfile = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "sync"], workspace = true } tower = { features = ["util"], workspace = true } diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs index 67ac39e9a6..58933e138c 100644 --- a/bin/validator/src/server/admin_service.rs +++ b/bin/validator/src/server/admin_service.rs @@ -421,9 +421,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers().get("content-type").unwrap(), "application/json",); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let response: ListValidatedPrivateTransactionsResponse = - serde_json::from_slice(&body).unwrap(); - assert!(response.transactions.is_empty()); + assert_eq!(body.as_ref(), br#"{"transactions":[]}"#); let response = app .clone() From 396e12f6d68fe0ceeda23020a1d6c93daf28764d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 30 Jul 2026 08:14:59 -0400 Subject: [PATCH 6/6] refactor(validator): simplify admin recovery surface --- .../commands/issue_private_record_share.rs | 4 +- bin/validator/src/commands/mod.rs | 4 +- bin/validator/src/db/migrations.rs | 2 +- .../src/db/migrations/001_initial.sql | 2 +- bin/validator/src/db/mod.rs | 17 ++--- ...sactions.sql => load_all_transactions.sql} | 0 bin/validator/src/lib.rs | 1 - bin/validator/src/private_record.rs | 69 ++----------------- bin/validator/src/server/admin_service.rs | 4 +- bin/validator/src/server/mod.rs | 5 +- bin/validator/src/storage_key.rs | 17 +---- compose/validator.yml | 2 +- 12 files changed, 24 insertions(+), 103 deletions(-) rename bin/validator/src/db/sql/{load_validated_private_transactions.sql => load_all_transactions.sql} (100%) diff --git a/bin/validator/src/commands/issue_private_record_share.rs b/bin/validator/src/commands/issue_private_record_share.rs index 42c4ae5c18..7f1baac772 100644 --- a/bin/validator/src/commands/issue_private_record_share.rs +++ b/bin/validator/src/commands/issue_private_record_share.rs @@ -27,10 +27,8 @@ pub(super) fn issue( .context("private record bundle is invalid")?; let request = PrivateRecordShareRequest::for_record(&record); - // Running this filesystem-restricted command is the demo's explicit release decision. - let allow = |_: &PrivateRecordShareRequest, _: &miden_validator::StoredPrivateRecord| true; let share = operator_key - .issue_private_record_share(&mut OsRng, &request, &record, &allow) + .issue_private_record_share(&mut OsRng, &request, &record) .context("failed to issue private record share")?; write_share(output, &share) diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 429ec10c8c..40beb97c9b 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -171,7 +171,7 @@ pub enum ValidatorCommand { listen: std::net::SocketAddr, /// Socket address at which to serve the private administration API. - #[arg(long = "admin-listen", env = ENV_ADMIN_LISTEN, value_name = "LISTEN")] + #[arg(long = "admin.listen", env = ENV_ADMIN_LISTEN, value_name = "LISTEN")] admin_listen: Option, #[command(flatten)] @@ -692,7 +692,7 @@ mod tests { }; assert_eq!(admin_listen, None); - let command = parse_start(&["--admin-listen", "127.0.0.1:50102"]) + let command = parse_start(&["--admin.listen", "127.0.0.1:50102"]) .expect("start with an admin listener must parse"); let ValidatorCommand::Start { admin_listen, .. } = command else { panic!("expected the start command"); diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 49557c13c4..0dd7577af7 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -71,7 +71,7 @@ mod tests { use super::*; const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "5ae520fbd4e0ec05c983b77d9eabdc685e6820f97b41d1aabbf85c357bebc961", + "f2f6af5e22d8d0273524a227417339279d1a694c3802a8f3f7cc4b31e21ee035", )]; #[test] diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index d5e1f92a5a..10588283af 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -1,5 +1,5 @@ CREATE TABLE validated_transactions ( - -- Local insertion order used by the private administration API. + -- Rowid-backed local insertion order used by the private administration API. insertion_sequence INTEGER PRIMARY KEY AUTOINCREMENT, -- Transaction ID, unique within this validator's database. id BLOB NOT NULL UNIQUE, diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index 052b97d890..662d5f7cdb 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -30,8 +30,7 @@ mod sql { include_str!("sql/load_private_records_by_key_epoch.sql"); pub(super) const LOAD_PRIVATE_RECORDS_BY_SETUP_CONTEXT: &str = include_str!("sql/load_private_records_by_setup_context.sql"); - pub(super) const LOAD_VALIDATED_PRIVATE_TRANSACTIONS: &str = - include_str!("sql/load_validated_private_transactions.sql"); + pub(super) const LOAD_ALL_TRANSACTIONS: &str = include_str!("sql/load_all_transactions.sql"); pub(super) const TRANSACTION_EXISTS: &str = include_str!("sql/transaction_exists.sql"); pub(super) const UPSERT_BLOCK_HEADER: &str = include_str!("sql/upsert_block_header.sql"); pub(super) const LOAD_CHAIN_TIP: &str = include_str!("sql/load_chain_tip.sql"); @@ -191,10 +190,10 @@ pub fn load_private_records_by_setup_context( } /// Loads all validated private transactions in insertion order. -pub(crate) fn load_validated_private_transactions( +pub(crate) fn load_all_transactions( tx: &ReadTx<'_>, ) -> Result, DatabaseError> { - tx.query(sql::LOAD_VALIDATED_PRIVATE_TRANSACTIONS, &[], private_record_from_row) + tx.query(sql::LOAD_ALL_TRANSACTIONS, &[], private_record_from_row) } fn private_record_from_row(row: &Row<'_>) -> Result { @@ -515,10 +514,7 @@ mod tests { .unwrap(); } - let loaded = db - .read("load validated private transactions", load_validated_private_transactions) - .await - .unwrap(); + let loaded = db.read("load all transactions", load_all_transactions).await.unwrap(); assert_eq!(loaded, records); } @@ -547,15 +543,14 @@ mod tests { .unwrap() .unwrap(); let request = PrivateRecordShareRequest::for_record(&stored); - let allow = |_: &PrivateRecordShareRequest, _: &StoredPrivateRecord| true; let mut first_rng = ChaCha20Rng::from_seed([41; 32]); let mut second_rng = ChaCha20Rng::from_seed([42; 32]); let shares = [ operators[0] - .issue_private_record_share(&mut first_rng, &request, &stored, &allow) + .issue_private_record_share(&mut first_rng, &request, &stored) .unwrap(), operators[1] - .issue_private_record_share(&mut second_rng, &request, &stored, &allow) + .issue_private_record_share(&mut second_rng, &request, &stored) .unwrap(), ]; diff --git a/bin/validator/src/db/sql/load_validated_private_transactions.sql b/bin/validator/src/db/sql/load_all_transactions.sql similarity index 100% rename from bin/validator/src/db/sql/load_validated_private_transactions.sql rename to bin/validator/src/db/sql/load_all_transactions.sql diff --git a/bin/validator/src/lib.rs b/bin/validator/src/lib.rs index 93ac76c532..c3e67aadd5 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -15,7 +15,6 @@ pub use private_record::{ PrivateRecordError, PrivateRecordId, PrivateRecordSealer, - PrivateRecordSharePolicy, PrivateRecordShareRequest, PrivateRecordStorageFields, StoredPrivateRecord, diff --git a/bin/validator/src/private_record.rs b/bin/validator/src/private_record.rs index 441c3385d2..a0fad43fdf 100644 --- a/bin/validator/src/private_record.rs +++ b/bin/validator/src/private_record.rs @@ -175,21 +175,6 @@ impl PrivateRecordShareRequest { } } -/// Decides whether an operator may issue a decryption share. -pub trait PrivateRecordSharePolicy { - /// Returns `true` when the request may receive a share. - fn allows(&self, request: &PrivateRecordShareRequest, record: &StoredPrivateRecord) -> bool; -} - -impl PrivateRecordSharePolicy for F -where - F: Fn(&PrivateRecordShareRequest, &StoredPrivateRecord) -> bool, -{ - fn allows(&self, request: &PrivateRecordShareRequest, record: &StoredPrivateRecord) -> bool { - self(request, record) - } -} - /// Public Golden key used to seal private records for one epoch. #[derive(Clone, Debug)] pub struct PrivateRecordSealer { @@ -570,9 +555,6 @@ pub enum PrivateRecordError { /// The request does not carry the record's exact canonical context. #[error("private record decryption context does not match the record")] DecryptionContextMismatch, - /// Policy denied the share request. - #[error("private record share request was denied")] - ShareDenied, /// The authenticated record cipher failed. #[error("failed to encrypt private record")] RecordEncryption, @@ -653,14 +635,6 @@ mod tests { test_private_record_sealer(EPOCH, [8; 32]) } - fn allow_all(_request: &PrivateRecordShareRequest, _record: &StoredPrivateRecord) -> bool { - true - } - - fn deny_all(_request: &PrivateRecordShareRequest, _record: &StoredPrivateRecord) -> bool { - false - } - fn threshold_record( operator_key: &GoldenOperatorKey, transaction_id: TransactionId, @@ -681,9 +655,7 @@ mod tests { seed: u8, ) -> Vec { let mut rng = ChaCha20Rng::from_seed([seed; 32]); - operator_key - .issue_private_record_share(&mut rng, request, record, &allow_all) - .unwrap() + operator_key.issue_private_record_share(&mut rng, request, record).unwrap() } fn transaction_inputs() -> TransactionInputs { @@ -881,17 +853,6 @@ mod tests { let plaintext = inputs.to_bytes(); let record = threshold_record(&operator_keys[0], transaction_id(), 20, &plaintext); let request = PrivateRecordShareRequest::for_record(&record); - let mut denied_rng = ChaCha20Rng::from_seed([21; 32]); - - assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut denied_rng, - &request, - &record, - &deny_all, - ), - Err(PrivateRecordError::ShareDenied), - )); let shares = [ issue_share(&operator_keys[0], &request, &record, 22), @@ -946,12 +907,7 @@ mod tests { ); let mut rng = ChaCha20Rng::from_seed([25; 32]); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_transaction, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_transaction, &record,), Err(PrivateRecordError::RecordIdMismatch), )); @@ -961,12 +917,7 @@ mod tests { request.context().to_vec(), ); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_epoch, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_epoch, &record,), Err(PrivateRecordError::KeyEpochMismatch), )); @@ -978,12 +929,7 @@ mod tests { wrong_context_bytes, ); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_context, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_context, &record,), Err(PrivateRecordError::DecryptionContextMismatch), )); @@ -991,12 +937,7 @@ mod tests { wrong_setup_fields.setup_context_id = [99; 32]; let wrong_setup = StoredPrivateRecord::from_storage_fields(wrong_setup_fields).unwrap(); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &request, - &wrong_setup, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &request, &wrong_setup,), Err(PrivateRecordError::SetupContextMismatch), )); } diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs index 58933e138c..14435bfdeb 100644 --- a/bin/validator/src/server/admin_service.rs +++ b/bin/validator/src/server/admin_service.rs @@ -10,7 +10,7 @@ use miden_protocol::utils::serde::Serializable; use rand_core_06::OsRng; use serde::{Deserialize, Serialize}; -use crate::db::load_validated_private_transactions; +use crate::db::load_all_transactions; use crate::{GoldenOperatorKey, PrivateRecordError, StoredPrivateRecord}; const LIST_TRANSACTIONS_PATH: &str = "/admin/transactions"; @@ -69,7 +69,7 @@ async fn list_validated_private_transactions( ) -> Result, ApiError> { let records = service .database - .read("list validated private transactions", load_validated_private_transactions) + .read("list validated private transactions", load_all_transactions) .await .map_err(|_error| ApiError::internal("failed to list validated private transactions"))?; diff --git a/bin/validator/src/server/mod.rs b/bin/validator/src/server/mod.rs index 9b211eac90..8f0a85ff60 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -73,9 +73,8 @@ pub struct ValidatorAdminServer { impl ValidatorAdminServer { /// Serves the private validator administration API. pub async fn serve(self, shutdown: CancellationToken) -> anyhow::Result<()> { - let listener = TcpListener::bind(self.address) - .await - .context("failed to bind validator admin address")?; + let listener = + TcpListener::bind(self.address).await.context("failed to bind admin address")?; self.serve_on(listener, shutdown).await } diff --git a/bin/validator/src/storage_key.rs b/bin/validator/src/storage_key.rs index efeff218f1..d2511a0f8a 100644 --- a/bin/validator/src/storage_key.rs +++ b/bin/validator/src/storage_key.rs @@ -16,12 +16,7 @@ use rand_core_06::{CryptoRng, RngCore}; use zeroize::Zeroizing; use crate::private_record::CONTENT_KEY_BYTES; -use crate::{ - PrivateRecordError, - PrivateRecordSharePolicy, - PrivateRecordShareRequest, - StoredPrivateRecord, -}; +use crate::{PrivateRecordError, PrivateRecordShareRequest, StoredPrivateRecord}; /// Golden group used for validator storage keys. type StorageGroup = Secp256k1GoldenGroup; @@ -226,7 +221,7 @@ impl GoldenOperatorKey { } /// Issues a canonical decryption share for one encrypted content key and exact context. - pub fn issue_decryption_share( + pub(crate) fn issue_decryption_share( &self, rng: &mut R, ciphertext_bytes: &[u8], @@ -257,22 +252,16 @@ impl GoldenOperatorKey { } /// Checks one private-record request and returns a canonical decryption share. - pub fn issue_private_record_share( + pub fn issue_private_record_share( &self, rng: &mut R, request: &PrivateRecordShareRequest, record: &StoredPrivateRecord, - policy: &P, ) -> Result, PrivateRecordError> where R: RngCore + CryptoRng, - P: PrivateRecordSharePolicy + ?Sized, { record.validate_share_request(request, self.key_epoch, self.setup_context_id())?; - if !policy.allows(request, record) { - return Err(PrivateRecordError::ShareDenied); - } - self.issue_decryption_share(rng, record.encrypted_record_key(), request.context()) } } diff --git a/compose/validator.yml b/compose/validator.yml index 10d41a2993..539237e9e3 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -16,7 +16,7 @@ x-validator: &validator - miden-validator - start - --listen=0.0.0.0:50101 - - --admin-listen=0.0.0.0:50102 + - --admin.listen=0.0.0.0:50102 services: validator-1: