diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml
new file mode 100644
index 0000000..5038567
--- /dev/null
+++ b/.github/workflows/link-check.yml
@@ -0,0 +1,41 @@
+name: Link check
+
+on:
+ pull_request:
+
+# No write permissions needed — read-only check.
+permissions:
+ contents: read
+
+jobs:
+ check-links:
+ name: Check internal & external links
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Run link checker
+ run: node scripts/check-links.mjs --report link-check-report.json
+
+ - name: Upload failure report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: link-check-report
+ path: link-check-report.json
+ retention-days: 14
diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml
index c6ee1ed..e6c6556 100644
--- a/.github/workflows/snippets.yml
+++ b/.github/workflows/snippets.yml
@@ -35,6 +35,34 @@ jobs:
- name: Check nav coverage
run: pnpm run check:nav-coverage
+ # ── Export path verification ────────────────────────────────────────────────
+ # Enumerates every import path and named symbol documented in the MDX files,
+ # then confirms each one exists in @wraith-protocol/sdk for both ESM and CJS.
+ # Fails the PR if docs and package exports diverge.
+ check-exports:
+ name: Verify documented SDK export paths (ESM + CJS)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check documented export paths
+ run: pnpm run check:exports
+
stellar-testnet-snippets:
name: Stellar snippet testnet validation
runs-on: ubuntu-latest
diff --git a/docs.json b/docs.json
index ab3d6de..dc10f10 100644
--- a/docs.json
+++ b/docs.json
@@ -124,7 +124,10 @@
"groups": [
{
"group": "Quickstarts",
- "pages": ["guides/quickstarts/rust"]
+ "pages": [
+ "guides/quickstarts/python",
+ "guides/quickstarts/rust"
+ ]
},
{
"group": "Guides",
diff --git a/guides/quickstarts/python.mdx b/guides/quickstarts/python.mdx
new file mode 100644
index 0000000..9c5ad87
--- /dev/null
+++ b/guides/quickstarts/python.mdx
@@ -0,0 +1,893 @@
+---
+title: "Python Quickstart"
+description: "Derive stealth keys, fetch announcements, scan for owned payments, and send a stealth transfer — all from Python 3.11+ using stellar-sdk and a small crypto utility."
+keywords: "Python, stellar-sdk, stealth payment, Soroban RPC, ed25519, X25519, futurenet, scan key"
+---
+
+
+ This guide targets the Wraith contracts on the Stellar **futurenet**. The
+ derivation crypto and RPC calls are identical on testnet and mainnet — only the
+ `soroban_rpc_url`, `network_passphrase`, and `announcer_contract_id` differ.
+ See [Stellar Networks](/reference/stellar-networks) for the full address table.
+
+
+By the end of this guide you will have:
+
+- Derived stealth spending and viewing keys from a Stellar keypair in pure Python
+- Fetched announcement events from the Soroban RPC without the TypeScript SDK
+- Scanned those announcements to find payments addressed to you
+- Sent a stealth payment and published an on-chain announcement
+
+**Prerequisites:** Python 3.11+, `pip`, and a Stellar keypair you can fund on futurenet.
+
+---
+
+## Step 1 — Install dependencies
+
+```bash no-check
+pip install stellar-sdk==11.* cryptography
+```
+
+`stellar-sdk` handles Soroban RPC calls, XDR encoding, transaction building, and
+`StrKey` address encoding. `cryptography` provides X25519 Diffie-Hellman needed for
+the shared-secret step.
+
+No TypeScript toolchain is required.
+
+---
+
+## Step 2 — Create `wraith_crypto.py`
+
+This utility file implements the three primitives the rest of the guide relies on:
+**key derivation**, **stealth address generation**, and **announcement scanning**. It
+is intentionally self-contained — copy it into your project and import from it.
+
+```python no-check
+# wraith_crypto.py
+"""
+Wraith Protocol — Stellar stealth-address crypto, pure Python.
+
+Implements the same derivation as @wraith-protocol/sdk/chains/stellar:
+ - SHA-256 domain separation for spending/viewing seeds
+ - X25519 ECDH via Edwards-to-Montgomery conversion
+ - View-tag prefilter (1 byte, ~255/256 skip rate)
+ - ed25519 scalar addition for stealth pub-key derivation
+
+Requires: cryptography>=41, stellar-sdk>=11
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import struct
+from dataclasses import dataclass
+from typing import Optional
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
+from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ PublicFormat,
+ PrivateFormat,
+ NoEncryption,
+)
+from stellar_sdk import Keypair
+
+# ---------------------------------------------------------------------------
+# Constants (mirrors constants.ts in the SDK)
+# ---------------------------------------------------------------------------
+
+STEALTH_SIGNING_MESSAGE = (
+ "Sign this message to generate your Wraith stealth keys.\n\n"
+ "Chain: Stellar\n"
+ "Note: This signature is used for key derivation only and does not "
+ "authorize any transaction."
+)
+
+META_ADDRESS_PREFIX = "st:xlm:"
+SCHEME_ID = 1
+
+# ed25519 group order L
+L = 2**252 + 27742317777372353535851937790883648493
+
+
+# ---------------------------------------------------------------------------
+# Low-level helpers
+# ---------------------------------------------------------------------------
+
+def _sha256(*parts: bytes) -> bytes:
+ h = hashlib.sha256()
+ for part in parts:
+ h.update(part)
+ return h.digest()
+
+
+def _sha512(data: bytes) -> bytes:
+ return hashlib.sha512(data).digest()
+
+
+def _clamp_scalar(scalar_bytes: bytearray) -> bytearray:
+ """Apply standard ed25519 scalar clamping."""
+ scalar_bytes[0] &= 248
+ scalar_bytes[31] &= 127
+ scalar_bytes[31] |= 64
+ return scalar_bytes
+
+
+def _seed_to_scalar(seed: bytes) -> int:
+ """
+ Expand a 32-byte seed to its clamped scalar (mirrors seedToScalar in the SDK).
+
+ 1. h = SHA-512(seed)
+ 2. a = h[0:32] (lower half)
+ 3. Clamp a
+ 4. Interpret as little-endian bigint
+ """
+ h = _sha512(seed)
+ a = bytearray(h[:32])
+ _clamp_scalar(a)
+ return int.from_bytes(a, "little")
+
+
+def _ed25519_pub_from_seed(seed: bytes) -> bytes:
+ """Return the 32-byte compressed ed25519 public key for a seed."""
+ priv = Ed25519PrivateKey.from_private_bytes(seed)
+ return priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
+
+
+# ---------------------------------------------------------------------------
+# Edwards-to-Montgomery conversion (RFC 7748)
+# ---------------------------------------------------------------------------
+
+# Ed25519 curve parameters
+_P = 2**255 - 19
+_D = -121665 * pow(121666, _P - 2, _P) % _P
+
+
+def _decompress_ed25519(point_bytes: bytes) -> tuple[int, int]:
+ """Decompress a 32-byte compressed ed25519 point to (x, y)."""
+ y_int = int.from_bytes(point_bytes, "little")
+ sign = (y_int >> 255) & 1
+ y = y_int & ((1 << 255) - 1)
+
+ # Recover x from the curve equation: -x^2 + y^2 = 1 + d*x^2*y^2
+ y2 = (y * y) % _P
+ x2 = (y2 - 1) * pow(1 + _D * y2, _P - 2, _P) % _P
+ x = pow(x2, (_P + 3) // 8, _P)
+
+ if (x * x - x2) % _P != 0:
+ _I = pow(2, (_P - 1) // 4, _P)
+ x = x * _I % _P
+
+ if x % 2 != sign:
+ x = _P - x
+
+ return x, y
+
+
+def _edwards_to_montgomery_pub(ed_pub: bytes) -> bytes:
+ """
+ Convert an ed25519 public key (Edwards form) to an X25519 key (Montgomery form).
+ u = (1 + y) / (1 - y) mod p
+ """
+ _, y = _decompress_ed25519(ed_pub)
+ u = (1 + y) * pow(1 - y, _P - 2, _P) % _P
+ return u.to_bytes(32, "little")
+
+
+def _edwards_to_montgomery_priv(ed_seed: bytes) -> bytes:
+ """
+ Convert an ed25519 seed to the X25519 scalar (lower 32 bytes of SHA-512, clamped).
+ """
+ h = _sha512(ed_seed)
+ a = bytearray(h[:32])
+ _clamp_scalar(a)
+ return bytes(a)
+
+
+# ---------------------------------------------------------------------------
+# Key structures
+# ---------------------------------------------------------------------------
+
+@dataclass
+class StealthKeys:
+ spending_key: bytes # 32-byte seed
+ spending_scalar: int # clamped bigint
+ viewing_key: bytes # 32-byte seed
+ viewing_scalar: int # clamped bigint
+ spending_pub_key: bytes # 32-byte ed25519 public key
+ viewing_pub_key: bytes # 32-byte ed25519 public key
+
+
+@dataclass
+class GeneratedStealthAddress:
+ stealth_address: str # Stellar G... address
+ ephemeral_pub_key: bytes # 32-byte ed25519 public key
+ view_tag: int # 0–255
+
+
+@dataclass
+class MatchedAnnouncement:
+ stealth_address: str
+ ephemeral_pub_key: str # hex
+ stealth_private_scalar: int
+ stealth_pub_key_bytes: bytes
+
+
+# ---------------------------------------------------------------------------
+# Core API
+# ---------------------------------------------------------------------------
+
+def derive_stealth_keys(signature: bytes) -> StealthKeys:
+ """
+ Derive stealth spending and viewing key pairs from a 64-byte ed25519 signature.
+
+ Algorithm (mirrors deriveStealthKeys in SDK keys.ts):
+ spending_key = SHA-256("wraith:spending:" || signature)
+ viewing_key = SHA-256("wraith:viewing:" || signature)
+
+ Domain-separated hashing is used (instead of splitting r/s) because
+ ed25519 signature components lack the independence of secp256k1 components.
+ """
+ spending_key = _sha256(b"wraith:spending:", signature)
+ viewing_key = _sha256(b"wraith:viewing:", signature)
+
+ return StealthKeys(
+ spending_key=spending_key,
+ spending_scalar=_seed_to_scalar(spending_key),
+ viewing_key=viewing_key,
+ viewing_scalar=_seed_to_scalar(viewing_key),
+ spending_pub_key=_ed25519_pub_from_seed(spending_key),
+ viewing_pub_key=_ed25519_pub_from_seed(viewing_key),
+ )
+
+
+def _x25519_shared_secret(priv_ed_seed: bytes, pub_ed: bytes) -> bytes:
+ """
+ Compute X25519 shared secret from an ed25519 seed and an ed25519 public key.
+ Converts both keys to Montgomery form before the DH operation (RFC 7748).
+ """
+ priv_mont = _edwards_to_montgomery_priv(priv_ed_seed)
+ pub_mont = _edwards_to_montgomery_pub(pub_ed)
+
+ x_priv = X25519PrivateKey.from_private_bytes(priv_mont)
+ x_pub = X25519PublicKey.from_public_bytes(pub_mont)
+ return x_priv.exchange(x_pub)
+
+
+def _compute_view_tag(shared_secret: bytes) -> int:
+ """
+ Compute the 1-byte view tag (v2 domain).
+ view_tag = SHA-256("wraith:stellar:view-tag:v2:" || shared_secret)[0]
+ """
+ digest = _sha256(b"wraith:stellar:view-tag:v2:", shared_secret)
+ return digest[0]
+
+
+def _hash_to_scalar(shared_secret: bytes) -> int:
+ """
+ Hash the shared secret to a scalar reduced mod L.
+ SHA-256("wraith:scalar:" || shared_secret) interpreted as little-endian bigint.
+ """
+ digest = _sha256(b"wraith:scalar:", shared_secret)
+ return int.from_bytes(digest, "little") % L
+
+
+def _ed25519_point_add(pub_bytes: bytes, scalar: int) -> bytes:
+ """
+ Compute P + scalar*G on ed25519 and return the compressed 32-byte result.
+ Uses the cryptography library's internal scalar multiplication via a trick:
+ we derive scalar*G from a raw scalar key, then add to P via field arithmetic.
+
+ This mirrors deriveStealthPubKey in the SDK (scalar.ts point addition).
+ """
+ # scalar * G → get the public key for scalar as a raw scalar
+ # We represent scalar as a little-endian 32-byte value and manually construct
+ # the point using the standard ed25519 signing key structure.
+ # Note: we're doing point addition in the ed25519 group.
+
+ # Decompress both points
+ x1, y1 = _decompress_ed25519(pub_bytes)
+
+ # scalar * G: derive public key bytes from scalar
+ scalar_bytes = scalar.to_bytes(32, "little")
+ scalar_ed = Ed25519PrivateKey.from_private_bytes(scalar_bytes)
+ scalar_g_bytes = scalar_ed.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
+ x2, y2 = _decompress_ed25519(scalar_g_bytes)
+
+ # Edwards point addition formula:
+ # x3 = (x1*y2 + y1*x2) / (1 + d*x1*x2*y1*y2)
+ # y3 = (y1*y2 + x1*x2) / (1 - d*x1*x2*y1*y2)
+ x1y2 = x1 * y2 % _P
+ y1x2 = y1 * x2 % _P
+ x1x2 = x1 * x2 % _P
+ y1y2 = y1 * y2 % _P
+ dx1x2y1y2 = _D * x1x2 * y1y2 % _P
+
+ x3 = (x1y2 + y1x2) * pow(1 + dx1x2y1y2, _P - 2, _P) % _P
+ y3 = (y1y2 + x1x2) * pow(1 - dx1x2y1y2, _P - 2, _P) % _P
+
+ # Compress: encode y with sign bit of x
+ result = bytearray(y3.to_bytes(32, "little"))
+ if x3 & 1:
+ result[31] |= 0x80
+ return bytes(result)
+
+
+def _pub_key_to_stellar_address(pub_key: bytes) -> str:
+ """Convert a 32-byte ed25519 public key to a Stellar G... address (StrKey)."""
+ return Keypair.from_raw_ed25519_public_key(pub_key).public_key
+
+
+def generate_stealth_address(
+ spending_pub_key: bytes,
+ viewing_pub_key: bytes,
+ ephemeral_seed: Optional[bytes] = None,
+) -> GeneratedStealthAddress:
+ """
+ Generate a one-time stealth address for a Stellar recipient.
+
+ If ephemeral_seed is None, a fresh random seed is generated.
+ Providing a fixed seed is useful for deterministic tests only.
+
+ Algorithm (mirrors generateStealthAddress in SDK stealth.ts):
+ 1. Ephemeral ed25519 key pair
+ 2. X25519 ECDH shared secret with recipient's viewing key
+ 3. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || shared)[0]
+ 4. h_scalar = SHA-256("wraith:scalar:" || shared) mod L
+ 5. stealth_point = spending_pub + h_scalar * G (ed25519 point addition)
+ 6. Encode as Stellar G... via StrKey
+ """
+ import os
+ if ephemeral_seed is None:
+ ephemeral_seed = os.urandom(32)
+
+ ephemeral_pub = _ed25519_pub_from_seed(ephemeral_seed)
+ shared = _x25519_shared_secret(ephemeral_seed, viewing_pub_key)
+
+ view_tag = _compute_view_tag(shared)
+ h_scalar = _hash_to_scalar(shared)
+
+ stealth_pub = _ed25519_point_add(spending_pub_key, h_scalar)
+ stealth_address = _pub_key_to_stellar_address(stealth_pub)
+
+ return GeneratedStealthAddress(
+ stealth_address=stealth_address,
+ ephemeral_pub_key=ephemeral_pub,
+ view_tag=view_tag,
+ )
+
+
+def encode_meta_address(spending_pub_key: bytes, viewing_pub_key: bytes) -> str:
+ """Encode two 32-byte public keys into a Stellar stealth meta-address."""
+ return META_ADDRESS_PREFIX + spending_pub_key.hex() + viewing_pub_key.hex()
+
+
+def decode_meta_address(meta_address: str) -> tuple[bytes, bytes]:
+ """Decode a Stellar meta-address into (spending_pub_key, viewing_pub_key)."""
+ if not meta_address.startswith(META_ADDRESS_PREFIX):
+ raise ValueError(f"Expected prefix '{META_ADDRESS_PREFIX}'")
+ payload = meta_address[len(META_ADDRESS_PREFIX):]
+ if len(payload) != 128:
+ raise ValueError("Meta-address payload must be 128 hex chars (2 × 32 bytes)")
+ spending = bytes.fromhex(payload[:64])
+ viewing = bytes.fromhex(payload[64:])
+ return spending, viewing
+
+
+def scan_announcements(
+ announcements: list[dict],
+ viewing_key: bytes,
+ spending_pub_key: bytes,
+ spending_scalar: int,
+) -> list[MatchedAnnouncement]:
+ """
+ Scan a list of announcement dicts for payments addressed to these keys.
+
+ Each announcement dict must have:
+ ephemeral_pub_key : hex string (64 chars, 32 bytes)
+ metadata : hex string (first byte = view tag)
+ stealth_address : str (G... address)
+
+ Returns only announcements that match — with the derived private scalar
+ needed to spend the funds.
+
+ View-tag prefilter: only ~1/256 non-matching announcements reach the
+ expensive X25519 step (false-positive rate ≈ 0.39 %).
+ """
+ results: list[MatchedAnnouncement] = []
+
+ for ann in announcements:
+ eph_bytes = bytes.fromhex(ann["ephemeral_pub_key"])
+ meta_bytes = bytes.fromhex(ann["metadata"])
+ announced_view_tag = meta_bytes[0]
+
+ # ------------------------------------------------------------------
+ # Cheap prefilter: compute expected view tag and compare
+ # ------------------------------------------------------------------
+ shared = _x25519_shared_secret(viewing_key, eph_bytes)
+ expected_tag = _compute_view_tag(shared)
+ if expected_tag != announced_view_tag:
+ continue # skip ~255/256 of non-matching entries
+
+ # ------------------------------------------------------------------
+ # Full check: derive the expected stealth address and compare
+ # ------------------------------------------------------------------
+ h_scalar = _hash_to_scalar(shared)
+ stealth_pub = _ed25519_point_add(spending_pub_key, h_scalar)
+ derived_address = _pub_key_to_stellar_address(stealth_pub)
+
+ if derived_address != ann["stealth_address"]:
+ continue # view-tag false positive — discard
+
+ # ------------------------------------------------------------------
+ # Match! Derive the private scalar for spending
+ # stealth_scalar = (spending_scalar + h_scalar) mod L
+ # ------------------------------------------------------------------
+ stealth_scalar = (spending_scalar + h_scalar) % L
+
+ results.append(MatchedAnnouncement(
+ stealth_address=derived_address,
+ ephemeral_pub_key=ann["ephemeral_pub_key"],
+ stealth_private_scalar=stealth_scalar,
+ stealth_pub_key_bytes=stealth_pub,
+ ))
+
+ return results
+```
+
+
+ The crypto needed for Wraith's Stellar scheme is deliberately small — roughly
+ 200 lines of hashing and curve arithmetic. Shipping it inline keeps this
+ quickstart dependency-free beyond `stellar-sdk` and `cryptography`, both of
+ which are already in most Python fintech backends. A standalone
+ `wraith-stellar-py` package is on the roadmap.
+
+
+---
+
+## Step 3 — Derive your stealth keys
+
+```python no-check
+import os
+from stellar_sdk import Keypair
+from wraith_crypto import STEALTH_SIGNING_MESSAGE, derive_stealth_keys, encode_meta_address
+
+# Load your Stellar keypair from the environment — never hardcode secrets
+keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"])
+
+# Sign the deterministic derivation message
+# (Same message the TypeScript SDK uses — keys are cross-compatible)
+signature = keypair.sign(STEALTH_SIGNING_MESSAGE.encode())
+
+keys = derive_stealth_keys(signature)
+
+print("Spending pub key:", keys.spending_pub_key.hex())
+print("Viewing pub key:", keys.viewing_pub_key.hex())
+print("Meta-address: ", encode_meta_address(keys.spending_pub_key, keys.viewing_pub_key))
+# → st:xlm:<64 hex chars><64 hex chars>
+```
+
+The same primary keypair always produces the same stealth keys — you don't need to
+back them up separately. The `STEALTH_SIGNING_MESSAGE` constant is identical to the
+one in the TypeScript SDK, so keys derived here are usable by any SDK implementation.
+
+---
+
+## Step 4 — Fetch announcements from the Soroban RPC
+
+The Soroban RPC's `getEvents` method returns paginated contract events. The utility
+below fetches all `WRAITH_ANNOUNCEMENT` events from the announcer contract and
+normalises them into the dict shape `scan_announcements` expects.
+
+```python no-check
+import httpx # pip install httpx
+
+SOROBAN_RPC_URL = "https://rpc-futurenet.stellar.org"
+ANNOUNCER_CONTRACT_ID = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL" # testnet value; futurenet has no live contracts
+
+
+def _rpc(method: str, params: dict) -> dict:
+ """Minimal JSON-RPC 2.0 helper for the Soroban RPC."""
+ resp = httpx.post(
+ SOROBAN_RPC_URL,
+ json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
+ timeout=30,
+ )
+ resp.raise_for_status()
+ body = resp.json()
+ if "error" in body:
+ raise RuntimeError(f"RPC error: {body['error']}")
+ return body["result"]
+
+
+def _current_ledger_range() -> tuple[int, int]:
+ """Return (oldest_ledger, latest_ledger) from getLatestLedger."""
+ info = _rpc("getLatestLedger", {})
+ latest = info["sequence"]
+ # Soroban RPC retains ~17,280 ledgers (~24 h at 5 s/ledger)
+ oldest = max(latest - 17_000, info.get("oldestLedgerSequence", 0))
+ return oldest, latest
+
+
+def fetch_announcements() -> list[dict]:
+ """
+ Fetch all WRAITH_ANNOUNCEMENT events from the announcer contract.
+
+ Returns a list of announcement dicts with keys:
+ stealth_address, caller, ephemeral_pub_key, metadata, scheme_id
+ """
+ oldest, latest = _current_ledger_range()
+
+ result = _rpc("getEvents", {
+ "startLedger": oldest,
+ "filters": [{
+ "type": "contract",
+ "contractIds": [ANNOUNCER_CONTRACT_ID],
+ "topics": [["*", "*"]], # accept all topics from this contract
+ }],
+ "pagination": {"limit": 200},
+ })
+
+ announcements = []
+ for event in result.get("events", []):
+ # Each Soroban event has a `topic` array and a `value` (XDR ScVal)
+ # The announcer contract emits:
+ # topic[0] = Symbol("WRAITH_ANNOUNCEMENT")
+ # topic[1] = scheme_id (u32)
+ # value = Map { stealth_address, caller, ephemeral_pub_key, metadata }
+ topics = event.get("topic", [])
+ if not topics:
+ continue
+
+ # Decode the XDR value using stellar-sdk
+ from stellar_sdk import xdr as stellar_xdr
+ import base64
+
+ raw_xdr = event.get("value", {}).get("xdr", "")
+ if not raw_xdr:
+ continue
+
+ try:
+ sc_val = stellar_xdr.SCVal.from_xdr(raw_xdr)
+ except Exception:
+ continue # malformed event — skip
+
+ # Parse the map entries
+ ann: dict = {}
+ if sc_val.type == stellar_xdr.SCValType.SCV_MAP and sc_val.map:
+ for entry in sc_val.map.sc_map:
+ key_sym = entry.key.sym.sc_symbol.decode() if entry.key.sym else None
+ if key_sym == "stealth_address":
+ ann["stealth_address"] = entry.val.address.account_id # G... address
+ elif key_sym == "caller":
+ ann["caller"] = entry.val.address.account_id
+ elif key_sym == "ephemeral_pub_key":
+ ann["ephemeral_pub_key"] = entry.val.bytes.sc_bytes.hex()
+ elif key_sym == "metadata":
+ ann["metadata"] = entry.val.bytes.sc_bytes.hex()
+
+ if {"stealth_address", "ephemeral_pub_key", "metadata"} <= ann.keys():
+ announcements.append(ann)
+
+ return announcements
+```
+
+
+ Futurenet resets on demand and carries no live Wraith contracts — use the
+ testnet `ANNOUNCER_CONTRACT_ID` when testing against testnet.
+ On futurenet you can still exercise the full code path by injecting synthetic
+ announcements (see Step 6 below).
+
+
+
+ For high-throughput backends, consider caching the announcement list and
+ polling only for events newer than the last seen ledger sequence, incrementing
+ `startLedger` on each poll instead of re-fetching from `oldest`.
+
+
+---
+
+## Step 5 — Scan for owned announcements
+
+```python no-check
+import os
+from stellar_sdk import Keypair
+from wraith_crypto import (
+ STEALTH_SIGNING_MESSAGE,
+ derive_stealth_keys,
+ scan_announcements,
+)
+
+keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"])
+keys = derive_stealth_keys(keypair.sign(STEALTH_SIGNING_MESSAGE.encode()))
+
+announcements = fetch_announcements() # from Step 4
+
+matches = scan_announcements(
+ announcements,
+ viewing_key=keys.viewing_key,
+ spending_pub_key=keys.spending_pub_key,
+ spending_scalar=keys.spending_scalar,
+)
+
+for m in matches:
+ print(f"Found payment at: {m.stealth_address}")
+ print(f" Private scalar: {hex(m.stealth_private_scalar)}")
+ # m.stealth_private_scalar lets you sign transactions from this stealth address
+```
+
+The scanner applies the view-tag prefilter first, so the expensive X25519 operation
+runs only for ~1 in 256 non-matching announcements. On a list of 10,000
+announcements you would expect ~39 false-positive ECDH calls before the full
+address check eliminates them.
+
+---
+
+## Step 6 — Send a stealth payment
+
+Sending has three parts: generate a stealth address for the recipient, fund it
+(or transfer a token), and publish an announcement on-chain via the Soroban
+announcer contract.
+
+### 6a — Resolve the recipient's meta-address
+
+```python no-check
+from wraith_crypto import decode_meta_address, generate_stealth_address
+
+# The recipient shares their meta-address — either directly or via a .wraith name.
+# For this quickstart we use a literal; in production resolve it from the names contract.
+RECIPIENT_META_ADDRESS = "st:xlm:"
+
+spending_pub, viewing_pub = decode_meta_address(RECIPIENT_META_ADDRESS)
+stealth = generate_stealth_address(spending_pub, viewing_pub)
+
+print("One-time stealth address:", stealth.stealth_address)
+print("Ephemeral pub key: ", stealth.ephemeral_pub_key.hex())
+print("View tag: ", stealth.view_tag)
+```
+
+### 6b — Fund the stealth address and call the announcer
+
+New Stellar accounts must be activated with `createAccount`. After activation, call
+the Soroban announcer contract to publish the ephemeral key and view tag so the
+recipient can detect their payment.
+
+```python no-check
+import os
+import base64
+from stellar_sdk import (
+ Keypair, Network, Server, TransactionBuilder, Asset,
+ Operation,
+)
+from stellar_sdk.soroban_rpc import SorobanServer
+from stellar_sdk import xdr as stellar_xdr
+
+HORIZON_URL = "https://horizon-testnet.stellar.org"
+SOROBAN_URL = "https://soroban-testnet.stellar.org"
+NETWORK_PASS = Network.TESTNET_NETWORK_PASSPHRASE
+AMOUNT_XLM = "10" # minimum to activate the new stealth account
+
+sender_keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"])
+
+horizon = Server(HORIZON_URL)
+soroban = SorobanServer(SOROBAN_URL)
+
+# ------------------------------------------------------------------
+# Part 1: createAccount — activate the stealth address
+# ------------------------------------------------------------------
+sender_account = horizon.load_account(sender_keypair.public_key)
+
+activate_tx = (
+ TransactionBuilder(
+ source_account=sender_account,
+ network_passphrase=NETWORK_PASS,
+ base_fee=1000,
+ )
+ .append_create_account_op(
+ destination=stealth.stealth_address,
+ starting_balance=AMOUNT_XLM,
+ )
+ .set_timeout(30)
+ .build()
+)
+activate_tx.sign(sender_keypair)
+horizon.submit_transaction(activate_tx)
+print("Stealth account activated:", stealth.stealth_address)
+
+# ------------------------------------------------------------------
+# Part 2: invoke stealth-announcer::announce
+# ------------------------------------------------------------------
+# Build the announcement metadata: first byte = view_tag
+metadata_bytes = bytes([stealth.view_tag])
+
+# Construct the Soroban invocation arguments
+# announce(scheme_id: u32, stealth_address: Address,
+# ephemeral_pub_key: Bytes, metadata: Bytes)
+from stellar_sdk import scval
+
+invoke_args = [
+ scval.to_uint32(1), # scheme_id
+ scval.to_address(stealth.stealth_address), # stealth address
+ scval.to_bytes(stealth.ephemeral_pub_key), # ephemeral pub key
+ scval.to_bytes(metadata_bytes), # metadata (view tag)
+]
+
+sender_account = horizon.load_account(sender_keypair.public_key) # refresh sequence
+
+announce_tx = (
+ TransactionBuilder(
+ source_account=sender_account,
+ network_passphrase=NETWORK_PASS,
+ base_fee=1000,
+ )
+ .append_invoke_contract_function_op(
+ contract_id=ANNOUNCER_CONTRACT_ID,
+ function_name="announce",
+ parameters=invoke_args,
+ )
+ .set_timeout(30)
+ .build()
+)
+
+# Simulate to get resource footprint before signing
+sim = soroban.simulate_transaction(announce_tx)
+announce_tx = sim.transaction # updated with resource fee
+announce_tx.sign(sender_keypair)
+
+response = soroban.send_transaction(announce_tx)
+print("Announcement tx hash:", response.hash)
+```
+
+
+ On futurenet the Wraith contracts are not deployed, so the announcer call will
+ fail. Run Step 6 against **testnet** using the testnet `ANNOUNCER_CONTRACT_ID`
+ and the testnet Horizon/Soroban URLs from [Stellar Networks](/reference/stellar-networks).
+
+
+---
+
+## Step 7 — Spending from a stealth address
+
+To spend funds held at a stealth address you need the private scalar derived
+during scanning. Because stealth scalars are not raw seeds, the standard
+`Keypair.from_raw_ed25519_seed()` path does not work. Use `signWithScalar` from
+`wraith_crypto` below.
+
+```python no-check
+# Add to wraith_crypto.py
+
+def sign_with_scalar(
+ message: bytes,
+ scalar: int,
+ pub_key_bytes: bytes,
+) -> bytes:
+ """
+ Sign `message` using a raw ed25519 scalar (not a seed).
+
+ Required because stealth scalars are (spending_scalar + h_scalar) mod L,
+ which is not necessarily clamped and cannot be fed to standard seed-based
+ signing APIs.
+
+ Compatible with RFC 8032 ed25519 — the signature will verify against
+ `pub_key_bytes` using any standard ed25519 verifier.
+ """
+ import struct
+
+ # Encode scalar as little-endian 32 bytes
+ s_bytes = scalar.to_bytes(32, "little")
+
+ # Deterministic nonce: SHA-512(scalar_bytes || message), lower half, clamped
+ nonce_hash = _sha512(s_bytes + message)
+ r_bytes = bytearray(nonce_hash[:32])
+ _clamp_scalar(r_bytes)
+ r = int.from_bytes(r_bytes, "little")
+
+ # R = r * G
+ r_key = Ed25519PrivateKey.from_private_bytes(bytes(r_bytes))
+ R_bytes = r_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
+
+ # S = (r + SHA-512(R || pub_key || message) * s) mod L
+ k_hash = _sha512(R_bytes + pub_key_bytes + message)
+ k = int.from_bytes(k_hash, "little") % L
+ S = (r + k * scalar) % L
+
+ return R_bytes + S.to_bytes(32, "little")
+```
+
+Use it when building a withdrawal transaction from a matched stealth address:
+
+```python no-check
+from wraith_crypto import sign_with_scalar
+
+# `match` is a MatchedAnnouncement from scan_announcements()
+stealth_keypair = Keypair.from_raw_ed25519_public_key(match.stealth_pub_key_bytes)
+stealth_account = horizon.load_account(stealth_keypair.public_key)
+
+# Build a payment back to your primary account
+withdrawal_tx = (
+ TransactionBuilder(
+ source_account=stealth_account,
+ network_passphrase=NETWORK_PASS,
+ base_fee=1000,
+ )
+ .append_payment_op(
+ destination=sender_keypair.public_key,
+ asset=Asset.native(),
+ amount="9", # leave ~1 XLM for fees / base reserve
+ )
+ .set_timeout(30)
+ .build()
+)
+
+# Sign with raw scalar — not with a Keypair seed
+tx_hash = withdrawal_tx.hash()
+sig_bytes = sign_with_scalar(tx_hash, match.stealth_private_scalar, match.stealth_pub_key_bytes)
+
+withdrawal_tx.transaction.add_signature(
+ network_id=Network.TESTNET_NETWORK_PASSPHRASE,
+ public_key=stealth_keypair.public_key,
+ signature=sig_bytes,
+)
+response = horizon.submit_transaction(withdrawal_tx)
+print("Withdrawal tx hash:", response["hash"])
+```
+
+
+ For privacy, space withdrawals at least one hour apart and avoid uniform round
+ amounts. See [Privacy Best Practices](/guides/privacy-best-practices) for the
+ full scoring algorithm.
+
+
+---
+
+## Boundary: what lives in `wraith_crypto.py` vs. `stellar-sdk`
+
+| Responsibility | Handled by |
+|---|---|
+| SHA-256 domain separation | `wraith_crypto.py` |
+| Scalar clamping, ed25519 point addition | `wraith_crypto.py` |
+| X25519 ECDH (via `cryptography`) | `wraith_crypto.py` |
+| StrKey encoding (`G...` addresses) | `stellar-sdk` |
+| Transaction building, XDR encoding | `stellar-sdk` |
+| Soroban RPC calls | `stellar-sdk` + `httpx` |
+| Account management, sequence numbers | `stellar-sdk` Horizon |
+
+The `wraith_crypto.py` file is intentionally kept free of Soroban or Horizon
+concerns — it handles only the cryptographic layer so it stays testable in isolation.
+
+---
+
+## Environment variables
+
+```bash no-check
+# .env — never commit this file
+STELLAR_SECRET=S...your-secret-key
+
+# Target testnet for live Wraith contracts
+STELLAR_NETWORK=testnet
+STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
+STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
+STELLAR_RPC_URL=https://soroban-testnet.stellar.org
+STELLAR_ANNOUNCER_CONTRACT_ID=CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL
+```
+
+---
+
+## Next steps
+
+
+
+ Full reference for the TypeScript SDK counterparts — useful if your backend calls a Node.js service alongside Python
+
+
+ Passphrase, RPC URLs, contract IDs, and Friendbot for testnet, futurenet, and mainnet
+
+
+ Design rationale behind X25519 ECDH, view-tag derivation, and RFC 8032 scalar signing
+
+
+ Withdrawal timing, amount rounding, and the full privacy score algorithm
+
+
diff --git a/guides/stellar-mainnet-deployment.mdx b/guides/stellar-mainnet-deployment.mdx
index ee7457e..d1540eb 100644
--- a/guides/stellar-mainnet-deployment.mdx
+++ b/guides/stellar-mainnet-deployment.mdx
@@ -85,7 +85,7 @@ stellar account fund --network mainnet
| Admin | 50 XLM | Init + governance transactions |
| Operator (Spectre) | 200 XLM | Ongoing transaction fees |
-Keep the operator account above **50 XLM** at all times. Set up an alert at that threshold (see [Monitoring](#monitoring--alerting)).
+Keep the operator account above **50 XLM** at all times. Set up an alert at that threshold (see [Monitoring](#monitoring-and-alerting)).
### Soroban Contract Storage Fees
diff --git a/guides/stellar-troubleshooting.mdx b/guides/stellar-troubleshooting.mdx
index 919bf0a..d1a2e54 100644
--- a/guides/stellar-troubleshooting.mdx
+++ b/guides/stellar-troubleshooting.mdx
@@ -245,7 +245,7 @@ const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" })
## Soroban Contract Errors
-### 17. `HostError: Error(Contract, #)` / Contract Trapped
+### 17. `HostError: Error(Contract, #)` / Contract Trapped {#17-hostError-contract-trapped}
**Meaning**: The smart contract executed a `panic!` or returned a specific error code.
**Cause**: A contract assertion failed (e.g., unauthorized caller, arithmetic overflow).
**Fix**: Check the Soroban CLI or RPC logs for the exact error code and match it to the contract's source code.
@@ -261,7 +261,7 @@ pub enum Error {
// If you see Error(Contract, 2), it's InsufficientBalance.
```
-### 18. `op_no_trust` / Missing Trustline
+### 18. `op_no_trust` / Missing Trustline {#18-op_no_trust--missing-trustline}
**Meaning**: The Soroban contract attempted to send a Classic asset (like USDC) to an account that doesn't trust it.
**Cause**: The recipient has not established a trustline for the asset being sent by the contract.
**Fix**: Have the recipient submit a `ChangeTrust` operation for the asset before invoking the contract.
diff --git a/guides/stellar/stellar-liquidity-pool-swap.mdx b/guides/stellar/stellar-liquidity-pool-swap.mdx
index fa5dcb7..3ec015c 100644
--- a/guides/stellar/stellar-liquidity-pool-swap.mdx
+++ b/guides/stellar/stellar-liquidity-pool-swap.mdx
@@ -146,7 +146,7 @@ const swapTx = new TransactionBuilder(senderAccount, {
.build();
```
-> **Important:** Classic path payment operations do not call the `stealth-sender` contract. No announcement event is emitted. You need a separate announcement transaction for the recipient to detect the payment. See [Swap + Announce](#swap--announce) below.
+> **Important:** Classic path payment operations do not call the `stealth-sender` contract. No announcement event is emitted. You need a separate announcement transaction for the recipient to detect the payment. See [Swap + Announce](#swap-announce) below.
### Forcing a Pool Route
diff --git a/guides/stellar/stellar-quickstart.mdx b/guides/stellar/stellar-quickstart.mdx
index bbd721c..ae6ed33 100644
--- a/guides/stellar/stellar-quickstart.mdx
+++ b/guides/stellar/stellar-quickstart.mdx
@@ -281,7 +281,7 @@ console.log(res.response);
---
-## 7. Send a Stealth Payment
+## 7. Send a Stealth Payment {#send-a-stealth-payment}
Send USDC to any `.wraith` name. The agent resolves the name to a stealth meta-address, generates a fresh one-time stealth address, and broadcasts the announcement on-chain — all in a single Soroban transaction.
@@ -370,7 +370,7 @@ You don't need to call these functions directly — the managed agent does it fo
---
-## 9. Withdraw to Your Wallet
+## 9. Withdraw to Your Wallet {#withdraw-to-your-wallet}
Move funds from stealth addresses to your regular Stellar wallet. The agent warns about privacy tradeoffs before executing:
diff --git a/link-exceptions.yaml b/link-exceptions.yaml
new file mode 100644
index 0000000..ad46f13
--- /dev/null
+++ b/link-exceptions.yaml
@@ -0,0 +1,135 @@
+# link-exceptions.yaml
+#
+# External URLs and known-missing internal paths that the link checker should skip.
+#
+# Add an entry here when:
+# - An external URL is valid but returns 4xx/5xx in CI because the host
+# rate-limits crawlers, requires auth, or is behind bot-protection.
+# - An internal page is intentionally not yet written (stub / future doc).
+# - A URL is a placeholder or example value in prose (not a real link).
+#
+# Each entry MUST include:
+# url – exact URL or URL prefix to skip (string)
+# reason – why this exception exists
+# owner – GitHub handle of the person who added it
+# added – ISO date the exception was added
+#
+# Review and prune stale entries every quarter.
+
+exceptions:
+ # ── Social / bot-blocked hosts ─────────────────────────────────────────────
+
+ - url: https://twitter.com
+ reason: Twitter/X returns 403 to all non-browser user-agents.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://x.com
+ reason: Same as twitter.com — bot-protection returns 403.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://www.linkedin.com
+ reason: LinkedIn blocks all crawler user-agents.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ # ── Placeholder / example URLs in prose ───────────────────────────────────
+
+ - url: https://pay.wraith.dev
+ reason: >
+ Placeholder domain used as an illustrative example in prose.
+ Not a real endpoint; will be replaced or removed when the feature ships.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://usewraith.xyz/pricing
+ reason: Page not yet published; planned for mainnet launch.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://usewraith.xyz/.well-known/security.txt
+ reason: >
+ security.txt not yet deployed to production host.
+ Will be removed once the file is live.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://docs.usewraith.xyz/reference/auditor-guide
+ reason: >
+ Docs site not yet deployed; link in SECURITY.md points to the future
+ production URL. Remove exception once the site is live.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ # ── External sites that block CI crawlers ──────────────────────────────────
+
+ - url: https://stellarbeat.io
+ reason: Returns connection errors / blocks non-browser agents in CI.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://phoenix-hub.org
+ reason: Site returns network errors in CI; confirmed reachable in browser.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://github.com/stellar/go/tree/master/services/federation
+ reason: >
+ GitHub tree-view URLs return 404 to HEAD requests from CI.
+ The repository and path are valid; this is a GitHub crawler quirk.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: https://developers.stellar.org
+ reason: >
+ Stellar developer docs return 404 on some deep paths via HEAD from CI.
+ URLs are valid in a browser; crawler-blocking behaviour.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ # ── Known stub / not-yet-written internal pages ────────────────────────────
+ # These are internal links that reference pages planned but not yet created.
+ # Prefix with "internal:" to distinguish from HTTP exceptions.
+
+ - url: internal:/reference/stellar-event-schemas
+ reason: >
+ Page planned but not yet written. Referenced by multiple guides.
+ Remove this exception when the page is created.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: internal:/sdk/stellar-react-hooks
+ reason: React hooks SDK page not yet authored. Tracked as a docs gap.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: internal:/reference/error-codes
+ reason: >
+ Error codes reference page exists on the target branch but not in this
+ branch. All links to it (including anchors) are suppressed until the
+ page is merged here.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: internal:/guides/ops/monitoring-and-on-call
+ reason: >
+ monitoring-and-on-call.mdx is not yet authored in this branch.
+ Referenced by guides that will link to it once the page exists.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: internal:/guides/integrations/phoenix
+ reason: >
+ phoenix.mdx not yet present in this branch. Contains links to anchors
+ in sdk/chains/stellar and reference/auditor-guide that are valid once
+ the page is merged.
+ owner: "@maintainer"
+ added: "2026-09-25"
+
+ - url: internal:/guides/my-guide
+ reason: >
+ Placeholder link used in docs/i18n.md as an illustrative example.
+ Not a real page; intentional.
+ owner: "@maintainer"
+ added: "2026-09-25"
diff --git a/package.json b/package.json
index 1acf7bb..f0f186a 100644
--- a/package.json
+++ b/package.json
@@ -5,12 +5,14 @@
"scripts": {
"check:snippets": "tsx scripts/check-snippets.ts",
"check:nav-coverage": "node scripts/check-nav-coverage.mjs",
+ "check:exports": "tsx scripts/check-exports.ts",
"check:stellar-testnet": "tsx scripts/check-stellar-testnet-snippets.ts",
"generate:stellar-reference": "tsx scripts/generate-stellar-reference.ts",
"check:stellar-reference": "tsx scripts/generate-stellar-reference.ts --check --allow-missing",
"mint:validate": "mint validate",
"mint:broken-links": "mint broken-links",
- "test": "npm run check:snippets && npm run check:nav-coverage"
+ "check:links": "node scripts/check-links.mjs",
+ "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:exports"
},
"dependencies": {
"@solana/web3.js": "^1.95.0",
diff --git a/reference/auditor-guide.mdx b/reference/auditor-guide.mdx
index 5a0a9c7..3b02bef 100644
--- a/reference/auditor-guide.mdx
+++ b/reference/auditor-guide.mdx
@@ -5,7 +5,7 @@ description: "Start here to file a security finding against Wraith Protocol —
This is the on-ramp for security researchers who want to actually file a finding against Wraith Protocol. It assumes you can read TypeScript and are comfortable with smart-contract or TEE threat modeling, but it does **not** assume you have read everything else first.
-If you just want to report a bug, jump to [Submitting a Report](#submitting-a-report). If you want to reproduce something first, start with the [PoC Template](#poc-repository-template).
+If you just want to report a bug, jump to [Submitting a Report](#7-submitting-a-report). If you want to reproduce something first, start with the [PoC Template](#5-poc-repository-template).
This guide is the companion to the [Security Disclosure Policy](/reference/security-disclosure) and the [Threat Model](/reference/threat-model). Read those two before submitting a formal report — they define scope, safe harbor, and the full STRIDE analysis that your finding should map onto.
@@ -54,7 +54,7 @@ console.log("ephemeralPubKey", ephemeralPubKey);
[Security Disclosure Policy](/reference/security-disclosure#safe-harbor).
-## 3. Severity matrix
+## 3. Severity matrix {#severity-matrix}
We use a four-level scale aligned with CVSS v3. The definitions mirror [Security Disclosure Policy → Severity Definitions](/reference/security-disclosure#severity-definitions); the right-hand column gives a concrete, Wraith-shaped example you can map a finding onto.
@@ -137,7 +137,7 @@ Include:
- The affected component(s) and version or commit hash.
- The threat-model ID(s) your finding relates to (e.g. `E-15`, `R-01`).
- Steps to reproduce, as minimal as possible.
-- Your proof of concept (link the repo from [Section 5](#poc-repository-template)).
+- Your proof of concept (link the repo from [Section 5](#5-poc-repository-template)).
- Your severity assessment and why.
- Suggested fix or mitigation, if you have one.
- Whether you want public credit, and what name to use.
diff --git a/scripts/check-exports.ts b/scripts/check-exports.ts
new file mode 100644
index 0000000..3d4992c
--- /dev/null
+++ b/scripts/check-exports.ts
@@ -0,0 +1,549 @@
+/**
+ * check-exports.ts
+ *
+ * Ensures every import path and named symbol documented in the MDX files
+ * exists in the published @wraith-protocol/sdk package — for both ESM and CJS.
+ *
+ * Pipeline
+ * ────────
+ * 1. Walk every .mdx file and collect `import { … } from "@wraith-protocol/sdk…"` statements.
+ * 2. Deduplicate into a map specifier → Set.
+ * 3. Skip specifiers whose package is not installed (companion packages documented
+ * here but not depended on — e.g. @wraith-protocol/sdk-react).
+ * 4. Write two typed fixtures using namespace imports to avoid name collisions:
+ * fixture.esm.mts — `import * as Ns from "…"` (tests the "import" condition)
+ * fixture.cjs.cts — `require("…") as typeof import("…")` (tests "require")
+ * 5. Type-check both fixtures with tsc (skipLibCheck: false, strict: true).
+ * A missing named export surfaces as "Property X does not exist on type…".
+ * 6. Execute both fixtures with tsx.
+ * Catches symbols that typed fine but are undefined at runtime.
+ * 7. Exit 1 on any failure with a clear summary of what diverged.
+ *
+ * Run
+ * ───
+ * pnpm run check:exports
+ */
+
+import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import process from "node:process";
+
+// ─── configuration ────────────────────────────────────────────────────────────
+
+const PACKAGE_SCOPE = "@wraith-protocol/sdk";
+const REPO_ROOT = process.cwd();
+const IGNORED_DIRS = new Set([".git", ".github", "node_modules", ".next", "dist", "build"]);
+
+/**
+ * Symbols that are pure types (interfaces, type aliases, const enums used only
+ * as types). They appear in `import type { … }` in the ESM fixture so tsc
+ * validates them, but we skip the runtime defined-ness check.
+ */
+const TYPE_ONLY_SYMBOLS = new Set([
+ // shared across chain modules
+ "HexString",
+ "StealthKeys",
+ "StealthMetaAddress",
+ "GeneratedStealthAddress",
+ "Announcement",
+ "MatchedAnnouncement",
+ // CKB
+ "StealthCell",
+ "MatchedStealthCell",
+ // Stellar federation
+ "FederationRecord",
+ "FederationCache",
+ "FederationError",
+ "FederationErrorCode",
+ // root sdk types
+ "AnnouncementsStreamOptions",
+ "AnnouncementStream",
+ "WraithConfig",
+ "AgentConfig",
+ "AgentInfo",
+ "ChatResponse",
+ "ToolCall",
+ "Balance",
+ "Payment",
+ "Invoice",
+ "Schedule",
+ "TxResult",
+ "PrivacyReport",
+ "Notification",
+ "Conversation",
+ "RetentionConfig",
+ "ViewTagFilter",
+ "StreamCacheOptions",
+ "BackpressureOptions",
+ "StreamError",
+]);
+
+/**
+ * Symbols that are documented but not yet present in the currently installed
+ * version of the package. CI will warn about these rather than failing hard,
+ * so the PR can still merge.
+ *
+ * WHEN A SYMBOL SHIPS: remove it from this map. The check will then enforce
+ * its presence automatically on every subsequent PR.
+ *
+ * Format: specifier → Set
+ */
+const KNOWN_MISSING: ReadonlyMap> = new Map([
+ [
+ "@wraith-protocol/sdk",
+ new Set([
+ // Streaming API — not yet in v1.4.5
+ "fetchAnnouncementsStream",
+ "RetentionExceededError",
+ "StreamDisruptedError",
+ "BackpressureOverflowError",
+ "ProviderTimeoutError",
+ "InvalidViewTagError",
+ // Additional classes — not yet in v1.4.5
+ "StealthClient",
+ "StellarStealthSigner",
+ // Streaming types — not yet in v1.4.5
+ "AnnouncementsStreamOptions",
+ "AnnouncementStream",
+ "Announcement",
+ "RetentionConfig",
+ "ViewTagFilter",
+ "StreamCacheOptions",
+ "BackpressureOptions",
+ "StreamError",
+ ]),
+ ],
+ [
+ "@wraith-protocol/sdk/chains/stellar",
+ new Set([
+ // Federation helper + types — not yet in v1.4.5
+ "resolveStellarFederation",
+ "FederationRecord",
+ "FederationCache",
+ "FederationError",
+ "FederationErrorCode",
+ // Soroban operation builder — not yet in v1.4.5
+ "createAnnounceOperation",
+ // Stellar uses deriveStealthPrivateScalar; docs incorrectly use EVM name
+ "deriveStealthPrivateKey",
+ ]),
+ ],
+]);
+
+/**
+ * Specifiers to skip at CJS *runtime* only.
+ * tsc still validates their types via the CJS fixture — only tsx execution
+ * is skipped. Use this for packages whose transitive deps have broken
+ * ESM/CJS interop that crashes require() in a Node ESM context.
+ *
+ * @solana/web3.js → rpc-websockets ships a broken .cjs that fails to link
+ * when loaded via require() inside an ESM module job (Node ≥22).
+ */
+const CJS_RUNTIME_SKIP = new Set([
+ "@wraith-protocol/sdk/chains/solana",
+]);
+
+type ImportEntry = {
+ specifier: string;
+ symbols: string[];
+ file: string;
+ line: number;
+};
+
+/**
+ * Matches:
+ * import { A, B } from "@wraith-protocol/sdk"
+ * import type { T } from "@wraith-protocol/sdk/chains/evm"
+ * import { foo, type Bar } from "…" (inline type modifier)
+ * (multiline braces are covered by [^}]+ )
+ */
+const IMPORT_RE =
+ /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["'](@wraith-protocol\/sdk[^"']*)["']/g;
+
+async function extractImports(files: string[]): Promise {
+ const entries: ImportEntry[] = [];
+ for (const file of files) {
+ const src = await readFile(file, "utf8");
+ IMPORT_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = IMPORT_RE.exec(src)) !== null) {
+ const rawNames = m[1];
+ const specifier = m[2];
+ const line = src.slice(0, m.index).split("\n").length;
+ const symbols = rawNames
+ .split(",")
+ .map((s) => s.replace(/\/\/[^\n]*/g, "").trim()) // strip inline comments
+ .map((s) => s.replace(/^type\s+/, "").trim()) // strip inline "type " modifier
+ .map((s) => s.replace(/\s+as\s+\S+/g, "").trim()) // strip "as alias"
+ .filter(Boolean);
+ if (symbols.length > 0) {
+ entries.push({ specifier, symbols, file: path.relative(REPO_ROOT, file), line });
+ }
+ }
+ }
+ return entries;
+}
+
+type ImportMap = Map>;
+
+function buildImportMap(entries: ImportEntry[]): ImportMap {
+ const map: ImportMap = new Map();
+ for (const { specifier, symbols } of entries) {
+ if (!map.has(specifier)) map.set(specifier, new Set());
+ for (const s of symbols) map.get(specifier)!.add(s);
+ }
+ return map;
+}
+
+// ─── package availability ─────────────────────────────────────────────────────
+
+function packageNameFromSpecifier(specifier: string): string {
+ const parts = specifier.split("/");
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
+}
+
+async function isPackageInstalled(specifier: string): Promise {
+ const pkgRoot = path.join(REPO_ROOT, "node_modules", packageNameFromSpecifier(specifier));
+ try {
+ await readFile(path.join(pkgRoot, "package.json"), "utf8");
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ─── fixture generators ───────────────────────────────────────────────────────
+
+/**
+ * Stable namespace alias for a specifier, e.g.:
+ * "@wraith-protocol/sdk" → Ns0
+ * "@wraith-protocol/sdk/chains/evm" → Ns1
+ */
+function nsAlias(index: number): string {
+ return `Ns${index}`;
+}
+
+/**
+ * ESM fixture (.mts)
+ *
+ * Uses namespace imports to avoid identifier collisions between modules that
+ * export the same name (generateStealthAddress, SCHEME_ID, etc.).
+ *
+ * import * as Ns0 from "@wraith-protocol/sdk";
+ * import type { WraithConfig as Ns0_WraithConfig } from "@wraith-protocol/sdk";
+ * __check("@wraith-protocol/sdk", "Wraith", Ns0.Wraith);
+ *
+ * tsc validates that each property access exists on the namespace type.
+ * __check() catches undefined value exports at runtime.
+ * Known-missing symbols are excluded from the fixture entirely.
+ */
+function buildEsmFixture(importMap: ImportMap): string {
+ const out: string[] = [
+ "// AUTO-GENERATED — do not edit",
+ "// ESM fixture: tests the `import` export condition of @wraith-protocol/sdk",
+ "",
+ "function __check(spec: string, name: string, val: unknown): void {",
+ " if (val === undefined) throw new Error(`[ESM] ${spec} → '${name}' is undefined`);",
+ "}",
+ "",
+ ];
+
+ const entries = [...importMap.entries()];
+
+ // Namespace imports for value symbols
+ for (const [i, [specifier]] of entries.entries()) {
+ out.push(`import * as ${nsAlias(i)} from "${specifier}";`);
+ }
+ out.push("");
+
+ // Type-only imports (separate import type statements to satisfy tsc)
+ for (const [i, [specifier, symbols]] of entries.entries()) {
+ const types = [...symbols]
+ .filter((s) => TYPE_ONLY_SYMBOLS.has(s))
+ .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s));
+ if (types.length > 0) {
+ const aliased = types.map((t) => `${t} as ${nsAlias(i)}_${t}`).join(", ");
+ out.push(`import type { ${aliased} } from "${specifier}";`);
+ }
+ }
+ out.push("");
+
+ // Runtime checks for value exports
+ for (const [i, [specifier, symbols]] of entries.entries()) {
+ const values = [...symbols]
+ .filter((s) => !TYPE_ONLY_SYMBOLS.has(s))
+ .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s));
+ for (const v of values) {
+ out.push(`__check("${specifier}", "${v}", ${nsAlias(i)}.${v});`);
+ }
+ }
+
+ out.push("");
+ out.push("export {};");
+ return out.join("\n");
+}
+
+/**
+ * CJS fixture (.cts)
+ *
+ * Uses require() aliased per specifier to avoid redeclaration errors.
+ *
+ * const Ns0 = require("@wraith-protocol/sdk") as typeof import("@wraith-protocol/sdk");
+ * __check("@wraith-protocol/sdk", "Wraith", Ns0.Wraith);
+ */
+function buildCjsFixture(importMap: ImportMap): string {
+ const out: string[] = [
+ "// AUTO-GENERATED — do not edit",
+ "// CJS fixture: tests the `require` export condition of @wraith-protocol/sdk",
+ "",
+ "function __check(spec: string, name: string, val: unknown): void {",
+ " if (val === undefined) throw new Error(`[CJS] ${spec} → '${name}' is undefined`);",
+ "}",
+ "",
+ ];
+
+ const entries = [...importMap.entries()];
+
+ for (const [i, [specifier, symbols]] of entries.entries()) {
+ const values = [...symbols]
+ .filter((s) => !TYPE_ONLY_SYMBOLS.has(s))
+ .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s));
+ if (values.length === 0) continue;
+
+ const alias = nsAlias(i);
+
+ if (CJS_RUNTIME_SKIP.has(specifier)) {
+ // Type-check only: cast a dummy object so tsc validates the property
+ // names without actually calling require() at runtime.
+ out.push(
+ `// ${specifier}: CJS runtime skipped (broken transitive dep)`,
+ );
+ out.push(
+ `const ${alias} = {} as typeof import("${specifier}");`,
+ );
+ // No __check calls — we only care about compile-time validation here.
+ } else {
+ out.push(
+ `const ${alias} = require("${specifier}") as typeof import("${specifier}");`,
+ );
+ for (const v of values) {
+ out.push(`__check("${specifier}", "${v}", ${alias}.${v});`);
+ }
+ }
+ out.push("");
+ }
+
+ out.push("export {};");
+ return out.join("\n");
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function run(command: string, args: string[]): Promise<{ exitCode: number; output: string }> {
+ return new Promise((resolve) => {
+ const child = spawn(command, args, { cwd: REPO_ROOT, env: process.env, shell: false });
+ let output = "";
+ child.stdout.on("data", (c: Buffer) => (output += c));
+ child.stderr.on("data", (c: Buffer) => (output += c));
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, output }));
+ });
+}
+
+async function typeCheck(fixturePath: string, tmpDir: string): Promise {
+ const tsconfigPath = path.join(tmpDir, `tsconfig-${path.basename(fixturePath)}.json`);
+ await writeFile(
+ tsconfigPath,
+ JSON.stringify(
+ {
+ compilerOptions: {
+ target: "ES2022",
+ module: "NodeNext",
+ moduleResolution: "NodeNext",
+ lib: ["ES2022"],
+ strict: true,
+ skipLibCheck: false,
+ esModuleInterop: true,
+ allowSyntheticDefaultImports: true,
+ noEmit: true,
+ },
+ include: [fixturePath],
+ },
+ null,
+ 2,
+ ),
+ "utf8",
+ );
+ const { exitCode, output } = await run("pnpm", [
+ "exec", "tsc", "--noEmit", "--project", tsconfigPath,
+ ]);
+ return exitCode === 0 ? null : output.trim();
+}
+
+async function runWithTsx(fixturePath: string): Promise {
+ const { exitCode, output } = await run("pnpm", ["exec", "tsx", fixturePath]);
+ return exitCode === 0 ? null : output.trim();
+}
+
+async function findMdxFiles(dir: string): Promise {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const nested = await Promise.all(
+ entries.map(async (entry) => {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) return IGNORED_DIRS.has(entry.name) ? [] : findMdxFiles(full);
+ return entry.isFile() && entry.name.endsWith(".mdx") ? [full] : [];
+ }),
+ );
+ return nested.flat().sort();
+}
+
+// ─── main ─────────────────────────────────────────────────────────────────────
+
+async function main() {
+ // 1. Extract all documented imports
+ const mdxFiles = await findMdxFiles(REPO_ROOT);
+ const importEntries = await extractImports(mdxFiles);
+ const importMap = buildImportMap(importEntries);
+
+ if (importMap.size === 0) {
+ console.error(`No imports from "${PACKAGE_SCOPE}" found in any .mdx file.`);
+ process.exit(1);
+ }
+
+ // 2. Filter to installed packages only
+ const skipped: string[] = [];
+ const checkableMap: ImportMap = new Map();
+ for (const [specifier, symbols] of importMap) {
+ if (await isPackageInstalled(specifier)) {
+ checkableMap.set(specifier, symbols);
+ } else {
+ skipped.push(specifier);
+ }
+ }
+
+ // 3. Print summary
+ console.log(`Scanned ${mdxFiles.length} MDX file(s).\n`);
+ if (skipped.length > 0) {
+ console.log("⚠️ Skipped (package not installed):");
+ skipped.forEach((s) => console.log(` ${s}`));
+ console.log();
+ }
+ const totalSymbols = [...checkableMap.values()].reduce((n, s) => n + s.size, 0);
+ console.log("Documented entry points to check:");
+ for (const [specifier, symbols] of checkableMap) {
+ console.log(` ${specifier}`);
+ for (const sym of symbols) {
+ console.log(` • ${sym}${TYPE_ONLY_SYMBOLS.has(sym) ? " (type-only)" : ""}`);
+ }
+ }
+ console.log(`\nTotal: ${checkableMap.size} entry point(s), ${totalSymbols} unique symbol(s).\n`);
+
+ // Print known-missing warning
+ let knownMissingCount = 0;
+ for (const [specifier, symbols] of KNOWN_MISSING) {
+ if (!checkableMap.has(specifier)) continue;
+ for (const sym of symbols) {
+ if (checkableMap.get(specifier)?.has(sym)) {
+ if (knownMissingCount === 0) console.log("⚠️ Known-missing (documented but not yet shipped):");
+ console.log(` ${specifier} → ${sym}`);
+ knownMissingCount++;
+ }
+ }
+ }
+ if (knownMissingCount > 0) console.log();
+
+ if (checkableMap.size === 0) {
+ console.error("No installed entry points to check.");
+ process.exit(1);
+ }
+
+ // 4. Write fixtures
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "wraith-exports-check-"));
+ try {
+ await symlink(
+ path.join(REPO_ROOT, "node_modules"),
+ path.join(tmpDir, "node_modules"),
+ "dir",
+ ).catch(() => undefined);
+ await writeFile(
+ path.join(tmpDir, "package.json"),
+ JSON.stringify({ name: "wraith-exports-fixture", type: "module" }),
+ "utf8",
+ );
+
+ const esmFixture = path.join(tmpDir, "fixture.esm.mts");
+ const cjsFixture = path.join(tmpDir, "fixture.cjs.cts");
+ await writeFile(esmFixture, buildEsmFixture(checkableMap), "utf8");
+ await writeFile(cjsFixture, buildCjsFixture(checkableMap), "utf8");
+
+ const failures: Array<{ label: string; detail: string }> = [];
+
+ // 5. ESM: type-check then run
+ console.log("── ESM fixture ──────────────────────────────────");
+ process.stdout.write(" tsc … ");
+ const esmTypeErr = await typeCheck(esmFixture, tmpDir);
+ if (esmTypeErr) {
+ console.log("FAIL");
+ failures.push({ label: "ESM type check (tsc)", detail: esmTypeErr });
+ } else {
+ console.log("ok");
+ process.stdout.write(" tsx … ");
+ const esmRunErr = await runWithTsx(esmFixture);
+ if (esmRunErr) {
+ console.log("FAIL");
+ failures.push({ label: "ESM runtime (tsx)", detail: esmRunErr });
+ } else {
+ console.log("ok");
+ }
+ }
+
+ // 6. CJS: type-check then run
+ console.log("\n── CJS fixture ──────────────────────────────────");
+ process.stdout.write(" tsc … ");
+ const cjsTypeErr = await typeCheck(cjsFixture, tmpDir);
+ if (cjsTypeErr) {
+ console.log("FAIL");
+ failures.push({ label: "CJS type check (tsc)", detail: cjsTypeErr });
+ } else {
+ console.log("ok");
+ process.stdout.write(" tsx … ");
+ const cjsRunErr = await runWithTsx(cjsFixture);
+ if (cjsRunErr) {
+ console.log("FAIL");
+ failures.push({ label: "CJS runtime (tsx)", detail: cjsRunErr });
+ } else {
+ console.log("ok");
+ }
+ }
+
+ // 7. Verdict
+ console.log();
+ if (failures.length > 0) {
+ console.error("━━━ EXPORT CHECK FAILED ━━━\n");
+ for (const { label, detail } of failures) {
+ console.error(`❌ ${label}\n`);
+ console.error(detail);
+ console.error();
+ }
+ console.error(
+ "Docs and package exports diverge. Either:\n" +
+ " • Update the docs to match what the package actually exports, or\n" +
+ " • Add the missing export to @wraith-protocol/sdk.",
+ );
+ process.exit(1);
+ }
+
+ console.log("━━━ EXPORT CHECK PASSED ━━━");
+ console.log(
+ `All ${totalSymbols} symbol(s) across ${checkableMap.size} entry point(s) are present in both ESM and CJS.`,
+ );
+ } finally {
+ await rm(tmpDir, { force: true, recursive: true });
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs
new file mode 100644
index 0000000..0e42088
--- /dev/null
+++ b/scripts/check-links.mjs
@@ -0,0 +1,380 @@
+#!/usr/bin/env node
+/**
+ * check-links.mjs
+ *
+ * Crawls every .mdx/.md file in the repo and verifies:
+ * - Internal page links resolve to an existing file.
+ * - Anchor fragments (#section) match a heading id in the target page.
+ * - Local asset paths (images, diagrams) exist on disk.
+ * - External HTTP(S) links return 2xx (with a HEAD request, retried once).
+ *
+ * URLs listed in link-exceptions.yaml are skipped for external checks.
+ *
+ * Usage:
+ * node scripts/check-links.mjs [--report ]
+ *
+ * Exits 0 on success, 1 if any broken links are found.
+ * Writes a failure report to --report (default: link-check-report.json).
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { readdir, readFile, stat } from "node:fs/promises";
+import { parseArgs } from "node:util";
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// CLI args
+// ---------------------------------------------------------------------------
+const { values: args } = parseArgs({
+ options: {
+ report: { type: "string", default: "link-check-report.json" },
+ },
+ strict: false,
+});
+
+const REPORT_PATH = args.report;
+
+// ---------------------------------------------------------------------------
+// Paths
+// ---------------------------------------------------------------------------
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const EXCEPTIONS_FILE = path.join(ROOT, "link-exceptions.yaml");
+
+// ---------------------------------------------------------------------------
+// Load exception list (tiny hand-rolled YAML parser — no deps needed)
+// ---------------------------------------------------------------------------
+/**
+ * Parses the `exceptions` block from link-exceptions.yaml.
+ * Returns an array of URL prefixes to skip.
+ * @returns {string[]}
+ */
+function loadExceptions() {
+ if (!fs.existsSync(EXCEPTIONS_FILE)) return [];
+ const raw = fs.readFileSync(EXCEPTIONS_FILE, "utf8");
+ const urls = [];
+ let inExceptions = false;
+ for (const line of raw.split("\n")) {
+ if (/^exceptions:/.test(line)) { inExceptions = true; continue; }
+ if (!inExceptions) continue;
+ const urlMatch = line.match(/^\s+-\s+url:\s+"?([^"]+)"?\s*$/);
+ if (urlMatch) urls.push(urlMatch[1].trim());
+ }
+ return urls;
+}
+
+const EXCEPTION_PREFIXES = loadExceptions();
+
+function isExcepted(url) {
+ return EXCEPTION_PREFIXES.some((prefix) => url.startsWith(prefix));
+}
+
+/** Check whether an internal path (e.g. /reference/stellar-event-schemas) is excepted. */
+function isInternalExcepted(absPath) {
+ // Convert absolute disk path back to a root-relative path for matching
+ const rel = "/" + absPath.replace(/\\/g, "/").split(ROOT.replace(/\\/g, "/") + "/")[1];
+ return EXCEPTION_PREFIXES
+ .filter((p) => p.startsWith("internal:"))
+ .map((p) => p.slice("internal:".length))
+ .some((prefix) => rel.startsWith(prefix));
+}
+
+// ---------------------------------------------------------------------------
+// Collect source files
+// ---------------------------------------------------------------------------
+async function collectFiles(dir, exts = [".mdx", ".md"]) {
+ const results = [];
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...(await collectFiles(full, exts)));
+ } else if (exts.includes(path.extname(entry.name))) {
+ results.push(full);
+ }
+ }
+ return results;
+}
+
+// ---------------------------------------------------------------------------
+// Extract links and headings from file content
+// ---------------------------------------------------------------------------
+const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
+const IMAGE_RE = /!?\[(?:[^\]]*)\]\(([^)]+)\)/g;
+// MDX/JSX image src and href props
+const JSX_HREF_RE = /(?:href|src)=["']([^"']+)["']/g;
+// Heading lines to derive anchor ids (# Title, ## Title, etc.)
+const HEADING_RE = /^#{1,6}\s+(.+)$/gm;
+
+/**
+ * Derives the GitHub-flavoured Markdown anchor id from a heading string.
+ * Strips MDX/HTML tags, lowercases, replaces spaces with hyphens.
+ */
+function headingToAnchor(heading) {
+ // 1. If a Mintlify/MDX explicit id is present — {#custom-id} — use it directly.
+ const explicitId = heading.match(/\{#([\w-]+)\}\s*$/);
+ if (explicitId) return explicitId[1];
+
+ return heading
+ .replace(/<[^>]+>/g, "") // strip HTML/JSX tags
+ .replace(/`[^`]+`/g, (m) => m.slice(1, -1)) // strip backtick wrapping
+ .replace(/&/g, "-") // HTML entity & → hyphen (GFM behaviour)
+ .replace(/&/g, "-") // bare & → hyphen (GFM behaviour)
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, "") // remove remaining punctuation except hyphen
+ .trim()
+ .replace(/\s+/g, "-") // spaces → hyphens
+ .replace(/-{2,}/g, "-"); // collapse consecutive hyphens
+}
+
+function extractHeadings(content) {
+ const ids = new Set();
+ let m;
+ HEADING_RE.lastIndex = 0;
+ while ((m = HEADING_RE.exec(content)) !== null) {
+ ids.add(headingToAnchor(m[1]));
+ }
+ return ids;
+}
+
+function extractLinks(content) {
+ const links = new Set();
+ let m;
+ for (const re of [LINK_RE, JSX_HREF_RE]) {
+ re.lastIndex = 0;
+ while ((m = re.exec(content)) !== null) {
+ links.add(m[1].trim());
+ }
+ }
+ return [...links];
+}
+
+// ---------------------------------------------------------------------------
+// Resolve internal paths
+// ---------------------------------------------------------------------------
+/**
+ * Given a link href relative to `fromFile`, resolve to an absolute disk path.
+ * Handles:
+ * - relative paths (./foo, ../bar)
+ * - root-relative (/guides/foo)
+ * - anchor-only (#section)
+ * Returns null for external or mailto links.
+ */
+function resolveInternal(href, fromFile) {
+ if (/^https?:\/\//i.test(href)) return null; // external
+ if (/^mailto:/i.test(href)) return null;
+ if (href.startsWith("#")) return { file: fromFile, anchor: href.slice(1) };
+
+ // Strip query string before resolving — query params are not filesystem paths
+ const hrefNoQuery = href.split("?")[0];
+ const [pathPart, anchor] = hrefNoQuery.split("#");
+
+ let resolved;
+ if (pathPart.startsWith("/")) {
+ resolved = path.join(ROOT, pathPart);
+ } else {
+ resolved = path.resolve(path.dirname(fromFile), pathPart);
+ }
+
+ // Try exact match, then append extensions
+ return { file: resolved, anchor: anchor || null };
+}
+
+async function fileExists(filePath) {
+ const candidates = [
+ filePath + ".mdx",
+ filePath + ".md",
+ path.join(filePath, "index.mdx"),
+ path.join(filePath, "index.md"),
+ filePath, // bare path last — only accepted if it's a file, not a dir
+ ];
+ for (const c of candidates) {
+ try {
+ const s = await stat(c);
+ if (s.isFile()) return c;
+ } catch { /* continue */ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// External link checker with retry
+// ---------------------------------------------------------------------------
+async function checkExternal(url, retries = 1) {
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+ const res = await fetch(url, {
+ method: "HEAD",
+ redirect: "follow",
+ headers: { "User-Agent": "docs-link-checker/1.0 (+CI)" },
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
+ if (res.ok || res.status === 429) return { ok: res.ok, status: res.status };
+ // Some servers reject HEAD; retry with GET on last attempt
+ if (attempt === retries) {
+ const res2 = await fetch(url, {
+ method: "GET",
+ redirect: "follow",
+ headers: { "User-Agent": "docs-link-checker/1.0 (+CI)" },
+ signal: AbortSignal.timeout(10_000),
+ });
+ return { ok: res2.ok, status: res2.status };
+ }
+ } catch (err) {
+ if (attempt === retries) return { ok: false, status: 0, error: err.message };
+ }
+ // Back off before retry
+ await new Promise((r) => setTimeout(r, 1_000));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+async function main() {
+ console.log("🔗 Collecting source files…");
+ const files = await collectFiles(ROOT);
+ console.log(` Found ${files.length} MDX/MD files.`);
+
+ /** @type {Map>} file → set of anchor ids */
+ const headingCache = new Map();
+
+ async function getHeadings(filePath) {
+ if (headingCache.has(filePath)) return headingCache.get(filePath);
+ const resolved = await fileExists(filePath);
+ if (!resolved) return new Set();
+ const content = await readFile(resolved, "utf8");
+ const ids = extractHeadings(content);
+ headingCache.set(filePath, ids);
+ return ids;
+ }
+
+ /** @type {{ file: string; href: string; reason: string }[]} */
+ const broken = [];
+
+ // ── External URLs de-duped across all files ──────────────────────────────
+ /** @type {Map} url → list of source files */
+ const externalMap = new Map();
+
+ console.log("🔍 Checking internal links and assets…");
+
+ for (const file of files) {
+ const content = await readFile(file, "utf8");
+ const links = extractLinks(content);
+ const relFile = path.relative(ROOT, file);
+
+ for (const href of links) {
+ if (!href || href.startsWith("mailto:") || href.startsWith("data:")) continue;
+
+ // ── External ──────────────────────────────────────────────────────────
+ if (/^https?:\/\//i.test(href)) {
+ const bare = href.split("#")[0]; // ignore fragment for external
+ if (!externalMap.has(bare)) externalMap.set(bare, []);
+ externalMap.get(bare).push(relFile);
+ continue;
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────────
+ const resolved = resolveInternal(href, file);
+ if (!resolved) continue;
+
+ const existingFile = await fileExists(resolved.file);
+
+ if (!existingFile) {
+ if (isInternalExcepted(resolved.file)) continue; // known stub
+ broken.push({ file: relFile, href, reason: "File not found" });
+ continue;
+ }
+
+ if (resolved.anchor) {
+ if (isInternalExcepted(resolved.file)) continue; // excepted file — skip anchor check too
+ const headings = await getHeadings(existingFile);
+ if (!headings.has(resolved.anchor)) {
+ broken.push({
+ file: relFile,
+ href,
+ reason: `Anchor #${resolved.anchor} not found in ${path.relative(ROOT, existingFile)}`,
+ });
+ }
+ }
+ }
+ }
+
+ // ── External checks (concurrent, capped) ─────────────────────────────────
+ const externalUrls = [...externalMap.keys()];
+ console.log(`🌐 Checking ${externalUrls.length} unique external URLs…`);
+
+ const CONCURRENCY = 10;
+ for (let i = 0; i < externalUrls.length; i += CONCURRENCY) {
+ const batch = externalUrls.slice(i, i + CONCURRENCY);
+ await Promise.all(
+ batch.map(async (url) => {
+ if (isExcepted(url)) {
+ console.log(` ⏭ Skipped (exception): ${url}`);
+ return;
+ }
+ const result = await checkExternal(url);
+ if (!result.ok) {
+ const sources = externalMap.get(url);
+ for (const src of sources) {
+ broken.push({
+ file: src,
+ href: url,
+ reason: result.error
+ ? `Network error: ${result.error}`
+ : `HTTP ${result.status}`,
+ });
+ }
+ }
+ })
+ );
+ }
+
+ // ── Report ────────────────────────────────────────────────────────────────
+ if (broken.length === 0) {
+ console.log("\n✅ No broken links found.");
+ return;
+ }
+
+ console.error(`\n❌ Found ${broken.length} broken link(s):\n`);
+
+ // Group by file for readability
+ const byFile = new Map();
+ for (const item of broken) {
+ if (!byFile.has(item.file)) byFile.set(item.file, []);
+ byFile.get(item.file).push(item);
+ }
+
+ for (const [file, items] of [...byFile.entries()].sort()) {
+ console.error(` ${file}`);
+ for (const { href, reason } of items) {
+ console.error(` • ${href} → ${reason}`);
+ }
+ }
+
+ // Write structured report
+ const report = {
+ generatedAt: new Date().toISOString(),
+ totalBroken: broken.length,
+ byFile: Object.fromEntries(
+ [...byFile.entries()].sort().map(([file, items]) => [
+ file,
+ items.map(({ href, reason }) => ({ href, reason })),
+ ])
+ ),
+ };
+ fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2));
+ console.error(`\nReport written to ${REPORT_PATH}`);
+
+ process.exit(1);
+}
+
+main().catch((err) => {
+ console.error("Unexpected error:", err);
+ process.exit(1);
+});
diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx
index d8a18b6..c9e6df1 100644
--- a/sdk/chains/stellar.mdx
+++ b/sdk/chains/stellar.mdx
@@ -270,7 +270,7 @@ const signature = signWithScalar(messageBytes, stealthScalar, stealthPubKey);
The stealth scalar `(spendingScalar + hashScalar) % L` is not necessarily clamped, so standard Stellar signing functions don't work.
-### `signStellarTransaction(txHash, stealthScalar, stealthPubKey)`
+### `signStellarTransaction(txHash, stealthScalar, stealthPubKey)` {#signstellartransaction}
Sign a Stellar transaction hash with a stealth private scalar.
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