Skip to content

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

Open
emg110 wants to merge 11 commits into
open-wallet-standard:mainfrom
GoPlausible:feat-add-algorand
Open

Feat: add algorand chain and AVM chain type support#124
emg110 wants to merge 11 commits into
open-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 2 times, most recently 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

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.

thank you! one more merge conflict, then can merge!

@emg110 emg110 force-pushed the feat-add-algorand branch from be81e09 to c0e9c95 Compare April 17, 2026 17:32
@emg110
Copy link
Copy Markdown
Author

emg110 commented Apr 17, 2026

Surely and gladly @njdawn. Rebased to newest and fixed the conflict in c0e9c95

@emg110
Copy link
Copy Markdown
Author

emg110 commented Apr 21, 2026

Hey dear @njdawn
New updates got merged again and apparently, I need to resolve and rebase again. Would you mind kindly ping me when you intend to merge so that I do the resolve and rebase immediately again and make it ready then. I will be most grateful.

@0xultravioleta
Copy link
Copy Markdown
Contributor

0xultravioleta commented May 1, 2026

@emg110 — solid implementation. Reviewing from production-experience perspective: we run Algorand x402 USDC payments through our x402-rs facilitator, live on mainnet at facilitator.ultravioletadao.xyz, so I can vouch the wire format produced here matches what a real facilitator settles.

Specifically validated against our impl:

  • "TX" / "MX" prefixes — same in our facilitator's verification path
  • Base32 + SHA-512/256 checksum address (58 chars) — match
  • Genesis-hash binding via msgpack gh field — signer is genesis-agnostic, network binding lives in the bytes (same model NEAR uses, see feat: Add NEAR Protocol chain support (Ed25519 + Borsh + NEP-413) #219)
  • ARC-52 BIP32-Ed25519 with Peikert g=9 — necessary because BIP-44 path m/44'/283'/account'/0/index has non-hardened components that SLIP-10 ed25519 (used for Solana/Sui/TON) can't support. Right call to add it as separate machinery.
  • AVM family pattern (ChainType::Avm) — right call. Voi shares the wire format. Our facilitator covers both behind a single algorand.rs module too.
  • CAIP-2 algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k — matches CAIP-30 truncated genesis hash spec

One question for visibility: the canonical msgpack encoder — vendored algonaut slice or rolled your own? Our facilitator pulls algonaut v0.4 + rmp-serde, but for OWS's lean philosophy a stripped encoder makes sense. If helpful, we can contribute test vectors derived from real on-chain x402 settlements after merge (Payment + ASA Transfer in atomic groups with fee-payer).

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.

0xultravioleta added a commit to 0xultravioleta/core that referenced this pull request May 3, 2026
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>
0xultravioleta added a commit to 0xultravioleta/core that referenced this pull request May 5, 2026
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>
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

3 participants