Skip to content

Feat: add algorand chain and AVM chain type support#124

Open
emg110 wants to merge 10 commits intoopen-wallet-standard:mainfrom
GoPlausible:feat-add-algorand
Open

Feat: add algorand chain and AVM chain type support#124
emg110 wants to merge 10 commits intoopen-wallet-standard:mainfrom
GoPlausible:feat-add-algorand

Conversation

@emg110
Copy link
Copy Markdown

@emg110 emg110 commented Mar 25, 2026

feat: add Algorand chain and AVM chain type support

Closes #101

This PR introduces AVM (Algorand Virtual Machine) as a supported chain type in the Open Wallet Standard, with Algorand as the first chain. The ChainType::Avm follows the same pattern as ChainType::Evm — a single chain type that can support multiple chains sharing the same VM (e.g., Algorand, Voi). The implementation requires no new CLI commands — existing commands work with --chain algorand or --chain avm.

Key Capabilities

  • ows sign message/tx --chain algorand (or --chain avm)
  • Algorand address derivation in ows wallet create and ows mnemonic derive
  • BIP32-Ed25519 hierarchical deterministic key derivation with Peikert's amendment

Technical Approach

HD Derivation: Implements BIP32-Ed25519 (Khovratovich 2017) with Peikert's amendment (g=9) as specified in ARC-52. Unlike SLIP-10 Ed25519 used by Solana/Sui, BIP32-Ed25519 supports both hardened and non-hardened child derivation, which is required for Algorand's BIP-44 path structure (m/44'/283'/account'/0/index).

Signing: Uses Ed25519 through ed25519-dalek's hazmat API with the full 96-byte BIP32-Ed25519 extended key (kL || kR || chainCode). The real kR is used as the RFC 8032 nonce source, matching the Algorand Foundation's rawSign() implementation exactly. sign_transaction prepends the "TX" domain separator internally; sign_message prepends "MX" (ARC-60). encode_signed_transaction produces canonical msgpack ready for algod's POST /v2/transactions.

Address format: Base32-encoded public key with 4-byte SHA-512/256 checksum (58 characters, no padding).

Chain Specifications

Property Value
Chain type Avm (like Evm — supports multiple AVM chains)
CAIP-2 algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k
BIP-44 coin type 283
Curve Ed25519 (BIP32-Ed25519, Peikert g=9)
Native asset ALGO

Files Changed

12 files across 4 crates + Node bindings (~900 lines):

  • ows-core: ChainType::Avm registration, CAIP-2 mapping (algorand: namespace), RPC endpoint, "avm" alias in parse_chain/FromStr
  • ows-signer: BIP32-Ed25519 derivation engine, AvmSigner (chains/avm.rs), Curve::Ed25519Bip32
  • ows-lib: Ed25519Bip32 curve handling, broadcast stub
  • bindings/node: Updated account count assertions (8 → 9), added algorand: chain ID check

Testing

  • 31 AVM/Algorand-specific tests covering root key generation, child derivation (6 address paths + 5 identity paths), extended private keys, address encoding, signing with real kR nonce, TX/MX domain separation, broadcast-ready msgpack encoding, and verification

  • All 500 workspace tests pass (247 in ows-signer, 135 in ows-lib, 67 in ows-core, 45 in ows-pay, 6 in ows-cli)

  • All 16 Node binding tests pass (updated for 9 chains)

  • Clippy clean with -D warnings

  • Cross-verified against the Algorand Foundation's TypeScript reference implementation (algorandfoundation/xHD-Wallet-API-ts, 122/122 tests pass) and AF's production wallet (Rocca) — all derived public keys match exactly

  • E2E tested on Algorand testnet — OWS-derived keys successfully signed and submitted transactions to algod (confirmed on-chain)

  • Test vectors use the same mnemonic and BIP-44 paths as the reference implementation

  • cargo test --workspace passes (500 tests, 0 failures)

  • cargo clippy --workspace -- -D warnings is clean

  • npm test passes (16/16 — Node binding tests updated for AVM)

  • Tested manually with ows CLI (ows mnemonic derive --chain algorand, --chain avm, and all-chains)

Notes

New Dependencies

Crate Version Purpose
curve25519-dalek 4 Ed25519 scalar base-point multiplication (noclamp) — already a transitive dep of ed25519-dalek
data-encoding 2 Base32 encoding for Algorand addresses

Related

@emg110 emg110 requested a review from njdawn as a code owner March 25, 2026 12:27
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

@emg110 is attempting to deploy a commit to the MoonPay Team on Vercel.

A member of the Team first needs to authorize it.

@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 25, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​data-encoding@​2.10.010010093100100

View full report

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

some findings from quick review - could you validate the below?

High: AvmSigner::default_derivation_path(index) returns m/44'/283'/{index}'/0/0, but the PR body itself describes the intended BIP-44 form as m/44'/283'/account'/0/index. Every other signer also uses index as the address index. As written, callers requesting index 1 will derive a different account, not the next address.

Medium: sign_transaction() signs the provided bytes and encode_signed_transaction() returns only the raw signature bytes, leaving msgpack signed-transaction assembly to the caller. That is a much looser contract than other chains and is easy to misuse from generic OWS transaction flows.

Medium: The Ed25519-BIP32 signing path acknowledges that it does not have the full extended key material and substitutes a derived nonce source. That needs stronger cross-validation against official Algorand tooling before it should be treated as production-grade.

@emg110
Copy link
Copy Markdown
Author

emg110 commented Mar 31, 2026

Thank you very much @njdawn 🙏
I will check and address those all in few hours

@emg110
Copy link
Copy Markdown
Author

emg110 commented Apr 3, 2026

Thank you @njdawn for very constructive comments 🙏
Here is a report on 3 comments you made:
I also added Algorand to documentations in commit 3367587


Finding 1 (High): default_derivation_path(index) Semantics

Your Comment

AvmSigner::default_derivation_path(index) returns m/44'/283'/{index}'/0/0, but the PR body itself describes the intended BIP-44 form as m/44'/283'/account'/0/index. Every other signer also uses index as the address index. As written, callers requesting index 1 will derive a different account, not the next address.

Not a bug. This is the Algorand ecosystem standard.

Evidence from the Algorand Foundation's Rocca Wallet

Rocca's onboarding (app/onboarding.tsx:340-367) and import (app/import.tsx:82-109) both call:

await key.store.generate({
  type: 'hd-derived-ed25519',
  algorithm: 'EdDSA',
  params: {
    parentKeyId: rootKeyId,
    context: 0,   // Address context
    account: 0,
    index: 0,
    derivation: 9 // Peikert's BIP32-Ed25519 (g=9)
  }
})

This flows through @algorandfoundation/react-native-keystore@algorandfoundation/keystore@algorandfoundation/xhd-wallet-api, which constructs the BIP-44 path in GetBIP44PathFromContext (x.hd.wallet.api.crypto.ts:66-75):

function GetBIP44PathFromContext(context: KeyContext, account: number, key_index: number): number[] {
    switch (context) {
        case KeyContext.Address:  // context=0
            return [harden(44), harden(283), harden(account), 0, key_index]
        case KeyContext.Identity: // context=1
            return [harden(44), harden(0), harden(account), 0, key_index]
    }
}

The Algorand BIP-44 convention is: m/44'/283'/account'/0/address_index. Wallet UIs enumerate accounts (each with address_index=0 as the primary address). The keyGen JSDoc at line 168 explicitly states:

@param keyIndex - key index. This value will be a SOFT derivation as part of BIP44.

Implementation Matches Exactly

AVM signerows/crates/ows-signer/src/chains/avm.rs:151-155:

fn default_derivation_path(&self, index: u32) -> String {
    // BIP-44 path for Algorand: m/44'/283'/account'/0/0
    // Using Peikert's BIP32-Ed25519 with mixed hardened/non-hardened
    format!("m/44'/283'/{}'/0/0", index)
}

This produces m/44'/283'/{index}'/0/0 — enumerating accounts with address_index=0, exactly as Rocca does.

Double checking "How Every Other Signer Does it"

Each chain is using index to derive based on their own design and so is Algorand. The ChainSigner trait at ows/crates/ows-signer/src/traits.rs:81 defines:

/// Returns the default BIP-44 derivation path template for this chain.
fn default_derivation_path(&self, index: u32) -> String;

The trait uses the generic name index — each chain maps it to the appropriate BIP-44 level per its ecosystem convention. Three other chains in the codebase also use index as the account level:

Chain Path Template index Represents Source
AVM m/44'/283'/{index}'/0/0 account ows/crates/ows-signer/src/chains/avm.rs:154
Solana m/44'/501'/{index}'/0' account ows/crates/ows-signer/src/chains/solana.rs:145
Sui m/44'/784'/{index}'/0'/0' account ows/crates/ows-signer/src/chains/sui.rs:161
TON m/44'/607'/{index}' account ows/crates/ows-signer/src/chains/ton.rs:201
// solana.rs:145
format!("m/44'/501'/{}'/0'", index)

// sui.rs:161
format!("m/44'/784'/{}'/0'/0'", index)

// ton.rs:201
format!("m/44'/607'/{}'", index)

Algorand test coverage identically matches the output of test coverage on referenced repositories from Algorand Foundation on derivation results.


Finding 2 (Medium): sign_transaction / encode_signed_transaction Contract

Your Comment

sign_transaction() signs the provided bytes and encode_signed_transaction() returns only the raw signature bytes, leaving msgpack signed-transaction assembly to the caller. That is a much looser contract than other chains and is easy to misuse from generic OWS transaction flows.

Valid point. Both issues addressed and fixed in Commit: 3eb707d


2a. sign_transaction / sign_message — Domain Separation Prefixes

The initial implementation delegated domain separation to the caller — they had to prepend "TX" or "MX" themselves before calling sign_transaction or sign_message. This mirrored the AF's lower-level signAlgoTransaction(prefixEncodedTx) convention but, as the reviewer notes, is easy to misuse in a generic multi-chain signer where callers should not need to know Algorand-specific prefixing rules.

Resolution: sign_transaction now prepends "TX" and sign_message now prepends "MX" internally. The caller passes raw msgpack bytes (for transactions) or raw message bytes; the signer handles Algorand domain separation:

Method Prefix Algorand Convention
sign_transaction "TX" Transaction signing — all Algorand wallets prepend this
sign_message "MX" Arbitrary message signing (ARC-60 convention)

Commit: 3eb707d


2b. encode_signed_transaction — Now Produces Broadcast-Ready Msgpack

The initial implementation returned only the raw 64-byte signature, requiring the application to assemble the Algorand signed transaction msgpack structure itself. It was indeed looser contract than other chains. OWS is the signing entity — what it signs should be ready to send to algod.

Resolution: encode_signed_transaction now produces a complete, canonical msgpack-encoded signed transaction ready for algod's POST /v2/transactions endpoint:

msgpack({ "sig": <64-byte Ed25519 signature>, "txn": <transaction object> })

The msgpack envelope is hand-encoded (no external dependency needed) since the structure is fixed:

fn encode_signed_transaction(&self, tx_bytes: &[u8], signature: &SignOutput) -> Result<Vec<u8>, SignerError> {
    // Hand-encoded canonical msgpack:
    //   0x82                       - fixmap with 2 entries
    //   0xa3 "sig"                 - fixstr(3) "sig"
    //   0xc4 0x40 <64 bytes>       - bin8, length 64, signature
    //   0xa3 "txn"                 - fixstr(3) "txn"
    //   <tx_bytes>                 - raw msgpack transaction object
    // Keys sorted alphabetically ("sig" < "txn") per Algorand canonical encoding.
    ...
}

The tx_bytes parameter is the msgpack-encoded unsigned transaction object (the same bytes passed to sign_transaction). The output can be sent directly to algod.


Finding 3 (Medium): Substituted Nonce Source in Ed25519-BIP32 Signing

Your Comment

The Ed25519-BIP32 signing path acknowledges that it does not have the full extended key material and substitutes a derived nonce source. That needs stronger cross-validation against official Algorand tooling before it should be treated as production-grade.

Valid point. Addressed and fixed in Commit: 3eb707d

What the AF's Implementation Does

We cross-validated against:

  1. @algorandfoundation/xhd-wallet-api v2.0.0-canary.1 — the AF's reference HD wallet library
  2. Rocca — the AF's production mobile wallet (depends on the above)

The xHD-Wallet-API-ts rawSign() function (x.hd.wallet.api.crypto.ts:137-159) uses the real kR from the full 96-byte extended key. The AF's rawSign receives the full extended key from deriveKey() — 96 bytes containing kL, kR, and chainCode. It uses kR directly as the nonce source (SHA-512(kR || data)).

The applied Fix

The ChainSigner trait accepts &[u8] — it does not enforce 32 bytes. The fix is straightforward:

  1. HdDeriver::derive_bip32_ed25519 — return the full 96 bytes [kL(32) || kR(32) || chainCode(32)] instead of only kL
  2. AvmSigner::sign_extended — extract kL (bytes 0..32) and kR (bytes 32..64) from the 96-byte input, use kR as the nonce source exactly as the AF's rawSign() does
  3. AvmSigner::public_key_from_scalar and AvmSigner::derive_address — extract kL from the first 32 bytes of the extended key

This makes our signing output byte-for-byte identical to the AF's implementation for the same key and message.


Summary Table

# Severity Finding Response Action Required
1 High default_derivation_path uses account not address index Clarified — matches AF's Rocca wallet and xHD-Wallet-API convention exactly. None. Clarified in this response.
2a Medium sign_transaction doesn't prepend "TX" Fixed — Indeed Internal prefixing is safer. Adding "TX" to sign_transaction and "MX" to sign_message. Done.
2b Medium encode_signed_transaction returns raw sig Fixed — OWS is the signer; output should be broadcast-ready. Now produces canonical msgpack signed transaction for algod. Done.
3 Medium Substituted nonce source Fixed — Fixed to use real kR matching AF's rawSign() exactly. Done.

Algorand Foundation sources referenced:

  • @algorandfoundation/Rocca — AF's mobile wallet for identity and credentials which uses HD wallets
  • @algorandfoundation/xhd-wallet-api v2.0.0-canary.1 — AF's reference HD wallet cryptography library
  • @algorandfoundation/keystore v1.0.0-canary.12 — AF's keystore abstraction
  • @algorandfoundation/react-native-keystore v1.0.0-canary.6 — AF's native keystore implementation

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

please rebase on main to fix merge conflicts!

@emg110
Copy link
Copy Markdown
Author

emg110 commented Apr 8, 2026

Surely and gladly! Thank you @njdawn

@emg110 emg110 force-pushed the feat-add-algorand branch from 95ddd89 to 3a797c1 Compare April 10, 2026 10:19
@emg110 emg110 force-pushed the feat-add-algorand branch from 3a797c1 to 7ee119b Compare April 10, 2026 10:35
@emg110
Copy link
Copy Markdown
Author

emg110 commented Apr 10, 2026

Dear @njdawn , all is ready now
Rebased and updated in 8b2447b then 3 new commits arrived on main and the rebased again in 7ee119b

  • Node: 19/19 pass (rebuilt native binary required)
  • Python: 15/15 pass (rebuilt via maturin)
  • Rust: 596/596 pass
  • Local E2E testnet: all 5 pass (confirmed on-chain)
  • cargo fmt: clean
  • cargo clippy -D warnings: clean
  • No conflict: confirmed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add Algorand + Voi chain support

2 participants