ICRC-1 Account Human Readable Format

Isn’t that going to be highly confusing to users? Now they have two entirely different things that look like principals: an actual principal and an encoded account.

2 Likes

Won’t they all be essentially the same thing to the user relative to the application they’re using (assuming a token wallet for example)? All principals will be encoded accounts that can be verified with the public key.

2 Likes

You will only need to display the Account in this format and nothing else needs to be visible to end-users.

2 Likes

Hello @levi ,

While exploring this concept, I was unable to locate a Rust implementation for the proposed account format (although one may already exist). Motivated by this, I endeavored to write my own. My primary focus was on a function that can decode account strings into an Account object. The function checks if the account string starts with “0x”, and treats a subaccount comprised solely of zeroes (or simply “0x”) as if no subaccount was provided.

Here is the function:

impl TryFrom<&str> for Account {
    type Error = AccountError;

    fn try_from(account: &str) -> Result<Self, Self::Error> {
        // Check if "0x" prefix exists and remove it
        let account = if account.starts_with("0x") {
            &account[2..]
        } else {
            return Err(AccountError::InvalidFormat);
        };

        let parts: Vec<&str> = account.split('.').collect();
        if parts.len() != 2 {
            return Err(AccountError::InvalidFormat);
        }

        // Check for "00.." and "" as None
        let subaccount = match parts[0] {
            s if s.chars().all(|c| c == '0') => None,
            s => {
                let mut bytes = hex::decode(s).map_err(|_| AccountError::InvalidSubaccount)?;
                bytes.resize(32, 0);

                Some(Subaccount(bytes))
            }
        };

        let owner = Principal::from_text(parts[1]).map_err(|e| AccountError::InvalidPrincipal(e))?;

        Ok(Self { owner, subaccount })
    }
}

Example usage:

// For a main-account (default subaccount)
let account_str1 = "0x.4kydj-ryaaa-aaaag-qaf7a-cai";
let account1 = Account::try_from(account_str1).unwrap();
println!("{:?}", account1); // Prints: Account { owner: PrincipalId { .. }, subaccount: None }

// For a main-account (default subaccount) with "00"
let account_str2 = "0x00.4kydj-ryaaa-aaaag-qaf7a-cai";
let account2 = Account::try_from(account_str2).unwrap();
println!("{:?}", account2); // Prints: Account { owner: PrincipalId { .. }, subaccount: None }

// For a short subaccount
let account_str3 = "0x0a.4kydj-ryaaa-aaaag-qaf7a-cai";
let account3 = Account::try_from(account_str3).unwrap();
println!("{:?}", account3); // Prints: Account { owner: PrincipalId { .. }, subaccount: Some([10]) }

// For a long subaccount
let account_str4 = "0xf2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2.4kydj-ryaaa-aaaag-qaf7a-cai";
let account4 = Account::try_from(account_str4).unwrap();
println!("{:?}", account4); // Prints: Account { owner: PrincipalId { .. }, subaccount: Some([...]) }

The purpose of this function is to parse the account string into a structured format while simultaneously validating the account’s integrity.

I’ve attempted to adhere closely to the proposed format and provide appropriate validation, but I would be grateful for any feedback or suggestions. Do you see any potential pitfalls or improvements in my implementation?

Any insights would be greatly appreciated!


1 Like

Hi @b3hr4d,
Since this conversation, the textual encoding for the ICRC-1 Account has changed. You can find the current spec here: ICRC-1/TextualEncoding.md at main · dfinity/ICRC-1 · GitHub. If you write an implementation for the current spec, I will check it and give feedback.

2 Likes

Nice, the spec is clear enough.

The updated Display implementation for Account is as follows:

impl fmt::Display for Account {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.subaccount {
            None => write!(f, "{}", self.owner),
            Some(subaccount) => {
                if subaccount.is_default() {
                    write!(f, "{}", self.owner)
                } else {
                    let checksum = self.compute_base32_checksum();
                    let hex_str = hex::encode(&subaccount.as_slice())
                        .trim_start_matches('0')
                        .to_owned();
                    write!(f, "{}-{}.{}", self.owner, checksum, hex_str)
                }
            }
        }
    }
}

and test result

let account_1 = Account {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: None,
};
assert_eq!(
    account_1.to_string(),
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"
);

let account_2 = Account {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount::from_slice(&[0u8; 32]).unwrap()),
};
assert_eq!(
    account_2.to_string(),
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"
);

let account_3 = Account {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount::from_slice(&[1u8; 32]).unwrap()),
};
assert_eq!(
    account_3.to_string(),
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-7s4rpcq.101010101010101010101010101010101010101010101010101010101010101"
);

I believe the results are correct as per the updated specification, but I would be grateful for any validation or feedback on this.

1 Like

The results are correct for the impl fmt::Display for Account, good work.

What about an impl TryFrom<&str> for Account with the current spec?

1 Like

I want to make a library for the subaccount&account later.

impl FromStr for ICRCAccount {
    type Err = ICRCAccountError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let n = s.len();

        if n == 0 {
            return Err(ICRCAccountError::Malformed("empty".into()));
        }

        let last_dash = s.rfind('-');
        let dot = s.find('.');

        match last_dash {
            None => {
                return Err(ICRCAccountError::Malformed(
                    "expected at least one dash ('-') character".into(),
                ));
            }
            Some(last_dash) => {
                if let Some(dot) = dot {
                    // There is a subaccount
                    let num_subaccount_digits = n - dot - 1;

                    if num_subaccount_digits > 64 {
                        return Err(ICRCAccountError::Malformed(
                            "the subaccount is too long (expected at most 64 characters)".into(),
                        ));
                    };

                    if dot < last_dash {
                        return Err(ICRCAccountError::Malformed(
                            "the subaccount separator does not follow the checksum separator"
                                .into(),
                        ));
                    };

                    if dot - last_dash - 1 != 7 {
                        return Err(ICRCAccountError::BadChecksum);
                    };

                    // The encoding ends with a dot, the subaccount is empty.
                    if dot == n - 1 {
                        return Err(ICRCAccountError::NotCanonical);
                    };

                    // The first digit after the dot must not be a zero.
                    if s.chars().nth(dot + 1).unwrap() == '0' {
                        return Err(ICRCAccountError::NotCanonical);
                    };

                    let principal_text = &s[..last_dash];
                    let owner = Principal::from_text(principal_text)
                        .map_err(|e| ICRCAccountError::InvalidPrincipal(e.to_string()))?;

                    let hex_str = &s[dot + 1..];

                    // Check that the subaccount is not the default.
                    if hex_str.chars().all(|c| c == '0') {
                        return Err(ICRCAccountError::NotCanonical);
                    };

                    let subaccount = Subaccount::from_hex(&hex_str)
                        .map_err(|e| ICRCAccountError::InvalidSubaccount(e.to_string()))?;

                    // Check that the checksum matches the subaccount.
                    let checksum = &s[last_dash + 1..dot];
                    let expected_checksum = base32_encode(
                        &ICRCAccount {
                            owner,
                            subaccount: Some(subaccount.clone()),
                        }
                        .compute_checksum(),
                    );

                    if checksum != expected_checksum {
                        return Err(ICRCAccountError::BadChecksum);
                    };

                    Ok(ICRCAccount {
                        owner,
                        subaccount: Some(subaccount),
                    })
                } else {
                    // There is no subaccount, so it's just a Principal
                    let owner = Principal::from_text(s)
                        .map_err(|e| ICRCAccountError::InvalidPrincipal(e.to_string()))?;
                    Ok(ICRCAccount {
                        owner,
                        subaccount: None,
                    })
                }
            }
        }
    }
}

and test result

let account_1 = ICRCAccount::from_text(
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
)
.unwrap();

let expected_1 = ICRCAccount {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: None,
};

assert_eq!(account_1, expected_1,);

let account_2 = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"
    .parse::<ICRCAccount>()
    .unwrap();

let expected_2 = ICRCAccount {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount([0u8; 32])),
};

assert_eq!(account_2, expected_2);

let account_3 = ICRCAccount::from_text(
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-7s4rpcq.101010101010101010101010101010101010101010101010101010101010101"
).unwrap();

let expected_3 = ICRCAccount {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount([1u8; 32])),
};

assert_eq!(account_3, expected_3);

let mut slices = [0u8; 32];
slices[31] = 0x01;

let account_4 = ICRCAccount {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount(slices)),
};

assert_eq!(
    account_4,
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1"
        .parse::<ICRCAccount>()
        .unwrap()
);

let slices = [
    0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
    0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
    0x1d, 0x1e, 0x1f, 0x20,
];

let account_5 = ICRCAccount {
    owner: Principal::from_text(
        "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
    )
    .unwrap(),
    subaccount: Some(Subaccount(slices)),
};

assert_eq!(
    account_5,
    "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
        .parse::<ICRCAccount>()
        .unwrap()
);
`

Great work!

I think it would make sense to merge your contributions into icrc-ledger-types ic/packages/icrc-ledger-types at master · dfinity/ic · GitHub

1 Like

Yes, that would be perfect!
I’ll do a pull request later then, also I have a small code for the subaccount:

impl Subaccount {
    pub fn new(nonce: u64) -> Self {
        let mut subaccount = [0; 32];
        // Convert the nonce into bytes in big-endian order
        let nonce_bytes = nonce.to_be_bytes();
        // Copy the nonce bytes into the subaccount array starting from the 25th byte
        // as the nonce in big-endian order with doing this we get the smallest ICRCAccount ids
        subaccount[24..].copy_from_slice(&nonce_bytes);

        Subaccount(subaccount)
    }

    pub fn nonce(&self) -> u64 {
        if self.0[0] == 29 {
            return 0;
        }

        let nonce_bytes = &self.0[24..];
        u64::from_be_bytes(nonce_bytes.try_into().unwrap())
    }

    pub fn is_default(&self) -> bool {
        self.0 == [0u8; 32]
    }

    pub fn as_slice(&self) -> &[u8] {
        &self.0
    }

    pub fn from_slice(slice: &[u8]) -> Result<Self, SubaccountError> {
        if slice.len() != 32 {
            return Err(SubaccountError::SliceError(
                "Slice must be 32 bytes long".to_string(),
            ));
        }

        let mut subaccount = [0; 32];
        subaccount.copy_from_slice(slice);

        Ok(Subaccount(subaccount))
    }

    pub fn to_vec(&self) -> Vec<u8> {
        self.0.to_vec()
    }

    pub fn to_hex(&self) -> String {
        hex::encode(&self.0)
    }

    pub fn from_hex(hex: &str) -> Result<Self, SubaccountError> {
        // add leading zeros if necessary
        let hex = if hex.len() < 64 {
            let mut hex = hex.to_string();
            hex.insert_str(0, &"0".repeat(64 - hex.len()));
            hex
        } else {
            hex.to_string()
        };

        let bytes = hex::decode(hex).map_err(|e| SubaccountError::HexError(e.to_string()))?;

        Subaccount::from_slice(&bytes)
    }

    pub fn from_base32(base32: &str) -> Result<Self, SubaccountError> {
        let bytes =
            base32_decode(base32).map_err(|e| SubaccountError::Base32Error(e.to_string()))?;
        Subaccount::from_slice(&bytes)
    }
}

impl Subaccount {
    pub fn account_identifier(&self, owner: CanisterId) -> AccountIdentifier {
        AccountIdentifier::new(owner, self.clone())
    }

    pub fn icrc_account(&self, owner: CanisterId) -> ICRCAccount {
        ICRCAccount::new(owner, Some(self.clone()))
    }
}

and some other “From” impl, should I add them or not?

Is the motoko version there up to date?

My Motoko is rusty (:wink:) but, at first sight the reference in the ICRC-1 repo seems to be up-to-date, agree?

1 Like

Yes that is super smart, I did write the rust implementation based on your Motoko code, thank you :+1:

Please make 2 separate PRs as these are 2 different features.

I really like the idea of a single transaction address which merges principal and sub account.

I got caught out thinking that ICRC sub accounts were unique (derived from the principal) like ICP accounts. Given both are 32 (64 hex) in length I think others might make this assumption.

I’m glad there is discussion on this topic. I wasnt a fan of confusion caused by some projects using principals and some using accounts.

What ever format is used to combine the principal + subaccount should be reversable… so that devs can extract both if needed.

Implement Human Readable Account Format and Subaccount Driving Feature by b3hr4d · Pull Request #108 · dfinity/ic (github.com)