Skip to content

Token Metadata extension to form standard.#2439

Open
afa7789 wants to merge 4 commits into0xMiden:nextfrom
afa7789:oz/metadata_extension
Open

Token Metadata extension to form standard.#2439
afa7789 wants to merge 4 commits into0xMiden:nextfrom
afa7789:oz/metadata_extension

Conversation

@afa7789
Copy link
Contributor

@afa7789 afa7789 commented Feb 12, 2026

Unified metadata: One place for account/faucet metadata: token (symbol, decimals, max_supply), owner, name, and content URI. Slot names live under miden::standards::metadata::* (and ownable for owner).

Layout: Token metadata and owner in slots 0–1; name in 2 words (name_0, name_1); content URI in 6 words (content_uri_0..5). Same layout in Rust and MASM.

Faucets: Basic and network fungible faucets support optional name and content URI; both re-export metadata getters (get_name, get_content_uri, get_token_metadata, get_max_supply, get_decimals, get_token_symbol; network also get_owner).

Standalone Info: Non-faucet accounts can use the metadata Info component (name + content URI) for future use (e.g. NFTs).

Testing: Unit tests in miden-standards (metadata storage, recovery); integration tests in miden-testing (MASM getters, faucet + metadata).

@afa7789 afa7789 marked this pull request as ready for review February 12, 2026 20:12
@afa7789 afa7789 force-pushed the oz/metadata_extension branch 2 times, most recently from 55282e1 to b1dba96 Compare February 14, 2026 12:48
account_storage_mode: AccountStorageMode,
auth_scheme: AuthScheme,
name: Option<TokenName>,
logo_uri: Option<TokenLogoURI>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have both logo_uri & content_uri? we can rename it later for NFTs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, renaming it, u right.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. contract_uri would be a better option.

@onurinanc
Copy link
Contributor

@afa7789 In the context of token metadata discussion #2423 : Let's keep the contractURI as optional.

Basically, instead of:

 name: Option<TokenName>,
 contract_uri: Option<TokenContractURI>

We need to have:

 name: TokenName,
 contract_uri: Option<TokenContractURI>

@onurinanc
Copy link
Contributor

We should also consider adding the corresponding metadata and the new constructor to the network fungible faucets: https://github.com/afa7789/miden-base/blob/f7426116833b1f76da3195738ccb838a52880f80/crates/miden-standards/src/account/faucets/network_fungible.rs#L93-L101

@onurinanc
Copy link
Contributor

@afa7789 we should also add a flag and procedure to change max_supply. It's basically similar to have we have done in optional_set_contract_uri

@afa7789
Copy link
Contributor Author

afa7789 commented Feb 17, 2026

@afa7789 we should also add a flag and procedure to change max_supply. It's basically similar to have we have done in optional_set_contract_uri

In this branch ? pr ?

@onurinanc
Copy link
Contributor

@afa7789 we should also add a flag and procedure to change max_supply. It's basically similar to have we have done in optional_set_contract_uri

In this branch ? pr ?

Yes, it would be better if you can have this in this PR.

@afa7789 afa7789 changed the title Oz/metadata extension Token Metadata extension to form standard. Feb 19, 2026
@afa7789
Copy link
Contributor Author

afa7789 commented Feb 19, 2026

@bobbinth @mmagician this is ready for review :)

@mmagician mmagician added the pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority label Feb 19, 2026
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a review - but I left a couple of comments inline. The main one is about how we can handle returning large amounts of data from account interface procedures.

#!
#! Invocation: call
@locals(24)
pub proc get_contract_uri
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we'll replace content URI with other fields, but I think how to return such large chunks of data is a point worth discussing:

Since we can't return more than 16 elements via the stack, the convention we usually use is to return hash of the underlying data, and then the caller can then "unhash" it locally. To make this less awkward to work with, we wrap such a procedure that does hashing/unhashing and then the caller can just use the wrapper.

The wrapper could look like so:

#! Write the content URI into the memory address specified by the ptr.
#!
#! Inputs:  [ptr, pad(15)]
#! Outputs: [pad(16)]
#!
#! Invocation: exec
pub proc get_content_uri
    # TODO: call.get_content_uri_commitment
    # TODO: unhash the commitment into memory
end

The unhashing can be done using miden::core::mem::pipe_double_words_preimage_to_memory (or related) procedure.

Notice two points:

  • We still need get_content_uri_commitment procedure which would be invoked via a call instruction. As a part of this procedure we should add an entry to the advice map to insert the actual URI data into it.
  • The get_content_uri procedure would need to be invoked using an exec instruction because it is just a wrapper around the actual account interface procedure.

AFAIK, we haven't used this pattern for account interface procedures before - so, would love to get some thoughts from @PhilippGackstatter and @mmagician if they think we could handle this somehow differently.

But, we do use this pattern for kernel procedures - e.g., in active_note::get_assets procedure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bobbinth I believe this is a common issue that we have also experienced this to have get_signers procedure in the authentication level (see the conversation here: #2390 (comment)). I know this pattern is also used in active_note::get_assets but it is complex to implement for each procedure when we face this situtation.

It would be great to have a more generic pattern to resolve this issue by providing an interface. (the procedure would have these fields initial_ptr, end_ptr, num_of_elements so that we can write to the memory and show in the stack easily and a generic way. CC. @PhilippGackstatter @mmagician

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to have a more generic pattern to resolve this issue by providing an interface. (the procedure would have these fields initial_ptr, end_ptr, num_of_elements so that we can write to the memory and show in the stack easily and a generic way

I think we have most of this already in the core library:

  • miden::core::mem::pipe_double_words_preimage_to_memory takes a hash and writes its pre-image to memory.
  • adv.insert_mem instruction injects data from memory into the advice provider.

The wrapper procedures would need to be customized for a specific use case, but they should be pretty simple. Let's put these together for a singe example (e.g., description) and then applying this to other fields should be pretty straight-forward.

@onurinanc
Copy link
Contributor

@afa7789 Additionally, as this discussion #2423 (comment) has been concluded, you can update the PR with the following:

  • name (mandatory): up to 2 words, ~64 bytes (UTF-8)
  • description (optional): up to 6 words, ~192 bytes (UTF-8)
  • logo_uri (optional): up to 6 words, ~192 bytes (UTF-8)
  • external_link (optional): up to 6 words, ~192 bytes (UTF-8)

For description, logo_uri, and external_link:

  • configuration flags will be introduced to indicate whether the account was initialized with these fields.
  • mutable/immutable flags will also be added for these fields. (similar to how you did in content_uri)

@afa7789 afa7789 force-pushed the oz/metadata_extension branch from d21959d to 5548bd5 Compare February 26, 2026 14:30
@afa7789 afa7789 force-pushed the oz/metadata_extension branch from 5548bd5 to 609b355 Compare February 26, 2026 14:43
- Updated the `create_basic_fungible_faucet` and `create_network_fungible_faucet` functions to use boolean flags for description, logo URI, and external link mutability instead of integer flags.
- Modified the `Info` struct to replace the previous flag system with separate boolean fields for initialized and mutable states.
- Adjusted storage layout documentation to reflect changes in metadata configuration.
- Updated tests to align with the new boolean flag system for mutability and initialization.
- Ensured backward compatibility by updating mock chain builder and test cases to use the new structure.
Comment on lines +57 to +58
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogoURI([Word; 6]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this type, ExternalLink, TokenName and Description use the encoded representation ([Word; 6]) instead of String to match TokenSymbol? In general I like matching existing approaches, but in this case, I think we should actually use the "un-encoded" internal representation (and refactor TokenSymbol at some point), because when you work with these types, you typically want them easily representable as string, e.g. to be able to (cheaply, without decoding) write things like if token_name.as_str() == "miden" { ... }, whereas you don't care about the encoded representation in such cases.

So eventually we should refactor TokenSymbol to use a string internally, but not for this PR. For the new types though I would suggest choosing a better representation, i.e.:

pub struct TokenName(Box<str>);
pub struct Description(Box<str>);
pub struct LogoURI(Box<str>);
pub struct ExternalLink(Box<str>);
  • Box<str> instead of String because these types are immutable and so String is unnecessary and Box<str>'s stack size is 33% smaller.
  • Each type should enforce as an invariant that it can be successfully encoded into the respective number of felts/words, e.g. iiuc check that it does not exceed NAME_UTF8_MAX_BYTES, contains valid characters, etc. at construction time.
  • We can add Description::to_words(&self) -> [Word; 6] (and analogously for the others) to get the encoded representation and probably try_from_word if necessary for the decoding.

Comment on lines +410 to +420
let metadata = TokenMetadata::with_supply(
symbol,
decimals,
max_supply,
token_supply,
name,
None,
None,
None,
)
.unwrap();
Copy link
Contributor

@PhilippGackstatter PhilippGackstatter Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks quite unwieldy because of all the optional fields. I would introduce a builder at this point, e.g.:

TokenMetadataBuilder::new(name, symbol, max_supply)
  .token_supply(...)
  .description(...)
  .logo_uri(...)
  .external_link(...)
  .build()?;

Token supply can default to 0 if not explicitly set as it does now, I think. Since symbol and name are so closely related, I would consider combining them (maybe in a separate PR) to further reduce the number of fields needed here. Though maybe this isn't really possible if these end up being part of different types (TokenMetadata and Info).

The builder as a whole could be done as a follow-up.

Comment on lines +15 to +16
//! | `metadata::initialized_config` | `[desc_init, logo_init, extlink_init, max_supply_mutable]` |
//! | `metadata::mutability_config` | `[desc_mutable, logo_mutable, extlink_mutable, max_supply_mutable]` |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it correct to have max_supply_mutable in both of these? It seems like it should not be present in initialized_config.

I'm also wondering if we definitely need those. If we do need them, I would combine this data into a single slot, e.g. by using a word with the following layout:

felt 0: [62 zero bits | is_desc_mutable (1 bit) | is_desc_initialized (1 bit)],
felt 1: [62 zero bits | is_logo_mutable (1 bit) | is_logo_initialized (1 bit)],
...

But maybe we can do without the _initialized ones in any case. For instance, for token symbols we encode the length into the felt, and so we know that a Felt::ZERO never encodes a valid token symbol, because valid symbols must have length > 0. If we could do something similar for description, logo, extlink, could we not say that an encoded [Word::empty(); 6] means they are absent? Then we wouldn't need at least the _initialized slot.

Comment on lines +215 to +221
let felts: Vec<Felt> = padded
.chunks_exact(8)
.map(|chunk| {
Felt::try_from(u64::from_le_bytes(chunk.try_into().unwrap()))
.expect("u64 values from 8-byte chunks fit in Felt")
})
.collect();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Felt::try_from(u64::from_le_bytes([u8::MAX; 8])) panics, so the expect message is not correct.

In general, I'm not clear on what our stance on the encoding is:

  • TokenSymbol uses our own custom encoding and is able to pack a lot into a single felt.
  • I think the compiler packs 4 u32s in a felt for its memory layouts, which I think may be what we're going for with TokenName?
  • And then we have this method used for description, logo and external link which wants to fill up a felt completely.

I think since storage is more premium than memory, it would make sense to try and be efficient, e.g. like TokenSymbol. Since this code will be used to decode token names, etc. from felts, I think the exact encoding is not as important, but I could be wrong.

Maybe we should try to generalize the TokenSymbol encoding (or something else that's efficient) for strings that works across multiple elements and words, and then use that across the board for symbol, name, description, logo uri and external link? This encoding would include the length of the string, which would make it easy to interpret all zero words as that field being absent and mapping it to None on the Rust side (related to my other comment).

cc @bobbinth

Comment on lines +260 to +271
StorageSlotName::new("miden::standards::metadata::external_link_0")
.expect("valid slot name"),
StorageSlotName::new("miden::standards::metadata::external_link_1")
.expect("valid slot name"),
StorageSlotName::new("miden::standards::metadata::external_link_2")
.expect("valid slot name"),
StorageSlotName::new("miden::standards::metadata::external_link_3")
.expect("valid slot name"),
StorageSlotName::new("miden::standards::metadata::external_link_4")
.expect("valid slot name"),
StorageSlotName::new("miden::standards::metadata::external_link_5")
.expect("valid slot name"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach is fine for now and should get much better with #2176.

///
/// Bytes are packed little-endian, 4 bytes per felt (8 felts total). The string is
/// zero-padded to 32 bytes. Returns an error if the UTF-8 byte length exceeds 32.
pub fn name_from_utf8(s: &str) -> Result<[Word; 2], NameUtf8Error> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think this module is quite packed and the relation of each function to the respective type is not as easy to understand. I think it could be clearer if these were methods on the respective types, e.g. TokenName::to_words(&self) -> [Word; 2] and TokenName::from_words([Word; 2]) -> Result<Self, _>. Similarly for the other encoding/decoding functions. Wdyt?

/// - Slot 12–17: logo_uri (6 Words)
/// - Slot 18–23: external_link (6 Words)
#[derive(Debug, Clone, Default)]
pub struct Info {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not part of TokenMetadata directly?

It feels very closely related, so I would consider including it. Making it a separate component means users always need to remember to include it in their account next to TokenMetadata and need to decode both TokenMetadata and Info to get all the related data, so this pushes some complexity up the stack.

It would also be nice if we could set mutability flags directly via the mentioned TokenMetadataBuilder, e.g. TokenMetadataBuilder::new(...).description("abc").mutable_description().build().

Related, I think Info does not be an AccountComponent, since it does not have any code. This suggests it is a set of standard storage slots but not a full account component (a combination of functionality / code and storage). So in the same way as TokenMetadata is not an account component (but more like a standardized storage slot), we could make Info a reusable set of storage slots. I would then include it in TokenMetadata, which in turn is included in BasicFungibleFaucet (a proper account component). Notably, this does not prevent reusing Info for other purposes in the future (such as for NFTs).

Naming: I think this is more aptly described as TokenMetadata. This is more generic metadata than what is currently called TokenMetadata which is specific to fungible assets. So maybe it is better to rename the current TokenMetadata to FungibleTokenMetadata to free up that name for this.

Comment on lines +343 to +345
description: Option<[Word; 6]>,
logo_uri: Option<[Word; 6]>,
external_link: Option<[Word; 6]>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not use the types we have for these, description: Description, etc.? I think this results in the APIs being too low-level, e.g. with_name([Word; 2]) or with_name_utf8(&str) are easy to use incorrectly (little help from the type system) and they don't enforce the invariants of the stronger types (min/max length, for instance).

This is a lesson learned from a few similarly low-level APIs we had in the past, where we had Word for too many different things and got things wrong too easily. One of the outcomes of that is #2431 which introduces a wrapper over a key: Word just to give more type safety.

Comment on lines +551 to +555
if let Some(logo_uri) = extension.logo_uri {
for (i, word) in logo_uri.iter().enumerate() {
storage_slots.push(StorageSlot::with_value(Info::logo_uri_slot(i).clone(), *word));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far, we basically never had "optional" storage slots. The main reason for this I think is that on-chain code does (not yet?) have the ability to check if a slot is present or not. Instead, accessing a slot via get_ APIs will simply panic if it isn't present, and account components therefore implicitly assume that all slots are present and initialized.

I guess here the situation is a bit different in that on-chain code never accesses the logo, external link, description or name of the token, and so maybe this is fine. At least I don't see a reason why this would be problematic.
Two caveats:

  • As long as the AccountStorage -> AccountComponent decoding process can deal with an absent storage slot, this should be fine.
  • The way we define if an account component is present is via procedure MAST roots and storage slots. The optional storage slots could not be used to help with this detection. If all slots are optional that would be problematic since then you can't say for sure if a component is absent or present. I think as long as one storage slot is guaranteed to be present (like the initialized_config for the Info), that should be fine.

(This is more of a general comment - I still think Info should not be an account component).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants