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