From e8e6c4bec20b5b21a5fe59a6cc9f1bad0891ebc0 Mon Sep 17 00:00:00 2001 From: 0xnullifier Date: Thu, 8 Oct 2026 00:49:49 +0530 Subject: [PATCH 1/2] feat(rpc): add IsInvitationCodeValid endpoint Add a read-only gRPC method that reports whether an invitation code can still be used for registration. It returns true when allowlist enforcement is disabled or when the code exists and no account has registered with it. Unknown and consumed codes return false. The request type is excluded from derived Debug so invitation codes never reach logs, following the RegisterAccountRequest pattern. Full nodes forward the query to the sequencer. --- .../src/rpc_sync/block_tests.rs | 5 ++ crates/proto/build.rs | 6 +- crates/proto/src/domain/account.rs | 48 ++++++++++++ crates/proto/src/domain/account/tests.rs | 12 +++ crates/rpc/src/server/admission.rs | 14 +++- crates/rpc/src/server/api.rs | 1 + .../server/api/is_invitation_code_valid.rs | 64 ++++++++++++++++ crates/rpc/src/tests.rs | 10 ++- crates/rpc/src/tests/allowlist.rs | 75 +++++++++++++++++++ docs/external/src/full-node/rpc.md | 4 +- docs/external/src/rpc/index.md | 2 +- docs/external/src/rpc/public-api.md | 4 + proto/proto/rpc.proto | 19 +++++ 13 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 crates/rpc/src/server/api/is_invitation_code_valid.rs diff --git a/crates/block-producer/src/rpc_sync/block_tests.rs b/crates/block-producer/src/rpc_sync/block_tests.rs index b42f6c6299..52b493cec4 100644 --- a/crates/block-producer/src/rpc_sync/block_tests.rs +++ b/crates/block-producer/src/rpc_sync/block_tests.rs @@ -322,6 +322,11 @@ unused_rpc!( proto::miden::node::v1::IsAccountAllowedRequest, proto::miden::node::v1::IsAccountAllowedResponse ); +unused_rpc!( + IsInvitationCodeValid, + proto::miden::node::v1::IsInvitationCodeValidRequest, + proto::miden::node::v1::IsInvitationCodeValidResponse +); unused_rpc!( GetNetworkNoteStatus, proto::miden::node::v1::GetNetworkNoteStatusRequest, diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 39f8450b66..19046125d9 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -64,7 +64,7 @@ fn generate_bindings(file_descriptors: &FileDescriptorSet, dst_dir: &Path) -> mi for &(proto_path, rust_path) in miden_objects::EXTERN_PATHS { prost_config.extern_path(proto_path, rust_path); } - prost_config.skip_debug(["RegisterAccountRequest"]); + prost_config.skip_debug(["RegisterAccountRequest", "IsInvitationCodeValidRequest"]); let mut messages = Vec::new(); for file in &file_descriptors.file { @@ -112,7 +112,9 @@ fn collect_message_names(parent: &str, descriptors: &[DescriptorProto], names: & for descriptor in descriptors { let name = format!("{parent}.{}", descriptor.name()); // The derive adds Debug without field redaction. Keep invitation codes out of Debug output. - if name == "miden.node.v1.RegisterAccountRequest" { + if name == "miden.node.v1.RegisterAccountRequest" + || name == "miden.node.v1.IsInvitationCodeValidRequest" + { continue; } collect_message_names(&name, &descriptor.nested_type, names); diff --git a/crates/proto/src/domain/account.rs b/crates/proto/src/domain/account.rs index 7788439fe7..275e531469 100644 --- a/crates/proto/src/domain/account.rs +++ b/crates/proto/src/domain/account.rs @@ -128,6 +128,54 @@ impl Verify for proto::miden::node::v1::DecodedIsAccountAllowedRequest { } } +// IS INVITATION CODE VALID REQUEST +// ================================================================================================ + +impl Debug for proto::miden::node::v1::IsInvitationCodeValidRequest { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IsInvitationCodeValidRequest").finish_non_exhaustive() + } +} + +/// Decoded invitation check fields. The invitation code remains unvalidated. +#[must_use = "decoded fields have not been verified"] +pub struct DecodedIsInvitationCodeValidRequest { + pub invitation_code: String, +} + +impl Debug for DecodedIsInvitationCodeValidRequest { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DecodedIsInvitationCodeValidRequest").finish_non_exhaustive() + } +} + +impl DecodeMessage for proto::miden::node::v1::IsInvitationCodeValidRequest { + type Decoded = DecodedIsInvitationCodeValidRequest; +} + +impl TryFrom + for DecodedIsInvitationCodeValidRequest +{ + type Error = ConversionError; + + fn try_from( + request: proto::miden::node::v1::IsInvitationCodeValidRequest, + ) -> Result { + Ok(Self { invitation_code: request.invitation_code }) + } +} + +/// Returns the raw invitation code. The handler validates the code because an empty code is +/// accepted when allowlist enforcement is disabled. +impl Verify for DecodedIsInvitationCodeValidRequest { + type Verified = String; + type Error = ConversionError; + + fn verify(self) -> Result { + Ok(self.invitation_code) + } +} + // ACCOUNT REQUEST // ================================================================================================ diff --git a/crates/proto/src/domain/account/tests.rs b/crates/proto/src/domain/account/tests.rs index d42e98ea85..45c43c63e1 100644 --- a/crates/proto/src/domain/account/tests.rs +++ b/crates/proto/src/domain/account/tests.rs @@ -29,6 +29,18 @@ fn registration_request_debug_hides_invitation_code() { assert_eq!(verified.account_id, account_id); } +#[test] +fn invitation_check_request_debug_hides_invitation_code() { + let code = "private invitation code"; + let request = + proto::miden::node::v1::IsInvitationCodeValidRequest { invitation_code: code.to_owned() }; + assert!(!format!("{request:?}").contains(code)); + + let decoded = request.decode_fields().unwrap(); + assert!(!format!("{decoded:?}").contains(code)); + assert_eq!(decoded.verify().unwrap(), code); +} + fn word_from_u32(arr: [u32; 4]) -> Word { Word::from(arr) } diff --git a/crates/rpc/src/server/admission.rs b/crates/rpc/src/server/admission.rs index 155d4b2c36..7de79068de 100644 --- a/crates/rpc/src/server/admission.rs +++ b/crates/rpc/src/server/admission.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use miden_node_store::DatabaseError; -use miden_node_store::allowlist::AccountAllowlist; +use miden_node_store::allowlist::{AccountAllowlist, InvitationCode, InvitationStatus}; use miden_node_tracing::{error, miden_instrument}; use miden_protocol::account::{AccountId, AccountUpdateDetails}; use miden_protocol::transaction::TxAccountUpdate; @@ -52,6 +52,18 @@ impl AccountAdmission { self.allowlist.contains_account(account_id).await } + /// Returns true if enforcement is disabled or the invitation code exists and is unused. + pub(crate) async fn is_invitation_code_valid( + &self, + invitation_code: InvitationCode, + ) -> Result { + if self.disabled { + return Ok(true); + } + let status = self.allowlist.invitation_status(invitation_code).await?; + Ok(status == InvitationStatus::Unused) + } + /// Rejects the submission if it creates an unregistered, non-network account. #[miden_instrument( target = COMPONENT, diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index aaecf5f3b7..fef56f86c7 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -90,6 +90,7 @@ mod get_note_script_by_root; mod get_notes_by_id; mod get_transaction_encryption_key; mod is_account_allowed; +mod is_invitation_code_valid; mod register_account; mod status; mod submit_auth_tx; diff --git a/crates/rpc/src/server/api/is_invitation_code_valid.rs b/crates/rpc/src/server/api/is_invitation_code_valid.rs new file mode 100644 index 0000000000..d30f403a12 --- /dev/null +++ b/crates/rpc/src/server/api/is_invitation_code_valid.rs @@ -0,0 +1,64 @@ +use miden_node_proto::errors::ConversionError; +use miden_node_proto::{DecodeMessageExt, generated as proto}; +use miden_node_store::allowlist::InvitationCode; +use miden_node_tracing::{ErrorReport, miden_instrument}; +use tonic::{Request, Status}; + +use super::{RpcBackend, RpcService}; +use crate::COMPONENT; + +#[tonic::async_trait] +impl proto::server::miden_node_v1_node_service::IsInvitationCodeValid for RpcService { + type Input = String; + type Output = bool; + + fn decode( + request: proto::miden::node::v1::IsInvitationCodeValidRequest, + ) -> tonic::Result { + request.decode_and_verify().map_err(ConversionError::into_status) + } + + fn encode( + valid: Self::Output, + ) -> tonic::Result { + Ok(proto::miden::node::v1::IsInvitationCodeValidResponse { valid }) + } + + // The invitation code is a secret. Do not record it on the span. + #[miden_instrument(target = COMPONENT, name = "is_invitation_code_valid", err)] + async fn handle( + &self, + invitation_code: Self::Input, + metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + match &self.backend { + RpcBackend::Sequencer { account_admission, .. } => { + if account_admission.is_disabled() { + return Ok(true); + } + let invitation = InvitationCode::new(&invitation_code) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + account_admission + .is_invitation_code_valid(invitation) + .await + .map_err(|error| Status::internal(error.as_report())) + }, + RpcBackend::FullNode { source_rpc, .. } => { + let mut request = + Request::new(proto::miden::node::v1::IsInvitationCodeValidRequest { + invitation_code, + }); + if let Some(accept) = metadata.get(http::header::ACCEPT.as_str()) { + request.metadata_mut().insert(http::header::ACCEPT.as_str(), accept.clone()); + } + source_rpc + .as_ref() + .clone() + .is_invitation_code_valid(request) + .await + .map(|response| response.into_inner().valid) + }, + } + } +} diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index b691f8f203..67d8ccbfe8 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -2132,9 +2132,14 @@ async fn full_nodes_forward_allowlist_requests_to_the_sequencer() { None, ); let registration = proto::miden::node::v1::RegisterAccountRequest { - invitation_code: code, + invitation_code: code.clone(), account_id: Some(account.into()), }; + let code_query = || { + Request::new(proto::miden::node::v1::IsInvitationCodeValidRequest { + invitation_code: code.clone(), + }) + }; let request = || { let mut request = Request::new(registration.clone()); request.metadata_mut().insert( @@ -2153,6 +2158,7 @@ async fn full_nodes_forward_allowlist_requests_to_the_sequencer() { query }; assert!(!rpc.is_account_allowed(query()).await.unwrap().into_inner().allowed); + assert!(!rpc.is_invitation_code_valid(code_query()).await.unwrap().into_inner().valid); assert_eq!( rpc.register_account(request()).await.unwrap_err().code(), tonic::Code::NotFound @@ -2164,9 +2170,11 @@ async fn full_nodes_forward_allowlist_requests_to_the_sequencer() { }) .await .unwrap(); + assert!(rpc.is_invitation_code_valid(code_query()).await.unwrap().into_inner().valid); rpc.register_account(request()).await.unwrap(); rpc.register_account(request()).await.unwrap(); assert!(rpc.is_account_allowed(query()).await.unwrap().into_inner().allowed); + assert!(!rpc.is_invitation_code_valid(code_query()).await.unwrap().into_inner().valid); let mut wrong_network = query(); wrong_network.metadata_mut().insert( ACCEPT.as_str(), diff --git a/crates/rpc/src/tests/allowlist.rs b/crates/rpc/src/tests/allowlist.rs index 41cb59fbd5..7796b03be8 100644 --- a/crates/rpc/src/tests/allowlist.rs +++ b/crates/rpc/src/tests/allowlist.rs @@ -546,3 +546,78 @@ async fn submission_endpoints_reject_unregistered_creation_without_partial_batch .await .unwrap(); } + +#[tokio::test] +async fn is_invitation_code_valid_respects_enforcement() { + let store = TestStore::start().await; + let allowlist = store.bootstrap_allowlist(); + let guard = TestServerGuard(CancellationToken::new()); + let block_producer = BlockProducerApi::new( + Arc::clone(&store.state), + 0.into(), + BlockProducerApiConfig::default(), + guard.0.clone(), + ); + let account = AccountId::dummy( + [0; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ); + let [unused, registered, unknown] = ["unused", "registered", "unknown"]; + for code in [unused, registered] { + allowlist + .import_invitation(InvitationEntry { + invitation_code: InvitationCode::new(code).unwrap(), + account_id: None, + }) + .await + .unwrap(); + } + allowlist + .register_account(InvitationCode::new(registered).unwrap(), account) + .await + .unwrap(); + let path = DataDirectory::load(store.data_directory.clone()) + .unwrap() + .allowlist_database_path(); + let disabled_allowlist = Arc::new(AccountAllowlist::load(&path).unwrap()); + + for (admission, enforced) in [ + (AccountAdmission::enabled(allowlist), true), + (AccountAdmission::disabled(disabled_allowlist), false), + ] { + // Disabled enforcement must not depend on database availability. + if !enforced { + fs_err::remove_file(&path).unwrap(); + } + let rpc = RpcService::new( + Arc::clone(&store.state), + RpcBackend::sequencer( + block_producer.clone(), + ValidatorClients::new(vec![dummy_client::()]).unwrap(), + admission, + ), + None, + NonZeroUsize::new(1).unwrap(), + None, + ); + let query = |code: &str| { + Request::new(proto::miden::node::v1::IsInvitationCodeValidRequest { + invitation_code: code.to_owned(), + }) + }; + if enforced { + assert_eq!( + rpc.is_invitation_code_valid(query("")).await.unwrap_err().code(), + tonic::Code::InvalidArgument + ); + } else { + assert!(rpc.is_invitation_code_valid(query("")).await.unwrap().into_inner().valid); + } + for (code, expected) in [(unused, true), (registered, !enforced), (unknown, !enforced)] { + let response = rpc.is_invitation_code_valid(query(code)).await.unwrap(); + assert_eq!(response.into_inner().valid, expected, "code {code:?}"); + } + } +} diff --git a/docs/external/src/full-node/rpc.md b/docs/external/src/full-node/rpc.md index 63e715f6a3..966c19f902 100644 --- a/docs/external/src/full-node/rpc.md +++ b/docs/external/src/full-node/rpc.md @@ -24,8 +24,8 @@ cannot reach a validator or its upstream source can no longer serve submitting c ## Account Registration -Full nodes forward `RegisterAccount` requests to their configured upstream RPC source. The sequencer stores the -registration. Full nodes do not maintain a local allowlist. This also applies when pre-authenticated transaction +Full nodes forward `RegisterAccount`, `IsAccountAllowed`, and `IsInvitationCodeValid` requests to their configured +upstream RPC source. The sequencer stores the registration. Full nodes do not maintain a local allowlist. This also applies when pre-authenticated transaction submission is configured. ## Transaction Submission diff --git a/docs/external/src/rpc/index.md b/docs/external/src/rpc/index.md index 1174ab5682..f570b85eaf 100644 --- a/docs/external/src/rpc/index.md +++ b/docs/external/src/rpc/index.md @@ -54,7 +54,7 @@ The RPC server supports: | Status and limits | `Status`, `GetLimits` | | State queries | `GetAccount`, `GetBlockByNumber`, `GetBlockHeaderByNumber`, `GetNotesById`, `GetNoteScriptByRoot` | | Transaction submission | `GetTransactionEncryptionKey`, `SubmitProvenTx`, `SubmitProvenTxBatch` | -| Account registration | `RegisterAccount` | +| Account registration | `RegisterAccount`, `IsAccountAllowed`, `IsInvitationCodeValid` | | State synchronization | `SyncTransactions`, `SyncNotes`, `SyncNullifiers`, `SyncAccountVault`, `SyncAccountStorageMaps`, `SyncChainMmr` | | Block streaming | `BlockSubscription`, `ProofSubscription` | | Network note debugging | `GetNetworkNoteStatus` | diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index ac4acff7fb..9635502a6b 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -56,6 +56,10 @@ invitation codes over a network. Do not log invitation codes. Full nodes forward `IsAccountAllowed` takes an account ID in `account_id` and returns `true` if allowlist enforcement is disabled or the account is allowlisted. Full nodes forward this query to the sequencer. +`IsInvitationCodeValid` takes an invitation code in `invitation_code` and returns `true` if allowlist enforcement is +disabled or the code exists and no account has registered with it. It does not consume the code. With enforcement +enabled, an empty code is rejected with `INVALID_ARGUMENT`. Full nodes forward this query to the sequencer. + ## Transaction Submission The sequencer requires registration before a transaction creates a non-network account. Transactions for existing diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index c2a30a0408..9cafe9a09d 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -46,6 +46,12 @@ service NodeService { // Returns true if account allowlist enforcement is disabled or the account is allowlisted. rpc IsAccountAllowed(IsAccountAllowedRequest) returns (IsAccountAllowedResponse) {} + // Returns true if account allowlist enforcement is disabled or the invitation code exists and + // is not registered to an account. + // Returns INVALID_ARGUMENT for an empty code when enforcement is enabled. + // Does not consume the code. + rpc IsInvitationCodeValid(IsInvitationCodeValidRequest) returns (IsInvitationCodeValidResponse) {} + // Returns block data for the specified block number, optionally including the block proof. rpc GetBlockByNumber(GetBlockByNumberRequest) returns (GetBlockByNumberResponse) {} @@ -164,6 +170,19 @@ message IsAccountAllowedResponse { bool allowed = 1; } +// IS INVITATION CODE VALID +// ================================================================================================ + +message IsInvitationCodeValidRequest { + // The original, nonempty invitation code. Codes are case-sensitive. + // Ignored when allowlist enforcement is disabled. + string invitation_code = 1; +} + +message IsInvitationCodeValidResponse { + bool valid = 1; +} + // BLOCK SUBSCRIPTION // ================================================================================================ From b22177a8ca8e46612c00f50b7b5ec76cc8b7dd85 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:39:58 +0200 Subject: [PATCH 2/2] docs(rpc): clarify invitation code validity and fix formatting --- crates/rpc/src/server/admission.rs | 5 ++++- docs/external/src/full-node/rpc.md | 4 ++-- docs/external/src/rpc/public-api.md | 9 ++++++--- proto/proto/rpc.proto | 9 +++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/rpc/src/server/admission.rs b/crates/rpc/src/server/admission.rs index 7de79068de..83007770a5 100644 --- a/crates/rpc/src/server/admission.rs +++ b/crates/rpc/src/server/admission.rs @@ -52,7 +52,10 @@ impl AccountAdmission { self.allowlist.contains_account(account_id).await } - /// Returns true if enforcement is disabled or the invitation code exists and is unused. + /// Returns true if the invitation code can be used to register an account. The code must be + /// registered in the invitation registry and unused. + /// + /// Always returns true when account allowlist enforcement is disabled. pub(crate) async fn is_invitation_code_valid( &self, invitation_code: InvitationCode, diff --git a/docs/external/src/full-node/rpc.md b/docs/external/src/full-node/rpc.md index 966c19f902..fc6f027025 100644 --- a/docs/external/src/full-node/rpc.md +++ b/docs/external/src/full-node/rpc.md @@ -25,8 +25,8 @@ cannot reach a validator or its upstream source can no longer serve submitting c ## Account Registration Full nodes forward `RegisterAccount`, `IsAccountAllowed`, and `IsInvitationCodeValid` requests to their configured -upstream RPC source. The sequencer stores the registration. Full nodes do not maintain a local allowlist. This also applies when pre-authenticated transaction -submission is configured. +upstream RPC source. The sequencer stores the registration. Full nodes do not maintain a local allowlist. This also +applies when pre-authenticated transaction submission is configured. ## Transaction Submission diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 9635502a6b..e19495639c 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -56,9 +56,12 @@ invitation codes over a network. Do not log invitation codes. Full nodes forward `IsAccountAllowed` takes an account ID in `account_id` and returns `true` if allowlist enforcement is disabled or the account is allowlisted. Full nodes forward this query to the sequencer. -`IsInvitationCodeValid` takes an invitation code in `invitation_code` and returns `true` if allowlist enforcement is -disabled or the code exists and no account has registered with it. It does not consume the code. With enforcement -enabled, an empty code is rejected with `INVALID_ARGUMENT`. Full nodes forward this query to the sequencer. +`IsInvitationCodeValid` returns `true` if the invitation code can be used to register an account. The code must be +registered in the invitation registry and unused. Send the code in `invitation_code`. This check does not consume the +code. + +When account allowlist enforcement is disabled, this method always returns `true`. With enforcement enabled, an empty +code is rejected with `INVALID_ARGUMENT`. Full nodes forward this query to the sequencer. ## Transaction Submission diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 9cafe9a09d..fc8de00b61 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -46,10 +46,11 @@ service NodeService { // Returns true if account allowlist enforcement is disabled or the account is allowlisted. rpc IsAccountAllowed(IsAccountAllowedRequest) returns (IsAccountAllowedResponse) {} - // Returns true if account allowlist enforcement is disabled or the invitation code exists and - // is not registered to an account. - // Returns INVALID_ARGUMENT for an empty code when enforcement is enabled. - // Does not consume the code. + // Returns true if the invitation code can be used to register an account. The code must be + // registered in the invitation registry and unused. This check does not consume the code. + // + // Always returns true when account allowlist enforcement is disabled. When enforcement is + // enabled, an empty code returns INVALID_ARGUMENT. rpc IsInvitationCodeValid(IsInvitationCodeValidRequest) returns (IsInvitationCodeValidResponse) {} // Returns block data for the specified block number, optionally including the block proof.