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)

1 Like

Exactly!!!. This Account urn is not it.

@bitbruce @mariop

The issue is, this format is not really needed from business perspective.

My dapp generated a wallet(Canister Principal + SubAccount Identifier)
h5boe-vqaaa-aaaam-ac36q-cai-ckw43vy.6867bf53ffcaa0cd11f3bb59be8e4d9e71b20d096cc822a5dffecc7561b026ce

How do i direct my users to send token to this?

The Internet Computer Dashboard doesn’t support this to be used to view balance
The NNS doesn’t support this to be used to do transfers and view balance
Exchanges doesn’t support

The Original ICP AccountIdentifier is better, because my Dapp can simply instruct users to send token to the Account ID, they and easily view their balance.

We should revert ICRC-1 account to accountidentifier Or ICRC ledgers should support account id.

My 2 cents

1 Like

ICRC-1 should Use Account-ID

@bitbruce complained about this.

How can ICRC be different from the Base ICP token?

ethereum Address can be used for all ERC-20 transactions.

But ICP Account-ID can’t be used for all ICRC-1 transactions.

The difference between the account formats is due to a fundamental choice regarding the privacy of the principal that controls an account. For the ICP ledger the link between accounts & the principal that controls them is hidden: given an ICP ledger account identifier one cannot (in general) determine the principal that controls them and the corresponding subaccount because this would require reverting the hash function.

For the ICRC ledger the choice made within the working group in charge of the design was to explicitly spell out the link.

Unifying the two is not really possible. The ICP ledger does have an ICRC transfer interface (but such transfers are recorded in the ICP ledgers as transfers between account identifiers where the principal & subaccount are hashed and not as ICRC-3 transactions). Furthermore, one cannot easily add a (legacy) ICP transfer interface to ICRC ledgers: when a legacy transfer is made, one should determine the principals & subaccounts involved which is not impossible, but highly non-trivial on ICRC ledgers.