From 03edec5bfce1daddcf63fc3e9f8d15f7b8c62af4 Mon Sep 17 00:00:00 2001 From: 0xrick Date: Sat, 4 Apr 2026 03:30:28 +0530 Subject: [PATCH 1/3] feat: Add Stellar (XLM) chain support --- bindings/node/README.md | 1 + bindings/node/__test__/index.spec.mjs | 17 +- bindings/python/README.md | 1 + bindings/python/tests/test_bindings.py | 5 +- docs/07-supported-chains.md | 7 +- ows/Cargo.lock | 54 ++ ows/README.md | 5 +- ows/crates/ows-cli/README.md | 5 +- ows/crates/ows-cli/src/commands/fund.rs | 64 ++ ows/crates/ows-core/README.md | 5 +- ows/crates/ows-core/src/chain.rs | 46 +- ows/crates/ows-core/src/config.rs | 13 +- ows/crates/ows-lib/README.md | 5 +- ows/crates/ows-lib/src/ops.rs | 33 +- ows/crates/ows-pay/src/fund.rs | 7 + ows/crates/ows-signer/Cargo.toml | 1 + ows/crates/ows-signer/README.md | 5 +- ows/crates/ows-signer/src/chains/mod.rs | 4 + ows/crates/ows-signer/src/chains/stellar.rs | 696 ++++++++++++++++++++ ows/crates/ows-signer/src/lib.rs | 22 +- readme/partials/supported-chains.md | 3 +- readme/templates/ows.md | 5 +- 22 files changed, 974 insertions(+), 30 deletions(-) create mode 100644 ows/crates/ows-signer/src/chains/stellar.rs diff --git a/bindings/node/README.md b/bindings/node/README.md index bd6badc4..5656bab3 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -62,6 +62,7 @@ ows sign tx --wallet agent-treasury --chain evm --tx "deadbeef..." | XRPL | secp256k1 | Base58Check (`r...`) | `m/44'/144'/0'/0/0` | | Spark (Bitcoin L2) | secp256k1 | spark: prefixed | `m/84'/0'/0'/0/0` | | Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` | +| Stellar | Ed25519 | StrKey Base32 (`G...`) | `m/44'/148'/{index}'` | ## CLI Reference diff --git a/bindings/node/__test__/index.spec.mjs b/bindings/node/__test__/index.spec.mjs index 8b73a041..8be960a0 100644 --- a/bindings/node/__test__/index.spec.mjs +++ b/bindings/node/__test__/index.spec.mjs @@ -51,7 +51,7 @@ describe('@open-wallet-standard/core', () => { it('derives addresses for all chains', () => { const phrase = generateMnemonic(12); - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'stellar']) { const addr = deriveAddress(phrase, chain); assert.ok(addr.length > 0, `address should be non-empty for ${chain}`); } @@ -59,10 +59,10 @@ describe('@open-wallet-standard/core', () => { // ---- Universal wallet lifecycle ---- - it('creates a universal wallet with 9 accounts', () => { + it('creates a universal wallet with 10 accounts', () => { const wallet = createWallet('lifecycle-test', undefined, 12, vaultDir); assert.equal(wallet.name, 'lifecycle-test'); - assert.equal(wallet.accounts.length, 9); + assert.equal(wallet.accounts.length, 10); const chainIds = wallet.accounts.map((a) => a.chainId); assert.ok(chainIds.some((c) => c.startsWith('eip155:'))); @@ -74,6 +74,7 @@ describe('@open-wallet-standard/core', () => { assert.ok(chainIds.some((c) => c.startsWith('ton:'))); assert.ok(chainIds.some((c) => c.startsWith('fil:'))); assert.ok(chainIds.some((c) => c.startsWith('xrpl:'))); + assert.ok(chainIds.some((c) => c.startsWith('stellar:'))); // List const wallets = listWallets(vaultDir); @@ -107,7 +108,7 @@ describe('@open-wallet-standard/core', () => { const wallet = importWalletMnemonic('mn-import', phrase, undefined, undefined, vaultDir); assert.equal(wallet.name, 'mn-import'); - assert.equal(wallet.accounts.length, 9); + assert.equal(wallet.accounts.length, 10); const evmAcct = wallet.accounts.find((a) => a.chainId.startsWith('eip155:')); assert.equal(evmAcct.address, expectedEvm); @@ -125,7 +126,7 @@ describe('@open-wallet-standard/core', () => { const wallet = importWalletPrivateKey('pk-secp', privkey, undefined, vaultDir, 'evm'); assert.equal(wallet.name, 'pk-secp'); - assert.equal(wallet.accounts.length, 9, 'should have all 9 chain accounts'); + assert.equal(wallet.accounts.length, 10, 'should have all 10 chain accounts'); // Sign on EVM (provided key's curve) const evmSig = signMessage('pk-secp', 'evm', 'hello', undefined, undefined, undefined, vaultDir); @@ -149,7 +150,7 @@ describe('@open-wallet-standard/core', () => { const privkey = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'; const wallet = importWalletPrivateKey('pk-ed', privkey, undefined, vaultDir, 'solana'); - assert.equal(wallet.accounts.length, 9); + assert.equal(wallet.accounts.length, 10); // Sign on Solana (provided key) const solSig = signMessage('pk-ed', 'solana', 'hello', undefined, undefined, undefined, vaultDir); @@ -177,7 +178,7 @@ describe('@open-wallet-standard/core', () => { ); assert.equal(wallet.name, 'pk-both'); - assert.equal(wallet.accounts.length, 9, 'should have all 9 chain accounts'); + assert.equal(wallet.accounts.length, 10, 'should have all 10 chain accounts'); // Sign on EVM (secp256k1 key) const evmSig = signMessage('pk-both', 'evm', 'hello', undefined, undefined, undefined, vaultDir); @@ -216,7 +217,7 @@ describe('@open-wallet-standard/core', () => { // Build a minimal tx with 1 sig slot (0x01) + 64 zero bytes + a message. const solTxHex = '01' + '00'.repeat(64) + 'deadbeefdeadbeef'; - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'stellar']) { const hex = chain === 'solana' ? solTxHex : txHex; const result = signTransaction('tx-signer', chain, hex, undefined, undefined, vaultDir); assert.ok(result.signature.length > 0, `signature should be non-empty for ${chain}`); diff --git a/bindings/python/README.md b/bindings/python/README.md index 1a577dec..4da9afe8 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -74,6 +74,7 @@ print(sig["signature"]) | XRPL | secp256k1 | Base58Check (`r...`) | `m/44'/144'/0'/0/0` | | Spark (Bitcoin L2) | secp256k1 | spark: prefixed | `m/84'/0'/0'/0/0` | | Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` | +| Stellar | Ed25519 | StrKey Base32 (`G...`) | `m/44'/148'/{index}'` | ## Architecture diff --git a/bindings/python/tests/test_bindings.py b/bindings/python/tests/test_bindings.py index 6f754a0b..50979583 100644 --- a/bindings/python/tests/test_bindings.py +++ b/bindings/python/tests/test_bindings.py @@ -40,7 +40,7 @@ def test_create_and_list_wallets(vault_dir): wallet = ows.create_wallet("test-wallet", vault_path_opt=vault_dir) assert wallet["name"] == "test-wallet" assert isinstance(wallet["accounts"], list) - assert len(wallet["accounts"]) == 9 + assert len(wallet["accounts"]) == 10 # Verify each chain family is present chain_ids = [a["chain_id"] for a in wallet["accounts"]] @@ -53,6 +53,7 @@ def test_create_and_list_wallets(vault_dir): assert any(c.startswith("ton:") for c in chain_ids) assert any(c.startswith("fil:") for c in chain_ids) assert any(c.startswith("xrpl:") for c in chain_ids) + assert any(c.startswith("stellar:") for c in chain_ids) wallets = ows.list_wallets(vault_path_opt=vault_dir) assert len(wallets) == 1 @@ -99,7 +100,7 @@ def test_import_wallet_mnemonic(vault_dir): "imported", phrase, vault_path_opt=vault_dir ) assert wallet["name"] == "imported" - assert len(wallet["accounts"]) == 9 + assert len(wallet["accounts"]) == 10 # EVM account should match derived address evm_account = next(a for a in wallet["accounts"] if a["chain_id"].startswith("eip155:")) diff --git a/docs/07-supported-chains.md b/docs/07-supported-chains.md index f66628bb..2f26ee55 100644 --- a/docs/07-supported-chains.md +++ b/docs/07-supported-chains.md @@ -39,6 +39,7 @@ OWS groups chains into families that share a cryptographic curve and address der | XRPL | secp256k1 | 144 | `m/44'/144'/0'/0/{index}` | Base58Check (`r...`) | `xrpl` | | Spark | secp256k1 | 8797555 | `m/84'/0'/0'/0/{index}` | `spark:` + compressed pubkey hex | `spark` | | Filecoin | secp256k1 | 461 | `m/44'/461'/0'/0/{index}` | `f1` + base32(blake2b-160) | `fil` | +| Stellar | ed25519 | 148 | `m/44'/148'/{index}'` | StrKey Base32 (`G...`) | `stellar` | ## Known Networks @@ -71,6 +72,7 @@ Each network has a canonical chain identifier. Endpoint discovery and transport | XRPL | `xrpl:mainnet` | | Spark | `spark:mainnet` | | Filecoin | `fil:mainnet` | +| Stellar | `stellar:pubnet` | Implementations MAY ship convenience endpoint defaults, but those defaults are deployment choices rather than OWS interoperability requirements. @@ -100,6 +102,8 @@ xrpl-testnet → xrpl:testnet xrpl-devnet → xrpl:devnet spark → spark:mainnet filecoin → fil:mainnet +stellar → stellar:pubnet +stellar-testnet → stellar:testnet ``` Aliases MUST be resolved to full CAIP-2 identifiers before any processing. They MUST NOT appear in wallet files, policy files, or audit logs. @@ -123,7 +127,8 @@ Master Seed (512 bits via PBKDF2) ├── m/44'/784'/0'/0'/0' → Sui Account 0 ├── m/44'/144'/0'/0/0 → XRPL Account 0 ├── m/84'/0'/0'/0/0 → Spark Account 0 - └── m/44'/461'/0'/0/0 → Filecoin Account 0 + ├── m/44'/461'/0'/0/0 → Filecoin Account 0 + └── m/44'/148'/{index}' → Stellar Account 0 ``` For mnemonic-based wallets, a single mnemonic derives accounts across all supported chains. Those wallet files store the encrypted mnemonic, and the signer derives the appropriate private key using each chain's coin type and derivation path. Wallets imported from raw private keys instead store encrypted curve-key material directly. diff --git a/ows/Cargo.lock b/ows/Cargo.lock index ce64e03d..23993e74 100644 --- a/ows/Cargo.lock +++ b/ows/Cargo.lock @@ -212,6 +212,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -497,6 +509,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -770,6 +793,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + [[package]] name = "fastrand" version = "2.3.0" @@ -1622,6 +1651,7 @@ dependencies = [ "sha2", "sha3", "signal-hook", + "stellar-xdr", "thiserror 2.0.18", "xrpl-rust", "zeroize", @@ -2457,6 +2487,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stellar-strkey" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +dependencies = [ + "base32", + "crate-git-revision", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-xdr" +version = "21.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50" +dependencies = [ + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "stellar-strkey", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/ows/README.md b/ows/README.md index cbf4e82c..ae8384d9 100644 --- a/ows/README.md +++ b/ows/README.md @@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -67,7 +67,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -84,6 +84,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License diff --git a/ows/crates/ows-cli/README.md b/ows/crates/ows-cli/README.md index cbf4e82c..ae8384d9 100644 --- a/ows/crates/ows-cli/README.md +++ b/ows/crates/ows-cli/README.md @@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -67,7 +67,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -84,6 +84,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License diff --git a/ows/crates/ows-cli/src/commands/fund.rs b/ows/crates/ows-cli/src/commands/fund.rs index b3cd7e8f..7b4b284f 100644 --- a/ows/crates/ows-cli/src/commands/fund.rs +++ b/ows/crates/ows-cli/src/commands/fund.rs @@ -8,6 +8,7 @@ fn find_account_for_chain<'a>( ) -> Result<&'a AccountInfo, CliError> { let chain_prefix = match chain { "solana" => "solana:", + "stellar" | "stellar-testnet" => "stellar:", _ => "eip155:", }; @@ -34,6 +35,26 @@ pub fn run(wallet_name: &str, chain: Option<&str>, token: Option<&str>) -> Resul eprintln!("Creating deposit for wallet \"{wallet_name}\" ({address})"); eprintln!("Target: {token_name} on {chain_name}"); + if chain_name == "stellar-testnet" { + eprintln!("\nfunding via Friendbot is available immediately:"); + println!("https://friendbot.stellar.org/?addr={address}"); + + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open") + .arg(&format!("https://friendbot.stellar.org/?addr={address}")) + .spawn(); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("xdg-open") + .arg(&format!("https://friendbot.stellar.org/?addr={address}")) + .spawn(); + } + + return Ok(()); + } + let rt = tokio::runtime::Runtime::new().map_err(|e| CliError::InvalidArgs(format!("tokio: {e}")))?; @@ -110,3 +131,46 @@ pub fn balance(wallet_name: &str, chain: Option<&str>) -> Result<(), CliError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use ows_lib::types::AccountInfo; + + fn mock_account(chain_id: &str) -> AccountInfo { + AccountInfo { + chain_id: chain_id.to_string(), + address: format!("addr_for_{chain_id}"), + derivation_path: String::new(), + } + } + + #[test] + fn test_find_account_for_chain() { + let accounts = vec![ + mock_account("eip155:1"), + mock_account("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + mock_account("stellar:pubnet"), + ]; + + // Should find Solana + let acct = find_account_for_chain(&accounts, "solana").unwrap(); + assert_eq!(acct.chain_id, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"); + + // Should find Stellar with 'stellar' + let acct = find_account_for_chain(&accounts, "stellar").unwrap(); + assert_eq!(acct.chain_id, "stellar:pubnet"); + + // Should find Stellar with 'stellar-testnet' + let acct = find_account_for_chain(&accounts, "stellar-testnet").unwrap(); + assert_eq!(acct.chain_id, "stellar:pubnet"); + + // Should fallback to EVM for unknown / base + let acct = find_account_for_chain(&accounts, "base").unwrap(); + assert_eq!(acct.chain_id, "eip155:1"); + + // Should error if chain prefix missing + let accounts_no_stellar = vec![mock_account("eip155:1")]; + assert!(find_account_for_chain(&accounts_no_stellar, "stellar").is_err()); + } +} diff --git a/ows/crates/ows-core/README.md b/ows/crates/ows-core/README.md index cbf4e82c..ae8384d9 100644 --- a/ows/crates/ows-core/README.md +++ b/ows/crates/ows-core/README.md @@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -67,7 +67,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -84,6 +84,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index 299ab523..c8e71f79 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -15,10 +15,11 @@ pub enum ChainType { Filecoin, Sui, Xrpl, + Stellar, } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 9] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 10] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -28,6 +29,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 9] = [ ChainType::Filecoin, ChainType::Sui, ChainType::Xrpl, + ChainType::Stellar, ]; /// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID. @@ -140,6 +142,17 @@ pub const KNOWN_CHAINS: &[Chain] = &[ chain_type: ChainType::Xrpl, chain_id: "xrpl:devnet", }, + // Stellar — SEP-0005 CAIP-2 namespace "stellar" + Chain { + name: "stellar", + chain_type: ChainType::Stellar, + chain_id: "stellar:pubnet", + }, + Chain { + name: "stellar-testnet", + chain_type: ChainType::Stellar, + chain_id: "stellar:testnet", + }, ]; /// Parse a chain string into a `Chain`. Accepts: @@ -204,6 +217,7 @@ pub fn parse_chain(s: &str) -> Result { EVM: ethereum, base, arbitrum, optimism, polygon, bsc, avalanche, plasma, etherlink\n \ Solana: solana\n \ Bitcoin: bitcoin\n \ + Stellar: stellar, stellar-testnet\n \ Other: cosmos, tron, ton, sui, filecoin, spark, xrpl\n\n\ Or use a CAIP-2 ID (eip155:8453) or bare EVM chain ID (8453)" )) @@ -228,6 +242,7 @@ impl ChainType { ChainType::Filecoin => "fil", ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", + ChainType::Stellar => "stellar", } } @@ -244,6 +259,8 @@ impl ChainType { ChainType::Filecoin => 461, ChainType::Sui => 784, ChainType::Xrpl => 144, + // SEP-0005: Stellar coin type is 148 (SLIP-44 registry) + ChainType::Stellar => 148, } } @@ -260,6 +277,7 @@ impl ChainType { "fil" => Some(ChainType::Filecoin), "sui" => Some(ChainType::Sui), "xrpl" => Some(ChainType::Xrpl), + "stellar" => Some(ChainType::Stellar), _ => None, } } @@ -278,6 +296,7 @@ impl fmt::Display for ChainType { ChainType::Filecoin => "filecoin", ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", + ChainType::Stellar => "stellar", }; write!(f, "{}", s) } @@ -298,6 +317,7 @@ impl FromStr for ChainType { "filecoin" => Ok(ChainType::Filecoin), "sui" => Ok(ChainType::Sui), "xrpl" => Ok(ChainType::Xrpl), + "stellar" => Ok(ChainType::Stellar), _ => Err(format!("unknown chain type: {}", s)), } } @@ -329,6 +349,7 @@ mod tests { (ChainType::Filecoin, "\"filecoin\""), (ChainType::Sui, "\"sui\""), (ChainType::Xrpl, "\"xrpl\""), + (ChainType::Stellar, "\"stellar\""), ] { let json = serde_json::to_string(&chain).unwrap(); assert_eq!(json, expected); @@ -349,6 +370,7 @@ mod tests { assert_eq!(ChainType::Filecoin.namespace(), "fil"); assert_eq!(ChainType::Sui.namespace(), "sui"); assert_eq!(ChainType::Xrpl.namespace(), "xrpl"); + assert_eq!(ChainType::Stellar.namespace(), "stellar"); } #[test] @@ -363,6 +385,8 @@ mod tests { assert_eq!(ChainType::Filecoin.default_coin_type(), 461); assert_eq!(ChainType::Sui.default_coin_type(), 784); assert_eq!(ChainType::Xrpl.default_coin_type(), 144); + // SEP-0005: Stellar uses coin type 148 + assert_eq!(ChainType::Stellar.default_coin_type(), 148); } #[test] @@ -380,6 +404,7 @@ mod tests { assert_eq!(ChainType::from_namespace("fil"), Some(ChainType::Filecoin)); assert_eq!(ChainType::from_namespace("sui"), Some(ChainType::Sui)); assert_eq!(ChainType::from_namespace("xrpl"), Some(ChainType::Xrpl)); + assert_eq!(ChainType::from_namespace("stellar"), Some(ChainType::Stellar)); assert_eq!(ChainType::from_namespace("unknown"), None); } @@ -507,7 +532,24 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 9); + assert_eq!(ALL_CHAIN_TYPES.len(), 10); + } + + #[test] + fn test_parse_chain_stellar() { + let chain = parse_chain("stellar").unwrap(); + assert_eq!(chain.name, "stellar"); + assert_eq!(chain.chain_type, ChainType::Stellar); + assert_eq!(chain.chain_id, "stellar:pubnet"); + + let testnet = parse_chain("stellar-testnet").unwrap(); + assert_eq!(testnet.chain_type, ChainType::Stellar); + assert_eq!(testnet.chain_id, "stellar:testnet"); + + // CAIP-2 ID also accepted + let via_caip2 = parse_chain("stellar:pubnet").unwrap(); + assert_eq!(via_caip2.chain_type, ChainType::Stellar); + assert_eq!(via_caip2.chain_id, "stellar:pubnet"); } #[test] diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 35275c50..b9eaf7b6 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -73,6 +73,15 @@ impl Config { "xrpl:devnet".into(), "https://s.devnet.rippletest.net:51234".into(), ); + // Stellar: prefer Soroban RPC for new projects; Horizon as legacy fallback + rpc.insert( + "stellar:pubnet".into(), + "https://horizon.stellar.org".into(), + ); + rpc.insert( + "stellar:testnet".into(), + "https://horizon-testnet.stellar.org".into(), + ); rpc } } @@ -252,8 +261,8 @@ mod tests { #[test] fn test_load_or_default_nonexistent() { let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json")); - // Should have all default RPCs - assert_eq!(config.rpc.len(), 18); + // Should have all default RPCs (20 after Stellar addition) + assert_eq!(config.rpc.len(), 20); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); } diff --git a/ows/crates/ows-lib/README.md b/ows/crates/ows-lib/README.md index cbf4e82c..ae8384d9 100644 --- a/ows/crates/ows-lib/README.md +++ b/ows/crates/ows-lib/README.md @@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -67,7 +67,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -84,6 +84,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index d0b4d190..e6619825 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -698,6 +698,7 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes), + ChainType::Stellar => broadcast_stellar(rpc_url, signed_bytes), } } @@ -729,6 +730,35 @@ fn broadcast_xrpl(rpc_url: &str, signed_bytes: &[u8]) -> Result Result { + use base64::Engine; + let b64_tx = base64::engine::general_purpose::STANDARD.encode(signed_bytes); + let url = format!("{}/transactions", rpc_url.trim_end_matches('/')); + + let output = std::process::Command::new("curl") + .args([ + "-fsSL", + "-X", + "POST", + "--data-urlencode", + &format!("tx={}", b64_tx), + &url, + ]) + .output() + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(OwsLibError::BroadcastFailed(format!( + "broadcast failed: {stderr} - {stdout}" + ))); + } + + let resp_str = String::from_utf8_lossy(&output.stdout).to_string(); + extract_json_field(&resp_str, "hash") +} + fn broadcast_evm(rpc_url: &str, signed_bytes: &[u8]) -> Result { let hex_tx = format!("0x{}", hex::encode(signed_bytes)); let body = serde_json::json!({ @@ -1045,7 +1075,7 @@ mod tests { create_wallet("multi-sign", None, None, Some(vault)).unwrap(); let chains = [ - "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", + "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "stellar", ]; for chain in &chains { let result = sign_message( @@ -1086,6 +1116,7 @@ mod tests { let chains = [ "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", + "stellar", ]; for chain in &chains { let tx = if *chain == "solana" { diff --git a/ows/crates/ows-pay/src/fund.rs b/ows/crates/ows-pay/src/fund.rs index cf8ddd4a..59268bb0 100644 --- a/ows/crates/ows-pay/src/fund.rs +++ b/ows/crates/ows-pay/src/fund.rs @@ -79,6 +79,13 @@ const MOONPAY_CHAINS: &[(&str, MoonPayChain)] = &[ moonpay_name: "solana", }, ), + ( + "stellar", + MoonPayChain { + display_name: "Stellar", + moonpay_name: "stellar", + }, + ), ]; const DEFAULT_MOONPAY_CHAIN: &MoonPayChain = &MoonPayChain { diff --git a/ows/crates/ows-signer/Cargo.toml b/ows/crates/ows-signer/Cargo.toml index 04d4d25a..1183af72 100644 --- a/ows/crates/ows-signer/Cargo.toml +++ b/ows/crates/ows-signer/Cargo.toml @@ -14,6 +14,7 @@ fast-kdf = [] [dependencies] ows-core = { path = "../ows-core", version = "=1.2.3" } xrpl-rust = { version = "1.1.0", default-features = false, features = ["core"] } +stellar-xdr = { version = "21.0.0", features = ["base64", "std"] } k256 = { version = "0.13", features = ["ecdsa", "arithmetic"] } ed25519-dalek = { version = "2", features = ["hazmat"] } coins-bip32 = "0.11" diff --git a/ows/crates/ows-signer/README.md b/ows/crates/ows-signer/README.md index cbf4e82c..ae8384d9 100644 --- a/ows/crates/ows-signer/README.md +++ b/ows/crates/ows-signer/README.md @@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -67,7 +67,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -84,6 +84,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index 4fe5fd4f..3b6d7d58 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -4,6 +4,7 @@ pub mod evm; pub mod filecoin; pub mod solana; pub mod spark; +pub mod stellar; pub mod sui; pub mod ton; pub mod tron; @@ -15,6 +16,7 @@ pub use self::evm::EvmSigner; pub use self::filecoin::FilecoinSigner; pub use self::solana::SolanaSigner; pub use self::spark::SparkSigner; +pub use self::stellar::StellarSigner; pub use self::sui::SuiSigner; pub use self::ton::TonSigner; pub use self::tron::TronSigner; @@ -36,5 +38,7 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Filecoin => Box::new(FilecoinSigner), ChainType::Sui => Box::new(SuiSigner), ChainType::Xrpl => Box::new(XrplSigner), + // Stellar defaults to mainnet; use StellarSigner::testnet() for testnet ops + ChainType::Stellar => Box::new(StellarSigner::mainnet()), } } diff --git a/ows/crates/ows-signer/src/chains/stellar.rs b/ows/crates/ows-signer/src/chains/stellar.rs new file mode 100644 index 00000000..261c6a90 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/stellar.rs @@ -0,0 +1,696 @@ +use crate::curve::Curve; +use crate::traits::{ChainSigner, SignOutput, SignerError}; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use ows_core::ChainType; +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------- +// Network passphrases (SEP-0005 §3) +// --------------------------------------------------------------------------- + +/// Mainnet network passphrase. +pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; +/// Testnet network passphrase. +pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015"; + +// --------------------------------------------------------------------------- +// XDR constants +// --------------------------------------------------------------------------- + +/// ENVELOPE_TYPE_TX = 2 (4-byte big-endian). +/// Covers both classic Payment operations and Soroban InvokeHostFunction ops — +/// they all use the same envelope type, so Soroban support comes for free. +const ENVELOPE_TYPE_TX: [u8; 4] = [0x00, 0x00, 0x00, 0x02]; + +// --------------------------------------------------------------------------- +// StrKey constants (SEP-0005 §2 / stellar-base strkey.js) +// --------------------------------------------------------------------------- + +/// Version byte for an Ed25519 public key account ID ("G..." address). +/// Value: 6 << 3 = 0x30. +const VERSION_BYTE_ACCOUNT_ID: u8 = 6 << 3; // 0x30 + +// --------------------------------------------------------------------------- +// StellarSigner +// --------------------------------------------------------------------------- + +/// Stellar chain signer (Ed25519, SLIP-10 hardened-only). +/// +/// # SEP-0005 compliance +/// Derivation path: `m/44'/148'/{index}'` +/// All three levels are hardened — mandatory for Ed25519 SLIP-10 and enforced +/// by the OWS HD deriver (`Curve::Ed25519` rejects non-hardened components). +/// +/// # Signature base +/// Stellar signs a `TransactionSignaturePayload` constructed as: +/// ```text +/// SHA256(network_passphrase) || ENVELOPE_TYPE_TX (4 bytes, big-endian) || tx_xdr_bytes +/// ``` +/// Forgetting the network hash is the "Stacks mistake" — our implementation +/// always prepends it before signing. +/// +/// # Soroban (smart-contract) compatibility +/// Classic and Soroban (InvokeHostFunction) transactions share the same +/// `ENVELOPE_TYPE_TX` constant, so this signer handles both without +/// any extra branching. +pub struct StellarSigner { + /// Pre-computed SHA256 hash of the network passphrase ("network ID"). + network_id: [u8; 32], +} + +impl StellarSigner { + /// Create a signer pinned to Stellar **mainnet**. + pub fn mainnet() -> Self { + Self { + network_id: Sha256::digest(MAINNET_PASSPHRASE.as_bytes()).into(), + } + } + + /// Create a signer pinned to Stellar **testnet**. + pub fn testnet() -> Self { + Self { + network_id: Sha256::digest(TESTNET_PASSPHRASE.as_bytes()).into(), + } + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + fn signing_key(private_key: &[u8]) -> Result { + let bytes: [u8; 32] = private_key.try_into().map_err(|_| { + SignerError::InvalidPrivateKey(format!( + "expected 32 bytes, got {}", + private_key.len() + )) + })?; + Ok(SigningKey::from_bytes(&bytes)) + } + + /// Build the Stellar signature base and return its SHA256 hash. + /// + /// `sign_transaction` requires signing the raw payload, NOT a double-hash. + /// Ed25519 (via ed25519-dalek) signs arbitrary-length messages internally + /// using SHA-512, so we DO NOT pre-hash again — we just pass the full + /// `network_id || ENVELOPE_TYPE_TX || tx_xdr_bytes` payload directly to + /// the Ed25519 signer. This matches the Stellar JS/Go/Python SDK behaviour. + fn signature_payload(&self, tx_xdr_bytes: &[u8]) -> Vec { + let mut payload = Vec::with_capacity(32 + 4 + tx_xdr_bytes.len()); + payload.extend_from_slice(&self.network_id); + payload.extend_from_slice(&ENVELOPE_TYPE_TX); + payload.extend_from_slice(tx_xdr_bytes); + payload + } + + /// Encode a 32-byte Ed25519 public key to a Stellar StrKey address ("G…"). + /// + /// Algorithm (stellar-base strkey.js): + /// 1. payload = [VERSION_BYTE_ACCOUNT_ID] + pubkey (33 bytes) + /// 2. checksum = CRC16-XModem(payload) (2 bytes, little-endian) + /// 3. encode = base32(payload + checksum) (no padding, 56 chars) + pub fn pubkey_to_strkey(pubkey: &[u8; 32]) -> String { + let mut payload = Vec::with_capacity(35); // 1 + 32 + 2 + payload.push(VERSION_BYTE_ACCOUNT_ID); + payload.extend_from_slice(pubkey); + + let crc = crc16_xmodem(&payload); + payload.push((crc & 0xFF) as u8); // low byte first (little-endian) + payload.push((crc >> 8) as u8); // high byte second + + base32_encode(&payload) + } +} + +// --------------------------------------------------------------------------- +// ChainSigner impl +// --------------------------------------------------------------------------- + +impl ChainSigner for StellarSigner { + fn chain_type(&self) -> ChainType { + ChainType::Stellar + } + + fn curve(&self) -> Curve { + Curve::Ed25519 + } + + /// BIP-44 coin type for Stellar (SLIP-44 #148). + fn coin_type(&self) -> u32 { + 148 + } + + /// SEP-0005 derivation path: `m/44'/148'/{index}'`. + /// + /// All three components are hardened (the `'` marks) — this is the + /// Stellar standard and is required for SLIP-10 Ed25519 security. + /// Using a non-hardened level here would be the "njdawn High-severity" + /// mistake seen in rejected PRs on other chains. + fn default_derivation_path(&self, index: u32) -> String { + format!("m/44'/148'/{}'", index) + } + + /// Derive a Stellar `G…` StrKey address from an Ed25519 private key. + fn derive_address(&self, private_key: &[u8]) -> Result { + let signing_key = Self::signing_key(private_key)?; + let verifying_key: VerifyingKey = signing_key.verifying_key(); + let pubkey_bytes: [u8; 32] = *verifying_key.as_bytes(); + Ok(Self::pubkey_to_strkey(&pubkey_bytes)) + } + + /// Sign an arbitrary message with Ed25519 (no extra hashing). + /// + /// Ed25519 signs raw bytes directly; the Ed25519-dalek library performs + /// SHA-512 internally per RFC 8032. No recovery ID (Ed25519 is deterministic + /// without recovery). + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { + let signing_key = Self::signing_key(private_key)?; + let signature = signing_key.sign(message); + Ok(SignOutput { + signature: signature.to_bytes().to_vec(), + recovery_id: None, + public_key: None, + }) + } + + /// Sign a Stellar transaction XDR body. + /// + /// `tx_xdr_bytes` — raw XDR bytes of the **Transaction** struct (the body + /// only, without the envelope wrapper or signatures array). This matches + /// what Stellar SDKs expose as `tx.toXDR()` on the inner transaction body. + /// + /// Internally constructs the signature payload per the Stellar spec: + /// ```text + /// payload = SHA256(network_passphrase) || ENVELOPE_TYPE_TX || tx_xdr_bytes + /// signature = Ed25519.sign(payload) + /// ``` + /// + /// The returned `signature` is the 64-byte Ed25519 signature that should + /// be placed inside the `TransactionEnvelope.signatures` array as a + /// `DecoratedSignature`. The caller is responsible for assembling the + /// final `TransactionEnvelope` XDR using the Stellar SDK. + fn sign_transaction( + &self, + private_key: &[u8], + tx_xdr_bytes: &[u8], + ) -> Result { + if tx_xdr_bytes.is_empty() { + return Err(SignerError::InvalidTransaction( + "transaction XDR bytes must not be empty".into(), + )); + } + let signing_key = Self::signing_key(private_key)?; + let payload = self.signature_payload(tx_xdr_bytes); + let signature = signing_key.sign(&payload); + Ok(SignOutput { + signature: signature.to_bytes().to_vec(), + recovery_id: None, + public_key: None, + }) + } + + /// Stellar has no widely adopted canonical off-chain message signing + /// convention (no EIP-191 equivalent). This implementation signs the + /// raw message bytes directly with Ed25519, which is valid for agent + /// use cases that control both sides of the protocol. + fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { + self.sign(private_key, message) + } +} + +// --------------------------------------------------------------------------- +// CRC16-XModem (used by Stellar StrKey encoding) +// --------------------------------------------------------------------------- + +/// Compute CRC-16/XMODEM over `data`. +/// +/// Polynomial: 0x1021, Init: 0x0000, RefIn: false, RefOut: false, XorOut: 0x0000. +/// This matches the stellar-base JavaScript implementation exactly. +fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x1021; + } else { + crc <<= 1; + } + } + } + crc +} + +// --------------------------------------------------------------------------- +// Base32 encoder (RFC 4648, no padding) +// --------------------------------------------------------------------------- + +/// RFC 4648 base32 alphabet (upper-case, no padding). +const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// Encode `data` as RFC 4648 base32 without padding. +fn base32_encode(data: &[u8]) -> String { + let mut output = String::with_capacity((data.len() * 8 + 4) / 5); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + + for &byte in data { + buffer = (buffer << 8) | (byte as u32); + bits += 8; + while bits >= 5 { + bits -= 5; + let idx = ((buffer >> bits) & 0x1F) as usize; + output.push(BASE32_ALPHABET[idx] as char); + } + } + if bits > 0 { + let idx = ((buffer << (5 - bits)) & 0x1F) as usize; + output.push(BASE32_ALPHABET[idx] as char); + } + output +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::hd::HdDeriver; + use crate::mnemonic::Mnemonic; + use ed25519_dalek::Verifier; + + const ABANDON_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn test_privkey() -> Vec { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let signer = StellarSigner::mainnet(); + let path = signer.default_derivation_path(0); + HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, Curve::Ed25519) + .unwrap() + .expose() + .to_vec() + } + + // ----------------------------------------------------------------------- + // Chain properties + // ----------------------------------------------------------------------- + + #[test] + fn test_chain_properties() { + let signer = StellarSigner::mainnet(); + assert_eq!(signer.chain_type(), ChainType::Stellar); + assert_eq!(signer.curve(), Curve::Ed25519); + assert_eq!(signer.coin_type(), 148); + } + + // ----------------------------------------------------------------------- + // SEP-0005 derivation path + // ----------------------------------------------------------------------- + + #[test] + fn test_derivation_path_format() { + let signer = StellarSigner::mainnet(); + // All three levels must be hardened (') — the "njdawn requirement" + assert_eq!(signer.default_derivation_path(0), "m/44'/148'/0'"); + assert_eq!(signer.default_derivation_path(1), "m/44'/148'/1'"); + assert_eq!(signer.default_derivation_path(9), "m/44'/148'/9'"); + } + + #[test] + fn test_derivation_path_is_all_hardened() { + let signer = StellarSigner::mainnet(); + for index in [0u32, 1, 5, 100] { + let path = signer.default_derivation_path(index); + // Every component must end with ' + for component in path[2..].split('/') { + assert!( + component.ends_with('\''), + "component '{}' in path '{}' is not hardened", + component, + path + ); + } + } + } + + /// Confirm SLIP-10 Ed25519 derivation succeeds for the SEP-0005 path. + /// Non-hardened paths would fail here — this test locks in correctness. + #[test] + fn test_sep0005_derivation_succeeds() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let signer = StellarSigner::mainnet(); + let path = signer.default_derivation_path(0); + let key = HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, Curve::Ed25519); + assert!(key.is_ok(), "SEP-0005 hardened derivation must succeed"); + assert_eq!(key.unwrap().len(), 32); + } + + // ----------------------------------------------------------------------- + // StrKey address encoding + // ----------------------------------------------------------------------- + + #[test] + fn test_address_starts_with_g() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let address = signer.derive_address(&privkey).unwrap(); + assert!( + address.starts_with('G'), + "Stellar address must start with 'G', got: {}", + address + ); + } + + #[test] + fn test_address_length_is_56() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let address = signer.derive_address(&privkey).unwrap(); + assert_eq!( + address.len(), + 56, + "Stellar StrKey address must be exactly 56 characters, got: {}", + address.len() + ); + } + + #[test] + fn test_address_is_uppercase_base32() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let address = signer.derive_address(&privkey).unwrap(); + assert!( + address.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), + "Stellar address must be uppercase base32, got: {}", + address + ); + } + + #[test] + fn test_address_deterministic() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let addr1 = signer.derive_address(&privkey).unwrap(); + let addr2 = signer.derive_address(&privkey).unwrap(); + assert_eq!(addr1, addr2); + } + + #[test] + fn test_different_keys_different_addresses() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let signer = StellarSigner::mainnet(); + + let addr0 = { + let key = HdDeriver::derive_from_mnemonic( + &mnemonic, "", &signer.default_derivation_path(0), Curve::Ed25519, + ).unwrap(); + signer.derive_address(key.expose()).unwrap() + }; + let addr1 = { + let key = HdDeriver::derive_from_mnemonic( + &mnemonic, "", &signer.default_derivation_path(1), Curve::Ed25519, + ).unwrap(); + signer.derive_address(key.expose()).unwrap() + }; + assert_ne!(addr0, addr1, "different indices must produce different addresses"); + } + + /// CRC16-XModem known vector: + /// crc16_xmodem(b"123456789") = 0x31C3 per the standard test suite. + #[test] + fn test_crc16_xmodem_known_vector() { + assert_eq!(crc16_xmodem(b"123456789"), 0x31C3); + } + + #[test] + fn test_crc16_xmodem_empty() { + assert_eq!(crc16_xmodem(b""), 0x0000); + } + + /// Base32 RFC 4648 known vector: encode(b"") == "" and + /// encode(b"f") == "MY" (no padding). + /// RFC 4648 §10 test vectors (no padding variant): + /// BASE32("") = "" + /// BASE32("f") = "MY" + /// BASE32("fo") = "MZXQ" + /// BASE32("foo") = "MZXW6" + /// BASE32("foob") = "MZXW6YQ" + /// BASE32("fooba") = "MZXW6YTB" + #[test] + fn test_base32_known_vectors() { + assert_eq!(base32_encode(b""), ""); + assert_eq!(base32_encode(b"f"), "MY"); + assert_eq!(base32_encode(b"fo"), "MZXQ"); + assert_eq!(base32_encode(b"foo"), "MZXW6"); + assert_eq!(base32_encode(b"foob"), "MZXW6YQ"); + assert_eq!(base32_encode(b"fooba"), "MZXW6YTB"); + } + + /// StrKey round-trip: pubkey_to_strkey produces a 56-char G-address for + /// the RFC-8032 test vector public key. + #[test] + fn test_strkey_rfc8032_vector() { + // RFC 8032 vector 1 public key + let pubkey_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + let pubkey: [u8; 32] = hex::decode(pubkey_hex).unwrap().try_into().unwrap(); + let address = StellarSigner::pubkey_to_strkey(&pubkey); + assert!(address.starts_with('G')); + assert_eq!(address.len(), 56); + } + + /// Cross-validate: address from derive_address == pubkey_to_strkey applied + /// to the raw verifying key bytes. + #[test] + fn test_address_matches_manual_strkey() { + let privkey = test_privkey(); + let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); + let pubkey: [u8; 32] = *signing_key.verifying_key().as_bytes(); + + let signer = StellarSigner::mainnet(); + let via_signer = signer.derive_address(&privkey).unwrap(); + let via_manual = StellarSigner::pubkey_to_strkey(&pubkey); + assert_eq!(via_signer, via_manual); + } + + // ----------------------------------------------------------------------- + // Ed25519 signing + // ----------------------------------------------------------------------- + + #[test] + fn test_sign_produces_64_byte_signature() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let result = signer.sign(&privkey, b"hello stellar").unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert!(result.public_key.is_none()); + } + + #[test] + fn test_sign_is_deterministic() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let sig1 = signer.sign(&privkey, b"test").unwrap(); + let sig2 = signer.sign(&privkey, b"test").unwrap(); + assert_eq!(sig1.signature, sig2.signature); + } + + #[test] + fn test_sign_verifies_with_ed25519_dalek() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let message = b"verify me"; + + let result = signer.sign(&privkey, message).unwrap(); + + let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); + let verifying_key = signing_key.verifying_key(); + let sig = ed25519_dalek::Signature::from_bytes( + &result.signature.as_slice().try_into().unwrap(), + ); + verifying_key.verify(message, &sig).expect("signature must verify"); + } + + // ----------------------------------------------------------------------- + // sign_transaction (Stellar signature base) + // ----------------------------------------------------------------------- + + #[test] + fn test_sign_transaction_produces_64_byte_signature() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let fake_xdr = b"fake_xdr_transaction_bytes"; + let result = signer.sign_transaction(&privkey, fake_xdr).unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + } + + #[test] + fn test_sign_transaction_is_deterministic() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let xdr = b"some_tx_xdr_bytes"; + let sig1 = signer.sign_transaction(&privkey, xdr).unwrap(); + let sig2 = signer.sign_transaction(&privkey, xdr).unwrap(); + assert_eq!(sig1.signature, sig2.signature); + } + + #[test] + fn test_sign_transaction_empty_errors() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + assert!(signer.sign_transaction(&privkey, b"").is_err()); + } + + /// Core interop validity check: sign_transaction(tx) must equal + /// sign(network_id || ENVELOPE_TYPE_TX || tx), proving the signature + /// base is constructed correctly. + #[test] + fn test_sign_transaction_equals_sign_of_signature_payload() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let tx_xdr = b"arbitrary_xdr_for_test"; + + let sig_tx = signer.sign_transaction(&privkey, tx_xdr).unwrap(); + + // Build the payload manually + let mut payload = Vec::new(); + payload.extend_from_slice(&signer.network_id); + payload.extend_from_slice(&ENVELOPE_TYPE_TX); + payload.extend_from_slice(tx_xdr); + let sig_direct = signer.sign(&privkey, &payload).unwrap(); + + assert_eq!( + sig_tx.signature, sig_direct.signature, + "sign_transaction must equal sign(network_id || ENVELOPE_TYPE_TX || tx)" + ); + } + + /// Mainnet and testnet produce DIFFERENT signatures for the same XDR bytes, + /// proving the network ID is included in the signature base (anti-replay). + #[test] + fn test_mainnet_and_testnet_produce_different_signatures() { + let privkey = test_privkey(); + let mainnet = StellarSigner::mainnet(); + let testnet = StellarSigner::testnet(); + let tx_xdr = b"same_xdr_bytes"; + + let sig_main = mainnet.sign_transaction(&privkey, tx_xdr).unwrap(); + let sig_test = testnet.sign_transaction(&privkey, tx_xdr).unwrap(); + + assert_ne!( + sig_main.signature, sig_test.signature, + "mainnet vs testnet signatures must differ (network passphrase is in the signature base)" + ); + } + + /// The signing payload verifies against the correct Ed25519 public key, + /// confirming end-to-end correctness of the full pipeline. + #[test] + fn test_sign_transaction_verifies_with_pubkey() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let tx_xdr = b"payment_tx_xdr"; + + let result = signer.sign_transaction(&privkey, tx_xdr).unwrap(); + + // Build expected payload + let mut payload = Vec::new(); + payload.extend_from_slice(&signer.network_id); + payload.extend_from_slice(&ENVELOPE_TYPE_TX); + payload.extend_from_slice(tx_xdr); + + let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); + let verifying_key = signing_key.verifying_key(); + let sig = ed25519_dalek::Signature::from_bytes( + &result.signature.as_slice().try_into().unwrap(), + ); + verifying_key + .verify(&payload, &sig) + .expect("signature must verify against the full payload"); + } + + // ----------------------------------------------------------------------- + // Network ID validation + // ----------------------------------------------------------------------- + + /// Verify the mainnet network ID matches the specified SHA256 value. + /// This is the "Stacks mistake" guard — wrong passphrase == wrong network ID. + #[test] + fn test_mainnet_network_id_is_correct() { + let expected = Sha256::digest(MAINNET_PASSPHRASE.as_bytes()); + let signer = StellarSigner::mainnet(); + assert_eq!( + signer.network_id, + expected.as_slice(), + "mainnet network ID must equal SHA256 of the official mainnet passphrase" + ); + } + + #[test] + fn test_testnet_network_id_is_correct() { + let expected = Sha256::digest(TESTNET_PASSPHRASE.as_bytes()); + let signer = StellarSigner::testnet(); + assert_eq!( + signer.network_id, + expected.as_slice(), + "testnet network ID must equal SHA256 of the official testnet passphrase" + ); + } + + #[test] + fn test_mainnet_and_testnet_network_ids_differ() { + let mainnet = StellarSigner::mainnet(); + let testnet = StellarSigner::testnet(); + assert_ne!(mainnet.network_id, testnet.network_id); + } + + // ----------------------------------------------------------------------- + // Error cases + // ----------------------------------------------------------------------- + + #[test] + fn test_derive_address_invalid_key_length() { + let signer = StellarSigner::mainnet(); + assert!(signer.derive_address(&[0u8; 16]).is_err()); + assert!(signer.derive_address(&[]).is_err()); + } + + #[test] + fn test_sign_invalid_key_length() { + let signer = StellarSigner::mainnet(); + assert!(signer.sign(&[0u8; 16], b"msg").is_err()); + } + + #[test] + fn test_sign_transaction_invalid_key_length() { + let signer = StellarSigner::mainnet(); + assert!(signer.sign_transaction(&[], b"xdr").is_err()); + assert!(signer.sign_transaction(&[0u8; 16], b"xdr").is_err()); + } + + // ----------------------------------------------------------------------- + // sign_message + // ----------------------------------------------------------------------- + + #[test] + fn test_sign_message_produces_valid_ed25519() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let message = b"agent-to-agent handshake"; + + let result = signer.sign_message(&privkey, message).unwrap(); + assert_eq!(result.signature.len(), 64); + + // Verify + let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); + let verifying_key = signing_key.verifying_key(); + let sig = ed25519_dalek::Signature::from_bytes( + &result.signature.as_slice().try_into().unwrap(), + ); + verifying_key.verify(message, &sig).expect("sign_message must verify"); + } +} diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index ee4c2603..fee38205 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -128,6 +128,23 @@ mod integration_tests { ); } + #[test] + fn test_full_pipeline_stellar() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let address = derive_address_for_chain(&mnemonic, ChainType::Stellar); + assert!( + address.starts_with('G'), + "Stellar address must start with 'G', got: {}", + address + ); + assert_eq!( + address.len(), + 56, + "Stellar StrKey address must be 56 chars, got: {}", + address.len() + ); + } + #[test] fn test_full_pipeline_filecoin() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); @@ -182,6 +199,7 @@ mod integration_tests { let spark_addr = derive_address_for_chain(&mnemonic, ChainType::Spark); let fil_addr = derive_address_for_chain(&mnemonic, ChainType::Filecoin); let xrpl_addr = derive_address_for_chain(&mnemonic, ChainType::Xrpl); + let stellar_addr = derive_address_for_chain(&mnemonic, ChainType::Stellar); // All addresses should be different let addrs = [ @@ -194,6 +212,7 @@ mod integration_tests { &spark_addr, &fil_addr, &xrpl_addr, + &stellar_addr, ]; for i in 0..addrs.len() { for j in (i + 1)..addrs.len() { @@ -239,7 +258,7 @@ mod integration_tests { fn test_sign_roundtrip_ed25519_chains() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); - for chain in [ChainType::Solana, ChainType::Ton] { + for chain in [ChainType::Solana, ChainType::Ton, ChainType::Stellar] { let signer = signer_for_chain(chain); let path = signer.default_derivation_path(0); let key = @@ -264,6 +283,7 @@ mod integration_tests { ChainType::Spark, ChainType::Filecoin, ChainType::Xrpl, + ChainType::Stellar, ] { let signer = signer_for_chain(chain); assert_eq!(signer.chain_type(), chain); diff --git a/readme/partials/supported-chains.md b/readme/partials/supported-chains.md index 8f34caf9..5e9eca77 100644 --- a/readme/partials/supported-chains.md +++ b/readme/partials/supported-chains.md @@ -11,4 +11,5 @@ | Sui | Ed25519 | 0x + BLAKE2b-256 hex | `m/44'/784'/0'/0'/0'` | | XRPL | secp256k1 | Base58Check (`r...`) | `m/44'/144'/0'/0/0` | | Spark (Bitcoin L2) | secp256k1 | spark: prefixed | `m/84'/0'/0'/0/0` | -| Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` | \ No newline at end of file +| Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` | +| Stellar | Ed25519 | StrKey Base32 (`G...`) | `m/44'/148'/{index}'` | \ No newline at end of file diff --git a/readme/templates/ows.md b/readme/templates/ows.md index 55f45534..0a36cca6 100644 --- a/readme/templates/ows.md +++ b/readme/templates/ows.md @@ -35,7 +35,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No import { createWallet, signMessage } from "@open-wallet-standard/core"; const wallet = createWallet("my-wallet"); -console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL +console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar const sig = signMessage("my-wallet", "evm", "hello"); console.log(sig.signature); @@ -46,7 +46,7 @@ console.log(sig.signature); | Crate | Description | |-------|-------------| | `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. | -| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. | +| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. | | `ows-lib` | Library interface used by language bindings and the CLI. | | `ows-pay` | x402 payment flows, service discovery, and funding helpers. | | `ows-cli` | The `ows` command-line tool. | @@ -63,6 +63,7 @@ console.log(sig.signature); - **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses - **XRPL** — secp256k1, Base58Check r-addresses - **Filecoin** — secp256k1, f1 base32 addresses +- **Stellar** — Ed25519, StrKey base32 addresses (G...) ## License From 4160e9ea315a736485b3bea7b88d5c1561f6318e Mon Sep 17 00:00:00 2001 From: soumyacodes007 Date: Sat, 4 Apr 2026 22:11:27 +0000 Subject: [PATCH 2/3] fix(stellar): correct testnet signing and envelope encoding --- bindings/node/Cargo.lock | 54 +++ bindings/node/__test__/index.spec.mjs | 2 +- bindings/python/Cargo.lock | 202 +++++---- ows/Cargo.lock | 1 + ows/crates/ows-cli/src/commands/fund.rs | 8 +- .../ows-cli/src/commands/sign_message.rs | 4 +- .../ows-cli/src/commands/sign_transaction.rs | 4 +- ows/crates/ows-core/src/chain.rs | 5 +- ows/crates/ows-lib/Cargo.toml | 1 + ows/crates/ows-lib/src/key_ops.rs | 6 +- ows/crates/ows-lib/src/ops.rs | 72 +++- ows/crates/ows-signer/src/chains/mod.rs | 17 +- ows/crates/ows-signer/src/chains/stellar.rs | 387 +++++++++++++----- ows/crates/ows-signer/src/lib.rs | 2 +- 14 files changed, 550 insertions(+), 215 deletions(-) diff --git a/bindings/node/Cargo.lock b/bindings/node/Cargo.lock index 13216b7a..f043c479 100644 --- a/bindings/node/Cargo.lock +++ b/bindings/node/Cargo.lock @@ -171,6 +171,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -413,6 +425,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -696,6 +719,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + [[package]] name = "ff" version = "0.13.1" @@ -1515,6 +1544,7 @@ dependencies = [ "sha2", "sha3", "signal-hook", + "stellar-xdr", "thiserror 2.0.18", "xrpl-rust", "zeroize", @@ -2154,6 +2184,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stellar-strkey" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +dependencies = [ + "base32", + "crate-git-revision", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-xdr" +version = "21.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50" +dependencies = [ + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "stellar-strkey", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/bindings/node/__test__/index.spec.mjs b/bindings/node/__test__/index.spec.mjs index 8be960a0..fed2a1bc 100644 --- a/bindings/node/__test__/index.spec.mjs +++ b/bindings/node/__test__/index.spec.mjs @@ -217,7 +217,7 @@ describe('@open-wallet-standard/core', () => { // Build a minimal tx with 1 sig slot (0x01) + 64 zero bytes + a message. const solTxHex = '01' + '00'.repeat(64) + 'deadbeefdeadbeef'; - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'stellar']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl']) { const hex = chain === 'solana' ? solTxHex : txHex; const result = signTransaction('tx-signer', chain, hex, undefined, undefined, vaultDir); assert.ok(result.signature.length > 0, `signature should be non-empty for ${chain}`); diff --git a/bindings/python/Cargo.lock b/bindings/python/Cargo.lock index 5e4539b2..403bf25a 100644 --- a/bindings/python/Cargo.lock +++ b/bindings/python/Cargo.lock @@ -162,6 +162,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -273,9 +285,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "shlex", @@ -395,6 +407,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -668,6 +691,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + [[package]] name = "ff" version = "0.13.1" @@ -836,7 +865,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.13.1", "slab", "tokio", "tokio-util", @@ -963,9 +992,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -978,7 +1007,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1043,12 +1071,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1056,9 +1085,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1069,9 +1098,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1083,15 +1112,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1103,15 +1132,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1168,9 +1197,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1213,9 +1242,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1258,9 +1287,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -1270,9 +1299,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" @@ -1434,6 +1463,7 @@ dependencies = [ "sha2", "sha3", "signal-hook", + "stellar-xdr", "thiserror 2.0.18", "xrpl-rust", "zeroize", @@ -1492,12 +1522,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs8" version = "0.10.2" @@ -1528,9 +1552,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1944,9 +1968,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2012,7 +2036,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.13.1", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -2062,9 +2086,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -2138,6 +2162,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stellar-strkey" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +dependencies = [ + "base32", + "crate-git-revision", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-xdr" +version = "21.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2675a71212ed39a806e415b0dbf4702879ff288ec7f5ee996dda42a135512b50" +dependencies = [ + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "stellar-strkey", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2313,9 +2361,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2338,9 +2386,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -2353,9 +2401,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2608,9 +2656,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -2622,9 +2670,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2632,9 +2680,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -2645,9 +2693,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -2669,7 +2717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -2682,7 +2730,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.13.1", "semver", ] @@ -2855,7 +2903,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.13.1", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -2886,7 +2934,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -2905,7 +2953,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "semver", "serde", @@ -2917,9 +2965,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -2947,7 +2995,7 @@ dependencies = [ "fnv", "hashbrown 0.15.5", "hex", - "indexmap 2.13.0", + "indexmap 2.13.1", "lazy_static", "rand 0.8.5", "rand_hc", @@ -2981,9 +3029,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2992,9 +3040,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3024,18 +3072,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3065,9 +3113,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3076,9 +3124,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3087,9 +3135,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/ows/Cargo.lock b/ows/Cargo.lock index 23993e74..ab43bccc 100644 --- a/ows/Cargo.lock +++ b/ows/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ "serde_json", "sha2", "sha3", + "stellar-xdr", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/ows/crates/ows-cli/src/commands/fund.rs b/ows/crates/ows-cli/src/commands/fund.rs index 7b4b284f..91280109 100644 --- a/ows/crates/ows-cli/src/commands/fund.rs +++ b/ows/crates/ows-cli/src/commands/fund.rs @@ -38,20 +38,20 @@ pub fn run(wallet_name: &str, chain: Option<&str>, token: Option<&str>) -> Resul if chain_name == "stellar-testnet" { eprintln!("\nfunding via Friendbot is available immediately:"); println!("https://friendbot.stellar.org/?addr={address}"); - + #[cfg(target_os = "macos")] { let _ = std::process::Command::new("open") - .arg(&format!("https://friendbot.stellar.org/?addr={address}")) + .arg(format!("https://friendbot.stellar.org/?addr={address}")) .spawn(); } #[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open") - .arg(&format!("https://friendbot.stellar.org/?addr={address}")) + .arg(format!("https://friendbot.stellar.org/?addr={address}")) .spawn(); } - + return Ok(()); } diff --git a/ows/crates/ows-cli/src/commands/sign_message.rs b/ows/crates/ows-cli/src/commands/sign_message.rs index 92d180ad..6c246c1b 100644 --- a/ows/crates/ows-cli/src/commands/sign_message.rs +++ b/ows/crates/ows-cli/src/commands/sign_message.rs @@ -1,5 +1,5 @@ use ows_signer::chains::EvmSigner; -use ows_signer::signer_for_chain; +use ows_signer::signer_for_chain_info; use crate::{parse_chain, CliError}; @@ -39,7 +39,7 @@ pub fn run( let chain = parse_chain(chain_str)?; let key = super::resolve_signing_key(wallet_name, chain.chain_type, index)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); let output = if let Some(td_json) = typed_data { if chain.chain_type != ows_core::ChainType::Evm { diff --git a/ows/crates/ows-cli/src/commands/sign_transaction.rs b/ows/crates/ows-cli/src/commands/sign_transaction.rs index 2a5a7e18..b6d85061 100644 --- a/ows/crates/ows-cli/src/commands/sign_transaction.rs +++ b/ows/crates/ows-cli/src/commands/sign_transaction.rs @@ -1,4 +1,4 @@ -use ows_signer::signer_for_chain; +use ows_signer::signer_for_chain_info; use crate::{parse_chain, CliError}; @@ -34,7 +34,7 @@ pub fn run( let tx_bytes = hex::decode(tx_hex_clean) .map_err(|e| CliError::InvalidArgs(format!("invalid hex transaction: {e}")))?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); let signable = signer.extract_signable_bytes(&tx_bytes)?; let output = signer.sign_transaction(key.expose(), signable)?; diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index c8e71f79..2cf1e6f0 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -404,7 +404,10 @@ mod tests { assert_eq!(ChainType::from_namespace("fil"), Some(ChainType::Filecoin)); assert_eq!(ChainType::from_namespace("sui"), Some(ChainType::Sui)); assert_eq!(ChainType::from_namespace("xrpl"), Some(ChainType::Xrpl)); - assert_eq!(ChainType::from_namespace("stellar"), Some(ChainType::Stellar)); + assert_eq!( + ChainType::from_namespace("stellar"), + Some(ChainType::Stellar) + ); assert_eq!(ChainType::from_namespace("unknown"), None); } diff --git a/ows/crates/ows-lib/Cargo.toml b/ows/crates/ows-lib/Cargo.toml index 661dca51..e922038c 100644 --- a/ows/crates/ows-lib/Cargo.toml +++ b/ows/crates/ows-lib/Cargo.toml @@ -35,4 +35,5 @@ sha3 = "0.10" k256 = { version = "0.13", features = ["ecdsa"] } ed25519-dalek = "2" bs58 = "0.5" +stellar-xdr = { version = "21.0.0", features = ["base64", "std"] } ows-signer = { path = "../ows-signer", version = "=1.2.3", features = ["fast-kdf"] } diff --git a/ows/crates/ows-lib/src/key_ops.rs b/ows/crates/ows-lib/src/key_ops.rs index 605e3cf4..ad0607cb 100644 --- a/ows/crates/ows-lib/src/key_ops.rs +++ b/ows/crates/ows-lib/src/key_ops.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::Path; use ows_core::{ApiKeyFile, EncryptedWallet, OwsError}; -use ows_signer::{decrypt, encrypt_with_hkdf, signer_for_chain, CryptoEnvelope, SecretBytes}; +use ows_signer::{decrypt, encrypt_with_hkdf, signer_for_chain_info, CryptoEnvelope, SecretBytes}; use crate::error::OwsLibError; use crate::key_store; @@ -135,7 +135,7 @@ pub fn sign_with_api_key( let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?; // 7. Sign (extract signable portion first — e.g. strips Solana sig-slot headers) - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(chain); let signable = signer.extract_signable_bytes(tx_bytes)?; let output = signer.sign_transaction(key.expose(), signable)?; @@ -194,7 +194,7 @@ pub fn sign_message_with_api_key( } let key = decrypt_key_from_api_key(&key_file, &wallet, token, chain.chain_type, index)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(chain); let output = signer.sign_message(key.expose(), msg_bytes)?; Ok(crate::types::SignResult { diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index e6619825..086dfd8f 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -6,8 +6,8 @@ use ows_core::{ ALL_CHAIN_TYPES, }; use ows_signer::{ - decrypt, encrypt, signer_for_chain, CryptoEnvelope, HdDeriver, Mnemonic, MnemonicStrength, - SecretBytes, + decrypt, encrypt, signer_for_chain, signer_for_chain_info, CryptoEnvelope, HdDeriver, Mnemonic, + MnemonicStrength, SecretBytes, }; use crate::error::OwsLibError; @@ -179,7 +179,7 @@ pub fn derive_address( ) -> Result { let chain = parse_chain(chain)?; let mnemonic = Mnemonic::from_phrase(mnemonic_phrase)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); let path = signer.default_derivation_path(index.unwrap_or(0)); let curve = signer.curve(); @@ -453,7 +453,7 @@ pub fn sign_transaction( // Owner mode: existing passphrase-based signing (unchanged) let chain = parse_chain(chain)?; let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); let signable = signer.extract_signable_bytes(&tx_bytes)?; let output = signer.sign_transaction(key.expose(), signable)?; @@ -501,7 +501,7 @@ pub fn sign_message( // Owner mode let chain = parse_chain(chain)?; let key = decrypt_signing_key(wallet, chain.chain_type, credential, index, vault_path)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); let output = signer.sign_message(key.expose(), &msg_bytes)?; Ok(SignResult { @@ -603,7 +603,7 @@ pub fn sign_encode_and_broadcast( rpc_url: Option<&str>, ) -> Result { let chain = parse_chain(chain)?; - let signer = signer_for_chain(chain.chain_type); + let signer = signer_for_chain_info(&chain); // 1. Extract signable portion (strips signature-slot headers for Solana; no-op for others) let signable = signer.extract_signable_bytes(tx_bytes)?; @@ -917,6 +917,10 @@ fn extract_json_field(json_str: &str, field: &str) -> Result String { + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx: StellarTransaction { + source_account: MuxedAccount::Ed25519(Uint256::from([7u8; 32])), + fee: 100, + seq_num: 1_i64.into(), + cond: Preconditions::None, + memo: Memo::None, + operations: Vec::::new() + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }, + signatures: Vec::::new() + .try_into() + .unwrap(), + }); + + hex::encode(envelope.to_xdr(Limits::none()).unwrap()) + } + const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // ================================================================ @@ -1113,14 +1138,16 @@ mod tests { solana_tx.extend_from_slice(&[0u8; 64]); // placeholder signature solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // message payload let solana_tx_hex = hex::encode(&solana_tx); + let stellar_tx_hex = stellar_unsigned_tx_hex(); let chains = [ - "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", - "stellar", + "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", "stellar", ]; for chain in &chains { let tx = if *chain == "solana" { &solana_tx_hex + } else if *chain == "stellar" { + &stellar_tx_hex } else { generic_tx_hex }; @@ -1133,6 +1160,35 @@ mod tests { } } + #[test] + fn stellar_testnet_signature_differs_from_pubnet() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path(); + create_wallet("stellar-networks", None, None, Some(vault)).unwrap(); + + let tx_hex = stellar_unsigned_tx_hex(); + let pubnet = sign_transaction( + "stellar-networks", + "stellar", + &tx_hex, + None, + None, + Some(vault), + ) + .unwrap(); + let testnet = sign_transaction( + "stellar-networks", + "stellar-testnet", + &tx_hex, + None, + None, + Some(vault), + ) + .unwrap(); + + assert_ne!(pubnet.signature, testnet.signature); + } + #[test] fn mnemonic_wallet_signing_is_deterministic() { let dir = tempfile::tempdir().unwrap(); diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index 3b6d7d58..188d68e7 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -23,10 +23,19 @@ pub use self::tron::TronSigner; pub use self::xrpl::XrplSigner; use crate::traits::ChainSigner; -use ows_core::ChainType; +use ows_core::{Chain, ChainType}; /// Get a default signer for a given chain type. pub fn signer_for_chain(chain: ChainType) -> Box { + signer_for_chain_id(chain, None) +} + +/// Get a signer for a specific chain ID when network-specific behavior matters. +pub fn signer_for_chain_info(chain: &Chain) -> Box { + signer_for_chain_id(chain.chain_type, Some(chain.chain_id)) +} + +fn signer_for_chain_id(chain: ChainType, chain_id: Option<&str>) -> Box { match chain { ChainType::Evm => Box::new(EvmSigner), ChainType::Solana => Box::new(SolanaSigner), @@ -38,7 +47,9 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Filecoin => Box::new(FilecoinSigner), ChainType::Sui => Box::new(SuiSigner), ChainType::Xrpl => Box::new(XrplSigner), - // Stellar defaults to mainnet; use StellarSigner::testnet() for testnet ops - ChainType::Stellar => Box::new(StellarSigner::mainnet()), + ChainType::Stellar => match chain_id { + Some("stellar:testnet") => Box::new(StellarSigner::testnet()), + _ => Box::new(StellarSigner::mainnet()), + }, } } diff --git a/ows/crates/ows-signer/src/chains/stellar.rs b/ows/crates/ows-signer/src/chains/stellar.rs index 261c6a90..54971496 100644 --- a/ows/crates/ows-signer/src/chains/stellar.rs +++ b/ows/crates/ows-signer/src/chains/stellar.rs @@ -3,29 +3,17 @@ use crate::traits::{ChainSigner, SignOutput, SignerError}; use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; use ows_core::ChainType; use sha2::{Digest, Sha256}; - -// --------------------------------------------------------------------------- -// Network passphrases (SEP-0005 §3) -// --------------------------------------------------------------------------- +use stellar_xdr::curr::{ + BytesM, DecoratedSignature, Limits, MuxedAccount, ReadXdr, Signature, SignatureHint, + Transaction as StellarTransaction, TransactionEnvelope, TransactionExt, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, WriteXdr, +}; /// Mainnet network passphrase. pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; /// Testnet network passphrase. pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015"; -// --------------------------------------------------------------------------- -// XDR constants -// --------------------------------------------------------------------------- - -/// ENVELOPE_TYPE_TX = 2 (4-byte big-endian). -/// Covers both classic Payment operations and Soroban InvokeHostFunction ops — -/// they all use the same envelope type, so Soroban support comes for free. -const ENVELOPE_TYPE_TX: [u8; 4] = [0x00, 0x00, 0x00, 0x02]; - -// --------------------------------------------------------------------------- -// StrKey constants (SEP-0005 §2 / stellar-base strkey.js) -// --------------------------------------------------------------------------- - /// Version byte for an Ed25519 public key account ID ("G..." address). /// Value: 6 << 3 = 0x30. const VERSION_BYTE_ACCOUNT_ID: u8 = 6 << 3; // 0x30 @@ -42,12 +30,9 @@ const VERSION_BYTE_ACCOUNT_ID: u8 = 6 << 3; // 0x30 /// by the OWS HD deriver (`Curve::Ed25519` rejects non-hardened components). /// /// # Signature base -/// Stellar signs a `TransactionSignaturePayload` constructed as: -/// ```text -/// SHA256(network_passphrase) || ENVELOPE_TYPE_TX (4 bytes, big-endian) || tx_xdr_bytes -/// ``` -/// Forgetting the network hash is the "Stacks mistake" — our implementation -/// always prepends it before signing. +/// Stellar signs the canonical XDR encoding of `TransactionSignaturePayload`. +/// This includes the network ID and the envelope type, so mainnet and testnet +/// signatures differ for the same transaction envelope. /// /// # Soroban (smart-contract) compatibility /// Classic and Soroban (InvokeHostFunction) transactions share the same @@ -79,27 +64,95 @@ impl StellarSigner { fn signing_key(private_key: &[u8]) -> Result { let bytes: [u8; 32] = private_key.try_into().map_err(|_| { - SignerError::InvalidPrivateKey(format!( - "expected 32 bytes, got {}", - private_key.len() - )) + SignerError::InvalidPrivateKey(format!("expected 32 bytes, got {}", private_key.len())) })?; Ok(SigningKey::from_bytes(&bytes)) } - /// Build the Stellar signature base and return its SHA256 hash. - /// - /// `sign_transaction` requires signing the raw payload, NOT a double-hash. - /// Ed25519 (via ed25519-dalek) signs arbitrary-length messages internally - /// using SHA-512, so we DO NOT pre-hash again — we just pass the full - /// `network_id || ENVELOPE_TYPE_TX || tx_xdr_bytes` payload directly to - /// the Ed25519 signer. This matches the Stellar JS/Go/Python SDK behaviour. - fn signature_payload(&self, tx_xdr_bytes: &[u8]) -> Vec { - let mut payload = Vec::with_capacity(32 + 4 + tx_xdr_bytes.len()); - payload.extend_from_slice(&self.network_id); - payload.extend_from_slice(&ENVELOPE_TYPE_TX); - payload.extend_from_slice(tx_xdr_bytes); - payload + fn parse_transaction_envelope(tx_xdr_bytes: &[u8]) -> Result { + TransactionEnvelope::from_xdr(tx_xdr_bytes, Limits::none()).map_err(|e| { + SignerError::InvalidTransaction(format!( + "invalid Stellar transaction envelope XDR: {e}" + )) + }) + } + + fn v0_transaction_to_v1(tx: &stellar_xdr::curr::TransactionV0) -> StellarTransaction { + StellarTransaction { + source_account: MuxedAccount::Ed25519(tx.source_account_ed25519.clone()), + fee: tx.fee, + seq_num: tx.seq_num.clone(), + cond: match tx.time_bounds.clone() { + Some(bounds) => stellar_xdr::curr::Preconditions::Time(bounds), + None => stellar_xdr::curr::Preconditions::None, + }, + memo: tx.memo.clone(), + operations: tx.operations.clone(), + ext: TransactionExt::V0, + } + } + + fn transaction_signature_payload( + &self, + envelope: &TransactionEnvelope, + ) -> Result, SignerError> { + let tagged_transaction = match envelope { + TransactionEnvelope::TxV0(env) => TransactionSignaturePayloadTaggedTransaction::Tx( + Self::v0_transaction_to_v1(&env.tx), + ), + TransactionEnvelope::Tx(env) => { + TransactionSignaturePayloadTaggedTransaction::Tx(env.tx.clone()) + } + TransactionEnvelope::TxFeeBump(env) => { + TransactionSignaturePayloadTaggedTransaction::TxFeeBump(env.tx.clone()) + } + }; + + TransactionSignaturePayload { + network_id: self.network_id.into(), + tagged_transaction, + } + .to_xdr(Limits::none()) + .map_err(|e| { + SignerError::InvalidTransaction(format!( + "failed to encode Stellar transaction signature payload: {e}" + )) + }) + } + + fn transaction_signature_digest( + &self, + envelope: &TransactionEnvelope, + ) -> Result<[u8; 32], SignerError> { + let payload = self.transaction_signature_payload(envelope)?; + Ok(Sha256::digest(payload).into()) + } + + fn verifying_key_bytes(private_key: &[u8]) -> Result<[u8; 32], SignerError> { + let signing_key = Self::signing_key(private_key)?; + Ok(*signing_key.verifying_key().as_bytes()) + } + + fn decorated_signature(signature: &SignOutput) -> Result { + let public_key = signature.public_key.as_ref().ok_or_else(|| { + SignerError::InvalidTransaction( + "stellar signed transaction encoding requires the signer's public key".into(), + ) + })?; + let pubkey_bytes: [u8; 32] = public_key.as_slice().try_into().map_err(|_| { + SignerError::InvalidTransaction(format!( + "stellar signer public key must be 32 bytes, got {}", + public_key.len() + )) + })?; + let sig_bytes: BytesM<64> = signature.signature.clone().try_into().map_err(|_| { + SignerError::InvalidTransaction("stellar signature must be 64 bytes".into()) + })?; + + Ok(DecoratedSignature { + hint: SignatureHint(pubkey_bytes[28..32].try_into().unwrap()), + signature: Signature(sig_bytes), + }) } /// Encode a 32-byte Ed25519 public key to a Stellar StrKey address ("G…"). @@ -115,7 +168,7 @@ impl StellarSigner { let crc = crc16_xmodem(&payload); payload.push((crc & 0xFF) as u8); // low byte first (little-endian) - payload.push((crc >> 8) as u8); // high byte second + payload.push((crc >> 8) as u8); // high byte second base32_encode(&payload) } @@ -172,22 +225,11 @@ impl ChainSigner for StellarSigner { }) } - /// Sign a Stellar transaction XDR body. - /// - /// `tx_xdr_bytes` — raw XDR bytes of the **Transaction** struct (the body - /// only, without the envelope wrapper or signatures array). This matches - /// what Stellar SDKs expose as `tx.toXDR()` on the inner transaction body. - /// - /// Internally constructs the signature payload per the Stellar spec: - /// ```text - /// payload = SHA256(network_passphrase) || ENVELOPE_TYPE_TX || tx_xdr_bytes - /// signature = Ed25519.sign(payload) - /// ``` + /// Sign a Stellar `TransactionEnvelope` XDR blob. /// - /// The returned `signature` is the 64-byte Ed25519 signature that should - /// be placed inside the `TransactionEnvelope.signatures` array as a - /// `DecoratedSignature`. The caller is responsible for assembling the - /// final `TransactionEnvelope` XDR using the Stellar SDK. + /// The input must be unsigned envelope XDR, not arbitrary bytes. The signer + /// parses the envelope, constructs the canonical `TransactionSignaturePayload`, + /// hashes it with SHA-256, then signs the 32-byte digest with Ed25519. fn sign_transaction( &self, private_key: &[u8], @@ -198,13 +240,62 @@ impl ChainSigner for StellarSigner { "transaction XDR bytes must not be empty".into(), )); } + let envelope = Self::parse_transaction_envelope(tx_xdr_bytes)?; let signing_key = Self::signing_key(private_key)?; - let payload = self.signature_payload(tx_xdr_bytes); - let signature = signing_key.sign(&payload); + let digest = self.transaction_signature_digest(&envelope)?; + let signature = signing_key.sign(&digest); Ok(SignOutput { signature: signature.to_bytes().to_vec(), recovery_id: None, - public_key: None, + public_key: Some(Self::verifying_key_bytes(private_key)?.to_vec()), + }) + } + + fn encode_signed_transaction( + &self, + tx_xdr_bytes: &[u8], + signature: &SignOutput, + ) -> Result, SignerError> { + let decorated = Self::decorated_signature(signature)?; + let envelope = Self::parse_transaction_envelope(tx_xdr_bytes)?; + + let signed_envelope = match envelope { + TransactionEnvelope::TxV0(mut env) => { + let mut signatures: Vec<_> = env.signatures.into(); + signatures.push(decorated); + env.signatures = signatures.try_into().map_err(|e| { + SignerError::InvalidTransaction(format!( + "failed to append Stellar signature to tx_v0 envelope: {e}" + )) + })?; + TransactionEnvelope::TxV0(env) + } + TransactionEnvelope::Tx(mut env) => { + let mut signatures: Vec<_> = env.signatures.into(); + signatures.push(decorated); + env.signatures = signatures.try_into().map_err(|e| { + SignerError::InvalidTransaction(format!( + "failed to append Stellar signature to tx envelope: {e}" + )) + })?; + TransactionEnvelope::Tx(env) + } + TransactionEnvelope::TxFeeBump(mut env) => { + let mut signatures: Vec<_> = env.signatures.into(); + signatures.push(decorated); + env.signatures = signatures.try_into().map_err(|e| { + SignerError::InvalidTransaction(format!( + "failed to append Stellar signature to fee bump envelope: {e}" + )) + })?; + TransactionEnvelope::TxFeeBump(env) + } + }; + + signed_envelope.to_xdr(Limits::none()).map_err(|e| { + SignerError::InvalidTransaction(format!( + "failed to encode signed Stellar transaction envelope: {e}" + )) }) } @@ -249,7 +340,7 @@ const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; /// Encode `data` as RFC 4648 base32 without padding. fn base32_encode(data: &[u8]) -> String { - let mut output = String::with_capacity((data.len() * 8 + 4) / 5); + let mut output = String::with_capacity((data.len() * 8).div_ceil(5)); let mut buffer: u32 = 0; let mut bits: u32 = 0; @@ -279,9 +370,12 @@ mod tests { use crate::hd::HdDeriver; use crate::mnemonic::Mnemonic; use ed25519_dalek::Verifier; + use stellar_xdr::curr::{ + Limits, Memo, Preconditions, Transaction as StellarTransaction, TransactionEnvelope, + TransactionExt, TransactionV1Envelope, Uint256, + }; - const ABANDON_PHRASE: &str = - "abandon abandon abandon abandon abandon abandon abandon abandon \ + const ABANDON_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon about"; fn test_privkey() -> Vec { @@ -294,6 +388,29 @@ mod tests { .to_vec() } + fn test_pubkey() -> [u8; 32] { + StellarSigner::verifying_key_bytes(&test_privkey()).unwrap() + } + + fn unsigned_test_envelope_xdr() -> Vec { + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx: StellarTransaction { + source_account: MuxedAccount::Ed25519(Uint256::from(test_pubkey())), + fee: 100, + seq_num: 1_i64.into(), + cond: Preconditions::None, + memo: Memo::None, + operations: Vec::::new() + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }, + signatures: Vec::::new().try_into().unwrap(), + }); + + envelope.to_xdr(Limits::none()).unwrap() + } + // ----------------------------------------------------------------------- // Chain properties // ----------------------------------------------------------------------- @@ -383,7 +500,9 @@ mod tests { let signer = StellarSigner::mainnet(); let address = signer.derive_address(&privkey).unwrap(); assert!( - address.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), + address + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), "Stellar address must be uppercase base32, got: {}", address ); @@ -405,17 +524,28 @@ mod tests { let addr0 = { let key = HdDeriver::derive_from_mnemonic( - &mnemonic, "", &signer.default_derivation_path(0), Curve::Ed25519, - ).unwrap(); + &mnemonic, + "", + &signer.default_derivation_path(0), + Curve::Ed25519, + ) + .unwrap(); signer.derive_address(key.expose()).unwrap() }; let addr1 = { let key = HdDeriver::derive_from_mnemonic( - &mnemonic, "", &signer.default_derivation_path(1), Curve::Ed25519, - ).unwrap(); + &mnemonic, + "", + &signer.default_derivation_path(1), + Curve::Ed25519, + ) + .unwrap(); signer.derive_address(key.expose()).unwrap() }; - assert_ne!(addr0, addr1, "different indices must produce different addresses"); + assert_ne!( + addr0, addr1, + "different indices must produce different addresses" + ); } /// CRC16-XModem known vector: @@ -508,10 +638,11 @@ mod tests { let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); let verifying_key = signing_key.verifying_key(); - let sig = ed25519_dalek::Signature::from_bytes( - &result.signature.as_slice().try_into().unwrap(), - ); - verifying_key.verify(message, &sig).expect("signature must verify"); + let sig = + ed25519_dalek::Signature::from_bytes(&result.signature.as_slice().try_into().unwrap()); + verifying_key + .verify(message, &sig) + .expect("signature must verify"); } // ----------------------------------------------------------------------- @@ -522,19 +653,20 @@ mod tests { fn test_sign_transaction_produces_64_byte_signature() { let privkey = test_privkey(); let signer = StellarSigner::mainnet(); - let fake_xdr = b"fake_xdr_transaction_bytes"; - let result = signer.sign_transaction(&privkey, fake_xdr).unwrap(); + let tx_xdr = unsigned_test_envelope_xdr(); + let result = signer.sign_transaction(&privkey, &tx_xdr).unwrap(); assert_eq!(result.signature.len(), 64); assert!(result.recovery_id.is_none()); + assert_eq!(result.public_key.as_deref(), Some(&test_pubkey()[..])); } #[test] fn test_sign_transaction_is_deterministic() { let privkey = test_privkey(); let signer = StellarSigner::mainnet(); - let xdr = b"some_tx_xdr_bytes"; - let sig1 = signer.sign_transaction(&privkey, xdr).unwrap(); - let sig2 = signer.sign_transaction(&privkey, xdr).unwrap(); + let tx_xdr = unsigned_test_envelope_xdr(); + let sig1 = signer.sign_transaction(&privkey, &tx_xdr).unwrap(); + let sig2 = signer.sign_transaction(&privkey, &tx_xdr).unwrap(); assert_eq!(sig1.signature, sig2.signature); } @@ -545,27 +677,33 @@ mod tests { assert!(signer.sign_transaction(&privkey, b"").is_err()); } + #[test] + fn test_sign_transaction_rejects_arbitrary_bytes() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + assert!(signer + .sign_transaction(&privkey, b"fake_xdr_transaction_bytes") + .is_err()); + } + /// Core interop validity check: sign_transaction(tx) must equal - /// sign(network_id || ENVELOPE_TYPE_TX || tx), proving the signature - /// base is constructed correctly. + /// sign(SHA256(network_id || ENVELOPE_TYPE_TX || tx)), proving the + /// signature base is constructed correctly. #[test] - fn test_sign_transaction_equals_sign_of_signature_payload() { + fn test_sign_transaction_equals_sign_of_signature_payload_digest() { let privkey = test_privkey(); let signer = StellarSigner::mainnet(); - let tx_xdr = b"arbitrary_xdr_for_test"; + let tx_xdr = unsigned_test_envelope_xdr(); + let envelope = StellarSigner::parse_transaction_envelope(&tx_xdr).unwrap(); - let sig_tx = signer.sign_transaction(&privkey, tx_xdr).unwrap(); + let sig_tx = signer.sign_transaction(&privkey, &tx_xdr).unwrap(); - // Build the payload manually - let mut payload = Vec::new(); - payload.extend_from_slice(&signer.network_id); - payload.extend_from_slice(&ENVELOPE_TYPE_TX); - payload.extend_from_slice(tx_xdr); - let sig_direct = signer.sign(&privkey, &payload).unwrap(); + let digest = signer.transaction_signature_digest(&envelope).unwrap(); + let sig_direct = signer.sign(&privkey, &digest).unwrap(); assert_eq!( sig_tx.signature, sig_direct.signature, - "sign_transaction must equal sign(network_id || ENVELOPE_TYPE_TX || tx)" + "sign_transaction must equal signing the SHA256 of the canonical TransactionSignaturePayload XDR" ); } @@ -576,10 +714,10 @@ mod tests { let privkey = test_privkey(); let mainnet = StellarSigner::mainnet(); let testnet = StellarSigner::testnet(); - let tx_xdr = b"same_xdr_bytes"; + let tx_xdr = unsigned_test_envelope_xdr(); - let sig_main = mainnet.sign_transaction(&privkey, tx_xdr).unwrap(); - let sig_test = testnet.sign_transaction(&privkey, tx_xdr).unwrap(); + let sig_main = mainnet.sign_transaction(&privkey, &tx_xdr).unwrap(); + let sig_test = testnet.sign_transaction(&privkey, &tx_xdr).unwrap(); assert_ne!( sig_main.signature, sig_test.signature, @@ -587,30 +725,51 @@ mod tests { ); } - /// The signing payload verifies against the correct Ed25519 public key, - /// confirming end-to-end correctness of the full pipeline. + /// The signing payload digest verifies against the correct Ed25519 public + /// key, confirming end-to-end correctness of the full pipeline. #[test] - fn test_sign_transaction_verifies_with_pubkey() { + fn test_sign_transaction_verifies_payload_digest_with_pubkey() { let privkey = test_privkey(); let signer = StellarSigner::mainnet(); - let tx_xdr = b"payment_tx_xdr"; + let tx_xdr = unsigned_test_envelope_xdr(); + let envelope = StellarSigner::parse_transaction_envelope(&tx_xdr).unwrap(); - let result = signer.sign_transaction(&privkey, tx_xdr).unwrap(); + let result = signer.sign_transaction(&privkey, &tx_xdr).unwrap(); - // Build expected payload - let mut payload = Vec::new(); - payload.extend_from_slice(&signer.network_id); - payload.extend_from_slice(&ENVELOPE_TYPE_TX); - payload.extend_from_slice(tx_xdr); + let digest = signer.transaction_signature_digest(&envelope).unwrap(); let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); let verifying_key = signing_key.verifying_key(); - let sig = ed25519_dalek::Signature::from_bytes( - &result.signature.as_slice().try_into().unwrap(), - ); + let sig = + ed25519_dalek::Signature::from_bytes(&result.signature.as_slice().try_into().unwrap()); verifying_key - .verify(&payload, &sig) - .expect("signature must verify against the full payload"); + .verify(&digest, &sig) + .expect("signature must verify against the payload digest"); + } + + #[test] + fn test_encode_signed_transaction_appends_decorated_signature() { + let privkey = test_privkey(); + let signer = StellarSigner::mainnet(); + let tx_xdr = unsigned_test_envelope_xdr(); + + let signed = signer + .encode_signed_transaction( + &tx_xdr, + &signer.sign_transaction(&privkey, &tx_xdr).unwrap(), + ) + .unwrap(); + let envelope = TransactionEnvelope::from_xdr(&signed, Limits::none()).unwrap(); + + match envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.signatures.len(), 1); + let expected_hint: [u8; 4] = test_pubkey()[28..32].try_into().unwrap(); + assert_eq!(env.signatures[0].hint.0, expected_hint); + assert_eq!(env.signatures[0].signature.0.len(), 64); + } + other => panic!("expected tx envelope, got {:?}", other.name()), + } } // ----------------------------------------------------------------------- @@ -668,8 +827,9 @@ mod tests { #[test] fn test_sign_transaction_invalid_key_length() { let signer = StellarSigner::mainnet(); - assert!(signer.sign_transaction(&[], b"xdr").is_err()); - assert!(signer.sign_transaction(&[0u8; 16], b"xdr").is_err()); + let tx_xdr = unsigned_test_envelope_xdr(); + assert!(signer.sign_transaction(&[], &tx_xdr).is_err()); + assert!(signer.sign_transaction(&[0u8; 16], &tx_xdr).is_err()); } // ----------------------------------------------------------------------- @@ -688,9 +848,10 @@ mod tests { // Verify let signing_key = SigningKey::from_bytes(&privkey.as_slice().try_into().unwrap()); let verifying_key = signing_key.verifying_key(); - let sig = ed25519_dalek::Signature::from_bytes( - &result.signature.as_slice().try_into().unwrap(), - ); - verifying_key.verify(message, &sig).expect("sign_message must verify"); + let sig = + ed25519_dalek::Signature::from_bytes(&result.signature.as_slice().try_into().unwrap()); + verifying_key + .verify(message, &sig) + .expect("sign_message must verify"); } } diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index fee38205..e13cb6de 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -10,7 +10,7 @@ pub mod rlp; pub mod traits; pub mod zeroizing; -pub use chains::signer_for_chain; +pub use chains::{signer_for_chain, signer_for_chain_info}; pub use crypto::{ decrypt, encrypt, encrypt_with_hkdf, CipherParams, CryptoEnvelope, CryptoError, HkdfKdfParams, KdfParams, KdfParamsVariant, From 4b5c554770443a23d340a08cdfcaa1e3d2bbcc6d Mon Sep 17 00:00:00 2001 From: soumyacodes007 Date: Sat, 4 Apr 2026 22:38:09 +0000 Subject: [PATCH 3/3] test(ows-lib): use valid xrpl fixture in all-chains tx test --- ows/crates/ows-lib/src/ops.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 086dfd8f..1bc16e7c 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -978,6 +978,10 @@ mod tests { hex::encode(envelope.to_xdr(Limits::none()).unwrap()) } + fn xrpl_unsigned_tx_hex() -> &'static str { + "12000024000000016140000000000F424068400000000000000C7321035D8892C99D4F17B2775EC428ED65B6335A5D588AC2057B81C8C38C59C72B68D98114B22CCE5BFD693ED7FA15B57B6B5370551B7E6DB58314F667B0CA50CC7709A220B0561B85E53A48461FA8" + } + const TEST_PRIVKEY: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // ================================================================ @@ -1139,6 +1143,7 @@ mod tests { solana_tx.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // message payload let solana_tx_hex = hex::encode(&solana_tx); let stellar_tx_hex = stellar_unsigned_tx_hex(); + let xrpl_tx_hex = xrpl_unsigned_tx_hex(); let chains = [ "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", "stellar", @@ -1148,6 +1153,8 @@ mod tests { &solana_tx_hex } else if *chain == "stellar" { &stellar_tx_hex + } else if *chain == "xrpl" { + xrpl_tx_hex } else { generic_tx_hex };