-
Notifications
You must be signed in to change notification settings - Fork 9
feat: algo25 package #335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: algo25 package #335
Changes from all commits
836b48c
15ddb2f
17cc7d4
aa66f99
cddb647
ff245e1
6d02094
782b1ae
7e4a76a
60a660f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| [package] | ||
| name = "algokit_algo25" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
|
|
||
| [dependencies] | ||
| sha2 = { workspace = true } |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| mod english; | ||
|
|
||
| use english::ENGLISH; | ||
| use sha2::{Digest, Sha512_256}; | ||
|
|
||
| pub const FAIL_TO_DECODE_MNEMONIC_ERROR_MSG: &str = "failed to decode mnemonic"; | ||
| pub const NOT_IN_WORDS_LIST_ERROR_MSG: &str = | ||
| "the mnemonic contains a word that is not in the wordlist"; | ||
|
|
||
| const SEED_BYTES_LENGTH: usize = 32; | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum MnemonicError { | ||
| InvalidSeedLength { expected: usize, found: usize }, | ||
| NotInWordsList, | ||
| FailedToDecodeMnemonic, | ||
| } | ||
|
|
||
| impl core::fmt::Display for MnemonicError { | ||
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | ||
| match self { | ||
| MnemonicError::InvalidSeedLength { expected, .. } => { | ||
| write!(f, "Seed length must be {expected}") | ||
| } | ||
| MnemonicError::NotInWordsList => write!(f, "{NOT_IN_WORDS_LIST_ERROR_MSG}"), | ||
| MnemonicError::FailedToDecodeMnemonic => { | ||
| write!(f, "{FAIL_TO_DECODE_MNEMONIC_ERROR_MSG}") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for MnemonicError {} | ||
|
|
||
| fn to_uint11_array(buffer8: &[u8]) -> Vec<u16> { | ||
| let mut buffer11 = Vec::new(); | ||
| let mut acc: u32 = 0; | ||
| let mut acc_bits: u32 = 0; | ||
|
|
||
| for octet in buffer8 { | ||
| acc |= u32::from(*octet) << acc_bits; | ||
| acc_bits += 8; | ||
|
|
||
| if acc_bits >= 11 { | ||
| buffer11.push((acc & 0x7ff) as u16); | ||
| acc >>= 11; | ||
| acc_bits -= 11; | ||
| } | ||
| } | ||
|
|
||
| if acc_bits != 0 { | ||
| buffer11.push(acc as u16); | ||
| } | ||
|
|
||
| buffer11 | ||
| } | ||
|
|
||
| fn to_uint8_array(buffer11: &[u16]) -> Vec<u8> { | ||
| let mut buffer8 = Vec::new(); | ||
| let mut acc: u32 = 0; | ||
| let mut acc_bits: u32 = 0; | ||
|
|
||
| for ui11 in buffer11 { | ||
| acc |= u32::from(*ui11) << acc_bits; | ||
| acc_bits += 11; | ||
|
|
||
| while acc_bits >= 8 { | ||
| buffer8.push((acc & 0xff) as u8); | ||
| acc >>= 8; | ||
| acc_bits -= 8; | ||
| } | ||
| } | ||
|
|
||
| if acc_bits != 0 { | ||
| buffer8.push(acc as u8); | ||
| } | ||
|
|
||
| buffer8 | ||
| } | ||
|
|
||
| fn apply_words(nums: &[u16]) -> Vec<&'static str> { | ||
| nums.iter().map(|n| ENGLISH[*n as usize]).collect() | ||
| } | ||
|
|
||
| fn english_index(word: &str) -> Option<usize> { | ||
| ENGLISH.iter().position(|candidate| *candidate == word) | ||
| } | ||
|
|
||
| fn compute_checksum(seed: &[u8; SEED_BYTES_LENGTH]) -> &'static str { | ||
| let mut hasher = Sha512_256::new(); | ||
| hasher.update(seed); | ||
| let hash = hasher.finalize(); | ||
|
|
||
| let uint11_hash = to_uint11_array(hash.as_ref()); | ||
| let words = apply_words(&uint11_hash); | ||
| words[0] | ||
| } | ||
|
|
||
| /// Converts a 32-byte key into a 25-word mnemonic including checksum. | ||
| pub fn mnemonic_from_seed(seed: &[u8]) -> Result<String, MnemonicError> { | ||
| if seed.len() != SEED_BYTES_LENGTH { | ||
| return Err(MnemonicError::InvalidSeedLength { | ||
| expected: SEED_BYTES_LENGTH, | ||
| found: seed.len(), | ||
| }); | ||
| } | ||
|
|
||
| let seed: [u8; SEED_BYTES_LENGTH] = | ||
| seed.try_into() | ||
| .map_err(|_| MnemonicError::InvalidSeedLength { | ||
| expected: SEED_BYTES_LENGTH, | ||
| found: seed.len(), | ||
| })?; | ||
|
|
||
| let uint11_array = to_uint11_array(&seed); | ||
| let words = apply_words(&uint11_array); | ||
| let checksum_word = compute_checksum(&seed); | ||
|
|
||
| Ok(format!("{} {checksum_word}", words.join(" "))) | ||
| } | ||
|
|
||
| /// Converts a mnemonic generated by this library back to the source 32-byte seed. | ||
| pub fn seed_from_mnemonic(mnemonic: &str) -> Result<[u8; SEED_BYTES_LENGTH], MnemonicError> { | ||
| let words: Vec<&str> = mnemonic.split_whitespace().collect(); | ||
|
|
||
| // Expect exactly 25 words: 24 data words + 1 checksum word. | ||
| if words.len() != 25 { | ||
| return Err(MnemonicError::FailedToDecodeMnemonic); | ||
| } | ||
|
|
||
| let key_words = &words[..24]; | ||
|
|
||
| for w in key_words { | ||
| if english_index(w).is_none() { | ||
| return Err(MnemonicError::NotInWordsList); | ||
| } | ||
| } | ||
|
|
||
| let checksum = words[24]; | ||
| let uint11_array: Vec<u16> = key_words | ||
| .iter() | ||
| .map(|word| english_index(word).expect("checked above") as u16) | ||
| .collect(); | ||
|
|
||
| let mut uint8_array = to_uint8_array(&uint11_array); | ||
|
|
||
| if uint8_array.len() != 33 { | ||
| return Err(MnemonicError::FailedToDecodeMnemonic); | ||
| } | ||
|
|
||
| if uint8_array[uint8_array.len() - 1] != 0x0 { | ||
| return Err(MnemonicError::FailedToDecodeMnemonic); | ||
| } | ||
|
|
||
| uint8_array.pop(); | ||
|
|
||
| let seed: [u8; SEED_BYTES_LENGTH] = uint8_array | ||
| .try_into() | ||
| .map_err(|_| MnemonicError::FailedToDecodeMnemonic)?; | ||
|
|
||
| if compute_checksum(&seed) == checksum { | ||
| return Ok(seed); | ||
| } | ||
|
|
||
| Err(MnemonicError::FailedToDecodeMnemonic) | ||
| } | ||
|
|
||
| /// Takes an Algorand secret key and returns its associated mnemonic. | ||
| pub fn secret_key_to_mnemonic(sk: &[u8]) -> Result<String, MnemonicError> { | ||
| let seed = sk | ||
| .get(..SEED_BYTES_LENGTH) | ||
| .ok_or(MnemonicError::InvalidSeedLength { | ||
| expected: SEED_BYTES_LENGTH, | ||
| found: sk.len(), | ||
| })?; | ||
| mnemonic_from_seed(seed) | ||
| } | ||
|
|
||
| /// Takes a mnemonic and returns the corresponding master derivation key. | ||
| pub fn mnemonic_to_master_derivation_key( | ||
| mn: &str, | ||
| ) -> Result<[u8; SEED_BYTES_LENGTH], MnemonicError> { | ||
| seed_from_mnemonic(mn) | ||
| } | ||
|
|
||
| /// Takes a master derivation key and returns the corresponding mnemonic. | ||
| pub fn master_derivation_key_to_mnemonic(mdk: &[u8]) -> Result<String, MnemonicError> { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it just for consistent naming or is this something to be added later? I remember the sdk has a derivation function similar to kmd, I'm guessing this is a stub
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These functions are here to align with TS/Py which in turn are aligned with algosdk: https://github.com/algorand/js-algorand-sdk//blob/da8f2e6cd616e9430f8bb8d56bb9d25dffe1b98a/src/mnemonic/mnemonic.ts#L164-L181 I assume they exist because the master key and an ed25519 key are different things technically even though they use the same algorithm
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahh that's interesting. I wouldn't have expected that, yea it must be a naming decision. I'm guessing KMD does the same |
||
| mnemonic_from_seed(mdk) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn seed_round_trip() { | ||
| let seed = [7u8; SEED_BYTES_LENGTH]; | ||
| let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode"); | ||
| let decoded = seed_from_mnemonic(&mnemonic).expect("mnemonic should decode"); | ||
| assert_eq!(decoded, seed); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_non_wordlist_words() { | ||
| let seed = [3u8; SEED_BYTES_LENGTH]; | ||
| let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode"); | ||
| let mut words: Vec<&str> = mnemonic.split(' ').collect(); | ||
| words[0] = "notaword"; | ||
| let broken = words.join(" "); | ||
|
|
||
| let err = seed_from_mnemonic(&broken).expect_err("should fail"); | ||
| assert_eq!(err, MnemonicError::NotInWordsList); | ||
| assert_eq!(err.to_string(), NOT_IN_WORDS_LIST_ERROR_MSG); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_bad_checksum() { | ||
| let seed = [11u8; SEED_BYTES_LENGTH]; | ||
| let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode"); | ||
| let mut words: Vec<&str> = mnemonic.split(' ').collect(); | ||
| words[24] = if words[24] == "abandon" { | ||
| "ability" | ||
| } else { | ||
| "abandon" | ||
| }; | ||
| let broken = words.join(" "); | ||
|
|
||
| let err = seed_from_mnemonic(&broken).expect_err("should fail checksum"); | ||
| assert_eq!(err, MnemonicError::FailedToDecodeMnemonic); | ||
| assert_eq!(err.to_string(), FAIL_TO_DECODE_MNEMONIC_ERROR_MSG); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_wrong_seed_length() { | ||
| let err = mnemonic_from_seed(&[1u8; 31]).expect_err("length mismatch should fail"); | ||
| assert_eq!( | ||
| err, | ||
| MnemonicError::InvalidSeedLength { | ||
| expected: SEED_BYTES_LENGTH, | ||
| found: 31 | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| [package] | ||
| name = "algokit_algo25_ffi" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
|
|
||
| [lib] | ||
| crate-type = ["lib", "cdylib", "staticlib"] | ||
|
|
||
| [features] | ||
| default = ["ffi_uniffi"] | ||
| ffi_uniffi = ["dep:uniffi"] | ||
|
|
||
| [dependencies] | ||
| algokit_algo25 = { path = "../algokit_algo25" } | ||
| uniffi = { workspace = true, features = [ | ||
| "scaffolding-ffi-buffer-fns", | ||
| ], optional = true } | ||
|
|
||
| [build-dependencies] | ||
| uniffi = { workspace = true, features = [ | ||
| "build", | ||
| "scaffolding-ffi-buffer-fns", | ||
| ] } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is
master_derivation_keyonly used for hd too? and not algo25?i needed a way to get Algo25 public key from mnemonic and thought this was it, but AI was saying it's not the right function, but for BIP39 seed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See this comment: #335 (comment)
I'll be adding HD support in the next PR
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh ok
one question then...i thought this crate was only algo25 based off name (e.g.
crates/algokit_algo25/src/lib.rs...)...will you rename if it includes HD functions too (e.g.crates/algokit_account/src/lib.rs...)? or will hd be it's own crate (e.g.crates/algokit_hd/src/lib.rs...)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question... maybe we rename this package to algokit_recovery and it'll have functions for both HD and algo25 mnemonics?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think
algokit_accountmight be better since if you add the random function...it'll have create account functionality in a way too. Oralgokit_wallet/algokit_address? Solana has Seed Vault, maybealgokit_seedcould work too?then
algokit_accountdetailoralgokit_accountinfocan have balance, rekey flag, ASAs...or anything beyond account keys/addresses? what do you think? or maybe ASA becomes it's own crate...i'm open-minded