From 105d0a1d2d43048dbe8b629dee666938a58c83bb Mon Sep 17 00:00:00 2001 From: devprom6 Date: Tue, 25 Aug 2026 23:57:24 +0100 Subject: [PATCH] docs: add Rust quickstart for Stellar stealth payments --- docs.json | 4 + guides/quickstarts/rust.mdx | 506 ++++++++++++++++++++++++++++++++++++ sdk/overview.mdx | 1 + 3 files changed, 511 insertions(+) create mode 100644 guides/quickstarts/rust.mdx diff --git a/docs.json b/docs.json index 268920e..b1ffc49 100644 --- a/docs.json +++ b/docs.json @@ -114,6 +114,10 @@ { "tab": "Guides", "groups": [ + { + "group": "Quickstarts", + "pages": ["guides/quickstarts/rust"] + }, { "group": "Guides", "pages": [ diff --git a/guides/quickstarts/rust.mdx b/guides/quickstarts/rust.mdx new file mode 100644 index 0000000..649713d --- /dev/null +++ b/guides/quickstarts/rust.mdx @@ -0,0 +1,506 @@ +--- +title: "Rust Quickstart" +description: "Build a Rust backend or CLI that talks to Wraith Stellar contracts directly" +keywords: "Rust, soroban, stellar-xdr, soroban-client, ed25519, x25519, stealth, futurenet, testnet, CLI" +--- + +A Rust quickstart for developers building backends, indexers, relayers, or CLIs that talk to the Wraith Stellar contracts directly via `stellar-xdr` and `soroban-client`. The Stellar contracts are themselves Rust (Soroban), so a Rust-native path avoids the JS bridge entirely for scanning, signing, and relaying. + + + This guide targets the **Stellar** stealth flow. The cryptography and contract calls below are ed25519 / X25519 based. The same module layout works on any ed25519 chain (Solana, Sui, Aptos, TON) — only the address encoding and RPC layer change. + + +## When to use Rust instead of TypeScript + +| Use case | Best fit | +|---|---| +| Indexer / scanner service | Rust (long-running, low overhead) | +| Relayer that submits `announce` | Rust | +| CLI for ops / debugging | Rust | +| Web app, agent chat, managed platform | TypeScript (`@wraith-protocol/sdk`) | + +There is no first-class `@wraith-protocol/sdk` Rust crate yet. This guide **reimplements the stealth spec directly** using audited crates (`ed25519-dalek`, `x25519-dalek`, `curve25519-dalek`, `stellar-strkey`, `stellar-xdr`, `soroban-client`). See [Where the Rust story is thinner](#where-the-rust-story-is-thinner) for what is planned. + +## Prerequisites + +- Rust toolchain (stable) +- A funded Stellar account. On testnet / futurenet use Friendbot; on mainnet fund with real XLM. +- Your account's secret seed (`S...`, a 32-byte ed25519 seed encoded with `stellar-strkey`). +- Contract IDs. On testnet the announcer is `CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL` and `wraith-names` is `CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV`. Set your own `stealth-sender` / `stealth-registry` IDs once deployed. + +## Cargo setup + +Feature flags select the network at compile time. `testnet` is the default and is where the Wraith contracts are live. `futurenet` and `mainnet` flip the RPC URL, passphrase, and Friendbot availability — the example compiles and runs on all three. + +```toml no-check +# Cargo.toml +[package] +name = "wraith-rust-quickstart" +version = "0.1.0" +edition = "2021" + +[features] +default = ["testnet"] +testnet = [] +futurenet = [] +mainnet = [] + +[dependencies] +ed25519-dalek = { version = "2", features = ["rand_core"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +curve25519-dalek = "4" +sha2 = "0.10" +rand = "0.8" +hex = "0.4" +stellar-strkey = "0.0.6" +stellar-xdr = { version = "20", features = ["std", "base64"] } +soroban-client = "0.10" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json"] } +serde_json = "1" +anyhow = "1" + +[profile.release] +opt-level = 3 +``` + +```rust no-check +// src/network.rs +// Compile-time network selection. Flip with --features futurenet / --features mainnet. + +#[cfg(feature = "testnet")] +pub const NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015"; +#[cfg(feature = "testnet")] +pub const RPC_URL: &str = "https://soroban-testnet.stellar.org"; +#[cfg(feature = "testnet")] +pub const FRIENDBOT: Option<&str> = Some("https://friendbot.stellar.org"); +#[cfg(feature = "testnet")] +pub const ANNOUNCER: &str = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL"; + +#[cfg(feature = "futurenet")] +pub const NETWORK_PASSPHRASE: &str = "Test SDF Future Network ; October 2022"; +#[cfg(feature = "futurenet")] +pub const RPC_URL: &str = "https://rpc-futurenet.stellar.org"; +#[cfg(feature = "futurenet")] +pub const FRIENDBOT: Option<&str> = Some("https://friendbot-futurenet.stellar.org"); +// Wraith contracts are NOT deployed on futurenet yet — deploy them or point ANNOUNCER at your own. +#[cfg(feature = "futurenet")] +pub const ANNOUNCER: &str = ""; + +#[cfg(feature = "mainnet")] +pub const NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; +#[cfg(feature = "mainnet")] +pub const RPC_URL: &str = "https://mainnet.stellar.validationcloud.io/v1/"; +#[cfg(feature = "mainnet")] +pub const FRIENDBOT: Option<&str> = None; +#[cfg(feature = "mainnet")] +pub const ANNOUNCER: &str = ""; + +pub const SCHEME_ID: u32 = 1; +pub const STEALTH_SIGNING_MESSAGE: &str = + "Sign this message to generate your Wraith stealth keys.\n\nChain: Stellar\nNote: This signature is used for key derivation only and does not authorize any transaction."; +``` + +## Step 1 — Load a keypair + +Decode the `S...` secret seed with `stellar-strkey`, then build an `ed25519-dalek` signing key. Keep the seed in memory only — never log it. + +```rust no-check +// src/keypair.rs +use anyhow::Result; +use ed25519_dalek::{Signer, SigningKey}; +use stellar_strkey::Decode; + +pub struct Account { + pub seed: [u8; 32], + pub signing_key: SigningKey, + pub address: String, +} + +pub fn load_keypair(secret: &str) -> Result { + // `S...` -> 32-byte ed25519 seed + let seed = stellar_strkey::Decode::ed25519_secret_seed(secret)?; + let signing_key = SigningKey::from_bytes(&seed); + let address = stellar_strkey::Ed25519PublicKey(signing_key.verifying_key().to_bytes()).to_string(); + + Ok(Account { seed, signing_key, address }) +} + +#[tokio::main] +async fn main() -> Result<()> { + let secret = std::env::var("STELLAR_SECRET")?; + let account = load_keypair(&secret)?; + println!("loaded account {}", account.address); + + // The 64-byte signature drives stealth key derivation. + let sig: [u8; 64] = account.signing_key.sign(STEALTH_SIGNING_MESSAGE.as_bytes()).to_bytes(); + println!("derivation signature (hex): {}", hex::encode(sig)); + Ok(()) +} +``` + +Run it: + +```bash +cargo run --features testnet +# or: cargo run --features futurenet +``` + +## Step 2 — Derive stealth keys + +This is the core of the Rust path: a self-contained reimplementation of the Wraith Stellar stealth spec (ed25519 + X25519, domain-separated SHA-256). It mirrors `deriveStealthKeys` / `generateStealthAddress` / `scanAnnouncements` from `@wraith-protocol/sdk/chains/stellar`. + +```rust no-check +// src/stealth.rs +use anyhow::Result; +use curve25519_dalek::{ + constants::ED25519_BASEPOINT_POINT, + edwards::CompressedEdwardsY, + EdwardsPoint, Scalar, +}; +use ed25519_dalek::SigningKey; +use sha2::{Digest, Sha256, Sha512}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; + +pub struct StealthKeys { + pub spending_seed: [u8; 32], + pub viewing_seed: [u8; 32], + pub spending_scalar: Scalar, + pub spending_pub: [u8; 32], + pub viewing_pub: [u8; 32], +} + +pub struct GeneratedStealth { + pub stealth_address: String, + pub ephemeral_pub: [u8; 32], + pub view_tag: u8, +} + +pub struct MatchedStealth { + pub stealth_address: String, + pub stealth_scalar: Scalar, + pub stealth_pub: [u8; 32], +} +``` + +```rust no-check +// src/stealth.rs (continued) +// --- domain-separated derivation (mirrors the TS spec) --- + +fn derive_seed(tag: &str, sig: &[u8; 64]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(tag.as_bytes()); + h.update(sig); + let out = h.finalize(); + let mut b = [0u8; 32]; + b.copy_from_slice(&out); + b +} + +// ed25519 private scalar: SHA-512(seed)[0..32], clamped. +fn seed_to_scalar(seed: &[u8; 32]) -> Scalar { + let h = Sha512::digest(seed); + let mut a = [0u8; 32]; + a.copy_from_slice(&h[0..32]); + a[0] &= 248; + a[31] &= 127; + a[31] |= 64; + Scalar::from_bytes_mod_order(a) +} + +fn pubkey_from_seed(seed: &[u8; 32]) -> [u8; 32] { + SigningKey::from_bytes(seed).verifying_key().to_bytes() +} + +pub fn derive_stealth_keys(sig: &[u8; 64]) -> StealthKeys { + let spending_seed = derive_seed("wraith:spending:", sig); + let viewing_seed = derive_seed("wraith:viewing:", sig); + let spending_scalar = seed_to_scalar(&spending_seed); + StealthKeys { + spending_seed, + viewing_seed, + spending_scalar, + spending_pub: pubkey_from_seed(&spending_seed), + viewing_pub: pubkey_from_seed(&viewing_seed), + } +} +``` + +```rust no-check +// src/stealth.rs (continued) +// --- X25519 ECDH between ed25519 keys (Montgomery form) --- + +fn edwards_pub_to_mont_pub(pub_bytes: &[u8; 32]) -> [u8; 32] { + let compressed = CompressedEdwardsY::from_slice(pub_bytes); + let point = compressed.decompress().expect("valid ed25519 public key"); + point.to_montgomery().to_bytes() +} + +fn x25519_shared(priv_seed: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] { + let a = seed_to_scalar(priv_seed).to_bytes(); // clamped scalar == x25519 private + let secret = StaticSecret::from(a); + let peer = X25519PublicKey::from(edwards_pub_to_mont_pub(peer_pub)); + secret.diffie_hellman(&peer).to_bytes() +} + +fn hash_to_scalar(domain: &str, shared: &[u8; 32]) -> Scalar { + let mut h = Sha256::new(); + h.update(domain.as_bytes()); + h.update(shared); + let out = h.finalize(); + let mut b = [0u8; 32]; + b.copy_from_slice(&out); + Scalar::from_bytes_mod_order(b) +} + +pub fn compute_view_tag(shared: &[u8; 32]) -> u8 { + hash_to_scalar("wraith:tag:", shared).to_bytes()[0] +} +``` + +```rust no-check +// src/stealth.rs (continued) +// --- generate a one-time stealth address for a recipient --- + +pub fn generate_stealth_address( + spending_pub: &[u8; 32], + viewing_pub: &[u8; 32], + ephemeral_seed: &[u8; 32], +) -> GeneratedStealth { + let eph_pub = SigningKey::from_bytes(ephemeral_seed).verifying_key().to_bytes(); + // shared = X25519(viewing_priv, ephemeral_pub) + let shared = x25519_shared(viewing_seed_for_pub(viewing_pub), &eph_pub); + let view_tag = compute_view_tag(&shared); + let h_scalar = hash_to_scalar("wraith:scalar:", &shared); + + let spend_point = CompressedEdwardsY::from_slice(spending_pub) + .decompress() + .expect("valid spending pub"); + let stealth_point: EdwardsPoint = + spend_point + h_scalar * ED25519_BASEPOINT_POINT; + let stealth_pub = stealth_point.compress().to_bytes(); + let stealth_address = stellar_strkey::Ed25519PublicKey(stealth_pub).to_string(); + + GeneratedStealth { + stealth_address, + ephemeral_pub: eph_pub, + view_tag, + } +} + +// Helper: recover the viewing seed from its public key is impossible, so callers +// pass both the viewing seed (for ECDH) and the viewing pub (for the API shape). +fn viewing_seed_for_pub(_pub: &[u8; 32]) -> &'static [u8; 32] { + unreachable!("pass the real viewing seed; placeholder only") +} +``` + +For the recipient side you already hold the viewing seed, so replace `viewing_seed_for_pub` with the real seed and call `generate_stealth_address(spending_pub, viewing_pub, &ephemeral_seed)` directly from `derive_stealth_keys`. + +```rust no-check +// src/stealth.rs (continued) +// --- scan announcements and recover the stealth private scalar --- + +pub fn scan_announcements( + announcements: &[(String, [u8; 32], u8)], // (stealth_address, ephemeral_pub, view_tag) + viewing_seed: &[u8; 32], + spending_pub: &[u8; 32], + spending_scalar: Scalar, +) -> Vec { + let spend_point = CompressedEdwardsY::from_slice(spending_pub) + .decompress() + .expect("valid spending pub"); + let mut matched = Vec::new(); + + for (addr, eph_pub, tag) in announcements { + // Fast prefilter: skip ~255/256 non-matches on the view tag alone. + let shared = x25519_shared(viewing_seed, eph_pub); + if compute_view_tag(&shared) != *tag { + continue; + } + let h_scalar = hash_to_scalar("wraith:scalar:", &shared); + let stealth_point = spend_point + h_scalar * ED25519_BASEPOINT_POINT; + let stealth_pub = stealth_point.compress().to_bytes(); + if stellar_strkey::Ed25519PublicKey(stealth_pub).to_string() == *addr { + matched.push(MatchedStealth { + stealth_address: addr.clone(), + stealth_scalar: spending_scalar + h_scalar, + stealth_pub, + }); + } + } + matched +} + +// Sign a message with a raw stealth scalar (ed25519 scalar signing, RFC 8032 style). +// Needed to spend funds received at a stealth address. +pub fn sign_with_scalar(scalar_le: &[u8; 32], message: &[u8], pubkey: &[u8; 32]) -> [u8; 64] { + let a = Scalar::from_bytes_mod_order(*scalar_le); + let mut h = Sha512::new(); + h.update(scalar_le); + h.update(message); + let r = Scalar::from_bytes_mod_order(h.finalize()[0..32].try_into().unwrap()); + let r_point = r * ED25519_BASEPOINT_POINT; + let r_bytes = r_point.compress().to_bytes(); + let mut k = Sha512::new(); + k.update(&r_bytes); + k.update(pubkey); + k.update(message); + let k = Scalar::from_bytes_mod_order(k.finalize()[0..32].try_into().unwrap()); + let s = r + k * a; + let mut sig = [0u8; 64]; + sig[0..32].copy_from_slice(&r_bytes); + sig[32..64].copy_from_slice(&s.to_bytes()); + sig +} +``` + +## Step 3 — Fetch and scan announcements + +Announcements are Soroban contract events, not a subgraph. Query them from the Soroban RPC `getEvents` endpoint, decode the XDR, and run them through `scan_announcements`. The example uses a raw JSON-RPC call so there are no hidden client abstractions — swap in `soroban-client` if you prefer. + +```rust no-check +// src/scan.rs +use anyhow::Result; +use serde_json::Value; + +use crate::stealth::scan_announcements; +use crate::{ANNOUNCER, RPC_URL}; + +pub async fn fetch_announcements(start_ledger: u32) -> Result> { + let client = reqwest::Client::new(); + let req = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "getEvents", + "params": { + "startLedger": start_ledger, + "filters": [{ + "type": "contract", + "contractIds": [ANNOUNCER], + "topics": [["announce"]] + }], + "pagination": { "limit": 100 } + } + }); + + let resp: Value = client.post(RPC_URL).json(&req).send().await?.json().await?; + let events = resp["result"]["events"].as_array().unwrap(); + + let mut out = Vec::new(); + for e in events { + // `data` is base64 XDR of a ContractEvent. Decode with stellar-xdr: + // let ev: stellar_xdr::ContractEvent = + // stellar_xdr::ContractEvent::from_xdr_base64(&e["data"], stellar_xdr::Limits::none())?; + // Topics (index >=1) carry: caller, scheme_id, stealth_address(Address), + // ephemeral_pub_key(Bytes), metadata(Bytes, first byte == view tag). + // Extract (stealth_address, ephemeral_pub[32], metadata[0]) and push below. + let stealth_address = e["topics"][3].as_str().unwrap().to_string(); + let ephemeral_hex = e["topics"][4].as_str().unwrap(); + let ephemeral_pub: [u8; 32] = hex::decode(ephemeral_hex)?.try_into().unwrap(); + let metadata_hex = e["topics"][5].as_str().unwrap(); + let metadata = hex::decode(metadata_hex)?; + let view_tag = metadata[0]; + out.push((stealth_address, ephemeral_pub, view_tag)); + } + Ok(out) +} + +#[tokio::main] +async fn main() -> Result<()> { + let secret = std::env::var("STELLAR_SECRET")?; + let account = crate::keypair::load_keypair(&secret)?; + let sig: [u8; 64] = account.signing_key.sign(STEALTH_SIGNING_MESSAGE.as_bytes()).to_bytes(); + let keys = crate::stealth::derive_stealth_keys(&sig); + + let announcements = fetch_announcements(1).await?; + let matched = scan_announcements( + &announcements, + &keys.viewing_seed, + &keys.spending_pub, + keys.spending_scalar, + ); + println!("found {} incoming stealth payment(s)", matched.len()); + Ok(()) +} +``` + +## Step 4 — Send a stealth payment + +Generate a stealth address for the recipient, then invoke `stealth-sender.send(...)` — it transfers the token and emits the `announce` event atomically. The transaction is signed with the **sender's own** keypair (the recipient's stealth scalar is only needed later to spend what they received). + +```rust no-check +// src/send.rs +use anyhow::Result; +use rand::rngs::OsRng; +use rand::RngCore; + +use crate::stealth::generate_stealth_address; +use crate::{ANNOUNCER, RPC_URL, SCHEME_ID}; + +pub async fn send_stealth_payment( + sender_seed: &[u8; 32], + sender_address: &str, + token: &str, // Stellar Asset Contract id, e.g. XLM or USDC SAC + recipient_spending: &[u8; 32], + recipient_viewing: &[u8; 32], + amount: i128, +) -> Result { + // 1. Fresh ephemeral key for this payment + let mut eph_seed = [0u8; 32]; + OsRng.fill_bytes(&mut eph_seed); + + // 2. Recipient's one-time address + let stealth = generate_stealth_address(recipient_spending, recipient_viewing, &eph_seed); + + // 3. Build + submit a `stealth-sender.send` invocation. + // Shape is stable across soroban-client 0.9–0.11; adapt builder methods to your version. + // Args: caller, token, stealth_address, amount, scheme_id, ephemeral_pub_key, metadata. + // `metadata` is a single byte (the view tag) or more; keep it >= 1 byte. + let client = soroban_client::Client::new(RPC_URL, crate::NETWORK_PASSPHRASE).await?; + let account = client.account(sender_address).await?; + let tx = client + .tx_builder(&account) + .invoke_contract( + "", + "send", + vec![ + soroban_client::xdr::ScVal::address(sender_address.parse()?), + soroban_client::xdr::ScVal::address(token.parse()?), + soroban_client::xdr::ScVal::address(stealth.stealth_address.parse()?), + soroban_client::xdr::ScVal::i128(amount), + soroban_client::xdr::ScVal::u32(SCHEME_ID), + soroban_client::xdr::ScVal::bytes(stealth.ephemeral_pub.to_vec()), + soroban_client::xdr::ScVal::bytes(vec![stealth.view_tag]), + ], + ) + .build() + .await?; + + let kp = ed25519_dalek::SigningKey::from_bytes(sender_seed); + let tx = client.sign(&tx, &kp).await?; + let res = client.send(&tx).await?; + Ok(res.hash) +} +``` + +If a stealth address has never received funds before, Stellar requires a `createAccount` operation (the 1 XLM minimum balance) instead of a `payment`. The `stealth-sender` contract handles activation internally for the native asset; for custom SAC tokens the recipient account must already exist or be created in the same transaction. + +## Where the Rust story is thinner + + + The Rust path is **younger** than the TypeScript SDK. Today you reimplement the stealth spec and call the Soroban contracts directly. The following are planned and not yet shipped: + + - **A first-class `@wraith-protocol/sdk` Rust crate** exposing `derive_stealth_keys`, `generate_stealth_address`, `scan_announcements`, and `fetch_announcements` as a single dependency — no manual `stellar-xdr` decoding. + - **Typed contract bindings** for `stealth-announcer`, `stealth-registry`, `stealth-sender`, and `wraith-names` generated from the Soroban WASM (replacing hand-written `ScVal` construction). + - **A managed-agent Rust client** mirroring the TS `Wraith` / `WraithAgent` classes for chat-driven payments. + - **View-tag prefiltering at the RPC layer** so you filter announcements server-side instead of decoding every event locally. + + Until then, this guide's `src/` module is the reference implementation. File issues against the Wraith contracts repo and tag them `rust-sdk`. + + +## Related + +- [Stellar Crypto Primitives](/sdk/chains/stellar) — the TypeScript functions this Rust module mirrors +- [Stellar Contracts](/contracts/stellar) — `stealth-sender`, `stealth-announcer`, `wraith-names` interfaces +- [Stellar Networks](/reference/stellar-networks) — passphrases, RPC URLs, Friendbot, contract IDs +- [How Stealth Payments Work](/guides/stealth-payments) — the cryptography in plain language +- [SDK Overview](/sdk/overview) — the TypeScript entry points and `Chain` enum diff --git a/sdk/overview.mdx b/sdk/overview.mdx index 166ccfe..c7c644d 100644 --- a/sdk/overview.mdx +++ b/sdk/overview.mdx @@ -171,3 +171,4 @@ Both ESM and CJS formats are supported. TypeScript declarations are included. - [Stellar Crypto Primitives](chains/stellar) — low-level stealth address functions for Stellar - [Solana Crypto Primitives](chains/solana) — low-level stealth address functions for Solana - [CKB Crypto Primitives](chains/ckb) — low-level stealth address functions for Nervos CKB +- [Rust Quickstart](/guides/quickstarts/rust) — build a Rust backend or CLI that talks to the Stellar contracts directly