Token Metadata extension to form standard.#2439
Token Metadata extension to form standard.#2439afa7789 wants to merge 4 commits into0xMiden:nextfrom
Conversation
55282e1 to
b1dba96
Compare
| account_storage_mode: AccountStorageMode, | ||
| auth_scheme: AuthScheme, | ||
| name: Option<TokenName>, | ||
| logo_uri: Option<TokenLogoURI>, |
There was a problem hiding this comment.
Why do we have both logo_uri & content_uri? we can rename it later for NFTs
There was a problem hiding this comment.
yeah, renaming it, u right.
There was a problem hiding this comment.
Thank you. contract_uri would be a better option.
|
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 |
|
@afa7789 we should also add a flag and procedure to change max_supply. It's basically similar to have we have done in |
In this branch ? pr ? |
Yes, it would be better if you can have this in this PR. |
|
@bobbinth @mmagician this is ready for review :) |
bobbinth
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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_commitmentprocedure which would be invoked via acallinstruction. 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_uriprocedure would need to be invoked using anexecinstruction 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.
There was a problem hiding this comment.
@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
There was a problem hiding this comment.
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_elementsso 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_memorytakes a hash and writes its pre-image to memory.adv.insert_meminstruction 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.
|
@afa7789 Additionally, as this discussion #2423 (comment) has been concluded, you can update the PR with the following:
For
|
d21959d to
5548bd5
Compare
5548bd5 to
609b355
Compare
- 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.
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct LogoURI([Word; 6]); |
There was a problem hiding this comment.
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 ofStringbecause these types are immutable and soStringis unnecessary andBox<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 probablytry_from_wordif necessary for the decoding.
| let metadata = TokenMetadata::with_supply( | ||
| symbol, | ||
| decimals, | ||
| max_supply, | ||
| token_supply, | ||
| name, | ||
| None, | ||
| None, | ||
| None, | ||
| ) | ||
| .unwrap(); |
There was a problem hiding this comment.
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.
| //! | `metadata::initialized_config` | `[desc_init, logo_init, extlink_init, max_supply_mutable]` | | ||
| //! | `metadata::mutability_config` | `[desc_mutable, logo_mutable, extlink_mutable, max_supply_mutable]` | |
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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:
TokenSymboluses 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 withTokenName? - 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
| 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"), |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
| description: Option<[Word; 6]>, | ||
| logo_uri: Option<[Word; 6]>, | ||
| external_link: Option<[Word; 6]>, |
There was a problem hiding this comment.
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.
| 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)); | ||
| } | ||
| } |
There was a problem hiding this comment.
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->AccountComponentdecoding 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_configfor theInfo), that should be fine.
(This is more of a general comment - I still think Info should not be an account component).
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).