diff --git a/Cargo.lock b/Cargo.lock index 4129dd61b..2fa175727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4294,6 +4294,7 @@ dependencies = [ "rand_chacha 0.10.0", "rayon", "serde", + "sha2 0.10.9", "tempfile", "thiserror 2.0.20", "thread-priority", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index e6453b7bd..f3bf4eb3f 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -40,6 +40,7 @@ rand = { workspace = true } rand_chacha = { workspace = true } rayon = { workspace = true } serde = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } thread-priority = { workspace = true } tokio = { features = ["fs", "rt-multi-thread"], workspace = true } diff --git a/crates/store/build.rs b/crates/store/build.rs index 7d5c4ad28..2857b9ea7 100644 --- a/crates/store/build.rs +++ b/crates/store/build.rs @@ -1,5 +1,9 @@ fn main() -> Result<(), Box> { miden_node_db::migration::Migrator::generate("src/db/migrations", "db_migrator.rs")?; + miden_node_db::migration::Migrator::generate( + "src/allowlist/migrations", + "allowlist_migrator.rs", + )?; // If we do one re-write, the default rules are disabled, // hence we need to trigger explicitly on `Cargo.toml`. diff --git a/crates/store/src/allowlist/invitation.rs b/crates/store/src/allowlist/invitation.rs new file mode 100644 index 000000000..3e97de6b0 --- /dev/null +++ b/crates/store/src/allowlist/invitation.rs @@ -0,0 +1,37 @@ +use std::fmt; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// A nonempty invitation code represented by its SHA-256 digest. +/// +/// The digest permits code matching without storing a code that an attacker can redeem after a database leak. +/// Construction does not retain the original bytes. Debug output hides the digest. +/// Callers must use random invitation codes with enough entropy to resist guessing. +#[derive(Clone, PartialEq, Eq)] +pub struct InvitationCode([u8; 32]); + +impl InvitationCode { + /// Computes a digest of the exact invitation code bytes without text normalization. + pub fn new(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(InvalidInvitationCode); + } + Ok(Self(Sha256::digest(bytes).into())) + } + + pub(crate) fn digest(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for InvitationCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("InvitationCode([REDACTED])") + } +} + +/// An invitation code must contain at least one byte. +#[derive(Debug, Error, PartialEq, Eq)] +#[error("invitation code must not be empty")] +pub struct InvalidInvitationCode; diff --git a/crates/store/src/allowlist/migrations.rs b/crates/store/src/allowlist/migrations.rs new file mode 100644 index 000000000..938fa327f --- /dev/null +++ b/crates/store/src/allowlist/migrations.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/allowlist_migrator.rs")); diff --git a/crates/store/src/allowlist/migrations/001_initial.sql b/crates/store/src/allowlist/migrations/001_initial.sql new file mode 100644 index 000000000..371a5ca43 --- /dev/null +++ b/crates/store/src/allowlist/migrations/001_initial.sql @@ -0,0 +1,11 @@ +CREATE TABLE account_allowlist ( + id INTEGER PRIMARY KEY, + account_id BLOB, + invitation_digest BLOB, + created_at BIGINT NOT NULL, + CHECK (account_id IS NOT NULL OR invitation_digest IS NOT NULL), + CHECK (length(invitation_digest) = 32) +); + +CREATE UNIQUE INDEX idx_account_allowlist_account_id ON account_allowlist(account_id); +CREATE UNIQUE INDEX idx_account_allowlist_invitation_digest ON account_allowlist(invitation_digest); diff --git a/crates/store/src/allowlist/mod.rs b/crates/store/src/allowlist/mod.rs new file mode 100644 index 000000000..414ff0a07 --- /dev/null +++ b/crates/store/src/allowlist/mod.rs @@ -0,0 +1,243 @@ +//! Stores account registrations and invitation codes for the sequencer. +//! +//! The registry contains unused invitation codes, accounts registered with an invitation code, and accounts added directly. +//! Registry membership does not depend on account deployment or transaction admission policy. + +use std::path::Path; + +use miden_node_db::sqlite::{DbReader, DbWriter, WriteTx}; +use miden_protocol::account::AccountId; +use thiserror::Error; + +use crate::DatabaseError; + +mod invitation; +mod migrations; +mod queries; + +pub use invitation::{InvalidInvitationCode, InvitationCode}; + +#[cfg(test)] +mod tests; + +/// An invitation code to import, with an optional account registration. +#[derive(Clone, Debug)] +pub struct InvitationEntry { + pub invitation_code: InvitationCode, + pub account_id: Option, +} + +/// The registration state of an invitation code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvitationStatus { + Unknown, + Unused, + Registered(AccountId), +} + +/// The result of a successful registration request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RegistrationOutcome { + Registered, + /// The same invitation code was already registered to the same account. + AlreadyRegistered, +} + +/// A registry operation failed. +#[derive(Debug, Error)] +pub enum AllowlistError { + #[error("invitation code does not exist")] + InvitationNotFound, + #[error("invitation code is already registered to another account")] + InvitationAlreadyUsed, + #[error("account {0} is already registered")] + AccountAlreadyRegistered(AccountId), + #[error("account registry database operation failed")] + Database(#[source] DatabaseError), +} + +/// Read-only access to the account registry. +#[derive(Clone)] +pub struct AccountAllowlistReader { + db: DbReader, +} + +impl AccountAllowlistReader { + /// Returns whether the registry contains the account. + pub async fn contains_account(&self, account_id: AccountId) -> Result { + self.db + .read("allowlist.contains_account", move |tx| { + queries::contains_account(tx, account_id) + }) + .await + .map_err(DatabaseError::DatabaseError) + } + + /// Returns the registration state of the invitation code. + pub async fn invitation_status( + &self, + invitation_code: InvitationCode, + ) -> Result { + self.db + .read("allowlist.invitation_status", move |tx| { + queries::invitation_status(tx, &invitation_code) + }) + .await + .map_err(DatabaseError::DatabaseError) + } +} + +/// Persistent account registry in a separate SQLite database. +/// +/// The registry has separate reader and writer pools. Its writes do not wait for block database writes. +/// Each entry records its creation time in UTC Unix seconds. Registration and retries preserve this time. +/// Write transactions acquire the write lock before they read registrations. +/// Each write operation commits all its changes together. Failed operations leave no changes. +pub struct AccountAllowlist { + writer: DbWriter, + reader: AccountAllowlistReader, +} + +impl std::ops::Deref for AccountAllowlist { + type Target = AccountAllowlistReader; + + fn deref(&self) -> &Self::Target { + &self.reader + } +} + +impl AccountAllowlist { + /// Creates the registry database and applies all migrations. + /// + /// The database file must not exist. + pub fn bootstrap(database_filepath: impl AsRef) -> Result<(), DatabaseError> { + let migrator = migrations::migrator() + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError)?; + migrator + .bootstrap(database_filepath) + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError) + } + + /// Opens the registry database after verifying its schema. + /// + /// The database must exist and have the latest schema. This method does not apply migrations. + pub fn load(database_filepath: impl AsRef) -> Result { + let database_filepath = database_filepath.as_ref(); + let migrator = migrations::migrator() + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError)?; + migrator + .verify_latest_schema(database_filepath) + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError)?; + let (writer, reader) = + miden_node_db::sqlite::open(database_filepath).map_err(DatabaseError::DatabaseError)?; + Ok(Self { + writer, + reader: AccountAllowlistReader { db: reader }, + }) + } + + /// Applies pending migrations to an existing registry database. + pub fn migrate(database_filepath: impl AsRef) -> Result<(), DatabaseError> { + let migrator = migrations::migrator() + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError)?; + migrator + .migrate(database_filepath) + .map_err(miden_node_db::DatabaseError::migration) + .map_err(DatabaseError::DatabaseError) + } + + /// Returns a read-only handle that shares the reader pool. + pub fn reader(&self) -> AccountAllowlistReader { + self.reader.clone() + } + + /// Imports invitation codes and their optional account registrations in one transaction. + /// + /// An entry without an account preserves any existing registration for its invitation code. + /// An entry with an account can register an unused invitation code. An identical registration has no effect. + /// A conflicting registration rejects the whole import. + pub async fn import_invitations( + &self, + entries: Vec, + ) -> Result<(), AllowlistError> { + self.transact("allowlist.import_invitations", move |tx| { + for entry in entries { + queries::import_invitation(tx, &entry)?; + } + Ok(()) + }) + .await + } + + /// Adds accounts without invitation codes in one transaction and returns the number of new registrations. + /// + /// Existing accounts keep their invitation code registrations, if any. + pub async fn add_accounts(&self, accounts: Vec) -> Result { + self.writer + .write("allowlist.add_accounts", move |tx| { + let mut inserted = 0; + for account_id in accounts { + inserted += queries::add_account(tx, account_id)?; + } + Ok::<_, miden_node_db::DatabaseError>(inserted) + }) + .await + .map_err(DatabaseError::DatabaseError) + } + + /// Registers an unused invitation code to an account in one transaction. + /// + /// A retry with the same invitation code and account succeeds without changes. + /// An account already registered by another method cannot consume an unused invitation code. + pub async fn register_account( + &self, + invitation_code: InvitationCode, + account_id: AccountId, + ) -> Result { + self.transact("allowlist.register_account", move |tx| { + queries::register_account(tx, &invitation_code, account_id) + }) + .await + } + + async fn transact( + &self, + name: &'static str, + query: impl FnOnce(&WriteTx<'_>) -> Result + Send + 'static, + ) -> Result { + let tx = self + .writer + .begin_write() + .await + .map_err(DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)?; + let result = tx + .run(name, move |tx| Ok::<_, miden_node_db::DatabaseError>(query(tx))) + .await + .map_err(DatabaseError::DatabaseError) + .map_err(AllowlistError::Database) + .and_then(std::convert::identity); + + match result { + Ok(value) => { + tx.commit() + .await + .map_err(DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)?; + Ok(value) + }, + Err(error) => { + tx.rollback() + .await + .map_err(DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)?; + Err(error) + }, + } + } +} diff --git a/crates/store/src/allowlist/queries.rs b/crates/store/src/allowlist/queries.rs new file mode 100644 index 000000000..744e3bafd --- /dev/null +++ b/crates/store/src/allowlist/queries.rs @@ -0,0 +1,151 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_protocol::account::AccountId; + +use super::{ + AllowlistError, + InvitationCode, + InvitationEntry, + InvitationStatus, + RegistrationOutcome, +}; + +pub(super) fn contains_account( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result { + Ok(tx + .query( + "SELECT EXISTS(SELECT 1 FROM account_allowlist WHERE account_id = ?1)", + &[&account_id], + |row| row.get::(0), + )? + .into_iter() + .next() + .unwrap_or(false)) +} + +pub(super) fn invitation_status( + tx: &ReadTx<'_>, + invitation: &InvitationCode, +) -> Result { + let account = tx + .query( + "SELECT account_id FROM account_allowlist WHERE invitation_digest = ?1", + &[&invitation.digest().to_vec()], + |row| row.get::>(0), + )? + .into_iter() + .next(); + + Ok(match account { + None => InvitationStatus::Unknown, + Some(None) => InvitationStatus::Unused, + Some(Some(account)) => InvitationStatus::Registered(account), + }) +} + +pub(super) fn add_account(tx: &WriteTx<'_>, account_id: AccountId) -> Result { + tx.execute( + "INSERT INTO account_allowlist (account_id, created_at) VALUES (?1, ?2) + ON CONFLICT(account_id) DO NOTHING", + &[&account_id, ¤t_timestamp()], + ) +} + +pub(super) fn import_invitation( + tx: &WriteTx<'_>, + entry: &InvitationEntry, +) -> Result<(), AllowlistError> { + match invitation_status(tx, &entry.invitation_code) + .map_err(crate::DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)? + { + InvitationStatus::Unknown => { + if let Some(account_id) = entry.account_id { + ensure_account_unregistered(tx, account_id)?; + } + tx.execute( + "INSERT INTO account_allowlist (invitation_digest, account_id, created_at) + VALUES (?1, ?2, ?3)", + &[ + &entry.invitation_code.digest().to_vec(), + &entry.account_id, + ¤t_timestamp(), + ], + ) + .map_err(crate::DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)?; + }, + InvitationStatus::Unused => { + if let Some(account_id) = entry.account_id { + bind_invitation(tx, &entry.invitation_code, account_id)?; + } + }, + InvitationStatus::Registered(account_id) => { + if entry.account_id.is_some_and(|requested| requested != account_id) { + return Err(AllowlistError::InvitationAlreadyUsed); + } + }, + } + Ok(()) +} + +pub(super) fn register_account( + tx: &WriteTx<'_>, + invitation: &InvitationCode, + account_id: AccountId, +) -> Result { + match invitation_status(tx, invitation) + .map_err(crate::DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)? + { + InvitationStatus::Unknown => Err(AllowlistError::InvitationNotFound), + InvitationStatus::Registered(registered) if registered == account_id => { + Ok(RegistrationOutcome::AlreadyRegistered) + }, + InvitationStatus::Registered(_) => Err(AllowlistError::InvitationAlreadyUsed), + InvitationStatus::Unused => { + bind_invitation(tx, invitation, account_id)?; + Ok(RegistrationOutcome::Registered) + }, + } +} + +fn bind_invitation( + tx: &WriteTx<'_>, + invitation: &InvitationCode, + account_id: AccountId, +) -> Result<(), AllowlistError> { + ensure_account_unregistered(tx, account_id)?; + tx.execute( + "UPDATE account_allowlist SET account_id = ?1 WHERE invitation_digest = ?2", + &[&account_id, &invitation.digest().to_vec()], + ) + .map_err(crate::DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)?; + Ok(()) +} + +fn ensure_account_unregistered( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result<(), AllowlistError> { + if contains_account(tx, account_id) + .map_err(crate::DatabaseError::DatabaseError) + .map_err(AllowlistError::Database)? + { + return Err(AllowlistError::AccountAlreadyRegistered(account_id)); + } + Ok(()) +} + +fn current_timestamp() -> i64 { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is before the Unix epoch") + .as_secs(); + i64::try_from(seconds).expect("Unix timestamp exceeds i64::MAX") +} diff --git a/crates/store/src/allowlist/tests.rs b/crates/store/src/allowlist/tests.rs new file mode 100644 index 000000000..d61b13112 --- /dev/null +++ b/crates/store/src/allowlist/tests.rs @@ -0,0 +1,291 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use assert_matches::assert_matches; +use miden_node_db::migration::{SchemaHash, SchemaHashes}; +use miden_protocol::account::AccountId; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PRIVATE_SENDER, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, +}; +use tempfile::TempDir; + +use super::{ + AccountAllowlist, + AllowlistError, + InvitationCode, + InvitationEntry, + InvitationStatus, + RegistrationOutcome, +}; +use crate::DataDirectory; + +fn setup() -> (TempDir, AccountAllowlist) { + let dir = tempfile::tempdir().unwrap(); + AccountAllowlist::bootstrap(data_directory(&dir).allowlist_database_path()).unwrap(); + let registry = reopen(&dir); + (dir, registry) +} + +fn reopen(dir: &TempDir) -> AccountAllowlist { + AccountAllowlist::load(data_directory(dir).allowlist_database_path()).unwrap() +} + +fn data_directory(dir: &TempDir) -> DataDirectory { + DataDirectory::load(dir.path().to_path_buf()).unwrap() +} + +fn account(index: usize) -> AccountId { + [ + ACCOUNT_ID_PRIVATE_SENDER, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ][index] + .try_into() + .unwrap() +} + +fn invitation(value: u8) -> InvitationCode { + InvitationCode::new(&[value; 16]).unwrap() +} + +fn entry(value: u8, account_id: Option) -> InvitationEntry { + InvitationEntry { + invitation_code: invitation(value), + account_id, + } +} + +#[test] +fn migration_schema_hashes_are_stable() { + const EXPECTED: [SchemaHash; 1] = [SchemaHash::from_hex( + "47ba3651faa1c3013f83adc935b605b51b55589fd60ee0446f424e1e45f30702", + )]; + let migrator = super::migrations::migrator().unwrap(); + pretty_assertions::assert_eq!(migrator.schema_hashes(), SchemaHashes(&EXPECTED)); +} + +#[tokio::test] +async fn registrations_and_creation_times_persist() { + const CREATED_AT: i64 = 946_684_800; + + let (dir, registry) = setup(); + let before = + i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap(); + let entries = vec![entry(1, None), entry(2, Some(account(1)))]; + registry.import_invitations(entries.clone()).await.unwrap(); + registry.add_accounts(vec![account(2)]).await.unwrap(); + let after = + i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap(); + + let path = data_directory(&dir).allowlist_database_path(); + let timestamps = registry + .reader + .db + .read("creation_times", |tx| { + tx.query("SELECT created_at FROM account_allowlist", &[], |row| row.get::(0)) + }) + .await + .unwrap(); + assert_eq!(timestamps.len(), 3); + assert!(timestamps.iter().all(|timestamp| (before..=after).contains(timestamp))); + // An earlier timestamp detects replacement without a clock delay. + registry + .writer + .write("set_creation_times", |tx| { + tx.execute("UPDATE account_allowlist SET created_at = ?1", &[&CREATED_AT]) + }) + .await + .unwrap(); + + registry.register_account(invitation(1), account(0)).await.unwrap(); + registry.import_invitations(entries).await.unwrap(); + assert_eq!( + registry.add_accounts(vec![account(0), account(1), account(2)]).await.unwrap(), + 0 + ); + drop(registry); + + AccountAllowlist::migrate(&path).unwrap(); + let registry = reopen(&dir); + for index in 0..3 { + assert!(registry.contains_account(account(index)).await.unwrap()); + } + for (code, id) in [(1, account(0)), (2, account(1))] { + assert_eq!( + registry.invitation_status(invitation(code)).await.unwrap(), + InvitationStatus::Registered(id) + ); + } + let timestamps = registry + .reader + .db + .read("creation_times", |tx| { + tx.query("SELECT created_at FROM account_allowlist", &[], |row| row.get::(0)) + }) + .await + .unwrap(); + assert_eq!(timestamps, vec![CREATED_AT; 3]); +} + +#[tokio::test] +async fn registration_rules() { + let (_dir, registry) = setup(); + let reader = registry.reader(); + + assert!(!reader.contains_account(account(0)).await.unwrap()); + assert_eq!( + reader.invitation_status(invitation(1)).await.unwrap(), + InvitationStatus::Unknown + ); + assert_matches!( + registry.register_account(invitation(1), account(0)).await, + Err(AllowlistError::InvitationNotFound) + ); + + registry.import_invitations(vec![entry(1, None), entry(2, None)]).await.unwrap(); + assert_eq!(reader.invitation_status(invitation(1)).await.unwrap(), InvitationStatus::Unused); + assert!(!reader.contains_account(account(0)).await.unwrap()); + + assert_eq!( + registry.register_account(invitation(1), account(0)).await.unwrap(), + RegistrationOutcome::Registered + ); + assert_eq!( + registry.register_account(invitation(1), account(0)).await.unwrap(), + RegistrationOutcome::AlreadyRegistered + ); + assert_matches!( + registry.register_account(invitation(1), account(1)).await, + Err(AllowlistError::InvitationAlreadyUsed) + ); + assert_matches!( + registry.register_account(invitation(2), account(0)).await, + Err(AllowlistError::AccountAlreadyRegistered(id)) if id == account(0) + ); + assert!(!registry.contains_account(account(1)).await.unwrap()); + assert_eq!( + registry.add_accounts(vec![account(0), account(1), account(1)]).await.unwrap(), + 1 + ); + assert_eq!(registry.add_accounts(vec![account(1)]).await.unwrap(), 0); + assert_matches!( + registry.register_account(invitation(2), account(1)).await, + Err(AllowlistError::AccountAlreadyRegistered(id)) if id == account(1) + ); + assert_eq!( + reader.invitation_status(invitation(1)).await.unwrap(), + InvitationStatus::Registered(account(0)) + ); + assert_eq!(reader.invitation_status(invitation(2)).await.unwrap(), InvitationStatus::Unused); + assert!(reader.contains_account(account(0)).await.unwrap()); + assert!(reader.contains_account(account(1)).await.unwrap()); +} + +#[tokio::test] +async fn conflicting_invitation_import_rolls_back_inserts_and_registrations() { + let (_dir, registry) = setup(); + registry + .import_invitations(vec![entry(1, None), entry(2, Some(account(0)))]) + .await + .unwrap(); + + assert_matches!( + registry + .import_invitations(vec![ + entry(3, Some(account(1))), + entry(1, Some(account(2))), + entry(2, Some(account(1))), + ]) + .await, + Err(AllowlistError::InvitationAlreadyUsed) + ); + + assert_eq!( + registry.invitation_status(invitation(1)).await.unwrap(), + InvitationStatus::Unused + ); + assert_eq!( + registry.invitation_status(invitation(2)).await.unwrap(), + InvitationStatus::Registered(account(0)) + ); + assert_eq!( + registry.invitation_status(invitation(3)).await.unwrap(), + InvitationStatus::Unknown + ); + assert!(!registry.contains_account(account(1)).await.unwrap()); + assert!(!registry.contains_account(account(2)).await.unwrap()); + + let entries = vec![entry(1, Some(account(2))), entry(3, Some(account(1)))]; + registry.import_invitations(entries.clone()).await.unwrap(); + registry.import_invitations(entries).await.unwrap(); + assert_eq!( + registry.invitation_status(invitation(1)).await.unwrap(), + InvitationStatus::Registered(account(2)) + ); + assert_eq!( + registry.invitation_status(invitation(3)).await.unwrap(), + InvitationStatus::Registered(account(1)) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_accounts_cannot_claim_the_same_invitation() { + let (dir, registry) = setup(); + registry.import_invitations(vec![entry(1, None)]).await.unwrap(); + let other = reopen(&dir); + + let (first, second) = tokio::join!( + registry.register_account(invitation(1), account(0)), + other.register_account(invitation(1), account(1)), + ); + let winner = match (first, second) { + (Ok(RegistrationOutcome::Registered), Err(AllowlistError::InvitationAlreadyUsed)) => 0, + (Err(AllowlistError::InvitationAlreadyUsed), Ok(RegistrationOutcome::Registered)) => 1, + results => panic!("expected one successful registration, got {results:?}"), + }; + assert_eq!( + registry.invitation_status(invitation(1)).await.unwrap(), + InvitationStatus::Registered(account(winner)) + ); + assert!(registry.contains_account(account(winner)).await.unwrap()); + assert!(!registry.contains_account(account(1 - winner)).await.unwrap()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_invitations_cannot_register_the_same_account() { + let (dir, registry) = setup(); + registry.import_invitations(vec![entry(1, None), entry(2, None)]).await.unwrap(); + let other = reopen(&dir); + + let (first, second) = tokio::join!( + registry.register_account(invitation(1), account(0)), + other.register_account(invitation(2), account(0)), + ); + let winner = match (first, second) { + (Ok(RegistrationOutcome::Registered), Err(AllowlistError::AccountAlreadyRegistered(_))) => { + 1 + }, + (Err(AllowlistError::AccountAlreadyRegistered(_)), Ok(RegistrationOutcome::Registered)) => { + 2 + }, + results => panic!("expected one successful registration, got {results:?}"), + }; + assert_eq!( + registry.invitation_status(invitation(winner)).await.unwrap(), + InvitationStatus::Registered(account(0)) + ); + assert_eq!( + registry.invitation_status(invitation(3 - winner)).await.unwrap(), + InvitationStatus::Unused + ); +} + +#[test] +fn invitation_codes_reject_empty_input_and_hide_debug_values() { + assert!(InvitationCode::new(&[]).is_err()); + let invitation = InvitationCode::new(b"private invitation code").unwrap(); + let debug = format!("{invitation:?}"); + assert!(!debug.contains("private invitation code")); + assert!(!debug.contains(&hex::encode(invitation.digest()))); +} diff --git a/crates/store/src/data_directory.rs b/crates/store/src/data_directory.rs index e83f9d3a3..975dbeabb 100644 --- a/crates/store/src/data_directory.rs +++ b/crates/store/src/data_directory.rs @@ -29,6 +29,10 @@ impl DataDirectory { self.0.join("miden-store.sqlite3") } + pub fn allowlist_database_path(&self) -> PathBuf { + self.0.join("miden-allowlist.sqlite3") + } + pub fn display(&self) -> std::path::Display<'_> { self.0.display() } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 16883b6e3..9705d23e4 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -3,6 +3,7 @@ extern crate miden_node_tracing as tracing; mod account_state_forest; mod accounts; +pub mod allowlist; mod blocks; mod data_directory; mod db;