diff --git a/docs.json b/docs.json index ab3d6de..dc10f10 100644 --- a/docs.json +++ b/docs.json @@ -124,7 +124,10 @@ "groups": [ { "group": "Quickstarts", - "pages": ["guides/quickstarts/rust"] + "pages": [ + "guides/quickstarts/python", + "guides/quickstarts/rust" + ] }, { "group": "Guides", diff --git a/guides/quickstarts/python.mdx b/guides/quickstarts/python.mdx new file mode 100644 index 0000000..9c5ad87 --- /dev/null +++ b/guides/quickstarts/python.mdx @@ -0,0 +1,893 @@ +--- +title: "Python Quickstart" +description: "Derive stealth keys, fetch announcements, scan for owned payments, and send a stealth transfer — all from Python 3.11+ using stellar-sdk and a small crypto utility." +keywords: "Python, stellar-sdk, stealth payment, Soroban RPC, ed25519, X25519, futurenet, scan key" +--- + + + This guide targets the Wraith contracts on the Stellar **futurenet**. The + derivation crypto and RPC calls are identical on testnet and mainnet — only the + `soroban_rpc_url`, `network_passphrase`, and `announcer_contract_id` differ. + See [Stellar Networks](/reference/stellar-networks) for the full address table. + + +By the end of this guide you will have: + +- Derived stealth spending and viewing keys from a Stellar keypair in pure Python +- Fetched announcement events from the Soroban RPC without the TypeScript SDK +- Scanned those announcements to find payments addressed to you +- Sent a stealth payment and published an on-chain announcement + +**Prerequisites:** Python 3.11+, `pip`, and a Stellar keypair you can fund on futurenet. + +--- + +## Step 1 — Install dependencies + +```bash no-check +pip install stellar-sdk==11.* cryptography +``` + +`stellar-sdk` handles Soroban RPC calls, XDR encoding, transaction building, and +`StrKey` address encoding. `cryptography` provides X25519 Diffie-Hellman needed for +the shared-secret step. + +No TypeScript toolchain is required. + +--- + +## Step 2 — Create `wraith_crypto.py` + +This utility file implements the three primitives the rest of the guide relies on: +**key derivation**, **stealth address generation**, and **announcement scanning**. It +is intentionally self-contained — copy it into your project and import from it. + +```python no-check +# wraith_crypto.py +""" +Wraith Protocol — Stellar stealth-address crypto, pure Python. + +Implements the same derivation as @wraith-protocol/sdk/chains/stellar: + - SHA-256 domain separation for spending/viewing seeds + - X25519 ECDH via Edwards-to-Montgomery conversion + - View-tag prefilter (1 byte, ~255/256 skip rate) + - ed25519 scalar addition for stealth pub-key derivation + +Requires: cryptography>=41, stellar-sdk>=11 +""" + +from __future__ import annotations + +import hashlib +import hmac +import struct +from dataclasses import dataclass +from typing import Optional + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + PrivateFormat, + NoEncryption, +) +from stellar_sdk import Keypair + +# --------------------------------------------------------------------------- +# Constants (mirrors constants.ts in the SDK) +# --------------------------------------------------------------------------- + +STEALTH_SIGNING_MESSAGE = ( + "Sign this message to generate your Wraith stealth keys.\n\n" + "Chain: Stellar\n" + "Note: This signature is used for key derivation only and does not " + "authorize any transaction." +) + +META_ADDRESS_PREFIX = "st:xlm:" +SCHEME_ID = 1 + +# ed25519 group order L +L = 2**252 + 27742317777372353535851937790883648493 + + +# --------------------------------------------------------------------------- +# Low-level helpers +# --------------------------------------------------------------------------- + +def _sha256(*parts: bytes) -> bytes: + h = hashlib.sha256() + for part in parts: + h.update(part) + return h.digest() + + +def _sha512(data: bytes) -> bytes: + return hashlib.sha512(data).digest() + + +def _clamp_scalar(scalar_bytes: bytearray) -> bytearray: + """Apply standard ed25519 scalar clamping.""" + scalar_bytes[0] &= 248 + scalar_bytes[31] &= 127 + scalar_bytes[31] |= 64 + return scalar_bytes + + +def _seed_to_scalar(seed: bytes) -> int: + """ + Expand a 32-byte seed to its clamped scalar (mirrors seedToScalar in the SDK). + + 1. h = SHA-512(seed) + 2. a = h[0:32] (lower half) + 3. Clamp a + 4. Interpret as little-endian bigint + """ + h = _sha512(seed) + a = bytearray(h[:32]) + _clamp_scalar(a) + return int.from_bytes(a, "little") + + +def _ed25519_pub_from_seed(seed: bytes) -> bytes: + """Return the 32-byte compressed ed25519 public key for a seed.""" + priv = Ed25519PrivateKey.from_private_bytes(seed) + return priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + + +# --------------------------------------------------------------------------- +# Edwards-to-Montgomery conversion (RFC 7748) +# --------------------------------------------------------------------------- + +# Ed25519 curve parameters +_P = 2**255 - 19 +_D = -121665 * pow(121666, _P - 2, _P) % _P + + +def _decompress_ed25519(point_bytes: bytes) -> tuple[int, int]: + """Decompress a 32-byte compressed ed25519 point to (x, y).""" + y_int = int.from_bytes(point_bytes, "little") + sign = (y_int >> 255) & 1 + y = y_int & ((1 << 255) - 1) + + # Recover x from the curve equation: -x^2 + y^2 = 1 + d*x^2*y^2 + y2 = (y * y) % _P + x2 = (y2 - 1) * pow(1 + _D * y2, _P - 2, _P) % _P + x = pow(x2, (_P + 3) // 8, _P) + + if (x * x - x2) % _P != 0: + _I = pow(2, (_P - 1) // 4, _P) + x = x * _I % _P + + if x % 2 != sign: + x = _P - x + + return x, y + + +def _edwards_to_montgomery_pub(ed_pub: bytes) -> bytes: + """ + Convert an ed25519 public key (Edwards form) to an X25519 key (Montgomery form). + u = (1 + y) / (1 - y) mod p + """ + _, y = _decompress_ed25519(ed_pub) + u = (1 + y) * pow(1 - y, _P - 2, _P) % _P + return u.to_bytes(32, "little") + + +def _edwards_to_montgomery_priv(ed_seed: bytes) -> bytes: + """ + Convert an ed25519 seed to the X25519 scalar (lower 32 bytes of SHA-512, clamped). + """ + h = _sha512(ed_seed) + a = bytearray(h[:32]) + _clamp_scalar(a) + return bytes(a) + + +# --------------------------------------------------------------------------- +# Key structures +# --------------------------------------------------------------------------- + +@dataclass +class StealthKeys: + spending_key: bytes # 32-byte seed + spending_scalar: int # clamped bigint + viewing_key: bytes # 32-byte seed + viewing_scalar: int # clamped bigint + spending_pub_key: bytes # 32-byte ed25519 public key + viewing_pub_key: bytes # 32-byte ed25519 public key + + +@dataclass +class GeneratedStealthAddress: + stealth_address: str # Stellar G... address + ephemeral_pub_key: bytes # 32-byte ed25519 public key + view_tag: int # 0–255 + + +@dataclass +class MatchedAnnouncement: + stealth_address: str + ephemeral_pub_key: str # hex + stealth_private_scalar: int + stealth_pub_key_bytes: bytes + + +# --------------------------------------------------------------------------- +# Core API +# --------------------------------------------------------------------------- + +def derive_stealth_keys(signature: bytes) -> StealthKeys: + """ + Derive stealth spending and viewing key pairs from a 64-byte ed25519 signature. + + Algorithm (mirrors deriveStealthKeys in SDK keys.ts): + spending_key = SHA-256("wraith:spending:" || signature) + viewing_key = SHA-256("wraith:viewing:" || signature) + + Domain-separated hashing is used (instead of splitting r/s) because + ed25519 signature components lack the independence of secp256k1 components. + """ + spending_key = _sha256(b"wraith:spending:", signature) + viewing_key = _sha256(b"wraith:viewing:", signature) + + return StealthKeys( + spending_key=spending_key, + spending_scalar=_seed_to_scalar(spending_key), + viewing_key=viewing_key, + viewing_scalar=_seed_to_scalar(viewing_key), + spending_pub_key=_ed25519_pub_from_seed(spending_key), + viewing_pub_key=_ed25519_pub_from_seed(viewing_key), + ) + + +def _x25519_shared_secret(priv_ed_seed: bytes, pub_ed: bytes) -> bytes: + """ + Compute X25519 shared secret from an ed25519 seed and an ed25519 public key. + Converts both keys to Montgomery form before the DH operation (RFC 7748). + """ + priv_mont = _edwards_to_montgomery_priv(priv_ed_seed) + pub_mont = _edwards_to_montgomery_pub(pub_ed) + + x_priv = X25519PrivateKey.from_private_bytes(priv_mont) + x_pub = X25519PublicKey.from_public_bytes(pub_mont) + return x_priv.exchange(x_pub) + + +def _compute_view_tag(shared_secret: bytes) -> int: + """ + Compute the 1-byte view tag (v2 domain). + view_tag = SHA-256("wraith:stellar:view-tag:v2:" || shared_secret)[0] + """ + digest = _sha256(b"wraith:stellar:view-tag:v2:", shared_secret) + return digest[0] + + +def _hash_to_scalar(shared_secret: bytes) -> int: + """ + Hash the shared secret to a scalar reduced mod L. + SHA-256("wraith:scalar:" || shared_secret) interpreted as little-endian bigint. + """ + digest = _sha256(b"wraith:scalar:", shared_secret) + return int.from_bytes(digest, "little") % L + + +def _ed25519_point_add(pub_bytes: bytes, scalar: int) -> bytes: + """ + Compute P + scalar*G on ed25519 and return the compressed 32-byte result. + Uses the cryptography library's internal scalar multiplication via a trick: + we derive scalar*G from a raw scalar key, then add to P via field arithmetic. + + This mirrors deriveStealthPubKey in the SDK (scalar.ts point addition). + """ + # scalar * G → get the public key for scalar as a raw scalar + # We represent scalar as a little-endian 32-byte value and manually construct + # the point using the standard ed25519 signing key structure. + # Note: we're doing point addition in the ed25519 group. + + # Decompress both points + x1, y1 = _decompress_ed25519(pub_bytes) + + # scalar * G: derive public key bytes from scalar + scalar_bytes = scalar.to_bytes(32, "little") + scalar_ed = Ed25519PrivateKey.from_private_bytes(scalar_bytes) + scalar_g_bytes = scalar_ed.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + x2, y2 = _decompress_ed25519(scalar_g_bytes) + + # Edwards point addition formula: + # x3 = (x1*y2 + y1*x2) / (1 + d*x1*x2*y1*y2) + # y3 = (y1*y2 + x1*x2) / (1 - d*x1*x2*y1*y2) + x1y2 = x1 * y2 % _P + y1x2 = y1 * x2 % _P + x1x2 = x1 * x2 % _P + y1y2 = y1 * y2 % _P + dx1x2y1y2 = _D * x1x2 * y1y2 % _P + + x3 = (x1y2 + y1x2) * pow(1 + dx1x2y1y2, _P - 2, _P) % _P + y3 = (y1y2 + x1x2) * pow(1 - dx1x2y1y2, _P - 2, _P) % _P + + # Compress: encode y with sign bit of x + result = bytearray(y3.to_bytes(32, "little")) + if x3 & 1: + result[31] |= 0x80 + return bytes(result) + + +def _pub_key_to_stellar_address(pub_key: bytes) -> str: + """Convert a 32-byte ed25519 public key to a Stellar G... address (StrKey).""" + return Keypair.from_raw_ed25519_public_key(pub_key).public_key + + +def generate_stealth_address( + spending_pub_key: bytes, + viewing_pub_key: bytes, + ephemeral_seed: Optional[bytes] = None, +) -> GeneratedStealthAddress: + """ + Generate a one-time stealth address for a Stellar recipient. + + If ephemeral_seed is None, a fresh random seed is generated. + Providing a fixed seed is useful for deterministic tests only. + + Algorithm (mirrors generateStealthAddress in SDK stealth.ts): + 1. Ephemeral ed25519 key pair + 2. X25519 ECDH shared secret with recipient's viewing key + 3. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || shared)[0] + 4. h_scalar = SHA-256("wraith:scalar:" || shared) mod L + 5. stealth_point = spending_pub + h_scalar * G (ed25519 point addition) + 6. Encode as Stellar G... via StrKey + """ + import os + if ephemeral_seed is None: + ephemeral_seed = os.urandom(32) + + ephemeral_pub = _ed25519_pub_from_seed(ephemeral_seed) + shared = _x25519_shared_secret(ephemeral_seed, viewing_pub_key) + + view_tag = _compute_view_tag(shared) + h_scalar = _hash_to_scalar(shared) + + stealth_pub = _ed25519_point_add(spending_pub_key, h_scalar) + stealth_address = _pub_key_to_stellar_address(stealth_pub) + + return GeneratedStealthAddress( + stealth_address=stealth_address, + ephemeral_pub_key=ephemeral_pub, + view_tag=view_tag, + ) + + +def encode_meta_address(spending_pub_key: bytes, viewing_pub_key: bytes) -> str: + """Encode two 32-byte public keys into a Stellar stealth meta-address.""" + return META_ADDRESS_PREFIX + spending_pub_key.hex() + viewing_pub_key.hex() + + +def decode_meta_address(meta_address: str) -> tuple[bytes, bytes]: + """Decode a Stellar meta-address into (spending_pub_key, viewing_pub_key).""" + if not meta_address.startswith(META_ADDRESS_PREFIX): + raise ValueError(f"Expected prefix '{META_ADDRESS_PREFIX}'") + payload = meta_address[len(META_ADDRESS_PREFIX):] + if len(payload) != 128: + raise ValueError("Meta-address payload must be 128 hex chars (2 × 32 bytes)") + spending = bytes.fromhex(payload[:64]) + viewing = bytes.fromhex(payload[64:]) + return spending, viewing + + +def scan_announcements( + announcements: list[dict], + viewing_key: bytes, + spending_pub_key: bytes, + spending_scalar: int, +) -> list[MatchedAnnouncement]: + """ + Scan a list of announcement dicts for payments addressed to these keys. + + Each announcement dict must have: + ephemeral_pub_key : hex string (64 chars, 32 bytes) + metadata : hex string (first byte = view tag) + stealth_address : str (G... address) + + Returns only announcements that match — with the derived private scalar + needed to spend the funds. + + View-tag prefilter: only ~1/256 non-matching announcements reach the + expensive X25519 step (false-positive rate ≈ 0.39 %). + """ + results: list[MatchedAnnouncement] = [] + + for ann in announcements: + eph_bytes = bytes.fromhex(ann["ephemeral_pub_key"]) + meta_bytes = bytes.fromhex(ann["metadata"]) + announced_view_tag = meta_bytes[0] + + # ------------------------------------------------------------------ + # Cheap prefilter: compute expected view tag and compare + # ------------------------------------------------------------------ + shared = _x25519_shared_secret(viewing_key, eph_bytes) + expected_tag = _compute_view_tag(shared) + if expected_tag != announced_view_tag: + continue # skip ~255/256 of non-matching entries + + # ------------------------------------------------------------------ + # Full check: derive the expected stealth address and compare + # ------------------------------------------------------------------ + h_scalar = _hash_to_scalar(shared) + stealth_pub = _ed25519_point_add(spending_pub_key, h_scalar) + derived_address = _pub_key_to_stellar_address(stealth_pub) + + if derived_address != ann["stealth_address"]: + continue # view-tag false positive — discard + + # ------------------------------------------------------------------ + # Match! Derive the private scalar for spending + # stealth_scalar = (spending_scalar + h_scalar) mod L + # ------------------------------------------------------------------ + stealth_scalar = (spending_scalar + h_scalar) % L + + results.append(MatchedAnnouncement( + stealth_address=derived_address, + ephemeral_pub_key=ann["ephemeral_pub_key"], + stealth_private_scalar=stealth_scalar, + stealth_pub_key_bytes=stealth_pub, + )) + + return results +``` + + + The crypto needed for Wraith's Stellar scheme is deliberately small — roughly + 200 lines of hashing and curve arithmetic. Shipping it inline keeps this + quickstart dependency-free beyond `stellar-sdk` and `cryptography`, both of + which are already in most Python fintech backends. A standalone + `wraith-stellar-py` package is on the roadmap. + + +--- + +## Step 3 — Derive your stealth keys + +```python no-check +import os +from stellar_sdk import Keypair +from wraith_crypto import STEALTH_SIGNING_MESSAGE, derive_stealth_keys, encode_meta_address + +# Load your Stellar keypair from the environment — never hardcode secrets +keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"]) + +# Sign the deterministic derivation message +# (Same message the TypeScript SDK uses — keys are cross-compatible) +signature = keypair.sign(STEALTH_SIGNING_MESSAGE.encode()) + +keys = derive_stealth_keys(signature) + +print("Spending pub key:", keys.spending_pub_key.hex()) +print("Viewing pub key:", keys.viewing_pub_key.hex()) +print("Meta-address: ", encode_meta_address(keys.spending_pub_key, keys.viewing_pub_key)) +# → st:xlm:<64 hex chars><64 hex chars> +``` + +The same primary keypair always produces the same stealth keys — you don't need to +back them up separately. The `STEALTH_SIGNING_MESSAGE` constant is identical to the +one in the TypeScript SDK, so keys derived here are usable by any SDK implementation. + +--- + +## Step 4 — Fetch announcements from the Soroban RPC + +The Soroban RPC's `getEvents` method returns paginated contract events. The utility +below fetches all `WRAITH_ANNOUNCEMENT` events from the announcer contract and +normalises them into the dict shape `scan_announcements` expects. + +```python no-check +import httpx # pip install httpx + +SOROBAN_RPC_URL = "https://rpc-futurenet.stellar.org" +ANNOUNCER_CONTRACT_ID = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL" # testnet value; futurenet has no live contracts + + +def _rpc(method: str, params: dict) -> dict: + """Minimal JSON-RPC 2.0 helper for the Soroban RPC.""" + resp = httpx.post( + SOROBAN_RPC_URL, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=30, + ) + resp.raise_for_status() + body = resp.json() + if "error" in body: + raise RuntimeError(f"RPC error: {body['error']}") + return body["result"] + + +def _current_ledger_range() -> tuple[int, int]: + """Return (oldest_ledger, latest_ledger) from getLatestLedger.""" + info = _rpc("getLatestLedger", {}) + latest = info["sequence"] + # Soroban RPC retains ~17,280 ledgers (~24 h at 5 s/ledger) + oldest = max(latest - 17_000, info.get("oldestLedgerSequence", 0)) + return oldest, latest + + +def fetch_announcements() -> list[dict]: + """ + Fetch all WRAITH_ANNOUNCEMENT events from the announcer contract. + + Returns a list of announcement dicts with keys: + stealth_address, caller, ephemeral_pub_key, metadata, scheme_id + """ + oldest, latest = _current_ledger_range() + + result = _rpc("getEvents", { + "startLedger": oldest, + "filters": [{ + "type": "contract", + "contractIds": [ANNOUNCER_CONTRACT_ID], + "topics": [["*", "*"]], # accept all topics from this contract + }], + "pagination": {"limit": 200}, + }) + + announcements = [] + for event in result.get("events", []): + # Each Soroban event has a `topic` array and a `value` (XDR ScVal) + # The announcer contract emits: + # topic[0] = Symbol("WRAITH_ANNOUNCEMENT") + # topic[1] = scheme_id (u32) + # value = Map { stealth_address, caller, ephemeral_pub_key, metadata } + topics = event.get("topic", []) + if not topics: + continue + + # Decode the XDR value using stellar-sdk + from stellar_sdk import xdr as stellar_xdr + import base64 + + raw_xdr = event.get("value", {}).get("xdr", "") + if not raw_xdr: + continue + + try: + sc_val = stellar_xdr.SCVal.from_xdr(raw_xdr) + except Exception: + continue # malformed event — skip + + # Parse the map entries + ann: dict = {} + if sc_val.type == stellar_xdr.SCValType.SCV_MAP and sc_val.map: + for entry in sc_val.map.sc_map: + key_sym = entry.key.sym.sc_symbol.decode() if entry.key.sym else None + if key_sym == "stealth_address": + ann["stealth_address"] = entry.val.address.account_id # G... address + elif key_sym == "caller": + ann["caller"] = entry.val.address.account_id + elif key_sym == "ephemeral_pub_key": + ann["ephemeral_pub_key"] = entry.val.bytes.sc_bytes.hex() + elif key_sym == "metadata": + ann["metadata"] = entry.val.bytes.sc_bytes.hex() + + if {"stealth_address", "ephemeral_pub_key", "metadata"} <= ann.keys(): + announcements.append(ann) + + return announcements +``` + + + Futurenet resets on demand and carries no live Wraith contracts — use the + testnet `ANNOUNCER_CONTRACT_ID` when testing against testnet. + On futurenet you can still exercise the full code path by injecting synthetic + announcements (see Step 6 below). + + + + For high-throughput backends, consider caching the announcement list and + polling only for events newer than the last seen ledger sequence, incrementing + `startLedger` on each poll instead of re-fetching from `oldest`. + + +--- + +## Step 5 — Scan for owned announcements + +```python no-check +import os +from stellar_sdk import Keypair +from wraith_crypto import ( + STEALTH_SIGNING_MESSAGE, + derive_stealth_keys, + scan_announcements, +) + +keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"]) +keys = derive_stealth_keys(keypair.sign(STEALTH_SIGNING_MESSAGE.encode())) + +announcements = fetch_announcements() # from Step 4 + +matches = scan_announcements( + announcements, + viewing_key=keys.viewing_key, + spending_pub_key=keys.spending_pub_key, + spending_scalar=keys.spending_scalar, +) + +for m in matches: + print(f"Found payment at: {m.stealth_address}") + print(f" Private scalar: {hex(m.stealth_private_scalar)}") + # m.stealth_private_scalar lets you sign transactions from this stealth address +``` + +The scanner applies the view-tag prefilter first, so the expensive X25519 operation +runs only for ~1 in 256 non-matching announcements. On a list of 10,000 +announcements you would expect ~39 false-positive ECDH calls before the full +address check eliminates them. + +--- + +## Step 6 — Send a stealth payment + +Sending has three parts: generate a stealth address for the recipient, fund it +(or transfer a token), and publish an announcement on-chain via the Soroban +announcer contract. + +### 6a — Resolve the recipient's meta-address + +```python no-check +from wraith_crypto import decode_meta_address, generate_stealth_address + +# The recipient shares their meta-address — either directly or via a .wraith name. +# For this quickstart we use a literal; in production resolve it from the names contract. +RECIPIENT_META_ADDRESS = "st:xlm:" + +spending_pub, viewing_pub = decode_meta_address(RECIPIENT_META_ADDRESS) +stealth = generate_stealth_address(spending_pub, viewing_pub) + +print("One-time stealth address:", stealth.stealth_address) +print("Ephemeral pub key: ", stealth.ephemeral_pub_key.hex()) +print("View tag: ", stealth.view_tag) +``` + +### 6b — Fund the stealth address and call the announcer + +New Stellar accounts must be activated with `createAccount`. After activation, call +the Soroban announcer contract to publish the ephemeral key and view tag so the +recipient can detect their payment. + +```python no-check +import os +import base64 +from stellar_sdk import ( + Keypair, Network, Server, TransactionBuilder, Asset, + Operation, +) +from stellar_sdk.soroban_rpc import SorobanServer +from stellar_sdk import xdr as stellar_xdr + +HORIZON_URL = "https://horizon-testnet.stellar.org" +SOROBAN_URL = "https://soroban-testnet.stellar.org" +NETWORK_PASS = Network.TESTNET_NETWORK_PASSPHRASE +AMOUNT_XLM = "10" # minimum to activate the new stealth account + +sender_keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"]) + +horizon = Server(HORIZON_URL) +soroban = SorobanServer(SOROBAN_URL) + +# ------------------------------------------------------------------ +# Part 1: createAccount — activate the stealth address +# ------------------------------------------------------------------ +sender_account = horizon.load_account(sender_keypair.public_key) + +activate_tx = ( + TransactionBuilder( + source_account=sender_account, + network_passphrase=NETWORK_PASS, + base_fee=1000, + ) + .append_create_account_op( + destination=stealth.stealth_address, + starting_balance=AMOUNT_XLM, + ) + .set_timeout(30) + .build() +) +activate_tx.sign(sender_keypair) +horizon.submit_transaction(activate_tx) +print("Stealth account activated:", stealth.stealth_address) + +# ------------------------------------------------------------------ +# Part 2: invoke stealth-announcer::announce +# ------------------------------------------------------------------ +# Build the announcement metadata: first byte = view_tag +metadata_bytes = bytes([stealth.view_tag]) + +# Construct the Soroban invocation arguments +# announce(scheme_id: u32, stealth_address: Address, +# ephemeral_pub_key: Bytes, metadata: Bytes) +from stellar_sdk import scval + +invoke_args = [ + scval.to_uint32(1), # scheme_id + scval.to_address(stealth.stealth_address), # stealth address + scval.to_bytes(stealth.ephemeral_pub_key), # ephemeral pub key + scval.to_bytes(metadata_bytes), # metadata (view tag) +] + +sender_account = horizon.load_account(sender_keypair.public_key) # refresh sequence + +announce_tx = ( + TransactionBuilder( + source_account=sender_account, + network_passphrase=NETWORK_PASS, + base_fee=1000, + ) + .append_invoke_contract_function_op( + contract_id=ANNOUNCER_CONTRACT_ID, + function_name="announce", + parameters=invoke_args, + ) + .set_timeout(30) + .build() +) + +# Simulate to get resource footprint before signing +sim = soroban.simulate_transaction(announce_tx) +announce_tx = sim.transaction # updated with resource fee +announce_tx.sign(sender_keypair) + +response = soroban.send_transaction(announce_tx) +print("Announcement tx hash:", response.hash) +``` + + + On futurenet the Wraith contracts are not deployed, so the announcer call will + fail. Run Step 6 against **testnet** using the testnet `ANNOUNCER_CONTRACT_ID` + and the testnet Horizon/Soroban URLs from [Stellar Networks](/reference/stellar-networks). + + +--- + +## Step 7 — Spending from a stealth address + +To spend funds held at a stealth address you need the private scalar derived +during scanning. Because stealth scalars are not raw seeds, the standard +`Keypair.from_raw_ed25519_seed()` path does not work. Use `signWithScalar` from +`wraith_crypto` below. + +```python no-check +# Add to wraith_crypto.py + +def sign_with_scalar( + message: bytes, + scalar: int, + pub_key_bytes: bytes, +) -> bytes: + """ + Sign `message` using a raw ed25519 scalar (not a seed). + + Required because stealth scalars are (spending_scalar + h_scalar) mod L, + which is not necessarily clamped and cannot be fed to standard seed-based + signing APIs. + + Compatible with RFC 8032 ed25519 — the signature will verify against + `pub_key_bytes` using any standard ed25519 verifier. + """ + import struct + + # Encode scalar as little-endian 32 bytes + s_bytes = scalar.to_bytes(32, "little") + + # Deterministic nonce: SHA-512(scalar_bytes || message), lower half, clamped + nonce_hash = _sha512(s_bytes + message) + r_bytes = bytearray(nonce_hash[:32]) + _clamp_scalar(r_bytes) + r = int.from_bytes(r_bytes, "little") + + # R = r * G + r_key = Ed25519PrivateKey.from_private_bytes(bytes(r_bytes)) + R_bytes = r_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + + # S = (r + SHA-512(R || pub_key || message) * s) mod L + k_hash = _sha512(R_bytes + pub_key_bytes + message) + k = int.from_bytes(k_hash, "little") % L + S = (r + k * scalar) % L + + return R_bytes + S.to_bytes(32, "little") +``` + +Use it when building a withdrawal transaction from a matched stealth address: + +```python no-check +from wraith_crypto import sign_with_scalar + +# `match` is a MatchedAnnouncement from scan_announcements() +stealth_keypair = Keypair.from_raw_ed25519_public_key(match.stealth_pub_key_bytes) +stealth_account = horizon.load_account(stealth_keypair.public_key) + +# Build a payment back to your primary account +withdrawal_tx = ( + TransactionBuilder( + source_account=stealth_account, + network_passphrase=NETWORK_PASS, + base_fee=1000, + ) + .append_payment_op( + destination=sender_keypair.public_key, + asset=Asset.native(), + amount="9", # leave ~1 XLM for fees / base reserve + ) + .set_timeout(30) + .build() +) + +# Sign with raw scalar — not with a Keypair seed +tx_hash = withdrawal_tx.hash() +sig_bytes = sign_with_scalar(tx_hash, match.stealth_private_scalar, match.stealth_pub_key_bytes) + +withdrawal_tx.transaction.add_signature( + network_id=Network.TESTNET_NETWORK_PASSPHRASE, + public_key=stealth_keypair.public_key, + signature=sig_bytes, +) +response = horizon.submit_transaction(withdrawal_tx) +print("Withdrawal tx hash:", response["hash"]) +``` + + + For privacy, space withdrawals at least one hour apart and avoid uniform round + amounts. See [Privacy Best Practices](/guides/privacy-best-practices) for the + full scoring algorithm. + + +--- + +## Boundary: what lives in `wraith_crypto.py` vs. `stellar-sdk` + +| Responsibility | Handled by | +|---|---| +| SHA-256 domain separation | `wraith_crypto.py` | +| Scalar clamping, ed25519 point addition | `wraith_crypto.py` | +| X25519 ECDH (via `cryptography`) | `wraith_crypto.py` | +| StrKey encoding (`G...` addresses) | `stellar-sdk` | +| Transaction building, XDR encoding | `stellar-sdk` | +| Soroban RPC calls | `stellar-sdk` + `httpx` | +| Account management, sequence numbers | `stellar-sdk` Horizon | + +The `wraith_crypto.py` file is intentionally kept free of Soroban or Horizon +concerns — it handles only the cryptographic layer so it stays testable in isolation. + +--- + +## Environment variables + +```bash no-check +# .env — never commit this file +STELLAR_SECRET=S...your-secret-key + +# Target testnet for live Wraith contracts +STELLAR_NETWORK=testnet +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_ANNOUNCER_CONTRACT_ID=CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL +``` + +--- + +## Next steps + + + + Full reference for the TypeScript SDK counterparts — useful if your backend calls a Node.js service alongside Python + + + Passphrase, RPC URLs, contract IDs, and Friendbot for testnet, futurenet, and mainnet + + + Design rationale behind X25519 ECDH, view-tag derivation, and RFC 8032 scalar signing + + + Withdrawal timing, amount rounding, and the full privacy score algorithm + + diff --git a/sdk/overview.mdx b/sdk/overview.mdx index c7c644d..bd815c1 100644 --- a/sdk/overview.mdx +++ b/sdk/overview.mdx @@ -171,4 +171,12 @@ 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 + +## Using Wraith from Python + +If your backend runs Python rather than Node.js, the TypeScript SDK is not required. +The [Python Quickstart](/guides/quickstarts/python) shows how to derive stealth keys, +fetch announcements from the Soroban RPC, scan for owned payments, and send a stealth +transfer — all with `stellar-sdk` and a small inline crypto utility, no TypeScript +toolchain needed. - [Rust Quickstart](/guides/quickstarts/rust) — build a Rust backend or CLI that talks to the Stellar contracts directly