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