Feat: add algorand chain and AVM chain type support#124
Conversation
|
@emg110 is attempting to deploy a commit to the MoonPay Team on Vercel. A member of the Team first needs to authorize it. |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
njdawn
left a comment
There was a problem hiding this comment.
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.
|
Thank you very much @njdawn 🙏 |
|
Thank you @njdawn for very constructive comments 🙏 Finding 1 (High):
|
| 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 andencode_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:
@algorandfoundation/xhd-wallet-apiv2.0.0-canary.1 — the AF's reference HD wallet library- 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:
HdDeriver::derive_bip32_ed25519— return the full 96 bytes[kL(32) || kR(32) || chainCode(32)]instead of onlykLAvmSigner::sign_extended— extractkL(bytes 0..32) andkR(bytes 32..64) from the 96-byte input, usekRas the nonce source exactly as the AF'srawSign()doesAvmSigner::public_key_from_scalarandAvmSigner::derive_address— extractkLfrom 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-apiv2.0.0-canary.1 — AF's reference HD wallet cryptography library@algorandfoundation/keystorev1.0.0-canary.12 — AF's keystore abstraction@algorandfoundation/react-native-keystorev1.0.0-canary.6 — AF's native keystore implementation
njdawn
left a comment
There was a problem hiding this comment.
please rebase on main to fix merge conflicts!
|
Surely and gladly! Thank you @njdawn |
3a797c1 to
7ee119b
Compare
|
Dear @njdawn , all is ready now
|
njdawn
left a comment
There was a problem hiding this comment.
thank you! one more merge conflict, then can merge!
be81e09 to
c0e9c95
Compare
|
Hey dear @njdawn |
|
@emg110 — solid implementation. Reviewing from production-experience perspective: we run Algorand x402 USDC payments through our Specifically validated against our impl:
One question for visibility: the canonical msgpack encoder — vendored LGTM modulo @njdawn's outstanding feedback. Eager to test against our facilitator's verification path once merged, and happy to file a follow-up PR for atomic-group signing helpers (the x402 fee-payer pattern) if that's of interest. |
Closes open-wallet-standard#219 Adds NEAR Protocol as a supported chain in OWS. Ed25519 over canonical Borsh transactions, with SHA-256 pre-hash before signing. Genesis-agnostic signer (network binding lives in Transaction.block_hash, same model Algorand uses for `gh`). ## Chain spec ChainType: Near CAIP-2: near:mainnet, near:testnet BIP-44 coin type: 397 (SLIP-44) Curve: Ed25519 (existing Curve::Ed25519) Default derivation path: m/44'/397'/{index}' (NEAR Foundation, hardened) Address (implicit): 64-char lowercase hex of the ed25519 pubkey TX serialization: Borsh TX signing preimage: Ed25519(SHA-256(borsh(Transaction))) encode_signed_transaction: borsh(Transaction) || 0x00 || sig64 signMessage (V1): raw ed25519 over message bytes (parity with Solana). NEP-413 follow-up tracked. Default RPC: https://rpc.{mainnet,testnet}.near.org ## Files (23 changed, +702 / -50) Core implementation: - ows-signer/src/chains/near.rs (new) — NearSigner, 15 inline tests - ows-signer/src/chains/mod.rs — module + factory registration - ows-core/src/chain.rs — ChainType::Near, KNOWN_CHAINS rows, namespace, coin_type, FromStr, Display, serde tests, parse_chain test. Also fixes pre-existing Spark omission from ALL_CHAIN_TYPES (was [10] now [12]). - ows-core/src/config.rs — default RPC URLs - ows-lib/src/near_rpc.rs (new) — broadcast_tx_commit JSON-RPC helper. Sanitized error messages: never embeds raw RPC response payload in Display output (would leak operational data — tx details, account identifiers — into logs/UI). - ows-lib/src/lib.rs, ops.rs — module export, broadcast dispatch, 3 integration-test loops include "near". - ows-pay/src/discovery.rs — format_near() with yoctoNEAR divisor (10^24 = 1 NEAR), wired into format_price(). 6 unit tests. Bindings tests: - bindings/node/__test__/index.spec.mjs — "near" in chain coverage, account count 10 -> 12 (Spark omission fix), spark:* assertion. - bindings/python/tests/test_bindings.py — same pattern, also fixes pre-existing missing "xrpl" in derive-all loop. Documentation (DUAL — docs/ AND website-docs/md/ kept in sync): - 07-supported-chains.md: chain families table, non-EVM networks, shorthand aliases, HD derivation tree. - 02-signing-interface.md: chain-specific signMessage / signTransaction semantics for NEAR. README normalization: - ows/README.md, bindings/{node,python}/README.md, readme/templates/{ows,node,python}.md, readme/partials/{supported-chains,why-ows}.md, skills/ows/SKILL.md — chain enumerations now consistently include Spark, Nano, NEAR (also fixed pre-existing Nano omissions). ## Why these design choices - No new dependencies. ed25519-dalek, sha2, hex, bs58, base64 are already in the workspace. We do NOT pull near-primitives (heavy: tokio + dozens of transitive crates). NearSigner is ~330 LoC. - Implicit account, not named. derive_address returns the implicit account ID (hex(pubkey)). Named accounts (alice.near) require on-chain registration and are out of scope for a stateless signer. - HD derivation reuses existing SLIP-10. NEAR's hardened-only path works through the existing ed25519 SLIP-10 implementation in hd.rs. No new HD machinery (in contrast to Algorand PR open-wallet-standard#124 which needed BIP32-Ed25519 with Peikert). - Stateless signing. block_hash inside the Transaction binds it to a network, so the signer doesn't need a network parameter at signing time. Same model Algorand PR open-wallet-standard#124 uses. ## Production reference A production x402 facilitator with full NEAR support runs at facilitator.ultravioletadao.xyz, source at UltravioletaDAO/x402-rs (Rust, ~600 LoC NEAR implementation). It uses NEP-366 SignedDelegateAction for gasless meta-transactions on the facilitator side; this PR provides the underlying signer primitives. Meta-tx wrapping belongs in ows-pay as a follow-up. ## Test strategy - 15 unit tests inline in chains/near.rs covering trait properties, derivation, RFC 8032 vector 1 implicit address, sign/verify roundtrip, determinism, sign_message parity, invalid key length, sha256 prehash semantics, encode_signed layout, sig length rejection, empty input rejection, full extract -> sign -> encode pipeline. - Byte-parity test against near-api-js test/unit/transactions/data/ transaction1.json: hard-coded canonical Transaction borsh hex (transfer test.near -> whatever.near, nonce=1). Verifies extract_signable_bytes is identity AND encode_signed_transaction emits exactly tx_bytes || 0x00 || sig64 matching near-api-js's borsh(SignedTransaction) layout. - 6 format_near unit tests: whole / fractional / zero / one yoctoNEAR / typical NEP-141 storage deposit / non-numeric input. - 3 ops.rs integration loops include "near" (derive_address_all_chains, mnemonic_wallet_sign_message_all_chains, mnemonic_wallet_sign_tx_all_chains). CI gates verified locally: cargo fmt --all --check clean cargo clippy --workspace --all-targets -- -D warnings clean cargo test --workspace 604 passed .githooks/pre-commit pass ## Out of scope (tracked as follow-ups) - NEP-413 prefixed message signing (V1 sign_message is raw ed25519 for parity with Solana; NEP-413 is a structurally distinct flow). - NEP-366 SignedDelegateAction (belongs in ows-pay, not the signer). - Named-account resolution (alice.near -> AccountId requires RPC). - Live testnet broadcast smoke test (Nano open-wallet-standard#109 didn't include one either; can be added behind #[ignore] later). References: - near-api-js: https://github.com/near/near-api-js - NEP-413: https://github.com/near/NEPs/blob/master/neps/nep-0413.md - NEP-366: https://github.com/near/NEPs/blob/master/neps/nep-0366.md - SLIP-44 coin type 397: https://github.com/satoshilabs/slips/blob/master/slip-0044.md - Nano PR open-wallet-standard#109 (merged) — closest structural template - Algorand PR open-wallet-standard#124 (open) — sibling ed25519 chain addition Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes open-wallet-standard#219 Adds NEAR Protocol as a supported chain in OWS. Ed25519 over canonical Borsh transactions, with SHA-256 pre-hash before signing. Genesis-agnostic signer (network binding lives in Transaction.block_hash, same model Algorand uses for `gh`). ## Chain spec ChainType: Near CAIP-2: near:mainnet, near:testnet BIP-44 coin type: 397 (SLIP-44) Curve: Ed25519 (existing Curve::Ed25519) Default derivation path: m/44'/397'/{index}' (NEAR Foundation, hardened) Address (implicit): 64-char lowercase hex of the ed25519 pubkey TX serialization: Borsh TX signing preimage: Ed25519(SHA-256(borsh(Transaction))) encode_signed_transaction: borsh(Transaction) || 0x00 || sig64 signMessage (V1): raw ed25519 over message bytes (parity with Solana). NEP-413 follow-up tracked. Default RPC: https://rpc.{mainnet,testnet}.near.org ## Files (23 changed, +702 / -50) Core implementation: - ows-signer/src/chains/near.rs (new) — NearSigner, 15 inline tests - ows-signer/src/chains/mod.rs — module + factory registration - ows-core/src/chain.rs — ChainType::Near, KNOWN_CHAINS rows, namespace, coin_type, FromStr, Display, serde tests, parse_chain test. Also fixes pre-existing Spark omission from ALL_CHAIN_TYPES (was [10] now [12]). - ows-core/src/config.rs — default RPC URLs - ows-lib/src/near_rpc.rs (new) — broadcast_tx_commit JSON-RPC helper. Sanitized error messages: never embeds raw RPC response payload in Display output (would leak operational data — tx details, account identifiers — into logs/UI). - ows-lib/src/lib.rs, ops.rs — module export, broadcast dispatch, 3 integration-test loops include "near". - ows-pay/src/discovery.rs — format_near() with yoctoNEAR divisor (10^24 = 1 NEAR), wired into format_price(). 6 unit tests. Bindings tests: - bindings/node/__test__/index.spec.mjs — "near" in chain coverage, account count 10 -> 12 (Spark omission fix), spark:* assertion. - bindings/python/tests/test_bindings.py — same pattern, also fixes pre-existing missing "xrpl" in derive-all loop. Documentation (DUAL — docs/ AND website-docs/md/ kept in sync): - 07-supported-chains.md: chain families table, non-EVM networks, shorthand aliases, HD derivation tree. - 02-signing-interface.md: chain-specific signMessage / signTransaction semantics for NEAR. README normalization: - ows/README.md, bindings/{node,python}/README.md, readme/templates/{ows,node,python}.md, readme/partials/{supported-chains,why-ows}.md, skills/ows/SKILL.md — chain enumerations now consistently include Spark, Nano, NEAR (also fixed pre-existing Nano omissions). ## Why these design choices - No new dependencies. ed25519-dalek, sha2, hex, bs58, base64 are already in the workspace. We do NOT pull near-primitives (heavy: tokio + dozens of transitive crates). NearSigner is ~330 LoC. - Implicit account, not named. derive_address returns the implicit account ID (hex(pubkey)). Named accounts (alice.near) require on-chain registration and are out of scope for a stateless signer. - HD derivation reuses existing SLIP-10. NEAR's hardened-only path works through the existing ed25519 SLIP-10 implementation in hd.rs. No new HD machinery (in contrast to Algorand PR open-wallet-standard#124 which needed BIP32-Ed25519 with Peikert). - Stateless signing. block_hash inside the Transaction binds it to a network, so the signer doesn't need a network parameter at signing time. Same model Algorand PR open-wallet-standard#124 uses. ## Production reference A production x402 facilitator with full NEAR support runs at facilitator.ultravioletadao.xyz, source at UltravioletaDAO/x402-rs (Rust, ~600 LoC NEAR implementation). It uses NEP-366 SignedDelegateAction for gasless meta-transactions on the facilitator side; this PR provides the underlying signer primitives. Meta-tx wrapping belongs in ows-pay as a follow-up. ## Test strategy - 15 unit tests inline in chains/near.rs covering trait properties, derivation, RFC 8032 vector 1 implicit address, sign/verify roundtrip, determinism, sign_message parity, invalid key length, sha256 prehash semantics, encode_signed layout, sig length rejection, empty input rejection, full extract -> sign -> encode pipeline. - Byte-parity test against near-api-js test/unit/transactions/data/ transaction1.json: hard-coded canonical Transaction borsh hex (transfer test.near -> whatever.near, nonce=1). Verifies extract_signable_bytes is identity AND encode_signed_transaction emits exactly tx_bytes || 0x00 || sig64 matching near-api-js's borsh(SignedTransaction) layout. - 6 format_near unit tests: whole / fractional / zero / one yoctoNEAR / typical NEP-141 storage deposit / non-numeric input. - 3 ops.rs integration loops include "near" (derive_address_all_chains, mnemonic_wallet_sign_message_all_chains, mnemonic_wallet_sign_tx_all_chains). CI gates verified locally: cargo fmt --all --check clean cargo clippy --workspace --all-targets -- -D warnings clean cargo test --workspace 604 passed .githooks/pre-commit pass ## Out of scope (tracked as follow-ups) - NEP-413 prefixed message signing (V1 sign_message is raw ed25519 for parity with Solana; NEP-413 is a structurally distinct flow). - NEP-366 SignedDelegateAction (belongs in ows-pay, not the signer). - Named-account resolution (alice.near -> AccountId requires RPC). - Live testnet broadcast smoke test (Nano open-wallet-standard#109 didn't include one either; can be added behind #[ignore] later). References: - near-api-js: https://github.com/near/near-api-js - NEP-413: https://github.com/near/NEPs/blob/master/neps/nep-0413.md - NEP-366: https://github.com/near/NEPs/blob/master/neps/nep-0366.md - SLIP-44 coin type 397: https://github.com/satoshilabs/slips/blob/master/slip-0044.md - Nano PR open-wallet-standard#109 (merged) — closest structural template - Algorand PR open-wallet-standard#124 (open) — sibling ed25519 chain addition Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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::Avmfollows the same pattern asChainType::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 algorandor--chain avm.Key Capabilities
ows sign message/tx --chain algorand(or--chain avm)ows wallet createandows mnemonic deriveTechnical 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 realkRis used as the RFC 8032 nonce source, matching the Algorand Foundation'srawSign()implementation exactly.sign_transactionprepends the"TX"domain separator internally;sign_messageprepends"MX"(ARC-60).encode_signed_transactionproduces canonical msgpack ready for algod'sPOST /v2/transactions.Address format: Base32-encoded public key with 4-byte SHA-512/256 checksum (58 characters, no padding).
Chain Specifications
Avm(likeEvm— supports multiple AVM chains)algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73kFiles Changed
12 files across 4 crates + Node bindings (~900 lines):
ChainType::Avmregistration, CAIP-2 mapping (algorand:namespace), RPC endpoint,"avm"alias inparse_chain/FromStrAvmSigner(chains/avm.rs),Curve::Ed25519Bip32Ed25519Bip32curve handling, broadcast stubalgorand:chain ID checkTesting
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 warningsCross-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 --workspacepasses (500 tests, 0 failures)cargo clippy --workspace -- -D warningsis cleannpm testpasses (16/16 — Node binding tests updated for AVM)Tested manually with
owsCLI (ows mnemonic derive --chain algorand,--chain avm, and all-chains)Notes
New Dependencies
curve25519-daleked25519-dalekdata-encodingRelated