Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
03154a5
feat(icrc-ledger-types): add MetadataKey type
mducroux Jan 5, 2026
08378c6
refactor: replace hardcoded metadata keys with const
mducroux Jan 5, 2026
c3edc99
refactor: icrc1_metadata returns MetadataKey instead of String
mducroux Jan 5, 2026
db89279
Automatically fixing code for linting and formatting issues
Jan 5, 2026
2d82842
fix build
mducroux Jan 5, 2026
cb406ef
add ICRC-106 key
mducroux Jan 5, 2026
7a78d5b
Merge branch 'mducroux/DEFI-1010-add-metadata-keys-icrc-ledger-types'…
mducroux Jan 5, 2026
e82af09
use explicit MetadataKey in hashmaps
mducroux Jan 5, 2026
a04776e
fix build
mducroux Jan 7, 2026
314af8b
Automatically fixing code for linting and formatting issues
Jan 7, 2026
6bb1d01
fix build
mducroux Jan 7, 2026
eda1eec
Update mod.rs
mducroux Jan 7, 2026
78ce22f
fix build
mducroux Jan 7, 2026
b3ba885
fix build
mducroux Jan 7, 2026
70d47d3
Automatically fixing code for linting and formatting issues
Jan 7, 2026
1d3c0b2
clippy
mducroux Jan 7, 2026
d552e5c
remove unused imports
mducroux Jan 7, 2026
b93e6ea
Merge branch 'master' into mducroux/DEFI-1010-add-metadata-keys-icrc-…
mducroux Jan 8, 2026
26261b0
improve doc
mducroux Jan 8, 2026
0b7dfe5
use type-state pattern to store checked/unchecked metadata keys
mducroux Jan 8, 2026
fbfe50e
revert: remove MetadataKey<Unchecked> and use string in init and upgr…
mducroux Jan 8, 2026
02334de
Automatically fixing code for linting and formatting issues
Jan 8, 2026
4b391f0
use candid encode in tests
mducroux Jan 8, 2026
18a7317
revert changes to legacy UpgradeArgs/InitArgs
mducroux Jan 9, 2026
1fb91e8
fix build
mducroux Jan 9, 2026
238a135
add setting metadata key tests during init/upgrade icrc1 ledger
mducroux Jan 9, 2026
4ce4c5b
Automatically fixing code for linting and formatting issues
Jan 9, 2026
f3d9799
Update lib.rs
mducroux Jan 9, 2026
3c22c9c
Automatically fixing code for linting and formatting issues
Jan 9, 2026
e1d5fe7
make entry err instead of panic
mducroux Jan 9, 2026
3b98373
Automatically fixing code for linting and formatting issues
Jan 9, 2026
b37f351
make key and namespace return an Option
mducroux Jan 9, 2026
03e86a0
Update Cargo.toml
mducroux Jan 13, 2026
3415b80
Update metadata_key.rs
mducroux Jan 13, 2026
3239802
remove unused dep
mducroux Jan 21, 2026
4461bf7
Automatically updated Cargo*.lock
Jan 21, 2026
2d5f324
remove reference to ISO-4217
mducroux Jan 21, 2026
771238f
inline CONST metadata values
mducroux Jan 21, 2026
af7d83e
Automatically fixing code for linting and formatting issues
Jan 21, 2026
b837851
add logs when accepting invalid metadata key during upgrades
mducroux Jan 21, 2026
0e8a0c9
Automatically fixing code for linting and formatting issues
Jan 21, 2026
10284c7
Update lib.rs
mducroux Jan 21, 2026
59bffda
Automatically fixing code for linting and formatting issues
Jan 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions packages/icrc-ledger-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ use icrc_ledger_types::icrc3::archive::{ArchivedRange, QueryBlockArchiveFn};
use icrc_ledger_types::icrc3::blocks::ICRC3DataCertificate;
use icrc_ledger_types::icrc3::blocks::{GetBlocksRequest, GetBlocksResponse};
use icrc_ledger_types::{
icrc::generic_metadata_value::MetadataValue as Value, icrc3::blocks::BlockRange,
icrc::generic_metadata_value::MetadataValue as Value, icrc::metadata_key::MetadataKey,
icrc3::blocks::BlockRange,
};

#[derive(Debug)]
Expand Down Expand Up @@ -126,15 +127,18 @@ impl Icrc1Agent {
}

/// Returns the list of metadata entries for this ledger
pub async fn metadata(&self, mode: CallMode) -> Result<Vec<(String, Value)>, Icrc1AgentError> {
pub async fn metadata(
&self,
mode: CallMode,
) -> Result<Vec<(MetadataKey, Value)>, Icrc1AgentError> {
Ok(match mode {
CallMode::Query => Decode!(
&self.query("icrc1_metadata", &Encode!()?).await?,
Vec<(String, Value)>
Vec<(MetadataKey, Value)>
)?,
CallMode::Update => Decode!(
&self.update("icrc1_metadata", &Encode!()?).await?,
Vec<(String, Value)>
Vec<(MetadataKey, Value)>
)?,
})
}
Expand Down
3 changes: 2 additions & 1 deletion packages/icrc-ledger-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use candid::Principal;
use candid::types::number::Nat;
use candid::utils::{ArgumentDecoder, ArgumentEncoder};
use icrc_ledger_types::icrc::generic_metadata_value::MetadataValue as Value;
use icrc_ledger_types::icrc::metadata_key::MetadataKey;
use icrc_ledger_types::icrc1::account::Account;
use icrc_ledger_types::icrc1::transfer::{BlockIndex, TransferArg, TransferError};
use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError};
Expand Down Expand Up @@ -55,7 +56,7 @@ impl<R: Runtime> ICRC1Client<R> {
.map(untuple)
}

pub async fn metadata(&self) -> Result<Vec<(String, Value)>, (i32, String)> {
pub async fn metadata(&self) -> Result<Vec<(MetadataKey, Value)>, (i32, String)> {
self.runtime
.call(self.ledger_canister_id, "icrc1_metadata", ())
.await
Expand Down
29 changes: 26 additions & 3 deletions packages/icrc-ledger-types/src/icrc/generic_metadata_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use candid::{CandidType, Deserialize, Int, Nat};
use serde::Serialize;
use serde_bytes::ByteBuf;

pub use crate::icrc::metadata_key::{MetadataKey, MetadataKeyError};

/// Variant type for the `icrc1_metadata` endpoint values. The corresponding metadata keys are
/// arbitrary Unicode strings and must follow the pattern `<namespace>:<key>`, where `<namespace>`
/// is a string not containing colons. The namespace `icrc1` is reserved for keys defined in the
Expand All @@ -17,9 +19,18 @@ pub enum MetadataValue {
}

impl MetadataValue {
/// Create a `(String, MetadataValue)` tuple for use in metadata maps.
pub fn entry(key: impl ToString, val: impl Into<MetadataValue>) -> (String, Self) {
(key.to_string(), val.into())
/// Create a `(MetadataKey, MetadataValue)` tuple for use in metadata maps.
///
/// The key must be a valid metadata key in the format `<namespace>:<key>`.
/// This is typically used with the predefined constants like `MetadataKey::ICRC1_NAME`.
///
/// # Panics
///
/// Panics if the key is not a valid metadata key format.
pub fn entry(key: &str, val: impl Into<MetadataValue>) -> (MetadataKey, Self) {
let metadata_key =
MetadataKey::parse(key).unwrap_or_else(|e| panic!("invalid metadata key '{key}': {e}"));
(metadata_key, val.into())
}
}

Expand Down Expand Up @@ -76,3 +87,15 @@ impl<'a> From<&'a [u8]> for MetadataValue {
MetadataValue::Blob(ByteBuf::from(bytes.to_vec()))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_metadata_value_entry() {
let entry = MetadataValue::entry(MetadataKey::ICRC1_NAME, "My Token");
assert_eq!(entry.0.as_str(), "icrc1:name");
assert_eq!(entry.1, MetadataValue::Text("My Token".to_string()));
}
}
268 changes: 268 additions & 0 deletions packages/icrc-ledger-types/src/icrc/metadata_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
//! Metadata key types for ICRC-1 ledger metadata.

use candid::{CandidType, Deserialize};
use serde::Serialize;
use std::fmt;

/// Error type for invalid metadata key format.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetadataKeyError {
/// The key does not contain a colon separator.
MissingColon,
/// The namespace (part before the first colon) contains a colon.
ColonInNamespace,
/// The namespace is empty.
EmptyNamespace,
/// The key part (after the colon) is empty.
EmptyKey,
}

impl fmt::Display for MetadataKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MetadataKeyError::MissingColon => {
write!(f, "metadata key must contain a colon separator")
}
MetadataKeyError::ColonInNamespace => {
write!(f, "namespace must not contain colons")
}
MetadataKeyError::EmptyNamespace => {
write!(f, "namespace must not be empty")
}
MetadataKeyError::EmptyKey => {
write!(f, "key part must not be empty")
}
}
}
}

impl std::error::Error for MetadataKeyError {}

/// A validated metadata key following the ICRC-1 standard format `<namespace>:<key>`.
///
/// Metadata keys are arbitrary Unicode strings that must follow the pattern `<namespace>:<key>`,
/// where `<namespace>` is a string not containing colons. The namespace `icrc1` is reserved
/// for keys defined in the ICRC-1 standard.
///
/// For more information, see the
/// [documentation of Metadata in the ICRC-1 standard](https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1#metadata).
///
/// # Examples
///
/// ```
/// use icrc_ledger_types::icrc::metadata_key::MetadataKey;
///
/// // Valid keys
/// let key = MetadataKey::new("icrc1", "name").unwrap();
/// assert_eq!(key.namespace(), "icrc1");
/// assert_eq!(key.key(), "name");
/// assert_eq!(key.as_str(), "icrc1:name");
///
/// // Parse from string
/// let key = MetadataKey::parse("myapp:decimals").unwrap();
/// assert_eq!(key.namespace(), "myapp");
/// assert_eq!(key.key(), "decimals");
/// ```
#[derive(
CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
pub struct MetadataKey(String);

impl MetadataKey {
// ==================== ICRC-1 Standard Keys ====================

/// The name of the token.
/// When present, should be the same as the result of the icrc1_name query call.
pub const ICRC1_NAME: &'static str = "icrc1:name";

/// The token currency code (see ISO-4217).
/// When present, should be the same as the result of the icrc1_symbol query call.
pub const ICRC1_SYMBOL: &'static str = "icrc1:symbol";

/// The number of decimals the token uses. For example, 8 means to divide the token amount by 108 to get its user representation.
/// When present, should be the same as the result of the icrc1_decimals query call.
pub const ICRC1_DECIMALS: &'static str = "icrc1:decimals";

/// The default transfer fee.
/// When present, should be the same as the result of the icrc1_fee query call.
pub const ICRC1_FEE: &'static str = "icrc1:fee";

/// The URL of the token logo. The value can contain the actual image if it's a Data URL.
pub const ICRC1_LOGO: &'static str = "icrc1:logo";

/// The maximum length of a memo in bytes.
pub const ICRC1_MAX_MEMO_LENGTH: &'static str = "icrc1:max_memo_length";

// ==================== ICRC-103 Keys ====================

/// Whether allowance data is public or not.
pub const ICRC103_PUBLIC_ALLOWANCES: &'static str = "icrc103:public_allowances";

/// The maximum number of allowances the ledger will return in response to a query.
pub const ICRC103_MAX_TAKE_VALUE: &'static str = "icrc103:max_take_value";

// ==================== ICRC-106 Keys ====================

/// The textual representation of the principal of the associated index canister.
pub const ICRC106_INDEX_PRINCIPAL: &'static str = "icrc106:index_principal";

/// Creates a new metadata key from namespace and key parts.
///
/// # Errors
///
/// Returns an error if:
/// - The namespace is empty
/// - The namespace contains a colon
/// - The key is empty
pub fn new(namespace: &str, key: &str) -> Result<Self, MetadataKeyError> {
if namespace.is_empty() {
return Err(MetadataKeyError::EmptyNamespace);
}
if namespace.contains(':') {
return Err(MetadataKeyError::ColonInNamespace);
}
if key.is_empty() {
return Err(MetadataKeyError::EmptyKey);
}
Ok(Self(format!("{namespace}:{key}")))
}

/// Parses a metadata key from a string in the format `<namespace>:<key>`.
///
/// # Errors
///
/// Returns an error if the string does not follow the required format.
pub fn parse(s: &str) -> Result<Self, MetadataKeyError> {
let colon_pos = s.find(':').ok_or(MetadataKeyError::MissingColon)?;
let namespace = &s[..colon_pos];
let key = &s[colon_pos + 1..];

if namespace.is_empty() {
return Err(MetadataKeyError::EmptyNamespace);
}
if key.is_empty() {
return Err(MetadataKeyError::EmptyKey);
}
Ok(Self(s.to_string()))
}

/// Returns the namespace part of the key.
pub fn namespace(&self) -> &str {
self.0
.find(':')
.map(|pos| &self.0[..pos])
.expect("BUG: MetadataKey should always contain a colon")
}

/// Returns the key part (after the namespace).
pub fn key(&self) -> &str {
self.0
.find(':')
.map(|pos| &self.0[pos + 1..])
.expect("BUG: MetadataKey should always contain a colon")
}

/// Returns the full key as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}

impl fmt::Display for MetadataKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

impl AsRef<str> for MetadataKey {
fn as_ref(&self) -> &str {
&self.0
}
}

impl From<MetadataKey> for String {
fn from(key: MetadataKey) -> Self {
key.0
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_metadata_key_new() {
let key = MetadataKey::new("icrc1", "name").unwrap();
assert_eq!(key.namespace(), "icrc1");
assert_eq!(key.key(), "name");
assert_eq!(key.as_str(), "icrc1:name");
}

#[test]
fn test_metadata_key_parse() {
let key = MetadataKey::parse("myapp:decimals").unwrap();
assert_eq!(key.namespace(), "myapp");
assert_eq!(key.key(), "decimals");
}

#[test]
fn test_metadata_key_with_colons_in_value() {
// Key part can contain colons
let key = MetadataKey::parse("myapp:some:complex:key").unwrap();
assert_eq!(key.namespace(), "myapp");
assert_eq!(key.key(), "some:complex:key");
}

#[test]
fn test_metadata_key_empty_namespace() {
assert_eq!(
MetadataKey::new("", "name"),
Err(MetadataKeyError::EmptyNamespace)
);
assert_eq!(
MetadataKey::parse(":name"),
Err(MetadataKeyError::EmptyNamespace)
);
}

#[test]
fn test_metadata_key_empty_key() {
assert_eq!(
MetadataKey::new("icrc1", ""),
Err(MetadataKeyError::EmptyKey)
);
assert_eq!(
MetadataKey::parse("icrc1:"),
Err(MetadataKeyError::EmptyKey)
);
}

#[test]
fn test_metadata_key_missing_colon() {
assert_eq!(
MetadataKey::parse("nonamespace"),
Err(MetadataKeyError::MissingColon)
);
}

#[test]
fn test_metadata_key_colon_in_namespace() {
assert_eq!(
MetadataKey::new("bad:namespace", "key"),
Err(MetadataKeyError::ColonInNamespace)
);
}

#[test]
fn test_metadata_key_display() {
let key = MetadataKey::new("icrc1", "symbol").unwrap();
assert_eq!(format!("{}", key), "icrc1:symbol");
}

#[test]
fn test_metadata_key_into_string() {
let key = MetadataKey::new("icrc1", "decimals").unwrap();
let s: String = key.into();
assert_eq!(s, "icrc1:decimals");
}
}
1 change: 1 addition & 0 deletions packages/icrc-ledger-types/src/icrc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
pub mod generic_metadata_value;
pub mod generic_value;
pub mod generic_value_predicate;
pub mod metadata_key;
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ fn icrc1_ledger_init_arg(
) -> LedgerInitArgs {
use ic_icrc1_ledger::FeatureFlags as LedgerFeatureFlags;
use icrc_ledger_types::icrc::generic_metadata_value::MetadataValue as LedgerMetadataValue;
use icrc_ledger_types::icrc::metadata_key::MetadataKey;
use icrc_ledger_types::icrc1::account::Account as LedgerAccount;

const LEDGER_FEE_SUBACCOUNT: [u8; 32] = [
Expand All @@ -928,7 +929,7 @@ fn icrc1_ledger_init_arg(
token_name: ledger_init_arg.token_name,
token_symbol: ledger_init_arg.token_symbol,
metadata: vec![(
"icrc1:logo".to_string(),
MetadataKey::parse(MetadataKey::ICRC1_LOGO).unwrap(),
LedgerMetadataValue::from(ledger_init_arg.token_logo),
)],
archive_options: icrc1_archive_options(
Expand Down
Loading
Loading