Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/block-producer/src/rpc_sync/block_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions crates/proto/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions crates/proto/src/domain/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<proto::miden::node::v1::IsInvitationCodeValidRequest>
for DecodedIsInvitationCodeValidRequest
{
type Error = ConversionError;

fn try_from(
request: proto::miden::node::v1::IsInvitationCodeValidRequest,
) -> Result<Self, Self::Error> {
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<Self::Verified, Self::Error> {
Ok(self.invitation_code)
}
}

// ACCOUNT REQUEST
// ================================================================================================

Expand Down
12 changes: 12 additions & 0 deletions crates/proto/src/domain/account/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 16 additions & 1 deletion crates/rpc/src/server/admission.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,6 +52,21 @@ impl AccountAdmission {
self.allowlist.contains_account(account_id).await
}

/// 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,
) -> Result<bool, DatabaseError> {
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,
Expand Down
1 change: 1 addition & 0 deletions crates/rpc/src/server/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions crates/rpc/src/server/api/is_invitation_code_valid.rs
Original file line number Diff line number Diff line change
@@ -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<Self::Input> {
request.decode_and_verify().map_err(ConversionError::into_status)
}

fn encode(
valid: Self::Output,
) -> tonic::Result<proto::miden::node::v1::IsInvitationCodeValidResponse> {
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<Self::Output> {
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)
},
}
}
}
10 changes: 9 additions & 1 deletion crates/rpc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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(),
Expand Down
75 changes: 75 additions & 0 deletions crates/rpc/src/tests/allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ValidatorClient>()]).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:?}");
}
}
}
6 changes: 3 additions & 3 deletions docs/external/src/full-node/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ 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
submission is configured.
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

Expand Down
2 changes: 1 addition & 1 deletion docs/external/src/rpc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 7 additions & 0 deletions docs/external/src/rpc/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ 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` 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

The sequencer requires registration before a transaction creates a non-network account. Transactions for existing
Expand Down
20 changes: 20 additions & 0 deletions proto/proto/rpc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ service NodeService {
// Returns true if account allowlist enforcement is disabled or the account is allowlisted.
rpc IsAccountAllowed(IsAccountAllowedRequest) returns (IsAccountAllowedResponse) {}

// 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.
rpc GetBlockByNumber(GetBlockByNumberRequest) returns (GetBlockByNumberResponse) {}

Expand Down Expand Up @@ -164,6 +171,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
// ================================================================================================

Expand Down
Loading