Skip to content

Commit 03edec5

Browse files
feat: Add Stellar (XLM) chain support
1 parent 56dc14d commit 03edec5

File tree

22 files changed

+974
-30
lines changed

22 files changed

+974
-30
lines changed

bindings/node/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ ows sign tx --wallet agent-treasury --chain evm --tx "deadbeef..."
6262
| XRPL | secp256k1 | Base58Check (`r...`) | `m/44'/144'/0'/0/0` |
6363
| Spark (Bitcoin L2) | secp256k1 | spark: prefixed | `m/84'/0'/0'/0/0` |
6464
| Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` |
65+
| Stellar | Ed25519 | StrKey Base32 (`G...`) | `m/44'/148'/{index}'` |
6566

6667
## CLI Reference
6768

bindings/node/__test__/index.spec.mjs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,18 @@ describe('@open-wallet-standard/core', () => {
5151

5252
it('derives addresses for all chains', () => {
5353
const phrase = generateMnemonic(12);
54-
for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl']) {
54+
for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'stellar']) {
5555
const addr = deriveAddress(phrase, chain);
5656
assert.ok(addr.length > 0, `address should be non-empty for ${chain}`);
5757
}
5858
});
5959

6060
// ---- Universal wallet lifecycle ----
6161

62-
it('creates a universal wallet with 9 accounts', () => {
62+
it('creates a universal wallet with 10 accounts', () => {
6363
const wallet = createWallet('lifecycle-test', undefined, 12, vaultDir);
6464
assert.equal(wallet.name, 'lifecycle-test');
65-
assert.equal(wallet.accounts.length, 9);
65+
assert.equal(wallet.accounts.length, 10);
6666

6767
const chainIds = wallet.accounts.map((a) => a.chainId);
6868
assert.ok(chainIds.some((c) => c.startsWith('eip155:')));
@@ -74,6 +74,7 @@ describe('@open-wallet-standard/core', () => {
7474
assert.ok(chainIds.some((c) => c.startsWith('ton:')));
7575
assert.ok(chainIds.some((c) => c.startsWith('fil:')));
7676
assert.ok(chainIds.some((c) => c.startsWith('xrpl:')));
77+
assert.ok(chainIds.some((c) => c.startsWith('stellar:')));
7778

7879
// List
7980
const wallets = listWallets(vaultDir);
@@ -107,7 +108,7 @@ describe('@open-wallet-standard/core', () => {
107108

108109
const wallet = importWalletMnemonic('mn-import', phrase, undefined, undefined, vaultDir);
109110
assert.equal(wallet.name, 'mn-import');
110-
assert.equal(wallet.accounts.length, 9);
111+
assert.equal(wallet.accounts.length, 10);
111112

112113
const evmAcct = wallet.accounts.find((a) => a.chainId.startsWith('eip155:'));
113114
assert.equal(evmAcct.address, expectedEvm);
@@ -125,7 +126,7 @@ describe('@open-wallet-standard/core', () => {
125126
const wallet = importWalletPrivateKey('pk-secp', privkey, undefined, vaultDir, 'evm');
126127

127128
assert.equal(wallet.name, 'pk-secp');
128-
assert.equal(wallet.accounts.length, 9, 'should have all 9 chain accounts');
129+
assert.equal(wallet.accounts.length, 10, 'should have all 10 chain accounts');
129130

130131
// Sign on EVM (provided key's curve)
131132
const evmSig = signMessage('pk-secp', 'evm', 'hello', undefined, undefined, undefined, vaultDir);
@@ -149,7 +150,7 @@ describe('@open-wallet-standard/core', () => {
149150
const privkey = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60';
150151
const wallet = importWalletPrivateKey('pk-ed', privkey, undefined, vaultDir, 'solana');
151152

152-
assert.equal(wallet.accounts.length, 9);
153+
assert.equal(wallet.accounts.length, 10);
153154

154155
// Sign on Solana (provided key)
155156
const solSig = signMessage('pk-ed', 'solana', 'hello', undefined, undefined, undefined, vaultDir);
@@ -177,7 +178,7 @@ describe('@open-wallet-standard/core', () => {
177178
);
178179

179180
assert.equal(wallet.name, 'pk-both');
180-
assert.equal(wallet.accounts.length, 9, 'should have all 9 chain accounts');
181+
assert.equal(wallet.accounts.length, 10, 'should have all 10 chain accounts');
181182

182183
// Sign on EVM (secp256k1 key)
183184
const evmSig = signMessage('pk-both', 'evm', 'hello', undefined, undefined, undefined, vaultDir);
@@ -216,7 +217,7 @@ describe('@open-wallet-standard/core', () => {
216217
// Build a minimal tx with 1 sig slot (0x01) + 64 zero bytes + a message.
217218
const solTxHex = '01' + '00'.repeat(64) + 'deadbeefdeadbeef';
218219

219-
for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl']) {
220+
for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'stellar']) {
220221
const hex = chain === 'solana' ? solTxHex : txHex;
221222
const result = signTransaction('tx-signer', chain, hex, undefined, undefined, vaultDir);
222223
assert.ok(result.signature.length > 0, `signature should be non-empty for ${chain}`);

bindings/python/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ print(sig["signature"])
7474
| XRPL | secp256k1 | Base58Check (`r...`) | `m/44'/144'/0'/0/0` |
7575
| Spark (Bitcoin L2) | secp256k1 | spark: prefixed | `m/84'/0'/0'/0/0` |
7676
| Filecoin | secp256k1 | f1 base32 | `m/44'/461'/0'/0/0` |
77+
| Stellar | Ed25519 | StrKey Base32 (`G...`) | `m/44'/148'/{index}'` |
7778

7879
## Architecture
7980

bindings/python/tests/test_bindings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_create_and_list_wallets(vault_dir):
4040
wallet = ows.create_wallet("test-wallet", vault_path_opt=vault_dir)
4141
assert wallet["name"] == "test-wallet"
4242
assert isinstance(wallet["accounts"], list)
43-
assert len(wallet["accounts"]) == 9
43+
assert len(wallet["accounts"]) == 10
4444

4545
# Verify each chain family is present
4646
chain_ids = [a["chain_id"] for a in wallet["accounts"]]
@@ -53,6 +53,7 @@ def test_create_and_list_wallets(vault_dir):
5353
assert any(c.startswith("ton:") for c in chain_ids)
5454
assert any(c.startswith("fil:") for c in chain_ids)
5555
assert any(c.startswith("xrpl:") for c in chain_ids)
56+
assert any(c.startswith("stellar:") for c in chain_ids)
5657

5758
wallets = ows.list_wallets(vault_path_opt=vault_dir)
5859
assert len(wallets) == 1
@@ -99,7 +100,7 @@ def test_import_wallet_mnemonic(vault_dir):
99100
"imported", phrase, vault_path_opt=vault_dir
100101
)
101102
assert wallet["name"] == "imported"
102-
assert len(wallet["accounts"]) == 9
103+
assert len(wallet["accounts"]) == 10
103104

104105
# EVM account should match derived address
105106
evm_account = next(a for a in wallet["accounts"] if a["chain_id"].startswith("eip155:"))

docs/07-supported-chains.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ OWS groups chains into families that share a cryptographic curve and address der
3939
| XRPL | secp256k1 | 144 | `m/44'/144'/0'/0/{index}` | Base58Check (`r...`) | `xrpl` |
4040
| Spark | secp256k1 | 8797555 | `m/84'/0'/0'/0/{index}` | `spark:` + compressed pubkey hex | `spark` |
4141
| Filecoin | secp256k1 | 461 | `m/44'/461'/0'/0/{index}` | `f1` + base32(blake2b-160) | `fil` |
42+
| Stellar | ed25519 | 148 | `m/44'/148'/{index}'` | StrKey Base32 (`G...`) | `stellar` |
4243

4344
## Known Networks
4445

@@ -71,6 +72,7 @@ Each network has a canonical chain identifier. Endpoint discovery and transport
7172
| XRPL | `xrpl:mainnet` |
7273
| Spark | `spark:mainnet` |
7374
| Filecoin | `fil:mainnet` |
75+
| Stellar | `stellar:pubnet` |
7476

7577
Implementations MAY ship convenience endpoint defaults, but those defaults are deployment choices rather than OWS interoperability requirements.
7678

@@ -100,6 +102,8 @@ xrpl-testnet → xrpl:testnet
100102
xrpl-devnet → xrpl:devnet
101103
spark → spark:mainnet
102104
filecoin → fil:mainnet
105+
stellar → stellar:pubnet
106+
stellar-testnet → stellar:testnet
103107
```
104108

105109
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)
123127
├── m/44'/784'/0'/0'/0' → Sui Account 0
124128
├── m/44'/144'/0'/0/0 → XRPL Account 0
125129
├── m/84'/0'/0'/0/0 → Spark Account 0
126-
└── m/44'/461'/0'/0/0 → Filecoin Account 0
130+
├── m/44'/461'/0'/0/0 → Filecoin Account 0
131+
└── m/44'/148'/{index}' → Stellar Account 0
127132
```
128133

129134
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.

ows/Cargo.lock

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ows/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No
5656
import { createWallet, signMessage } from "@open-wallet-standard/core";
5757

5858
const wallet = createWallet("my-wallet");
59-
console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL
59+
console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar
6060

6161
const sig = signMessage("my-wallet", "evm", "hello");
6262
console.log(sig.signature);
@@ -67,7 +67,7 @@ console.log(sig.signature);
6767
| Crate | Description |
6868
|-------|-------------|
6969
| `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. |
70-
| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. |
70+
| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. |
7171
| `ows-lib` | Library interface used by language bindings and the CLI. |
7272
| `ows-pay` | x402 payment flows, service discovery, and funding helpers. |
7373
| `ows-cli` | The `ows` command-line tool. |
@@ -84,6 +84,7 @@ console.log(sig.signature);
8484
- **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses
8585
- **XRPL** — secp256k1, Base58Check r-addresses
8686
- **Filecoin** — secp256k1, f1 base32 addresses
87+
- **Stellar** — Ed25519, StrKey base32 addresses (G...)
8788

8889
## License
8990

ows/crates/ows-cli/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The bindings are **standalone** — they embed the Rust core via native FFI. No
5656
import { createWallet, signMessage } from "@open-wallet-standard/core";
5757

5858
const wallet = createWallet("my-wallet");
59-
console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, and XRPL
59+
console.log(wallet.accounts); // addresses for EVM, Solana, Bitcoin, Cosmos, Tron, TON, Filecoin, Sui, XRPL, and Stellar
6060

6161
const sig = signMessage("my-wallet", "evm", "hello");
6262
console.log(sig.signature);
@@ -67,7 +67,7 @@ console.log(sig.signature);
6767
| Crate | Description |
6868
|-------|-------------|
6969
| `ows-core` | Types, CAIP-2/10 parsing, errors, config. Zero crypto dependencies. |
70-
| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, and Filecoin. |
70+
| `ows-signer` | ChainSigner trait, HD derivation, address derivation for EVM, Solana, XRPL, Sui, Bitcoin, Cosmos, Tron, TON, Spark, Filecoin, and Stellar. |
7171
| `ows-lib` | Library interface used by language bindings and the CLI. |
7272
| `ows-pay` | x402 payment flows, service discovery, and funding helpers. |
7373
| `ows-cli` | The `ows` command-line tool. |
@@ -84,6 +84,7 @@ console.log(sig.signature);
8484
- **Spark** (Bitcoin L2) — secp256k1, spark: prefixed addresses
8585
- **XRPL** — secp256k1, Base58Check r-addresses
8686
- **Filecoin** — secp256k1, f1 base32 addresses
87+
- **Stellar** — Ed25519, StrKey base32 addresses (G...)
8788

8889
## License
8990

ows/crates/ows-cli/src/commands/fund.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ fn find_account_for_chain<'a>(
88
) -> Result<&'a AccountInfo, CliError> {
99
let chain_prefix = match chain {
1010
"solana" => "solana:",
11+
"stellar" | "stellar-testnet" => "stellar:",
1112
_ => "eip155:",
1213
};
1314

@@ -34,6 +35,26 @@ pub fn run(wallet_name: &str, chain: Option<&str>, token: Option<&str>) -> Resul
3435
eprintln!("Creating deposit for wallet \"{wallet_name}\" ({address})");
3536
eprintln!("Target: {token_name} on {chain_name}");
3637

38+
if chain_name == "stellar-testnet" {
39+
eprintln!("\nfunding via Friendbot is available immediately:");
40+
println!("https://friendbot.stellar.org/?addr={address}");
41+
42+
#[cfg(target_os = "macos")]
43+
{
44+
let _ = std::process::Command::new("open")
45+
.arg(&format!("https://friendbot.stellar.org/?addr={address}"))
46+
.spawn();
47+
}
48+
#[cfg(target_os = "linux")]
49+
{
50+
let _ = std::process::Command::new("xdg-open")
51+
.arg(&format!("https://friendbot.stellar.org/?addr={address}"))
52+
.spawn();
53+
}
54+
55+
return Ok(());
56+
}
57+
3758
let rt =
3859
tokio::runtime::Runtime::new().map_err(|e| CliError::InvalidArgs(format!("tokio: {e}")))?;
3960

@@ -110,3 +131,46 @@ pub fn balance(wallet_name: &str, chain: Option<&str>) -> Result<(), CliError> {
110131

111132
Ok(())
112133
}
134+
135+
#[cfg(test)]
136+
mod tests {
137+
use super::*;
138+
use ows_lib::types::AccountInfo;
139+
140+
fn mock_account(chain_id: &str) -> AccountInfo {
141+
AccountInfo {
142+
chain_id: chain_id.to_string(),
143+
address: format!("addr_for_{chain_id}"),
144+
derivation_path: String::new(),
145+
}
146+
}
147+
148+
#[test]
149+
fn test_find_account_for_chain() {
150+
let accounts = vec![
151+
mock_account("eip155:1"),
152+
mock_account("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
153+
mock_account("stellar:pubnet"),
154+
];
155+
156+
// Should find Solana
157+
let acct = find_account_for_chain(&accounts, "solana").unwrap();
158+
assert_eq!(acct.chain_id, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
159+
160+
// Should find Stellar with 'stellar'
161+
let acct = find_account_for_chain(&accounts, "stellar").unwrap();
162+
assert_eq!(acct.chain_id, "stellar:pubnet");
163+
164+
// Should find Stellar with 'stellar-testnet'
165+
let acct = find_account_for_chain(&accounts, "stellar-testnet").unwrap();
166+
assert_eq!(acct.chain_id, "stellar:pubnet");
167+
168+
// Should fallback to EVM for unknown / base
169+
let acct = find_account_for_chain(&accounts, "base").unwrap();
170+
assert_eq!(acct.chain_id, "eip155:1");
171+
172+
// Should error if chain prefix missing
173+
let accounts_no_stellar = vec![mock_account("eip155:1")];
174+
assert!(find_account_for_chain(&accounts_no_stellar, "stellar").is_err());
175+
}
176+
}

0 commit comments

Comments
 (0)