Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
# Crates that produce FFI bindings
crates = ["algokit_transact", "algokit_crypto"]
crates = ["algokit_transact", "algokit_crypto", "algokit_algo25"]
items = []
for crate in crates:
pascal = ''.join(p.capitalize() for p in crate.split('_'))
Expand Down
38 changes: 36 additions & 2 deletions .github/workflows/swift_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,43 @@ jobs:
run: cargo pkg ${{ env.CRATE }} swift

# Ideally we'd use a matrix for the platforms, but due to the limitations of Mac runners on GitHub it's probably better to just have a single job with multiple steps
- name: Detect iOS simulator
id: ios_sim
run: |
DEST_ID="$(python3 - <<'PY'
import json, subprocess, re

data = json.loads(subprocess.check_output(
["xcrun", "simctl", "list", "devices", "available", "-j"],
text=True
))

def runtime_version(key: str):
# Example key: com.apple.CoreSimulator.SimRuntime.iOS-18-2
m = re.search(r"iOS-(\d+)-(\d+)", key)
return (int(m.group(1)), int(m.group(2))) if m else (-1, -1)

ios_runtimes = [k for k in data["devices"].keys() if "SimRuntime.iOS-" in k]
ios_runtimes.sort(key=runtime_version, reverse=True)

udid = None
for runtime in ios_runtimes:
devices = data["devices"][runtime]
iphones = [d for d in devices if d.get("isAvailable") and d.get("name", "").startswith("iPhone")]
if iphones:
udid = iphones[0]["udid"]
break

if not udid:
raise SystemExit("No available iPhone simulator found")

print(udid)
PY
)"
echo "destination=id=${DEST_ID}" >> "$GITHUB_OUTPUT"

- name: Test (iOS)
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "platform=iOS Simulator,name=iPhone 17,OS=latest"
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "${{ steps.ios_sim.outputs.destination }}"
- name: Test (macOS)
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "platform=macOS"
- name: Test (Catalyst)
Expand All @@ -74,4 +109,3 @@ jobs:
with:
name: ${{ env.PACKAGE }}
path: packages/swift/${{ env.PACKAGE }}.zip

16 changes: 16 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ members = [
"tools/api_tools",
"crates/algokit_crypto",
"crates/algokit_crypto_ffi",
"crates/algokit_algo25",
"crates/algokit_algo25_ffi",
]

[workspace.dependencies]
Expand Down
7 changes: 7 additions & 0 deletions crates/algokit_algo25/Cargo.toml
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 }
213 changes: 213 additions & 0 deletions crates/algokit_algo25/src/english.rs

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions crates/algokit_algo25/src/lib.rs
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(
Copy link
Member

@michaeltchuang michaeltchuang Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is master_derivation_key only 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

Copy link
Collaborator Author

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

Copy link
Member

@michaeltchuang michaeltchuang Feb 22, 2026

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

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...)?

Copy link
Collaborator Author

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?

Copy link
Member

@michaeltchuang michaeltchuang Feb 22, 2026

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?

i think algokit_account might be better since if you add the random function...it'll have create account functionality in a way too. Or algokit_wallet / algokit_address? Solana has Seed Vault, maybe algokit_seed could work too?

then algokit_accountdetail or algokit_accountinfo can 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

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> {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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

Copy link
Collaborator

Choose a reason for hiding this comment

The 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
}
);
}
}
23 changes: 23 additions & 0 deletions crates/algokit_algo25_ffi/Cargo.toml
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",
] }
Loading