Skip to main content

miden_standards/account/metadata/
schema_commitment.rs

1//! Account storage schema commitment component.
2//!
3//! [`AccountSchemaCommitment`] computes a commitment over the merged storage schemas of all
4//! account components and stores the result in a dedicated slot. The companion
5//! [`AccountBuilderSchemaCommitmentExt`] trait adds a convenience method to
6//! [`AccountBuilder`](miden_protocol::account::AccountBuilder) for building accounts with an
7//! automatically computed schema commitment.
8
9use alloc::collections::BTreeMap;
10
11use miden_protocol::Word;
12use miden_protocol::account::component::{
13    AccountComponentMetadata,
14    SchemaType,
15    StorageSchema,
16    StorageSlotSchema,
17    WordSchema,
18};
19use miden_protocol::account::{
20    Account,
21    AccountBuilder,
22    AccountComponent,
23    StorageSlot,
24    StorageSlotName,
25};
26use miden_protocol::assembly::Library;
27use miden_protocol::errors::{AccountError, ComponentMetadataError};
28use miden_protocol::utils::serde::Deserializable;
29use miden_protocol::utils::sync::LazyLock;
30
31// CONSTANTS
32// ================================================================================================
33
34// Initialize the Storage Schema library only once.
35static STORAGE_SCHEMA_LIBRARY: LazyLock<Library> = LazyLock::new(|| {
36    let bytes = include_bytes!(concat!(
37        env!("OUT_DIR"),
38        "/assets/account_components/metadata/schema_commitment.masl"
39    ));
40    Library::read_from_bytes(bytes).expect("Shipped Storage Schema library is well-formed")
41});
42
43/// Schema commitment slot name.
44static SCHEMA_COMMITMENT_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
45    StorageSlotName::new("miden::standards::metadata::storage_schema::commitment")
46        .expect("storage slot name should be valid")
47});
48
49// ACCOUNT SCHEMA COMMITMENT
50// ================================================================================================
51
52/// An [`AccountComponent`] exposing the account storage schema commitment.
53///
54/// The [`AccountSchemaCommitment`] component can be constructed from a list of [`StorageSchema`],
55/// from which a commitment is computed and then inserted into the slot returned by
56/// [`AccountSchemaCommitment::schema_commitment_slot()`].
57///
58/// It reexports the `get_schema_commitment` procedure from
59/// `miden::standards::metadata::storage_schema`.
60///
61/// ## Storage Layout
62///
63/// - [`Self::schema_commitment_slot`]: Storage schema commitment.
64pub struct AccountSchemaCommitment {
65    schema_commitment: Word,
66}
67
68impl AccountSchemaCommitment {
69    /// Name of the component is set to match the path of the corresponding module in the standards
70    /// library.
71    const NAME: &str = "miden::standards::metadata::storage_schema";
72    /// Creates a new [`AccountSchemaCommitment`] component from storage schemas.
73    ///
74    /// The input schemas are merged into a single schema before the final commitment is computed.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the schemas contain conflicting definitions for the same slot name.
79    pub fn new<'a>(
80        schemas: impl IntoIterator<Item = &'a StorageSchema>,
81    ) -> Result<Self, ComponentMetadataError> {
82        Ok(Self {
83            schema_commitment: compute_schema_commitment(schemas)?,
84        })
85    }
86
87    /// Creates a new [`AccountSchemaCommitment`] component from a [`StorageSchema`].
88    pub fn from_schema(storage_schema: &StorageSchema) -> Result<Self, ComponentMetadataError> {
89        Self::new(core::slice::from_ref(storage_schema))
90    }
91
92    /// Returns the [`StorageSlotName`] where the schema commitment is stored.
93    pub fn schema_commitment_slot() -> &'static StorageSlotName {
94        &SCHEMA_COMMITMENT_SLOT_NAME
95    }
96
97    /// Returns the [`AccountComponentMetadata`] for this component.
98    pub fn component_metadata() -> AccountComponentMetadata {
99        let storage_schema = StorageSchema::new([(
100            Self::schema_commitment_slot().clone(),
101            StorageSlotSchema::value(
102                "Commitment to the storage schema of an account",
103                WordSchema::new_simple(SchemaType::native_word()),
104            ),
105        )])
106        .expect("storage schema should be valid");
107
108        AccountComponentMetadata::new(Self::NAME)
109            .with_description("Component exposing the account storage schema commitment")
110            .with_storage_schema(storage_schema)
111    }
112}
113
114impl From<AccountSchemaCommitment> for AccountComponent {
115    fn from(schema_commitment: AccountSchemaCommitment) -> Self {
116        let metadata = AccountSchemaCommitment::component_metadata();
117        let storage = vec![StorageSlot::with_value(
118            AccountSchemaCommitment::schema_commitment_slot().clone(),
119            schema_commitment.schema_commitment,
120        )];
121
122        AccountComponent::new(STORAGE_SCHEMA_LIBRARY.clone(), storage, metadata)
123            .expect("AccountSchemaCommitment is a valid account component")
124    }
125}
126
127// ACCOUNT BUILDER EXTENSION
128// ================================================================================================
129
130/// An extension trait for [`AccountBuilder`] that provides a convenience method for building an
131/// account with an [`AccountSchemaCommitment`] component.
132pub trait AccountBuilderSchemaCommitmentExt {
133    /// Builds an [`Account`] out of the configured builder after computing the storage schema
134    /// commitment from all components currently in the builder and adding an
135    /// [`AccountSchemaCommitment`] component.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if:
140    /// - The components' storage schemas contain conflicting definitions for the same slot name.
141    /// - [`AccountBuilder::build`] fails.
142    fn build_with_schema_commitment(self) -> Result<Account, AccountError>;
143}
144
145impl AccountBuilderSchemaCommitmentExt for AccountBuilder {
146    fn build_with_schema_commitment(self) -> Result<Account, AccountError> {
147        let schema_commitment =
148            AccountSchemaCommitment::new(self.storage_schemas()).map_err(|err| {
149                AccountError::other_with_source("failed to compute account schema commitment", err)
150            })?;
151
152        self.with_component(schema_commitment).build()
153    }
154}
155
156// HELPERS
157// ================================================================================================
158
159/// Computes the schema commitment.
160///
161/// The account schema commitment is computed from the merged schema commitment.
162/// If the passed list of schemas is empty, [`Word::empty()`] is returned.
163fn compute_schema_commitment<'a>(
164    schemas: impl IntoIterator<Item = &'a StorageSchema>,
165) -> Result<Word, ComponentMetadataError> {
166    let mut schemas = schemas.into_iter().peekable();
167    if schemas.peek().is_none() {
168        return Ok(Word::empty());
169    }
170
171    let mut merged_slots = BTreeMap::new();
172
173    for schema in schemas {
174        for (slot_name, slot_schema) in schema.iter() {
175            match merged_slots.get(slot_name) {
176                None => {
177                    merged_slots.insert(slot_name.clone(), slot_schema.clone());
178                },
179                // Slot exists, check if the schema is the same before erroring
180                Some(existing) => {
181                    if existing != slot_schema {
182                        return Err(ComponentMetadataError::InvalidSchema(format!(
183                            "conflicting definitions for storage slot `{slot_name}`",
184                        )));
185                    }
186                },
187            }
188        }
189    }
190
191    let merged_schema = StorageSchema::new(merged_slots)?;
192
193    Ok(merged_schema.commitment())
194}
195
196// TESTS
197// ================================================================================================
198
199#[cfg(test)]
200mod tests {
201    use miden_protocol::Word;
202    use miden_protocol::account::AccountBuilder;
203    use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
204    use miden_protocol::account::component::AccountComponentMetadata;
205
206    use super::{AccountBuilderSchemaCommitmentExt, AccountSchemaCommitment};
207    use crate::account::auth::{AuthSingleSig, NoAuth};
208
209    #[test]
210    fn storage_schema_commitment_is_order_independent() {
211        let toml_a = r#"
212            name = "Component A"
213            description = "Component A schema"
214            version = "0.1.0"
215
216            [[storage.slots]]
217            name = "test::slot_a"
218            type = "word"
219        "#;
220
221        let toml_b = r#"
222            name = "Component B"
223            description = "Component B schema"
224            version = "0.1.0"
225
226            [[storage.slots]]
227            name = "test::slot_b"
228            description = "description is committed to"
229            type = "word"
230        "#;
231
232        let metadata_a = AccountComponentMetadata::from_toml(toml_a).unwrap();
233        let metadata_b = AccountComponentMetadata::from_toml(toml_b).unwrap();
234
235        let schema_a = metadata_a.storage_schema().clone();
236        let schema_b = metadata_b.storage_schema().clone();
237
238        // Create one component for each of two different accounts, but switch orderings
239        let component_a =
240            AccountSchemaCommitment::new(&[schema_a.clone(), schema_b.clone()]).unwrap();
241        let component_b = AccountSchemaCommitment::new(&[schema_b, schema_a]).unwrap();
242
243        let account_a = AccountBuilder::new([1u8; 32])
244            .with_auth_component(NoAuth)
245            .with_component(component_a)
246            .build()
247            .unwrap();
248
249        let account_b = AccountBuilder::new([2u8; 32])
250            .with_auth_component(NoAuth)
251            .with_component(component_b)
252            .build()
253            .unwrap();
254
255        let slot_name = AccountSchemaCommitment::schema_commitment_slot();
256        let commitment_a = account_a.storage().get_item(slot_name).unwrap();
257        let commitment_b = account_b.storage().get_item(slot_name).unwrap();
258
259        assert_eq!(commitment_a, commitment_b);
260    }
261
262    #[test]
263    fn storage_schema_commitment_is_empty_for_no_schemas() {
264        let component = AccountSchemaCommitment::new(&[]).unwrap();
265
266        assert_eq!(component.schema_commitment, Word::empty());
267    }
268
269    #[test]
270    fn build_with_schema_commitment_adds_schema_commitment_component() {
271        let auth_component = AuthSingleSig::new(
272            PublicKeyCommitment::from(Word::empty()),
273            AuthScheme::EcdsaK256Keccak,
274        );
275
276        let account = AccountBuilder::new([1u8; 32])
277            .with_auth_component(auth_component)
278            .build_with_schema_commitment()
279            .unwrap();
280
281        // The auth component has 2 slots (public key and scheme ID) and the schema commitment adds
282        // 1 more.
283        assert_eq!(account.storage().num_slots(), 3);
284
285        // The auth component's public key slot should be accessible.
286        assert!(account.storage().get_item(AuthSingleSig::public_key_slot()).is_ok());
287
288        // The schema commitment slot should be non-empty since we have a component with a schema.
289        let slot_name = AccountSchemaCommitment::schema_commitment_slot();
290        let commitment = account.storage().get_item(slot_name).unwrap();
291        assert_ne!(commitment, Word::empty());
292    }
293}