Skip to main content

miden_node_store/allowlist/
mod.rs

1//! Stores account registrations and invitation codes for the sequencer.
2//!
3//! The registry contains unused invitation codes, accounts registered with an invitation code, and accounts added directly.
4//! Registry membership does not depend on account deployment or transaction admission policy.
5
6use std::path::Path;
7
8use miden_node_db::sqlite::{DbReader, DbWriter, WriteTx};
9use miden_node_tracing::miden_instrument;
10use miden_protocol::account::AccountId;
11use thiserror::Error;
12
13use crate::{COMPONENT, DatabaseError};
14
15mod invitation;
16mod migrations;
17mod queries;
18
19pub use invitation::{InvalidInvitationCode, InvitationCode};
20
21#[cfg(test)]
22mod tests;
23
24/// An invitation code to import, with an optional account registration.
25#[derive(Clone, Debug)]
26pub struct InvitationEntry {
27    pub invitation_code: InvitationCode,
28    pub account_id: Option<AccountId>,
29}
30
31/// Changes made by an invitation import.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct InvitationImportOutcome {
34    pub invitation_added: bool,
35    /// The account registered by this import. Identical retries return `None`.
36    pub registered_account: Option<AccountId>,
37}
38
39/// The registration state of an invitation code.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum InvitationStatus {
42    Unknown,
43    Unused,
44    Registered(AccountId),
45}
46
47/// An invitation's registration and allowlist entry timestamp in UTC Unix seconds.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct InvitationInfo {
50    pub account_id: Option<AccountId>,
51    pub allowlisted_at: i64,
52}
53
54/// The result of a successful registration request.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum RegistrationOutcome {
57    Registered,
58    /// The same invitation code was already registered to the same account.
59    AlreadyRegistered,
60}
61
62/// A registry operation failed.
63#[derive(Debug, Error)]
64pub enum AllowlistError {
65    #[error("invitation code does not exist")]
66    InvitationNotFound,
67    #[error("invitation code is already registered to another account")]
68    InvitationAlreadyUsed,
69    #[error("account {0} is already registered")]
70    AccountAlreadyRegistered(AccountId),
71    #[error("account registry database operation failed")]
72    Database(#[source] DatabaseError),
73}
74
75/// Read-only access to the account registry.
76#[derive(Clone)]
77pub struct AccountAllowlistReader {
78    db: DbReader,
79}
80
81impl AccountAllowlistReader {
82    /// Returns when the account's allowlist entry was added, if it exists.
83    #[miden_instrument(
84        target = COMPONENT,
85        name = "store.allowlist.allowlisted_at",
86        fields(account.id = account_id),
87        err,
88    )]
89    pub async fn allowlisted_at(
90        &self,
91        account_id: AccountId,
92    ) -> Result<Option<i64>, DatabaseError> {
93        self.db
94            .read("allowlist.allowlisted_at", move |tx| queries::allowlisted_at(tx, account_id))
95            .await
96            .map_err(DatabaseError::DatabaseError)
97    }
98
99    /// Returns the invitation's registration and allowlist entry timestamp, if it exists.
100    #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_info", err)]
101    pub async fn invitation_info(
102        &self,
103        invitation_code: InvitationCode,
104    ) -> Result<Option<InvitationInfo>, DatabaseError> {
105        self.db
106            .read("allowlist.invitation_info", move |tx| {
107                queries::invitation_info(tx, &invitation_code)
108            })
109            .await
110            .map_err(DatabaseError::DatabaseError)
111    }
112
113    /// Returns whether the registry contains the account.
114    #[miden_instrument(
115        target = COMPONENT,
116        name = "store.allowlist.contains_account",
117        fields(account.id = account_id),
118        err,
119    )]
120    pub async fn contains_account(&self, account_id: AccountId) -> Result<bool, DatabaseError> {
121        self.db
122            .read("allowlist.contains_account", move |tx| {
123                queries::contains_account(tx, account_id)
124            })
125            .await
126            .map_err(DatabaseError::DatabaseError)
127    }
128
129    /// Returns the registration state of the invitation code.
130    #[miden_instrument(target = COMPONENT, name = "store.allowlist.invitation_status", err)]
131    pub async fn invitation_status(
132        &self,
133        invitation_code: InvitationCode,
134    ) -> Result<InvitationStatus, DatabaseError> {
135        self.db
136            .read("allowlist.invitation_status", move |tx| {
137                queries::invitation_status(tx, &invitation_code)
138            })
139            .await
140            .map_err(DatabaseError::DatabaseError)
141    }
142}
143
144/// Persistent account registry in a separate SQLite database.
145///
146/// The registry has separate reader and writer pools. Its writes do not wait for block database writes.
147/// Each entry records when it was added to the allowlist in UTC Unix seconds. Registration and retries preserve this time.
148/// Write transactions acquire the write lock before they read registrations.
149/// Each write operation commits all its changes together. Failed operations leave no changes.
150pub struct AccountAllowlist {
151    writer: DbWriter,
152    reader: AccountAllowlistReader,
153}
154
155impl std::ops::Deref for AccountAllowlist {
156    type Target = AccountAllowlistReader;
157
158    fn deref(&self) -> &Self::Target {
159        &self.reader
160    }
161}
162
163impl AccountAllowlist {
164    /// Creates the registry database and applies all migrations.
165    ///
166    /// The database file must not exist.
167    pub fn bootstrap(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
168        let migrator = migrations::migrator()
169            .map_err(miden_node_db::DatabaseError::migration)
170            .map_err(DatabaseError::DatabaseError)?;
171        migrator
172            .bootstrap(database_filepath)
173            .map_err(miden_node_db::DatabaseError::migration)
174            .map_err(DatabaseError::DatabaseError)
175    }
176
177    /// Opens the registry database after verifying its schema.
178    ///
179    /// The database must exist and have the latest schema. This method does not apply migrations.
180    pub fn load(database_filepath: impl AsRef<Path>) -> Result<Self, DatabaseError> {
181        let database_filepath = database_filepath.as_ref();
182        let migrator = migrations::migrator()
183            .map_err(miden_node_db::DatabaseError::migration)
184            .map_err(DatabaseError::DatabaseError)?;
185        migrator
186            .verify_latest_schema(database_filepath)
187            .map_err(miden_node_db::DatabaseError::migration)
188            .map_err(DatabaseError::DatabaseError)?;
189        let (writer, reader) =
190            miden_node_db::sqlite::open(database_filepath).map_err(DatabaseError::DatabaseError)?;
191        Ok(Self {
192            writer,
193            reader: AccountAllowlistReader { db: reader },
194        })
195    }
196
197    /// Applies pending migrations to an existing registry database.
198    pub fn migrate(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
199        let migrator = migrations::migrator()
200            .map_err(miden_node_db::DatabaseError::migration)
201            .map_err(DatabaseError::DatabaseError)?;
202        migrator
203            .migrate(database_filepath)
204            .map_err(miden_node_db::DatabaseError::migration)
205            .map_err(DatabaseError::DatabaseError)
206    }
207
208    /// Returns a read-only handle that shares the reader pool.
209    pub fn reader(&self) -> AccountAllowlistReader {
210        self.reader.clone()
211    }
212
213    /// Imports an invitation code with an optional account registration.
214    /// Returns whether the invitation is new and which account the import registered.
215    ///
216    /// An entry without an account preserves any existing registration for its invitation code.
217    /// An entry with an account can register an unused invitation code. An identical registration has no effect.
218    /// A conflicting registration leaves the registry unchanged.
219    #[miden_instrument(
220        target = COMPONENT,
221        name = "store.allowlist.import_invitation",
222        fields(account.id = entry.account_id),
223        err,
224    )]
225    pub async fn import_invitation(
226        &self,
227        entry: InvitationEntry,
228    ) -> Result<InvitationImportOutcome, AllowlistError> {
229        self.transact("allowlist.import_invitation", move |tx| {
230            queries::import_invitation(tx, &entry)
231        })
232        .await
233    }
234
235    /// Adds an account without an invitation code. Returns true if the registration is new.
236    ///
237    /// An existing account keeps its invitation code registration, if any.
238    #[miden_instrument(
239        target = COMPONENT,
240        name = "store.allowlist.add_account",
241        fields(account.id = account_id),
242        err,
243    )]
244    pub async fn add_account(&self, account_id: AccountId) -> Result<bool, DatabaseError> {
245        self.writer
246            .write("allowlist.add_account", move |tx| queries::add_account(tx, account_id))
247            .await
248            .map_err(DatabaseError::DatabaseError)
249    }
250
251    /// Registers an unused invitation code to an account in one transaction.
252    ///
253    /// A retry with the same invitation code and account succeeds without changes.
254    /// An account already registered by another method cannot consume an unused invitation code.
255    #[miden_instrument(
256        target = COMPONENT,
257        name = "store.allowlist.register_account",
258        fields(account.id = account_id),
259        err,
260    )]
261    pub async fn register_account(
262        &self,
263        invitation_code: InvitationCode,
264        account_id: AccountId,
265    ) -> Result<RegistrationOutcome, AllowlistError> {
266        self.transact("allowlist.register_account", move |tx| {
267            queries::register_account(tx, &invitation_code, account_id)
268        })
269        .await
270    }
271
272    async fn transact<T: Send + 'static>(
273        &self,
274        name: &'static str,
275        query: impl FnOnce(&WriteTx<'_>) -> Result<T, AllowlistError> + Send + 'static,
276    ) -> Result<T, AllowlistError> {
277        let tx = self
278            .writer
279            .begin_write()
280            .await
281            .map_err(DatabaseError::DatabaseError)
282            .map_err(AllowlistError::Database)?;
283        let result = tx
284            .run(name, move |tx| Ok::<_, miden_node_db::DatabaseError>(query(tx)))
285            .await
286            .map_err(DatabaseError::DatabaseError)
287            .map_err(AllowlistError::Database)
288            .and_then(std::convert::identity);
289
290        match result {
291            Ok(value) => {
292                tx.commit()
293                    .await
294                    .map_err(DatabaseError::DatabaseError)
295                    .map_err(AllowlistError::Database)?;
296                Ok(value)
297            },
298            Err(error) => {
299                tx.rollback()
300                    .await
301                    .map_err(DatabaseError::DatabaseError)
302                    .map_err(AllowlistError::Database)?;
303                Err(error)
304            },
305        }
306    }
307}