ICRC-1 Account Human Readable Format

Can someone summarize what these abbreviated ids (first and last N characters) are being used for?

For example, is it the wallet owners own accounts? Or is it accounts that the wallet owner has transacted with in the past? Or is it where a wallet owner is going to send money to?

I just want to make sure that we check if there is any unsafe usage going on where an attacker can somehow benefit from a collision in the displayed characters. We need to know what we’re designing for and have to make sure we don’t promote anything unsafe.

1 Like

If the subaccount is on the right, then it is easier for a user to leave out some of the last characters of the subaccount.
If the subaccount is on the left, then it must start with a 0x so when a user copies the id we can be sure the subaccount-beginning is correct, and it is impossible to leave out the last characters of the subaccount when copying the id in one shot, because there must be a . in the id.

1 Like

Hey People! @roman-kashitsyn mentions that we can make the textual-format of the icrc1-account as a principal(owner+subaccount) . This format is with the following benefits:

  • It is with a checksum of the owner-principal and subaccount together - checksum(owner-principal+subaccount) - which makes it 100% copy-safe/error-resistant so that a user cannot send funds to an incorrectly copied account-id.
  • The main(default) account of an account-owner is the account-owner’s-principal. A user’s-principal is the account-id of the user’s-main-account.

Do the people want the textual format of the icrc1-account-id that is 100% copy-safe, can hold subaccounts, and keeps the user’s-principal as the id of the user’s-main-account?

Looks like a win-win-win.

6 Likes

Oh wow, this looks very very promising. Let’s explore it

1 Like

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)