diff --git a/docs.json b/docs.json
index ab3d6de..bc8adc2 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",
@@ -167,6 +170,7 @@
"guides/stellar-custom-assets",
"guides/stellar/stellar-liquidity-pool-swap",
"guides/stellar/stellar-path-payment",
+ "guides/stellar/sponsored-batch-send",
"guides/stellar/passkey-signing",
"guides/stellar/stellar-quickstart",
"guides/stellar/wraith-names-lifecycle",
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/guides/stellar-fees.mdx b/guides/stellar-fees.mdx
index 21ca13e..ba289cf 100644
--- a/guides/stellar-fees.mdx
+++ b/guides/stellar-fees.mdx
@@ -172,6 +172,7 @@ To ensure smooth operations in production apps, implement these heuristics:
## See Also
+- [Sponsored Batch Send](/guides/stellar/sponsored-batch-send) — paymaster covers 1 XLM reserves for every new stealth recipient; ideal for airdrops and payroll
- [Reflector Oracle Integration](/guides/integrations/reflector) — plug Reflector into Wraith flows for inline fiat conversion alongside fee estimation
- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production recipes for Stellar agents
- [Stellar Payment Links](/guides/stellar-payment-links) — generate payment links with fiat amounts
diff --git a/guides/stellar/sponsored-batch-send.mdx b/guides/stellar/sponsored-batch-send.mdx
new file mode 100644
index 0000000..3cfb2a5
--- /dev/null
+++ b/guides/stellar/sponsored-batch-send.mdx
@@ -0,0 +1,824 @@
+---
+title: "Sponsored Batch Send: Paymaster Covers Reserves"
+description: "Use BeginSponsoringFutureReserves with Wraith batch send so a single paymaster account pays the 1 XLM minimum reserve for every new stealth recipient — ideal for airdrops and payroll."
+keywords: "Stellar, soroban, sponsored reserves, BeginSponsoringFutureReserves, EndSponsoringFutureReserves, batch send, airdrop, payroll, stealth address, paymaster, XLM reserve"
+---
+
+When you send tokens to a brand-new stealth address on Stellar, the recipient account must be
+activated with a **1 XLM minimum base reserve**. For a single payment this is a minor inconvenience.
+For an airdrop to 100 recipients it becomes a coordination problem: who carries those 100 XLM, and
+how is the cost tracked and recovered?
+
+Stellar's **sponsored reserves** feature solves this cleanly. A paymaster account wraps the entire
+batch in `BeginSponsoringFutureReserves` / `EndSponsoringFutureReserves` operations, taking
+responsibility for every new account's reserve in a single atomic transaction. The paymaster can
+recoup the cost later or absorb it as an operational expense — either way, recipients need zero XLM
+to start spending.
+
+> **Prerequisite reading:** Understand how stealth addresses, batch send, and fee-bump transactions
+> work before continuing.
+> - [Stellar Quickstart](/guides/stellar/stellar-quickstart) — stealth keys, meta-addresses, scanning
+> - [Stellar Fee Estimation & Budgeting](/guides/stellar-fees) — fee-bump sponsorship and cost model
+
+---
+
+## How Stellar Sponsored Reserves Work
+
+Every Stellar account requires a minimum XLM balance — **1 XLM base reserve** — before it can hold
+any balance. Normally the sender pays this by funding the account with `createAccount`. With sponsored
+reserves the paymaster pays instead:
+
+```
+BeginSponsoringFutureReserves(sponsored = recipient_address)
+ └─ CreateAccount(destination = recipient_address, startingBalance = "0")
+EndSponsoringFutureReserves(sponsored = recipient_address)
+```
+
+The `BeginSponsoringFutureReserves` operation designates a sponsor for any ledger entries created
+between it and the matching `EndSponsoringFutureReserves`. The `CreateAccount` with
+`startingBalance = "0"` activates the account while the sponsor covers the 1 XLM reserve obligation.
+
+### Reserve vs. Fee — What the Paymaster Actually Pays
+
+| Cost | Who pays | When refunded |
+|---|---|---|
+| **1 XLM base reserve** per new account | Sponsor (paymaster) | When the sponsored account is merged with `AccountMerge` |
+| **Trustline reserve** (0.5 XLM per non-native asset) | Sponsor or recipient, depending on who calls `ChangeTrust` under sponsorship | Same: on trustline removal |
+| **Transaction inclusion fee** | Transaction source account (or fee-bump payer) | Never — burned by network |
+| **Soroban resource fee** | Transaction source account (or fee-bump payer) | Never — burned by network |
+
+Key insight: **reserves are a loan, not a burn.** The paymaster's 100 XLM for 100 accounts is locked
+on-chain but returned in full when every sponsored account is eventually merged. Fees, by contrast,
+are gone permanently.
+
+### When Sponsorship Is Refunded
+
+A sponsor recovers its locked XLM when the sponsored account calls `AccountMerge` and designates a
+merge destination. The `AccountMerge` operation transfers the account's balance to the destination
+and unlinks all sponsored ledger entries, returning the reserve obligation to the sponsor's available
+balance.
+
+Recipients can do this after spending stealth funds: sweep the token balance out, then merge the
+empty stealth account, returning the 1 XLM reserve to the paymaster.
+
+---
+
+## Transaction Structure for a Sponsored Batch
+
+Each recipient in the batch requires a sponsor triplet: begin-sponsoring → create-account →
+end-sponsoring. These are interleaved with the stealth payment and announcement operations.
+
+For N recipients the operation sequence inside one transaction is:
+
+```
+[For each recipient i = 1..N]
+ BeginSponsoringFutureReserves(sponsored = stealth_address_i)
+ CreateAccount(destination = stealth_address_i, startingBalance = "0")
+ EndSponsoringFutureReserves(sponsored = stealth_address_i)
+ Payment(destination = stealth_address_i, asset = TOKEN, amount = amount_i)
+
+[Once, after all recipients]
+ InvokeContractFunction(stealth-sender::batch_send, announcements[])
+```
+
+Stellar allows up to **100 operations per transaction**, so the practical limit per transaction is:
+- 3 sponsor operations × N recipients + N payment operations + 1 batch announce = 4N + 1 operations
+- Solving for the max: `4N + 1 ≤ 100` → **N ≤ 24 recipients per transaction**
+
+For larger batches (e.g. 100 recipients) split into chunks of 24 and submit multiple transactions,
+each wrapped in its own fee-bump if desired.
+
+
+ If recipients already have funded accounts (returning airdrop participants, payroll employees with
+ existing wallets) you can skip the sponsorship triplet for those addresses. Check existence with
+ `server.loadAccount()` before building the transaction and only add sponsorship for new accounts.
+
+
+---
+
+## Prerequisites
+
+```bash
+npm install @wraith-protocol/sdk @stellar/stellar-sdk
+```
+
+Environment variables used in the examples below:
+
+```bash
+SPONSOR_SECRET=S... # Paymaster secret key — must hold enough XLM for reserves
+SENDER_SECRET=S... # Token sender secret key — holds the tokens to distribute
+STELLAR_RPC_URL=https://soroban-testnet.stellar.org
+STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
+```
+
+The sponsor and sender can be the **same account** for simplicity, or separate accounts for cleaner
+accounting. The examples keep them separate so each role is explicit.
+
+---
+
+## Step 1 — Derive Stealth Addresses for All Recipients
+
+Before building the transaction you need a stealth address and ephemeral key for each recipient.
+
+```typescript
+import {
+ decodeStealthMetaAddress,
+ generateStealthAddress,
+} from "@wraith-protocol/sdk/chains/stellar";
+
+// One meta-address per recipient (loaded from your recipients list)
+const recipientMetaAddresses: string[] = [
+ "st:xlm:aabbcc...001122...", // recipient 1
+ "st:xlm:ddeeff...334455...", // recipient 2
+ // ...up to 24 per transaction chunk
+];
+
+interface StealthRecipient {
+ stealthAddress: string;
+ ephemeralPubKey: Uint8Array;
+ viewTag: number;
+}
+
+function deriveRecipients(metaAddresses: string[]): StealthRecipient[] {
+ return metaAddresses.map((meta) => {
+ const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(meta);
+ const { stealthAddress, ephemeralPubKey, viewTag } = generateStealthAddress(
+ spendingPubKey,
+ viewingPubKey,
+ );
+ return { stealthAddress, ephemeralPubKey, viewTag };
+ });
+}
+```
+
+---
+
+## Step 2 — Check Which Accounts Already Exist
+
+Skip sponsorship for accounts that are already funded to avoid wasting operations.
+
+```typescript
+import { Horizon } from "@stellar/stellar-sdk";
+
+const horizon = new Horizon.Server(
+ process.env.STELLAR_HORIZON_URL ?? "https://horizon-testnet.stellar.org",
+);
+
+async function filterNewAccounts(
+ recipients: StealthRecipient[],
+): Promise> {
+ const newAccounts = new Set();
+
+ await Promise.all(
+ recipients.map(async ({ stealthAddress }) => {
+ try {
+ await horizon.loadAccount(stealthAddress);
+ // Account exists — no sponsorship needed
+ } catch {
+ // loadAccount throws when the account is not found
+ newAccounts.add(stealthAddress);
+ }
+ }),
+ );
+
+ return newAccounts;
+}
+```
+
+---
+
+## Step 3 — Build the Sponsored Batch Transaction
+
+This is the core of the pattern. For each new account we sandwich a `createAccount` between
+`BeginSponsoringFutureReserves` and `EndSponsoringFutureReserves`, then add a payment, and finally
+invoke `batch_send` once for all the announcements.
+
+```typescript
+import {
+ Keypair,
+ Networks,
+ Operation,
+ TransactionBuilder,
+ Asset,
+} from "@stellar/stellar-sdk";
+import { rpc as SorobanRpc, xdr, Address, nativeToScVal } from "@stellar/stellar-sdk";
+import {
+ getDeployment,
+ createAnnounceOperation,
+ bytesToHex,
+} from "@wraith-protocol/sdk/chains/stellar";
+
+const NETWORK_PASSPHRASE = Networks.TESTNET;
+const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
+const SEND_AMOUNT = "10"; // Each recipient gets 10 USDC
+
+async function buildSponsoredBatch(
+ recipients: StealthRecipient[],
+ newAccounts: Set,
+): Promise> {
+ const sponsorKeypair = Keypair.fromSecret(process.env.SPONSOR_SECRET!);
+ const senderKeypair = Keypair.fromSecret(process.env.SENDER_SECRET!);
+
+ // Load sponsor account for sequence number (sponsor is the transaction source)
+ const sponsorAccount = await horizon.loadAccount(sponsorKeypair.publicKey());
+
+ const deployment = getDeployment("stellar");
+ const usdcAsset = new Asset("USDC", USDC_ISSUER);
+
+ const builder = new TransactionBuilder(sponsorAccount, {
+ fee: "1000000", // High fee ceiling — prepareTransaction will tighten this
+ networkPassphrase: NETWORK_PASSPHRASE,
+ });
+
+ // ── Sponsor + payment operations for each recipient ──────────────────────
+ for (const { stealthAddress } of recipients) {
+ if (newAccounts.has(stealthAddress)) {
+ // 1. Tell the network this account's reserves will be sponsored
+ builder.addOperation(
+ Operation.beginSponsoringFutureReserves({
+ sponsoredId: stealthAddress,
+ source: sponsorKeypair.publicKey(),
+ }),
+ );
+
+ // 2. Activate the account — startingBalance "0" is valid because the
+ // sponsor covers the 1 XLM reserve obligation
+ builder.addOperation(
+ Operation.createAccount({
+ destination: stealthAddress,
+ startingBalance: "0",
+ source: sponsorKeypair.publicKey(),
+ }),
+ );
+
+ // 3. End the sponsorship window for this specific account.
+ // The `source` here must be the sponsored account itself.
+ builder.addOperation(
+ Operation.endSponsoringFutureReserves({
+ source: stealthAddress,
+ }),
+ );
+ }
+
+ // 4. Send USDC to the stealth address (from the sender account, not the sponsor)
+ builder.addOperation(
+ Operation.payment({
+ destination: stealthAddress,
+ asset: usdcAsset,
+ amount: SEND_AMOUNT,
+ source: senderKeypair.publicKey(),
+ }),
+ );
+ }
+
+ // ── Single batch announce operation for all recipients ───────────────────
+ const announceOp = createAnnounceOperation({
+ contractId: deployment.contracts.announcer,
+ announcements: recipients.map(({ stealthAddress, ephemeralPubKey, viewTag }) => ({
+ stealthAddress,
+ ephemeralPubKey,
+ viewTag,
+ asset: "USDC",
+ amount: SEND_AMOUNT,
+ })),
+ source: senderKeypair.publicKey(),
+ });
+
+ builder.addOperation(announceOp);
+
+ return builder.setTimeout(120).build();
+}
+```
+
+---
+
+## Step 4 — Simulate, Sign, and Submit
+
+Because the transaction includes a Soroban invocation the fees must be calculated via simulation
+before signing. `prepareTransaction` from the Soroban RPC handles this.
+
+```typescript
+import { rpc as SorobanRpc } from "@stellar/stellar-sdk";
+
+const soroban = new SorobanRpc.Server(
+ process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org",
+);
+
+async function submitSponsoredBatch(
+ recipients: StealthRecipient[],
+ newAccounts: Set,
+): Promise {
+ const sponsorKeypair = Keypair.fromSecret(process.env.SPONSOR_SECRET!);
+ const senderKeypair = Keypair.fromSecret(process.env.SENDER_SECRET!);
+
+ // 1. Build the unsigned transaction
+ const tx = await buildSponsoredBatch(recipients, newAccounts);
+
+ // 2. Simulate to calculate Soroban resource fees
+ const preparedTx = await soroban.prepareTransaction(tx);
+
+ // 3. Sign with BOTH the sponsor (transaction source) and the sender
+ // (source of payment and announce operations).
+ // Each new stealth address must also authorize EndSponsoringFutureReserves —
+ // but since the stealth address key is controlled by the sender SDK
+ // (it was just derived), we can sign on its behalf here.
+ preparedTx.sign(sponsorKeypair);
+ preparedTx.sign(senderKeypair);
+
+ // Sign each new stealth address key for EndSponsoringFutureReserves
+ for (const { stealthAddress } of recipients) {
+ if (newAccounts.has(stealthAddress)) {
+ // In a real airdrop the stealth private key is derived from the ephemeral scalar.
+ // Here we illustrate that the sender SDK controls these keys at send time.
+ // See deriveStealthPrivateScalar in @wraith-protocol/sdk/chains/stellar.
+ const stealthKeypair = Keypair.fromPublicKey(stealthAddress);
+ // NOTE: sign with the actual derived scalar — shown in the full example below
+ void stealthKeypair; // placeholder — see Step 5 for the full signing loop
+ }
+ }
+
+ // 4. Submit
+ const result = await soroban.sendTransaction(preparedTx);
+
+ if (result.status === "ERROR") {
+ throw new Error(`Transaction failed: ${JSON.stringify(result.errorResult)}`);
+ }
+
+ // 5. Poll for confirmation
+ let getResult = await soroban.getTransaction(result.hash);
+ while (
+ getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ getResult = await soroban.getTransaction(result.hash);
+ }
+
+ if (getResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
+ throw new Error(`Transaction finalized with FAILED status: ${result.hash}`);
+ }
+
+ console.log("Batch confirmed:", result.hash);
+ return result.hash;
+}
+```
+
+---
+
+## Step 5 — Full Airdrop Example (100 Recipients)
+
+This self-contained example ties everything together. It processes 100 recipients in chunks of 24,
+derives stealth addresses, checks for existing accounts, builds the sponsored batch, signs
+`EndSponsoringFutureReserves` with derived stealth scalars, and submits each chunk.
+
+```typescript
+import {
+ Keypair,
+ Networks,
+ Operation,
+ TransactionBuilder,
+ Asset,
+} from "@stellar/stellar-sdk";
+import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";
+import {
+ decodeStealthMetaAddress,
+ generateStealthAddress,
+ deriveStealthPrivateScalar,
+ getDeployment,
+ createAnnounceOperation,
+} from "@wraith-protocol/sdk/chains/stellar";
+
+// ── Configuration ─────────────────────────────────────────────────────────────
+
+const HORIZON_URL = "https://horizon-testnet.stellar.org";
+const RPC_URL = "https://soroban-testnet.stellar.org";
+const PASSPHRASE = Networks.TESTNET;
+
+// NOTE: swap these for mainnet when ready:
+// const HORIZON_URL = "https://horizon.stellar.org";
+// const RPC_URL = "https://rpc.mainnet.stellar.org"; // your provider
+// const PASSPHRASE = Networks.PUBLIC;
+
+const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
+const SEND_AMOUNT = "10"; // USDC per recipient
+const CHUNK_SIZE = 24; // max recipients per transaction (4N+1 ≤ 100 ops)
+
+const horizon = new Horizon.Server(HORIZON_URL);
+const soroban = new SorobanRpc.Server(RPC_URL);
+const deployment = getDeployment("stellar");
+const usdcAsset = new Asset("USDC", USDC_ISSUER);
+
+// ── Keypairs ──────────────────────────────────────────────────────────────────
+
+const sponsorKeypair = Keypair.fromSecret(process.env.SPONSOR_SECRET!);
+const senderKeypair = Keypair.fromSecret(process.env.SENDER_SECRET!);
+
+// ── Recipient list (100 meta-addresses loaded from your data source) ──────────
+
+const ALL_META_ADDRESSES: string[] = Array.from(
+ { length: 100 },
+ (_, i) => `st:xlm:placeholder${i.toString().padStart(3, "0")}`, // replace with real meta-addresses
+);
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+async function accountExists(address: string): Promise {
+ try {
+ await horizon.loadAccount(address);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function chunkArray(arr: T[], size: number): T[][] {
+ const chunks: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) {
+ chunks.push(arr.slice(i, i + size));
+ }
+ return chunks;
+}
+
+// ── Per-chunk transaction builder ─────────────────────────────────────────────
+
+interface ChunkRecipient {
+ metaAddress: string;
+ stealthAddress: string;
+ ephemeralPubKey: Uint8Array;
+ ephemeralPrivKey: Uint8Array; // needed to sign EndSponsoringFutureReserves
+ viewTag: number;
+ isNew: boolean;
+}
+
+async function prepareChunk(metaAddresses: string[]): Promise {
+ return Promise.all(
+ metaAddresses.map(async (meta) => {
+ const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(meta);
+ const { stealthAddress, ephemeralPubKey, ephemeralPrivKey, viewTag } =
+ generateStealthAddress(spendingPubKey, viewingPubKey);
+ const isNew = !(await accountExists(stealthAddress));
+ return { metaAddress: meta, stealthAddress, ephemeralPubKey, ephemeralPrivKey, viewTag, isNew };
+ }),
+ );
+}
+
+async function processChunk(chunk: string[], chunkIndex: number): Promise {
+ console.log(`\nChunk ${chunkIndex + 1}: processing ${chunk.length} recipients…`);
+
+ // 1. Derive stealth addresses and check existence in parallel
+ const recipients = await prepareChunk(chunk);
+ const newCount = recipients.filter((r) => r.isNew).length;
+ console.log(` ${newCount} new accounts need sponsorship, ${chunk.length - newCount} existing`);
+
+ // 2. Load sponsor account for sequence number
+ const sponsorAccount = await horizon.loadAccount(sponsorKeypair.publicKey());
+
+ const builder = new TransactionBuilder(sponsorAccount, {
+ fee: "1000000",
+ networkPassphrase: PASSPHRASE,
+ });
+
+ // 3. Add sponsor + payment ops for each recipient
+ for (const r of recipients) {
+ if (r.isNew) {
+ builder.addOperation(
+ Operation.beginSponsoringFutureReserves({
+ sponsoredId: r.stealthAddress,
+ source: sponsorKeypair.publicKey(),
+ }),
+ );
+ builder.addOperation(
+ Operation.createAccount({
+ destination: r.stealthAddress,
+ startingBalance: "0", // sponsor covers the 1 XLM reserve
+ source: sponsorKeypair.publicKey(),
+ }),
+ );
+ builder.addOperation(
+ Operation.endSponsoringFutureReserves({
+ source: r.stealthAddress, // must be signed by the sponsored account
+ }),
+ );
+ }
+
+ builder.addOperation(
+ Operation.payment({
+ destination: r.stealthAddress,
+ asset: usdcAsset,
+ amount: SEND_AMOUNT,
+ source: senderKeypair.publicKey(),
+ }),
+ );
+ }
+
+ // 4. Single batch announce for all announcements in this chunk
+ builder.addOperation(
+ createAnnounceOperation({
+ contractId: deployment.contracts.announcer,
+ announcements: recipients.map((r) => ({
+ stealthAddress: r.stealthAddress,
+ ephemeralPubKey: r.ephemeralPubKey,
+ viewTag: r.viewTag,
+ asset: "USDC",
+ amount: SEND_AMOUNT,
+ })),
+ source: senderKeypair.publicKey(),
+ }),
+ );
+
+ const tx = builder.setTimeout(180).build();
+
+ // 5. Simulate and prepare Soroban fees
+ const preparedTx = await soroban.prepareTransaction(tx);
+
+ // 6. Sign: sponsor + sender + each new stealth address
+ // (EndSponsoringFutureReserves requires a signature from the sponsored account)
+ preparedTx.sign(sponsorKeypair);
+ preparedTx.sign(senderKeypair);
+
+ for (const r of recipients) {
+ if (r.isNew) {
+ // The ephemeral private key controls the stealth address at send time.
+ // We derive the stealth Keypair from the scalar to sign EndSponsoringFutureReserves.
+ const stealthScalar = deriveStealthPrivateScalar(
+ r.ephemeralPrivKey,
+ r.ephemeralPubKey,
+ r.viewTag,
+ );
+ // Convert scalar to a Stellar Keypair via raw seed bytes
+ const stealthKeypair = Keypair.fromRawEd25519Seed(
+ Buffer.from(stealthScalar.toString(16).padStart(64, "0"), "hex"),
+ );
+ preparedTx.sign(stealthKeypair);
+ }
+ }
+
+ // 7. Submit and poll
+ const sendResult = await soroban.sendTransaction(preparedTx);
+ if (sendResult.status === "ERROR") {
+ throw new Error(
+ `Chunk ${chunkIndex + 1} submission error: ${JSON.stringify(sendResult.errorResult)}`,
+ );
+ }
+
+ let pollResult = await soroban.getTransaction(sendResult.hash);
+ while (pollResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) {
+ await new Promise((r) => setTimeout(r, 2000));
+ pollResult = await soroban.getTransaction(sendResult.hash);
+ }
+
+ if (pollResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
+ throw new Error(`Chunk ${chunkIndex + 1} failed on-chain: ${sendResult.hash}`);
+ }
+
+ console.log(` ✓ Chunk ${chunkIndex + 1} confirmed: ${sendResult.hash}`);
+ return sendResult.hash;
+}
+
+// ── Main entry point ──────────────────────────────────────────────────────────
+
+async function runAirdrop(): Promise {
+ const chunks = chunkArray(ALL_META_ADDRESSES, CHUNK_SIZE);
+ console.log(
+ `Starting airdrop: ${ALL_META_ADDRESSES.length} recipients → ${chunks.length} transactions`,
+ );
+
+ const hashes: string[] = [];
+ for (let i = 0; i < chunks.length; i++) {
+ const hash = await processChunk(chunks[i]!, i);
+ hashes.push(hash);
+ }
+
+ console.log(`\nAirdrop complete. Transaction hashes:\n${hashes.join("\n")}`);
+}
+
+runAirdrop().catch((err) => {
+ console.error("Airdrop failed:", err);
+ process.exit(1);
+});
+```
+
+---
+
+## Cost Model for 100 Recipients
+
+Assuming all 100 accounts are new (worst case):
+
+| Line item | Calculation | Total (stroops) | Total (XLM) |
+|---|---|---:|---:|
+| Sponsor reserves | 100 × 1 XLM (locked, refundable) | — | **100 XLM locked** |
+| Inclusion fee (per chunk, 5 chunks of 20) | 5 × (4×20+1) × 100 | 40,500 | ~0.00405 XLM |
+| Soroban resource fee (batch announce ×5) | 5 × ~110,000 | 550,000 | ~0.055 XLM |
+| **Total burned (non-refundable)** | | **590,500** | **~0.059 XLM** |
+| **Total locked (refundable on merge)** | | — | **100 XLM** |
+
+Compare with the naive approach (100 separate `createAccount` + `send` transactions): the
+non-refundable fee cost is roughly the same, but sponsorship consolidates accounting and removes
+the requirement for each recipient to hold XLM before spending.
+
+See [Stellar Fee Estimation & Budgeting](/guides/stellar-fees) for per-operation baselines and
+Soroban resource fee formulas.
+
+---
+
+## Failure Modes
+
+### Sponsor Account Authorization Revocation (`op_not_authorized`)
+
+**Cause:** The sponsor's account has had its signing authority changed (e.g. via `SetOptions` with a
+new signer or threshold change) between building and submitting the transaction, or the sponsor
+account itself has been flagged by an asset issuer.
+
+**Detection:** The transaction result will show `op_not_authorized` on one of the
+`BeginSponsoringFutureReserves` operations.
+
+**Recovery:**
+
+```typescript
+// Before building the batch, verify the sponsor account is in good standing
+async function verifySponsor(sponsorAddress: string): Promise {
+ const account = await horizon.loadAccount(sponsorAddress);
+
+ // Check the account is not flagged
+ if ((account.flags as { auth_revocable?: boolean }).auth_revocable) {
+ throw new Error("Sponsor account has auth_revocable set — reserved entries may be revoked.");
+ }
+
+ // Verify available balance covers reserves
+ const xlmBalance = account.balances.find((b) => b.asset_type === "native");
+ if (!xlmBalance) throw new Error("Sponsor has no XLM balance entry.");
+
+ const available = parseFloat(xlmBalance.balance) - parseFloat((account as unknown as { min_balance: string }).min_balance ?? "0");
+ const reservesNeeded = 100; // 1 XLM × 100 recipients
+ if (available < reservesNeeded) {
+ throw new Error(
+ `Sponsor has ${available} XLM available but needs ${reservesNeeded} XLM for reserves.`,
+ );
+ }
+
+ console.log(`Sponsor verified. Available: ${available} XLM`);
+}
+```
+
+### Insufficient Sponsor Reserves (`op_low_reserve`)
+
+**Cause:** The sponsor account's available balance (total balance minus its own minimum reserve) is
+less than 1 XLM × number of new accounts being sponsored in the transaction.
+
+**Calculation:** The sponsor needs:
+
+```
+available_xlm ≥ (new_accounts_in_chunk × 1 XLM) + own_base_reserve (1 XLM) + fee_buffer (1 XLM)
+```
+
+**Recovery:** Fund the sponsor account before the batch run. A sponsor for 100 recipients should
+hold at least 103 XLM (100 reserves + 1 base reserve + 2 XLM fee buffer).
+
+```typescript
+async function checkSponsorCapacity(
+ sponsorAddress: string,
+ newAccountCount: number,
+): Promise {
+ const account = await horizon.loadAccount(sponsorAddress);
+ const xlmEntry = account.balances.find((b) => b.asset_type === "native");
+ if (!xlmEntry) throw new Error("No XLM balance.");
+
+ const totalBalance = parseFloat(xlmEntry.balance);
+ // Base reserve = 1 XLM + 0.5 XLM per existing sub-entry
+ const subEntries = (account as unknown as { subentry_count: number }).subentry_count ?? 0;
+ const ownReserve = 1 + subEntries * 0.5;
+ const feeBuffer = 2;
+ const reservesRequired = newAccountCount * 1;
+ const required = ownReserve + feeBuffer + reservesRequired;
+
+ if (totalBalance < required) {
+ throw new Error(
+ `Sponsor needs ${required} XLM but only has ${totalBalance} XLM. ` +
+ `Fund with at least ${(required - totalBalance).toFixed(2)} more XLM.`,
+ );
+ }
+
+ console.log(
+ `Sponsor capacity OK: ${totalBalance} XLM available, ${required} XLM required.`,
+ );
+}
+```
+
+### `EndSponsoringFutureReserves` Signature Missing (`tx_bad_auth`)
+
+**Cause:** `EndSponsoringFutureReserves` must be signed by the sponsored account (the new stealth
+address). If the signing loop is missing or uses the wrong key, the transaction fails auth checks.
+
+**Fix:** Ensure every new stealth address signs the transaction. The stealth address private key
+is derived from `deriveStealthPrivateScalar` using the ephemeral key generated at send time.
+
+### Token Transfer Fails After Successful Account Creation (`op_no_trust`)
+
+**Cause:** When sending non-native assets (USDC, etc.) to a brand-new stealth account, the account
+needs a trustline for the asset before it can receive it. `CreateAccount` alone does not establish
+trustlines.
+
+**Fix:** Add a `ChangeTrust` operation inside the sponsorship window so the trustline reserve is
+also sponsored:
+
+```typescript
+// Add after CreateAccount, before EndSponsoringFutureReserves
+builder.addOperation(
+ Operation.changeTrust({
+ asset: usdcAsset,
+ limit: "1000000",
+ source: stealthAddress, // the new account establishes its own trustline
+ }),
+);
+// EndSponsoringFutureReserves will cover the 0.5 XLM trustline reserve too
+```
+
+This increases the sponsor's reserve obligation per account from **1 XLM to 1.5 XLM**.
+
+---
+
+## Recovering Sponsored Reserves
+
+When a stealth address holder has spent their tokens and wants to release the paymaster's locked XLM,
+they call `AccountMerge`:
+
+```typescript
+import { Keypair, Operation, TransactionBuilder, Networks } from "@stellar/stellar-sdk";
+import { deriveStealthPrivateScalar } from "@wraith-protocol/sdk/chains/stellar";
+
+async function mergeStealthAccount(
+ stealthPrivScalar: bigint,
+ mergeDestination: string, // typically the paymaster or recipient's main wallet
+): Promise {
+ const stealthKeypair = Keypair.fromRawEd25519Seed(
+ Buffer.from(stealthPrivScalar.toString(16).padStart(64, "0"), "hex"),
+ );
+
+ const stealthAccount = await horizon.loadAccount(stealthKeypair.publicKey());
+
+ const tx = new TransactionBuilder(stealthAccount, {
+ fee: "100",
+ networkPassphrase: Networks.TESTNET,
+ })
+ .addOperation(
+ Operation.accountMerge({
+ destination: mergeDestination,
+ }),
+ )
+ .setTimeout(60)
+ .build();
+
+ tx.sign(stealthKeypair);
+
+ const result = await horizon.submitTransaction(tx);
+ console.log("Account merged, reserves returned. Hash:", result.hash);
+ return result.hash;
+}
+```
+
+After the merge, the 1 XLM (or 1.5 XLM with trustline) reserved by the paymaster is credited back
+to the paymaster's spendable balance.
+
+---
+
+## Futurenet Testing
+
+Futurenet runs the latest Soroban preview protocol. Wraith contracts are not deployed there, but
+you can test the sponsorship and account-creation mechanics in isolation:
+
+```typescript
+import { Networks } from "@stellar/stellar-sdk";
+import { rpc as SorobanRpc } from "@stellar/stellar-sdk";
+
+const FUTURENET_RPC = "https://rpc-futurenet.stellar.org";
+const FUTURENET_PASSPHRASE = Networks.FUTURENET; // "Test SDF Future Network ; October 2022"
+const FUTURENET_FRIENDBOT = "https://friendbot-futurenet.stellar.org";
+
+// Fund sponsor on Futurenet
+async function fundOnFuturenet(address: string): Promise {
+ const res = await fetch(`${FUTURENET_FRIENDBOT}?addr=${address}`);
+ if (!res.ok) throw new Error(`Friendbot failed: ${res.status}`);
+ console.log("Funded on Futurenet:", address);
+}
+
+// Swap these into any of the examples above to target Futurenet:
+// const horizon = new Horizon.Server("https://horizon-futurenet.stellar.org"); // if available
+const futurenetSoroban = new SorobanRpc.Server(FUTURENET_RPC);
+void futurenetSoroban; // ready for use with FUTURENET_PASSPHRASE
+```
+
+
+ Futurenet resets without notice and runs pre-release protocol versions. Use it to
+ validate sponsorship mechanics against upcoming Stellar upgrades, not for integration testing.
+ For full end-to-end airdrop testing with Wraith contracts, use **testnet**.
+ See [Stellar Networks](/reference/stellar-networks) for a full comparison.
+
+
+---
+
+## See Also
+
+- [Stellar Fee Estimation & Budgeting](/guides/stellar-fees) — fee-bump sponsorship, Soroban resource fees, batch send cost baselines
+- [Stellar Quickstart](/guides/stellar/stellar-quickstart) — stealth keys, meta-addresses, scanning
+- [Stellar Path Payments](/guides/stellar/stellar-path-payment) — pay in any asset, receive target token at stealth address
+- [Stellar Networks](/reference/stellar-networks) — testnet, Futurenet, and mainnet endpoints and contract IDs
+- [Stellar Troubleshooting](/guides/stellar-troubleshooting) — common transaction error codes and fixes
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