From 749c1cb733ac708818e1849d1a74f36ede9a696f Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 11:32:29 +0300 Subject: [PATCH 1/2] fix(docs): make the Python quickstart match the SDK and the v2 announcer Running the quickstart against vectors from the SDK showed it could not work: - scalar * G came from Ed25519PrivateKey.from_private_bytes(), which hashes and clamps the input, so stealth addresses never matched the SDK and sign_with_scalar produced signatures that do not verify. - The view tag hashed the shared secret under the v2 prefix. The v2 tag is SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0], with no ECDH. - fetch_announcements filtered on two-topic events and read a map value, but the v2 announcer emits four topics and a (stealth, R, metadata) tuple. It now pages through getEvents with the cursor. - announce() used scheme 1; the deployed v2 announcer asserts scheme 2. - Step 6b used simulate_transaction(...).transaction instead of prepare_transaction(). Step 7 called a nonexistent add_signature() and left too little XLM for the base reserve; it now merges the account. The guide now targets testnet, where the contracts live, pins stellar-sdk 16.1.0, cryptography 50.0.2 and httpx 0.28.1, and drops every no-check fence. --- guides/quickstarts/python.mdx | 533 ++++++++++++++++------------------ 1 file changed, 256 insertions(+), 277 deletions(-) diff --git a/guides/quickstarts/python.mdx b/guides/quickstarts/python.mdx index 9c5ad87..209769f 100644 --- a/guides/quickstarts/python.mdx +++ b/guides/quickstarts/python.mdx @@ -1,14 +1,14 @@ --- 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" +keywords: "Python, stellar-sdk, stealth payment, Soroban RPC, ed25519, X25519, testnet, 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. + This guide targets the Wraith contracts on Stellar **testnet**. The derivation + crypto and RPC calls are identical on mainnet — only the RPC URLs, + `NETWORK_PASS`, 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: @@ -17,20 +17,25 @@ By the end of this guide you will have: - 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 +- Spent from a stealth address with its derived private scalar -**Prerequisites:** Python 3.11+, `pip`, and a Stellar keypair you can fund on futurenet. +**Prerequisites:** Python 3.11+, `pip`, and a Stellar testnet keypair funded with Friendbot. + +The steps build on each other: run them in order in one Python session, or paste +them into one script. --- ## Step 1 — Install dependencies -```bash no-check -pip install stellar-sdk==11.* cryptography +```bash +pip install stellar-sdk==16.1.0 cryptography==50.0.2 httpx==0.28.1 ``` `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. +`StrKey` address encoding. `cryptography` provides the ed25519 and X25519 +primitives the crypto utility builds on, and `httpx` makes the JSON-RPC calls in +Step 4. These are the versions the docs CI runs this guide against. No TypeScript toolchain is required. @@ -42,7 +47,7 @@ This utility file implements the three primitives the rest of the guide relies o **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 +```python # wraith_crypto.py """ Wraith Protocol — Stellar stealth-address crypto, pure Python. @@ -50,28 +55,25 @@ 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 + - v2 view tag over the ephemeral and viewing public keys (1 byte, ~255/256 skip rate) + - ed25519 point arithmetic for stealth public keys + +The curve arithmetic uses Python integers and is not constant-time. It suits +scanning and tooling; keep production signing keys on hardened code. -Requires: cryptography>=41, stellar-sdk>=11 +Requires: stellar-sdk and cryptography (pinned versions in Step 1) """ from __future__ import annotations import hashlib -import hmac -import struct +import os 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 cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from stellar_sdk import Keypair # --------------------------------------------------------------------------- @@ -86,7 +88,7 @@ STEALTH_SIGNING_MESSAGE = ( ) META_ADDRESS_PREFIX = "st:xlm:" -SCHEME_ID = 1 +SCHEME_ID = 2 # the stealth-announcer v2 contract only accepts scheme 2 # ed25519 group order L L = 2**252 + 27742317777372353535851937790883648493 @@ -103,8 +105,11 @@ def _sha256(*parts: bytes) -> bytes: return h.digest() -def _sha512(data: bytes) -> bytes: - return hashlib.sha512(data).digest() +def _sha512(*parts: bytes) -> bytes: + h = hashlib.sha512() + for part in parts: + h.update(part) + return h.digest() def _clamp_scalar(scalar_bytes: bytearray) -> bytearray: @@ -124,8 +129,7 @@ def _seed_to_scalar(seed: bytes) -> int: 3. Clamp a 4. Interpret as little-endian bigint """ - h = _sha512(seed) - a = bytearray(h[:32]) + a = bytearray(_sha512(seed)[:32]) _clamp_scalar(a) return int.from_bytes(a, "little") @@ -137,35 +141,83 @@ def _ed25519_pub_from_seed(seed: bytes) -> bytes: # --------------------------------------------------------------------------- -# Edwards-to-Montgomery conversion (RFC 7748) +# ed25519 point arithmetic (affine coordinates on -x^2 + y^2 = 1 + d*x^2*y^2) # --------------------------------------------------------------------------- -# Ed25519 curve parameters _P = 2**255 - 19 _D = -121665 * pow(121666, _P - 2, _P) % _P +_SQRT_M1 = pow(2, (_P - 1) // 4, _P) + +Point = tuple[int, int] +_IDENTITY: Point = (0, 1) -def _decompress_ed25519(point_bytes: bytes) -> tuple[int, int]: - """Decompress a 32-byte compressed ed25519 point to (x, y).""" +def _decompress_ed25519(point_bytes: bytes) -> Point: + """Decompress a 32-byte ed25519 point to (x, y), rejecting invalid encodings.""" + if len(point_bytes) != 32: + raise ValueError("ed25519 points are 32 bytes") y_int = int.from_bytes(point_bytes, "little") - sign = (y_int >> 255) & 1 + sign = y_int >> 255 y = y_int & ((1 << 255) - 1) + if y >= _P: + raise ValueError("not a valid ed25519 point") - # 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 + # Recover x from the curve equation: x^2 = (y^2 - 1) / (d*y^2 + 1) + y2 = y * y % _P + x2 = (y2 - 1) * pow(_D * y2 + 1, _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 - + x = x * _SQRT_M1 % _P + if (x * x - x2) % _P != 0 or (x == 0 and sign == 1): + raise ValueError("not a valid ed25519 point") if x % 2 != sign: x = _P - x - return x, y +def _compress_ed25519(point: Point) -> bytes: + """Encode (x, y) as 32 bytes: y little-endian, sign of x in the top bit.""" + x, y = point + encoded = bytearray(y.to_bytes(32, "little")) + if x & 1: + encoded[31] |= 0x80 + return bytes(encoded) + + +def _point_add(p1: Point, p2: Point) -> Point: + """ + Edwards point addition (complete on ed25519, so it also doubles): + x3 = (x1*y2 + y1*x2) / (1 + d*x1*x2*y1*y2) + y3 = (y1*y2 + x1*x2) / (1 - d*x1*x2*y1*y2) + """ + x1, y1 = p1 + x2, y2 = p2 + t = _D * x1 * x2 * y1 * y2 % _P + x3 = (x1 * y2 + y1 * x2) * pow(1 + t, _P - 2, _P) % _P + y3 = (y1 * y2 + x1 * x2) * pow(1 - t, _P - 2, _P) % _P + return x3, y3 + + +def _scalar_mult(scalar: int, point: Point) -> Point: + """scalar * point by double-and-add.""" + result, addend = _IDENTITY, point + while scalar: + if scalar & 1: + result = _point_add(result, addend) + addend = _point_add(addend, addend) + scalar >>= 1 + return result + + +# Base point G: y = 4/5, x even +_BASE = _decompress_ed25519((4 * pow(5, _P - 2, _P) % _P).to_bytes(32, "little")) + + +def _scalar_mult_base(scalar: int) -> bytes: + """Return scalar * G, compressed.""" + return _compress_ed25519(_scalar_mult(scalar, _BASE)) + + def _edwards_to_montgomery_pub(ed_pub: bytes) -> bytes: """ Convert an ed25519 public key (Edwards form) to an X25519 key (Montgomery form). @@ -180,10 +232,7 @@ 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) + return bytes(_clamp_scalar(bytearray(_sha512(ed_seed)[:32]))) # --------------------------------------------------------------------------- @@ -230,6 +279,8 @@ def derive_stealth_keys(signature: bytes) -> StealthKeys: Domain-separated hashing is used (instead of splitting r/s) because ed25519 signature components lack the independence of secp256k1 components. """ + if len(signature) != 64: + raise ValueError("Expected a 64-byte ed25519 signature") spending_key = _sha256(b"wraith:spending:", signature) viewing_key = _sha256(b"wraith:viewing:", signature) @@ -256,13 +307,14 @@ def _x25519_shared_secret(priv_ed_seed: bytes, pub_ed: bytes) -> bytes: return x_priv.exchange(x_pub) -def _compute_view_tag(shared_secret: bytes) -> int: +def compute_view_tag(ephemeral_pub_key: bytes, viewing_pub_key: bytes) -> int: """ - Compute the 1-byte view tag (v2 domain). - view_tag = SHA-256("wraith:stellar:view-tag:v2:" || shared_secret)[0] + Compute the 1-byte v2 view tag (mirrors computeAnnouncementViewTag in the SDK). + view_tag = SHA-256("wraith:stellar:view-tag:v2:" || ephemeral_pub || viewing_pub)[0] + + It needs no ECDH, so scanners reject ~255/256 announcements with one hash. """ - digest = _sha256(b"wraith:stellar:view-tag:v2:", shared_secret) - return digest[0] + return _sha256(b"wraith:stellar:view-tag:v2:", ephemeral_pub_key, viewing_pub_key)[0] def _hash_to_scalar(shared_secret: bytes) -> int: @@ -274,45 +326,14 @@ def _hash_to_scalar(shared_secret: bytes) -> int: return int.from_bytes(digest, "little") % L -def _ed25519_point_add(pub_bytes: bytes, scalar: int) -> bytes: +def _derive_stealth_pub_key(spending_pub_key: bytes, h_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). + stealth_pub = spending_pub + h_scalar * G + (mirrors deriveStealthPubKey in SDK scalar.ts) """ - # 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) + spending_point = _decompress_ed25519(spending_pub_key) + h_point = _scalar_mult(h_scalar, _BASE) + return _compress_ed25519(_point_add(spending_point, h_point)) def _pub_key_to_stellar_address(pub_key: bytes) -> str: @@ -334,28 +355,24 @@ def generate_stealth_address( 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] + 3. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || ephemeral_pub || viewing_pub)[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) + stealth_pub = _derive_stealth_pub_key(spending_pub_key, h_scalar) return GeneratedStealthAddress( - stealth_address=stealth_address, + stealth_address=_pub_key_to_stellar_address(stealth_pub), ephemeral_pub_key=ephemeral_pub, - view_tag=view_tag, + view_tag=compute_view_tag(ephemeral_pub, viewing_pub_key), ) @@ -386,6 +403,7 @@ def scan_announcements( Scan a list of announcement dicts for payments addressed to these keys. Each announcement dict must have: + scheme_id : int (2 for the v2 announcer) ephemeral_pub_key : hex string (64 chars, 32 bytes) metadata : hex string (first byte = view tag) stealth_address : str (G... address) @@ -393,29 +411,36 @@ def scan_announcements( 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 %). + View-tag prefilter: the tag costs one SHA-256, so only ~1/256 of the + non-matching announcements reach the X25519 step (false-positive rate ≈ 0.39 %). """ + viewing_pub_key = _ed25519_pub_from_seed(viewing_key) 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] + if ann.get("scheme_id") != SCHEME_ID: + continue + try: + eph_bytes = bytes.fromhex(ann["ephemeral_pub_key"]) + announced_view_tag = bytes.fromhex(ann["metadata"])[0] + except (KeyError, ValueError, IndexError): + continue # malformed announcement # ------------------------------------------------------------------ - # Cheap prefilter: compute expected view tag and compare + # Cheap prefilter: compare the view tag before any curve operation # ------------------------------------------------------------------ - shared = _x25519_shared_secret(viewing_key, eph_bytes) - expected_tag = _compute_view_tag(shared) - if expected_tag != announced_view_tag: + if compute_view_tag(eph_bytes, viewing_pub_key) != announced_view_tag: continue # skip ~255/256 of non-matching entries # ------------------------------------------------------------------ # Full check: derive the expected stealth address and compare # ------------------------------------------------------------------ + try: + shared = _x25519_shared_secret(viewing_key, eph_bytes) + except ValueError: + continue # the ephemeral key is not a valid ed25519 point h_scalar = _hash_to_scalar(shared) - stealth_pub = _ed25519_point_add(spending_pub_key, h_scalar) + stealth_pub = _derive_stealth_pub_key(spending_pub_key, h_scalar) derived_address = _pub_key_to_stellar_address(stealth_pub) if derived_address != ann["stealth_address"]: @@ -426,6 +451,8 @@ def scan_announcements( # stealth_scalar = (spending_scalar + h_scalar) mod L # ------------------------------------------------------------------ stealth_scalar = (spending_scalar + h_scalar) % L + if stealth_scalar == 0: + continue results.append(MatchedAnnouncement( stealth_address=derived_address, @@ -438,8 +465,8 @@ def scan_announcements( ``` - The crypto needed for Wraith's Stellar scheme is deliberately small — roughly - 200 lines of hashing and curve arithmetic. Shipping it inline keeps this + The crypto needed for Wraith's Stellar scheme is deliberately small — a few + hundred 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. @@ -449,7 +476,7 @@ def scan_announcements( ## Step 3 — Derive your stealth keys -```python no-check +```python import os from stellar_sdk import Keypair from wraith_crypto import STEALTH_SIGNING_MESSAGE, derive_stealth_keys, encode_meta_address @@ -462,10 +489,11 @@ keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"]) signature = keypair.sign(STEALTH_SIGNING_MESSAGE.encode()) keys = derive_stealth_keys(signature) +meta_address = encode_meta_address(keys.spending_pub_key, keys.viewing_pub_key) 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)) +print("Meta-address: ", meta_address) # → st:xlm:<64 hex chars><64 hex chars> ``` @@ -477,15 +505,18 @@ one in the TypeScript SDK, so keys derived here are usable by any SDK implementa ## 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 +The Soroban RPC's `getEvents` method returns contract events one page at a time. +The utility below pages through recent events from the announcer contract and normalises them into the dict shape `scan_announcements` expects. -```python no-check -import httpx # pip install httpx +```python +import httpx +from stellar_sdk import scval, xdr as stellar_xdr -SOROBAN_RPC_URL = "https://rpc-futurenet.stellar.org" -ANNOUNCER_CONTRACT_ID = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL" # testnet value; futurenet has no live contracts +from wraith_crypto import SCHEME_ID + +SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org" +ANNOUNCER_CONTRACT_ID = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL" # stealth-announcer v2, testnet def _rpc(method: str, params: dict) -> dict: @@ -502,125 +533,87 @@ def _rpc(method: str, params: dict) -> dict: 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 _decode(scval_xdr: str): + """Decode a base64 XDR ScVal into plain Python values.""" + return scval.to_native(stellar_xdr.SCVal.from_xdr(scval_xdr)) -def fetch_announcements() -> list[dict]: +def fetch_announcements(lookback_ledgers: int = 17_000) -> list[dict]: """ - Fetch all WRAITH_ANNOUNCEMENT events from the announcer contract. + Fetch recent announcements from the stealth-announcer contract. + + The v2 announcer publishes each announcement as + topics = ("announce", scheme_id, view_tag_bucket, metadata_kind) + value = (stealth_address, ephemeral_pub_key, metadata) - Returns a list of announcement dicts with keys: - stealth_address, caller, ephemeral_pub_key, metadata, scheme_id + Returns a list of dicts with keys: + scheme_id, stealth_address, ephemeral_pub_key, metadata """ - oldest, latest = _current_ledger_range() - - result = _rpc("getEvents", { - "startLedger": oldest, - "filters": [{ - "type": "contract", - "contractIds": [ANNOUNCER_CONTRACT_ID], - "topics": [["*", "*"]], # accept all topics from this contract - }], + latest = _rpc("getLatestLedger", {})["sequence"] + filters = [{"type": "contract", "contractIds": [ANNOUNCER_CONTRACT_ID]}] + # The RPC keeps a limited event history (~17,280 ledgers is ~24 h at 5 s/ledger) + params = { + "startLedger": max(latest - lookback_ledgers, 1), + "filters": filters, "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) + while True: + page = _rpc("getEvents", params) + events = page.get("events", []) + if not events: + break + + for event in events: + topics = [_decode(topic) for topic in event["topic"]] + if len(topics) != 4 or topics[0] != "announce" or topics[1] != SCHEME_ID: + continue # not a v2 announcement + stealth_address, ephemeral_pub_key, metadata = _decode(event["value"]) + announcements.append({ + "scheme_id": topics[1], + "stealth_address": stealth_address.address, # G... address + "ephemeral_pub_key": ephemeral_pub_key.hex(), + "metadata": metadata.hex(), + }) + + # The cursor continues after the last event; it replaces startLedger + params = {"filters": filters, "pagination": {"cursor": page["cursor"], "limit": 200}} 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`. + For high-throughput backends, cache the announcement list and keep the last + cursor. Each poll then asks only for events after it instead of re-fetching the + whole window. --- ## 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())) +```python +from wraith_crypto import scan_announcements announcements = fetch_announcements() # from Step 4 matches = scan_announcements( announcements, - viewing_key=keys.viewing_key, + viewing_key=keys.viewing_key, # `keys` from Step 3 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 + # m.stealth_private_scalar signs for this address (Step 7). + # Treat it like a secret key: never log or share it. ``` -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 +The scanner checks the view tag first. It costs one SHA-256, 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. --- @@ -633,12 +626,13 @@ announcer contract. ### 6a — Resolve the recipient's meta-address -```python no-check +```python 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:" +# In production the recipient shares their meta-address, directly or through a +# .wraith name. Here you pay your own meta-address from Step 3, so Steps 4 and 5 +# find the payment once the announcement lands. +RECIPIENT_META_ADDRESS = meta_address spending_pub, viewing_pub = decode_meta_address(RECIPIENT_META_ADDRESS) stealth = generate_stealth_address(spending_pub, viewing_pub) @@ -654,25 +648,21 @@ New Stellar accounts must be activated with `createAccount`. After activation, c the Soroban announcer contract to publish the ephemeral key and view tag so the recipient can detect their payment. -```python no-check +```python 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 +from stellar_sdk import Keypair, Network, Server, SorobanServer, TransactionBuilder, scval +from stellar_sdk.soroban_rpc import SendTransactionStatus -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 +from wraith_crypto import SCHEME_ID + +HORIZON_URL = "https://horizon-testnet.stellar.org" +NETWORK_PASS = Network.TESTNET_NETWORK_PASSPHRASE +AMOUNT_XLM = "10" # starting balance for the stealth account (the minimum is 1 XLM) sender_keypair = Keypair.from_secret(os.environ["STELLAR_SECRET"]) -horizon = Server(HORIZON_URL) -soroban = SorobanServer(SOROBAN_URL) +horizon = Server(HORIZON_URL) +soroban = SorobanServer(SOROBAN_RPC_URL) # from Step 4 # ------------------------------------------------------------------ # Part 1: createAccount — activate the stealth address @@ -699,19 +689,14 @@ 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 - +# ephemeral_pub_key: BytesN<32>, metadata: Bytes) +# The first metadata byte is the view tag. 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) + scval.to_uint32(SCHEME_ID), # scheme_id (2) + scval.to_address(stealth.stealth_address), # stealth address + scval.to_bytes(stealth.ephemeral_pub_key), # ephemeral pub key + scval.to_bytes(bytes([stealth.view_tag])), # metadata (view tag) ] sender_account = horizon.load_account(sender_keypair.public_key) # refresh sequence @@ -731,19 +716,22 @@ announce_tx = ( .build() ) -# Simulate to get resource footprint before signing -sim = soroban.simulate_transaction(announce_tx) -announce_tx = sim.transaction # updated with resource fee +# Simulate, then attach the resource footprint and fee before signing +announce_tx = soroban.prepare_transaction(announce_tx) announce_tx.sign(sender_keypair) -response = soroban.send_transaction(announce_tx) -print("Announcement tx hash:", response.hash) +sent = soroban.send_transaction(announce_tx) +if sent.status != SendTransactionStatus.PENDING: + raise RuntimeError(f"Announcement rejected: {sent.status}") +result = soroban.poll_transaction(sent.hash) # waits for the ledger to close +print("Announcement tx:", sent.hash, result.status.value) ``` - 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). + Fund the sender on testnet with Friendbot before running Step 6. On mainnet, + swap in the mainnet Horizon and RPC URLs, `Network.PUBLIC_NETWORK_PASSPHRASE`, + and the mainnet `ANNOUNCER_CONTRACT_ID` from + [Stellar Networks](/reference/stellar-networks). --- @@ -752,84 +740,75 @@ print("Announcement tx hash:", response.hash) 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. +`Keypair.from_raw_ed25519_seed()` path does not work. Add `sign_with_scalar` to +`wraith_crypto.py`: -```python no-check +```python # Add to wraith_crypto.py -def sign_with_scalar( - message: bytes, - scalar: int, - pub_key_bytes: bytes, -) -> bytes: +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. + signing APIs. Mirrors signWithScalar in SDK scalar.ts, byte for byte. 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 + if not 0 < scalar < L: + raise ValueError("Scalar must be in range (0, L)") 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") + # Deterministic nonce: r = SHA-512(SHA-256(scalar_bytes) || message) mod L + r = int.from_bytes(_sha512(_sha256(s_bytes), message), "little") % L # R = r * G - r_key = Ed25519PrivateKey.from_private_bytes(bytes(r_bytes)) - R_bytes = r_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + R_bytes = _scalar_mult_base(r) # 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 + k = int.from_bytes(_sha512(R_bytes, pub_key_bytes, message), "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: +Use it to withdraw from a matched stealth address. The example merges the +stealth account into your primary account, which sends its whole balance (base +reserve included) and closes the one-time account: + +```python +from stellar_sdk import DecoratedSignature -```python no-check from wraith_crypto import sign_with_scalar -# `match` is a MatchedAnnouncement from scan_announcements() +if not matches: + raise SystemExit("No payments yet. Re-run Steps 4 and 5 after the Step 6 announcement lands.") + +match = matches[0] # a MatchedAnnouncement from Step 5 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 - ) + .append_account_merge_op(destination=sender_keypair.public_key) .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, +# Sign with the raw scalar — not with a Keypair seed +sig_bytes = sign_with_scalar( + withdrawal_tx.hash(), match.stealth_private_scalar, match.stealth_pub_key_bytes +) +withdrawal_tx.signatures.append( + DecoratedSignature(stealth_keypair.signature_hint(), sig_bytes) ) + response = horizon.submit_transaction(withdrawal_tx) print("Withdrawal tx hash:", response["hash"]) ``` @@ -861,7 +840,7 @@ concerns — it handles only the cryptographic layer so it stays testable in iso ## Environment variables -```bash no-check +```bash # .env — never commit this file STELLAR_SECRET=S...your-secret-key From 978ba1313ecfb0f51393147fc621965ed613ef2d Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 11:32:29 +0300 Subject: [PATCH 2/2] ci(docs): run the Python quickstart offline with pinned dependencies scripts/python-quickstart/run_quickstart.py extracts wraith_crypto.py and the steps from guides/quickstarts/python.mdx and runs them in page order. The RPC, Horizon and Soroban calls go to fakes that serve recorded v2 announcer events and verify each transaction's signatures. Results are checked against vectors.json, generated from the pinned SDK by generate-vectors.ts (--check in CI), and Step 6a against an independent libsodium derivation. requirements.txt pins the full dependency tree; --bootstrap builds a venv with it. check:snippets-extended now runs check:python-quickstart. Closes #188 --- docs/CONTRIBUTING.md | 23 + package.json | 4 +- scripts/python-quickstart/generate-vectors.ts | 229 +++++++ scripts/python-quickstart/requirements.txt | 25 + scripts/python-quickstart/run_quickstart.py | 572 ++++++++++++++++++ scripts/python-quickstart/vectors.json | 168 +++++ 6 files changed, 1020 insertions(+), 1 deletion(-) create mode 100644 scripts/python-quickstart/generate-vectors.ts create mode 100644 scripts/python-quickstart/requirements.txt create mode 100644 scripts/python-quickstart/run_quickstart.py create mode 100644 scripts/python-quickstart/vectors.json diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7a32990..5c61ef3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -44,6 +44,29 @@ request must hit a route documented on the page, and each fixture is compiled wi When an example calls an endpoint, add one fixture per request it makes. `no-check` is not allowed on that page. +### Python quickstart + +`guides/quickstarts/python.mdx` runs end to end in CI, offline, as part of +`check:snippets-extended`: + +```bash +npm run check:python-quickstart +``` + +`scripts/python-quickstart/run_quickstart.py` builds `wraith_crypto.py` from the +fence that starts with `# wraith_crypto.py` (plus any `# Add to wraith_crypto.py` +fences), then runs every other Python fence in page order in one session. The +Soroban RPC, Horizon, and Soroban transaction calls go to local fakes that serve +recorded announcer events and verify each transaction's signatures. Results are +compared with `scripts/python-quickstart/vectors.json`, which +`npm run generate:python-quickstart-vectors` computes from the pinned SDK. + +Dependencies are pinned in `scripts/python-quickstart/requirements.txt`. The +`--bootstrap` flag creates a virtualenv with those pins under +`node_modules/.cache`. Every Python fence in the guide must run. A fence that +cannot run needs both words after the language, `python Pseudocode no-check`, so +readers see it is pseudocode. + ## Navigation coverage Every `.mdx` page in the shipped taxonomy (root pages plus `architecture/`, diff --git a/package.json b/package.json index 6e19237..64c95f2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "check:placeholders": "tsx scripts/check-placeholders.ts", "check:snippets": "tsx scripts/check-snippets.ts && tsx scripts/check-api-examples.ts", "check:api-examples": "tsx scripts/check-api-examples.ts", - "check:snippets-extended": "tsx scripts/check-snippets-extended.ts", + "check:snippets-extended": "tsx scripts/check-snippets-extended.ts && npm run check:python-quickstart", + "check:python-quickstart": "tsx scripts/python-quickstart/generate-vectors.ts --check && python3 scripts/python-quickstart/run_quickstart.py --bootstrap", + "generate:python-quickstart-vectors": "tsx scripts/python-quickstart/generate-vectors.ts", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", "check:contract-registry": "node scripts/check-contract-registry.mjs", "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", diff --git a/scripts/python-quickstart/generate-vectors.ts b/scripts/python-quickstart/generate-vectors.ts new file mode 100644 index 0000000..7ab477f --- /dev/null +++ b/scripts/python-quickstart/generate-vectors.ts @@ -0,0 +1,229 @@ +/** + * generate-vectors.ts + * + * Writes scripts/python-quickstart/vectors.json: the expected values the Python + * quickstart (guides/quickstarts/python.mdx) must reproduce, computed with the + * pinned @wraith-protocol/sdk Stellar primitives. + * + * tsx scripts/python-quickstart/generate-vectors.ts # write vectors.json + * tsx scripts/python-quickstart/generate-vectors.ts --check # fail if it is stale + * + * The announcement events use the stealth-announcer v2 layout + * (wraith-protocol/contracts stellar/stealth-announcer): topics + * ("announce", scheme_id, view_tag_bucket, metadata_kind) and value + * (stealth_address, ephemeral_pub_key, metadata). View tags use the v2 + * derivation SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0] + * (architecture/view-tag-scanner.mdx). + */ + +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { Address, Keypair, xdr } from "@stellar/stellar-sdk"; +import { + STEALTH_SIGNING_MESSAGE, + bytesToHex, + deriveStealthKeys, + deriveStealthPrivateScalar, + encodeStealthMetaAddress, + generateStealthAddress, + hexToBytes, + signWithScalar, +} from "@wraith-protocol/sdk/chains/stellar"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outFile = path.join(here, "vectors.json"); + +const ANNOUNCER_CONTRACT_ID = "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL"; +const SCHEME_ID = 2; +const METADATA_KIND_VIEW_TAG = 1; +const LATEST_LEDGER = 1_990_000; + +const sha256 = (...parts: Array) => { + const hash = createHash("sha256"); + for (const part of parts) hash.update(part); + return new Uint8Array(hash.digest()); +}; + +const seed = (label: string) => sha256(`wraith:docs:python-quickstart:${label}`); + +/** The v2 view tag the stealth-announcer v2 contract indexes by. */ +function viewTagV2(ephemeralPubKey: Uint8Array, viewingPubKey: Uint8Array) { + return sha256("wraith:stellar:view-tag:v2:", ephemeralPubKey, viewingPubKey)[0]; +} + +/** A Stellar account and the stealth keys derived from its signature. */ +function identity(label: string) { + const keypair = Keypair.fromRawEd25519Seed(Buffer.from(seed(label))); + const signature = new Uint8Array(keypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE))); + const keys = deriveStealthKeys(signature); + return { keypair, signature, keys }; +} + +/** Pays `recipient` through a fresh stealth address, as the SDK builders do. */ +function pay(recipient: ReturnType, ephemeralLabel: string) { + const ephemeralSeed = seed(ephemeralLabel); + const generated = generateStealthAddress( + recipient.keys.spendingPubKey, + recipient.keys.viewingPubKey, + ephemeralSeed, + ); + const viewTag = viewTagV2(generated.ephemeralPubKey, recipient.keys.viewingPubKey); + return { ephemeralSeed, ...generated, viewTag }; +} + +function announcementEvent( + index: number, + stealthAddress: string, + ephemeralPubKey: Uint8Array, + metadata: Uint8Array, +) { + const ledger = LATEST_LEDGER - 100 + index; + const topic = [ + xdr.ScVal.scvSymbol("announce"), + xdr.ScVal.scvU32(SCHEME_ID), + xdr.ScVal.scvU32(metadata[0]), + xdr.ScVal.scvU32(METADATA_KIND_VIEW_TAG), + ]; + const value = xdr.ScVal.scvVec([ + new Address(stealthAddress).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from(metadata)), + ]); + + return { + type: "contract", + ledger, + ledgerClosedAt: new Date(Date.UTC(2026, 9, 1, 12, 0, index * 5)).toISOString(), + contractId: ANNOUNCER_CONTRACT_ID, + id: `${(BigInt(ledger) << 32n).toString().padStart(19, "0")}-${String(index).padStart(10, "0")}`, + txHash: bytesToHex(sha256(`tx:${index}`)), + inSuccessfulContractCall: true, + topic: topic.map((entry) => entry.toXDR("base64")), + value: value.toXDR("base64"), + }; +} + +function buildVectors() { + const owner = identity("owner"); + const other = identity("other-recipient"); + + // Announcements 0 and 2 pay the owner. 1 and 4 pay someone else. 3 is a decoy: + // it carries the owner's view tag for its ephemeral key but another stealth + // address, so it passes the view-tag prefilter and must fail the address check. + const toOwner = [pay(owner, "ephemeral:0"), pay(owner, "ephemeral:2")]; + const toOther = [pay(other, "ephemeral:1"), pay(other, "ephemeral:4")]; + const decoy = pay(other, "ephemeral:3"); + const decoyTag = viewTagV2(decoy.ephemeralPubKey, owner.keys.viewingPubKey); + + const announcements = [ + { payment: toOwner[0], viewTag: toOwner[0].viewTag }, + { payment: toOther[0], viewTag: toOther[0].viewTag }, + { payment: toOwner[1], viewTag: toOwner[1].viewTag }, + { payment: decoy, viewTag: decoyTag }, + { payment: toOther[1], viewTag: toOther[1].viewTag }, + ]; + + const events = announcements.map(({ payment, viewTag }, index) => + announcementEvent( + index, + payment.stealthAddress, + payment.ephemeralPubKey, + Uint8Array.of(viewTag), + ), + ); + + const matches = [0, 2].map((index) => { + const payment = announcements[index].payment; + const scalar = deriveStealthPrivateScalar( + owner.keys.spendingScalar, + owner.keys.viewingKey, + payment.ephemeralPubKey, + ); + const stealthPubKey = Keypair.fromPublicKey(payment.stealthAddress).rawPublicKey(); + return { + index, + stealthAddress: payment.stealthAddress, + stealthPrivateScalar: scalar.toString(), + stealthPubKey: bytesToHex(new Uint8Array(stealthPubKey)), + }; + }); + + const message = sha256("message"); + const signature = signWithScalar( + message, + BigInt(matches[0].stealthPrivateScalar), + hexToBytes(matches[0].stealthPubKey), + ); + + return { + $comment: + "Generated by scripts/python-quickstart/generate-vectors.ts from the pinned " + + "@wraith-protocol/sdk Stellar primitives. Do not edit by hand; run `pnpm run " + + "generate:python-quickstart-vectors`.", + stealthSigningMessage: STEALTH_SIGNING_MESSAGE, + owner: { + secret: owner.keypair.secret(), + publicKey: owner.keypair.publicKey(), + signature: bytesToHex(owner.signature), + spendingKey: bytesToHex(owner.keys.spendingKey), + viewingKey: bytesToHex(owner.keys.viewingKey), + spendingScalar: owner.keys.spendingScalar.toString(), + viewingScalar: owner.keys.viewingScalar.toString(), + spendingPubKey: bytesToHex(owner.keys.spendingPubKey), + viewingPubKey: bytesToHex(owner.keys.viewingPubKey), + metaAddress: encodeStealthMetaAddress(owner.keys.spendingPubKey, owner.keys.viewingPubKey), + }, + stealthAddresses: toOwner.map((payment) => ({ + ephemeralSeed: bytesToHex(payment.ephemeralSeed), + ephemeralPubKey: bytesToHex(payment.ephemeralPubKey), + stealthAddress: payment.stealthAddress, + viewTag: payment.viewTag, + })), + rpc: { + announcerContractId: ANNOUNCER_CONTRACT_ID, + latestLedger: LATEST_LEDGER, + events, + }, + announcements: announcements.map(({ payment, viewTag }) => ({ + scheme_id: SCHEME_ID, + stealth_address: payment.stealthAddress, + ephemeral_pub_key: bytesToHex(payment.ephemeralPubKey), + metadata: bytesToHex(Uint8Array.of(viewTag)), + })), + matches, + scalarSignature: { + message: bytesToHex(message), + scalar: matches[0].stealthPrivateScalar, + publicKey: matches[0].stealthPubKey, + signature: bytesToHex(signature), + }, + }; +} + +async function main() { + const content = `${JSON.stringify(buildVectors(), null, 2)}\n`; + + if (process.argv.includes("--check")) { + const current = await readFile(outFile, "utf8").catch(() => null); + if (current !== content) { + console.error( + "scripts/python-quickstart/vectors.json is out of date. " + + "Run `pnpm run generate:python-quickstart-vectors` and commit the result.", + ); + process.exit(1); + } + console.log("Python quickstart vectors are in sync with the pinned SDK."); + return; + } + + await writeFile(outFile, content, "utf8"); + console.log(`Wrote ${path.relative(process.cwd(), outFile)}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/python-quickstart/requirements.txt b/scripts/python-quickstart/requirements.txt new file mode 100644 index 0000000..74b0f7a --- /dev/null +++ b/scripts/python-quickstart/requirements.txt @@ -0,0 +1,25 @@ +# Pinned dependencies for guides/quickstarts/python.mdx, checked by run_quickstart.py. +# Direct: stellar-sdk, cryptography, httpx (Step 1 of the guide). The rest is their +# dependency tree, frozen from a clean Python 3.11 environment. +annotated-types==0.8.0 +anyio==4.15.1 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.2 +cryptography==50.0.2 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.20 +mnemonic==0.21 +pycparser==3.11 +pydantic==2.14.1 +pydantic_core==2.50.1 +PyNaCl==1.6.2 +requests==2.34.2 +requests-sse==0.5.3 +stellar-sdk==16.1.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +urllib3==2.8.0 +xdrlib3==0.1.1 diff --git a/scripts/python-quickstart/run_quickstart.py b/scripts/python-quickstart/run_quickstart.py new file mode 100644 index 0000000..942dd90 --- /dev/null +++ b/scripts/python-quickstart/run_quickstart.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +run_quickstart.py: runs guides/quickstarts/python.mdx offline. + +The guide's Python fences are the fixture. The fence that starts with +`# wraith_crypto.py` becomes that module, fences that start with +`# Add to wraith_crypto.py` are appended to it, and every other Python fence runs +in page order in one namespace, the way a reader pastes the steps into one +session. A Python fence may only be skipped if its info string says both +`no-check` and `pseudocode`. + +Network calls go to fakes instead of testnet: + - httpx.post answers getLatestLedger and serves the announcer events from + vectors.json, two per getEvents page, so the cursor loop runs. + - stellar_sdk.Server (Horizon) and stellar_sdk.SorobanServer check each + transaction's network and signatures and record it. + +The results are compared with vectors.json, which generate-vectors.ts computes +with the pinned TypeScript SDK, and with an independent libsodium (PyNaCl) +derivation for the random stealth address in Step 6a. + +Installed packages must match requirements.txt exactly. With --bootstrap the +script creates a virtualenv under node_modules/.cache with those pins and +re-runs itself there. + +Usage + python3 scripts/python-quickstart/run_quickstart.py [--bootstrap] +""" + +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import tempfile +import traceback +from dataclasses import dataclass +from importlib import metadata +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +GUIDE = REPO / "guides" / "quickstarts" / "python.mdx" +GUIDE_LABEL = "guides/quickstarts/python.mdx" +VECTORS = HERE / "vectors.json" +REQUIREMENTS = HERE / "requirements.txt" +MODULE = "wraith_crypto" +TESTNET_PASSPHRASE = "Test SDF Network ; September 2015" +EVENTS_PER_PAGE = 2 + + +class QuickstartError(Exception): + """A check failed; the message says where and why.""" + + +# --------------------------------------------------------------------------- +# Pinned dependencies +# --------------------------------------------------------------------------- + +def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def pin_mismatches() -> list[str]: + problems = [] + for raw in REQUIREMENTS.read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + name, _, version = line.partition("==") + try: + installed = metadata.version(name) + except metadata.PackageNotFoundError: + problems.append(f"{name}=={version} is not installed") + continue + if installed != version: + problems.append(f"{name}=={version} is pinned, {installed} is installed") + return problems + + +def ensure_pinned_environment(argv: list[str]) -> None: + problems = pin_mismatches() + if not problems: + return + if "--bootstrap" not in argv or os.environ.get("WRAITH_QUICKSTART_VENV"): + raise SystemExit( + "Python dependencies do not match scripts/python-quickstart/requirements.txt:\n " + + "\n ".join(problems) + + "\nInstall them with `python3 -m pip install -r scripts/python-quickstart/requirements.txt`" + " in a virtualenv, or run with --bootstrap." + ) + + digest = hashlib.sha256(REQUIREMENTS.read_bytes()).hexdigest()[:12] + venv = REPO / "node_modules" / ".cache" / "python-quickstart" / f"venv-{digest}" + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if not python.exists(): + print(f"Creating {venv.relative_to(REPO)} with the pinned dependencies...", flush=True) + subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True) + subprocess.run( + [str(python), "-m", "pip", "install", "--quiet", "--disable-pip-version-check", + "-r", str(REQUIREMENTS)], + check=True, + ) + env = {**os.environ, "WRAITH_QUICKSTART_VENV": "1"} + args = [arg for arg in argv if arg != "--bootstrap"] + completed = subprocess.run([str(python), str(Path(__file__).resolve()), *args], env=env) + raise SystemExit(completed.returncode) + + +# --------------------------------------------------------------------------- +# Extracting the fixture from the guide +# --------------------------------------------------------------------------- + +@dataclass +class Fence: + lang: str + info: str + code: str + line: int # 1-based MDX line of the first line inside the fence + + +def parse_fences(markdown: str) -> list[Fence]: + fences: list[Fence] = [] + open_fence: dict[str, Any] | None = None + for index, line in enumerate(markdown.split("\n")): + if open_fence is None: + match = re.match(r"^```([A-Za-z0-9_-]*)(.*)$", line) + if match: + open_fence = {"lang": match[1].lower(), "info": match[2].strip(), + "line": index + 2, "body": []} + continue + if line.strip() == "```": + fences.append(Fence(open_fence["lang"], open_fence["info"], + "\n".join(open_fence["body"]), open_fence["line"])) + open_fence = None + continue + open_fence["body"].append(line) + return fences + + +@dataclass +class Fixture: + module_source: str + module_parts: list[Fence] + steps: list[Fence] + pseudocode: list[Fence] + + +def extract_fixture(markdown: str) -> Fixture: + module_parts: list[Fence] = [] + steps: list[Fence] = [] + pseudocode: list[Fence] = [] + + for fence in parse_fences(markdown): + if fence.lang not in ("python", "py"): + continue + if re.search(r"\bno-check\b", fence.info): + if not re.search(r"\bpseudocode\b", fence.info, re.IGNORECASE): + raise QuickstartError( + f"{GUIDE_LABEL}:{fence.line - 1}: Python fences run as part of the fixture. " + "Remove `no-check`, or mark pseudocode as ```python Pseudocode no-check." + ) + pseudocode.append(fence) + continue + + first_line = fence.code.lstrip().split("\n", 1)[0].strip() + if first_line == f"# {MODULE}.py": + if module_parts: + raise QuickstartError(f"{GUIDE_LABEL}:{fence.line}: a second `# {MODULE}.py` fence") + module_parts.append(fence) + elif first_line == f"# Add to {MODULE}.py": + if not module_parts: + raise QuickstartError(f"{GUIDE_LABEL}:{fence.line}: `# Add to {MODULE}.py` " + f"comes before the `# {MODULE}.py` fence") + module_parts.append(fence) + else: + steps.append(fence) + + if not module_parts: + raise QuickstartError(f"{GUIDE_LABEL}: no fence starts with `# {MODULE}.py`") + if not steps: + raise QuickstartError(f"{GUIDE_LABEL}: no runnable steps found") + + module_source = "\n\n".join(part.code for part in module_parts) + "\n" + return Fixture(module_source, module_parts, steps, pseudocode) + + +# --------------------------------------------------------------------------- +# Network fakes +# --------------------------------------------------------------------------- + +def _require(condition: bool, message: str) -> None: + if not condition: + raise QuickstartError(message) + + +class FakeRpc: + """Stands in for httpx.post against the Soroban RPC.""" + + def __init__(self, vectors: dict) -> None: + self.latest = vectors["rpc"]["latestLedger"] + self.contract_id = vectors["rpc"]["announcerContractId"] + self.events = vectors["rpc"]["events"] + self.calls: list[str] = [] + + def post(self, url: str, json: dict | None = None, timeout: float | None = None, **_: Any): + _require(isinstance(json, dict) and json.get("jsonrpc") == "2.0", + f"RPC call to {url} is not JSON-RPC 2.0") + method, params = json.get("method"), json.get("params") or {} + self.calls.append(method) + if method == "getLatestLedger": + result = {"id": "fixture", "protocolVersion": 23, "sequence": self.latest} + elif method == "getEvents": + result = self._get_events(params) + else: + raise QuickstartError(f"Unexpected RPC method {method!r}") + return SimpleNamespace( + status_code=200, + raise_for_status=lambda: None, + json=lambda: {"jsonrpc": "2.0", "id": json.get("id"), "result": result}, + ) + + def _get_events(self, params: dict) -> dict: + filters = params.get("filters") or [] + _require( + len(filters) == 1 and filters[0].get("contractIds") == [self.contract_id], + f"getEvents must filter on the announcer {self.contract_id}, got {filters}", + ) + pagination = params.get("pagination") or {} + cursor = pagination.get("cursor") + if cursor is None: + start = params.get("startLedger") + _require(isinstance(start, int) and 1 <= start <= self.latest, + f"getEvents startLedger must be within 1..{self.latest}, got {start!r}") + offset = 0 + else: + _require("startLedger" not in params, + "getEvents takes either startLedger or a cursor, not both") + offset = int(cursor) + page = self.events[offset:offset + EVENTS_PER_PAGE] + return { + "events": page, + "cursor": str(offset + len(page)), + "latestLedger": self.latest, + "oldestLedger": self.latest - 17_280, + } + + +def _verify_signed(envelope: Any, signer: str, label: str) -> None: + from stellar_sdk import Keypair + + _require(envelope.network_passphrase == TESTNET_PASSPHRASE, + f"{label}: built for {envelope.network_passphrase!r}, not testnet") + keypair = Keypair.from_public_key(signer) + tx_hash = envelope.hash() + for decorated in envelope.signatures: + if decorated.signature_hint != keypair.signature_hint(): + continue + try: + keypair.verify(tx_hash, decorated.signature) + return + except Exception: + continue + raise QuickstartError(f"{label}: no valid signature from {signer}") + + +class FakeHorizon: + """Stands in for stellar_sdk.Server against Horizon.""" + + instances: list["FakeHorizon"] = [] + + def __init__(self, horizon_url: str = "", *_: Any, **__: Any) -> None: + self.url = horizon_url + self.sequences: dict[str, int] = {} + self.submitted: list[Any] = [] + FakeHorizon.instances.append(self) + + def load_account(self, account_id: Any): + from stellar_sdk import Account + + address = getattr(account_id, "public_key", None) or getattr(account_id, "account_id", None) or account_id + return Account(address, self.sequences.setdefault(address, 4_200_000_000)) + + def submit_transaction(self, envelope: Any, *_: Any, **__: Any) -> dict: + source = envelope.transaction.source.account_id + _verify_signed(envelope, source, "Horizon transaction") + _require(envelope.transaction.sequence == self.sequences.get(source, 4_200_000_000) + 1, + f"Horizon transaction from {source} reuses a stale sequence number") + self.sequences[source] = envelope.transaction.sequence + self.submitted.append(envelope) + return {"hash": envelope.hash_hex(), "successful": True} + + +class FakeSoroban: + """Stands in for stellar_sdk.SorobanServer.""" + + instances: list["FakeSoroban"] = [] + + def __init__(self, server_url: str = "", *_: Any, **__: Any) -> None: + self.url = server_url + self.prepared: list[Any] = [] + self.sent: list[Any] = [] + FakeSoroban.instances.append(self) + + def prepare_transaction(self, envelope: Any, *_: Any, **__: Any): + _require(not envelope.signatures, "prepare_transaction must run before signing") + self.prepared.append(envelope) + return envelope + + def send_transaction(self, envelope: Any): + from stellar_sdk.soroban_rpc import SendTransactionStatus + + _require(any(envelope is prepared for prepared in self.prepared), + "send_transaction got a transaction that never went through prepare_transaction") + _verify_signed(envelope, envelope.transaction.source.account_id, "Soroban transaction") + self.sent.append(envelope) + return SimpleNamespace(status=SendTransactionStatus.PENDING, hash=envelope.hash_hex()) + + def poll_transaction(self, transaction_hash: str, *_: Any, **__: Any): + from stellar_sdk.soroban_rpc import GetTransactionStatus + + _require(any(env.hash_hex() == transaction_hash for env in self.sent), + f"poll_transaction for unknown hash {transaction_hash}") + return SimpleNamespace(status=GetTransactionStatus.SUCCESS, transaction_hash=transaction_hash) + + +# --------------------------------------------------------------------------- +# Independent reference (libsodium via PyNaCl) +# --------------------------------------------------------------------------- + +L = 2**252 + 27742317777372353535851937790883648493 + + +def reference_stealth_address(ephemeral_pub: bytes, viewing_seed: bytes, spending_pub: bytes) -> str: + from nacl import bindings + from stellar_sdk import Keypair + + viewing_pub = Keypair.from_raw_ed25519_seed(viewing_seed).raw_public_key() + x_priv = bindings.crypto_sign_ed25519_sk_to_curve25519(viewing_seed + viewing_pub) + x_pub = bindings.crypto_sign_ed25519_pk_to_curve25519(ephemeral_pub) + shared = bindings.crypto_scalarmult(x_priv, x_pub) + h = int.from_bytes(hashlib.sha256(b"wraith:scalar:" + shared).digest(), "little") % L + h_point = bindings.crypto_scalarmult_ed25519_base_noclamp(h.to_bytes(32, "little")) + return Keypair.from_raw_ed25519_public_key( + bindings.crypto_core_ed25519_add(spending_pub, h_point) + ).public_key + + +def reference_view_tag(ephemeral_pub: bytes, viewing_pub: bytes) -> int: + return hashlib.sha256(b"wraith:stellar:view-tag:v2:" + ephemeral_pub + viewing_pub).digest()[0] + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +def check_module(module: Any, vectors: dict) -> int: + """Compares the guide's wraith_crypto.py with the TypeScript SDK vectors.""" + from stellar_sdk import Keypair + + owner = vectors["owner"] + checks = 0 + + def same(actual: Any, expected: Any, what: str) -> None: + nonlocal checks + _require(actual == expected, f"{MODULE}.{what}: got {actual!r}, the SDK gives {expected!r}") + checks += 1 + + same(module.STEALTH_SIGNING_MESSAGE, vectors["stealthSigningMessage"], "STEALTH_SIGNING_MESSAGE") + + signature = Keypair.from_secret(owner["secret"]).sign(module.STEALTH_SIGNING_MESSAGE.encode()) + same(signature.hex(), owner["signature"], "STEALTH_SIGNING_MESSAGE signature") + keys = module.derive_stealth_keys(signature) + same(keys.spending_key.hex(), owner["spendingKey"], "derive_stealth_keys spending_key") + same(keys.viewing_key.hex(), owner["viewingKey"], "derive_stealth_keys viewing_key") + same(str(keys.spending_scalar), owner["spendingScalar"], "derive_stealth_keys spending_scalar") + same(str(keys.viewing_scalar), owner["viewingScalar"], "derive_stealth_keys viewing_scalar") + same(keys.spending_pub_key.hex(), owner["spendingPubKey"], "derive_stealth_keys spending_pub_key") + same(keys.viewing_pub_key.hex(), owner["viewingPubKey"], "derive_stealth_keys viewing_pub_key") + same(module.encode_meta_address(keys.spending_pub_key, keys.viewing_pub_key), + owner["metaAddress"], "encode_meta_address") + same(module.decode_meta_address(owner["metaAddress"]), + (keys.spending_pub_key, keys.viewing_pub_key), "decode_meta_address") + + for expected in vectors["stealthAddresses"]: + generated = module.generate_stealth_address( + keys.spending_pub_key, keys.viewing_pub_key, bytes.fromhex(expected["ephemeralSeed"]) + ) + same(generated.stealth_address, expected["stealthAddress"], "generate_stealth_address address") + same(generated.ephemeral_pub_key.hex(), expected["ephemeralPubKey"], + "generate_stealth_address ephemeral_pub_key") + same(generated.view_tag, expected["viewTag"], "generate_stealth_address view_tag") + + found = module.scan_announcements( + [dict(item) for item in vectors["announcements"]], + keys.viewing_key, keys.spending_pub_key, keys.spending_scalar, + ) + same([(m.stealth_address, str(m.stealth_private_scalar), m.stealth_pub_key_bytes.hex()) + for m in found], + [(m["stealthAddress"], m["stealthPrivateScalar"], m["stealthPubKey"]) + for m in vectors["matches"]], + "scan_announcements matches") + + signed = vectors["scalarSignature"] + signature = module.sign_with_scalar( + bytes.fromhex(signed["message"]), int(signed["scalar"]), bytes.fromhex(signed["publicKey"]) + ) + same(signature.hex(), signed["signature"], "sign_with_scalar") + Keypair.from_raw_ed25519_public_key(bytes.fromhex(signed["publicKey"])).verify( + bytes.fromhex(signed["message"]), signature + ) + return checks + + +def check_flow(ns: dict, vectors: dict, rpc: FakeRpc) -> list[str]: + """Checks what the steps did, using the names the guide defines.""" + from stellar_sdk import Address, scval + from stellar_sdk.operation import AccountMerge, CreateAccount, InvokeHostFunction + + owner = vectors["owner"] + results = [] + + def need(name: str) -> Any: + _require(name in ns, f"the guide's steps never define `{name}`") + return ns[name] + + keys = need("keys") + _require(keys.spending_pub_key.hex() == owner["spendingPubKey"] + and keys.viewing_pub_key.hex() == owner["viewingPubKey"], + "Step 3 derived different keys than the SDK") + _require(need("meta_address") == owner["metaAddress"], "Step 3 meta_address differs from the SDK") + results.append("Step 3 keys and meta-address match the SDK") + + _require(need("ANNOUNCER_CONTRACT_ID") == vectors["rpc"]["announcerContractId"], + "ANNOUNCER_CONTRACT_ID differs from the announcer in vectors.json") + pages = rpc.calls.count("getEvents") + _require(pages >= 2, "fetch_announcements stopped before the second getEvents page") + _require(need("announcements") == vectors["announcements"], + "Step 4 parsed announcements differ from the events in vectors.json") + results.append(f"Step 4 parsed {len(vectors['announcements'])} v2 events over {pages} getEvents pages") + + matches = need("matches") + _require([m.stealth_address for m in matches] == [m["stealthAddress"] for m in vectors["matches"]], + "Step 5 found different payments than the SDK scanner") + results.append(f"Step 5 found the {len(matches)} payments the SDK finds, and skipped the decoy") + + stealth = need("stealth") + spending_pub, viewing_pub = bytes.fromhex(owner["spendingPubKey"]), bytes.fromhex(owner["viewingPubKey"]) + expected_address = reference_stealth_address( + stealth.ephemeral_pub_key, bytes.fromhex(owner["viewingKey"]), spending_pub + ) + _require(stealth.stealth_address == expected_address, + "Step 6a stealth address differs from the libsodium reference derivation") + _require(stealth.view_tag == reference_view_tag(stealth.ephemeral_pub_key, viewing_pub), + "Step 6a view tag differs from the v2 derivation") + results.append("Step 6a stealth address and view tag match an independent libsodium derivation") + + horizon_txs = [env for server in FakeHorizon.instances for env in server.submitted] + soroban_txs = [env for server in FakeSoroban.instances for env in server.sent] + sender = need("sender_keypair").public_key + _require(len(horizon_txs) == 2 and len(soroban_txs) == 1, + f"expected 2 Horizon and 1 Soroban transactions, got {len(horizon_txs)} and {len(soroban_txs)}") + + activate = horizon_txs[0].transaction + op = activate.operations[0] + _require(activate.source.account_id == sender and isinstance(op, CreateAccount) + and op.destination == stealth.stealth_address, + "Step 6b createAccount does not fund the Step 6a stealth address from the sender") + + announce = soroban_txs[0].transaction + op = announce.operations[0] + _require(isinstance(op, InvokeHostFunction), "Step 6b announcement is not a contract call") + call = op.host_function.invoke_contract + _require(Address.from_xdr_sc_address(call.contract_address).address == vectors["rpc"]["announcerContractId"] + and call.function_name.sc_symbol == b"announce", + "Step 6b does not call announce on the announcer contract") + args = [scval.to_native(arg) for arg in call.args] + _require(len(args) == 4 and args[0] == 2 and args[1].address == stealth.stealth_address + and args[2] == stealth.ephemeral_pub_key and args[3][:1] == bytes([stealth.view_tag]), + f"Step 6b announce arguments are wrong: {args!r}") + results.append("Step 6b createAccount and announce(2, stealth, R, [view tag]) are signed for testnet") + + withdrawal = horizon_txs[1].transaction + op = withdrawal.operations[0] + _require(withdrawal.source.account_id == matches[0].stealth_address and isinstance(op, AccountMerge) + and op.destination.account_id == sender, + "Step 7 does not merge the matched stealth account into the sender") + results.append("Step 7 merge is signed by sign_with_scalar and verifies against the stealth key") + return results + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def run(fixture: Fixture, vectors: dict) -> None: + import httpx + import stellar_sdk + + workdir = Path(tempfile.mkdtemp(prefix="wraith-python-quickstart-")) + (workdir / f"{MODULE}.py").write_text(fixture.module_source) + sys.path.insert(0, str(workdir)) + sys.modules.pop(MODULE, None) + + try: + module = __import__(MODULE) + except Exception as error: # noqa: BLE001 + raise QuickstartError(f"{MODULE}.py from {GUIDE_LABEL} does not import:\n" + + "".join(traceback.format_exception(error))) from error + module_checks = check_module(module, vectors) + + rpc = FakeRpc(vectors) + httpx.post = rpc.post + stellar_sdk.Server = FakeHorizon + stellar_sdk.SorobanServer = FakeSoroban + os.environ["STELLAR_SECRET"] = vectors["owner"]["secret"] + + ns: dict[str, Any] = {"__name__": "__quickstart__"} + print(f"Running {len(fixture.steps)} steps from {GUIDE_LABEL} offline") + for step in fixture.steps: + # Pad with blank lines so tracebacks point at the real MDX line numbers + code = compile("\n" * (step.line - 1) + step.code, GUIDE_LABEL, "exec") + output = io.StringIO() + try: + with contextlib.redirect_stdout(output): + exec(code, ns) # noqa: S102 - the guide is the code under test + except QuickstartError as error: + raise QuickstartError(f"{GUIDE_LABEL}:{step.line}: {error}") from error + except BaseException as error: # noqa: BLE001 + raise QuickstartError( + f"The step at {GUIDE_LABEL}:{step.line} failed:\n" + + "".join(traceback.format_exception(error)) + ) from error + printed = output.getvalue().strip() + print(f" ok {GUIDE_LABEL}:{step.line}") + for line in printed.splitlines(): + print(f" {line}") + + results = check_flow(ns, vectors, rpc) + print(f"\n{MODULE}.py matches the TypeScript SDK on {module_checks} checks") + for result in results: + print(f" {result}") + if fixture.pseudocode: + lines = ", ".join(str(fence.line) for fence in fixture.pseudocode) + print(f"Skipped pseudocode fences at lines {lines}") + print("\nPython quickstart ran offline.") + + +def main(argv: list[str]) -> int: + if sys.version_info < (3, 11): + print("The Python quickstart targets Python 3.11+.", file=sys.stderr) + return 1 + ensure_pinned_environment(argv) + try: + fixture = extract_fixture(GUIDE.read_text()) + vectors = json.loads(VECTORS.read_text()) + run(fixture, vectors) + except QuickstartError as error: + print(f"Python quickstart check failed:\n\n{error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/python-quickstart/vectors.json b/scripts/python-quickstart/vectors.json new file mode 100644 index 0000000..d07ba33 --- /dev/null +++ b/scripts/python-quickstart/vectors.json @@ -0,0 +1,168 @@ +{ + "$comment": "Generated by scripts/python-quickstart/generate-vectors.ts from the pinned @wraith-protocol/sdk Stellar primitives. Do not edit by hand; run `pnpm run generate:python-quickstart-vectors`.", + "stealthSigningMessage": "Sign this message to generate your Wraith stealth keys.\n\nChain: Stellar\nNote: This signature is used for key derivation only and does not authorize any transaction.", + "owner": { + "secret": "SDOPMOTKW6U6YUOUQF44C4ZG2D4GC46UJPMOJGK5MKBTDKEJHWIZBA6P", + "publicKey": "GCWSBJO4NRFHAXZISP4VT5SN5BLC7RIIBPVCYKK2TBBI5VFX7B47JFRF", + "signature": "edbab2c5d58fdbbd08995f649f2fdc5bb70d9061b59f40dafa5e049a916c569f6caf00358759aa4a2639e3debd2d4464c91e27a4cbc7b7655c11e17eaacc0d06", + "spendingKey": "49ce21c9fc403478fee764bcc25e343de47744fa3d7c0860bb376cee990523ec", + "viewingKey": "2872bccf9e4d56b0e9f2227495e632e168086a382aed8149a8673832d913dc62", + "spendingScalar": "36165898451058754716118145400569639394529583296247500707205305789287386495712", + "viewingScalar": "44469554120281652802293231179823107218516810350333748018472723505713148190392", + "spendingPubKey": "951028f9a75bc86c14ef130e66de85f5946d4e1699baf94c73aef5444d6ff65f", + "viewingPubKey": "b67a08148e7afdf0b3bf0eb90cbd981e83d6a37dbd24ee6b74b99b4783a0cd91", + "metaAddress": "st:xlm:951028f9a75bc86c14ef130e66de85f5946d4e1699baf94c73aef5444d6ff65fb67a08148e7afdf0b3bf0eb90cbd981e83d6a37dbd24ee6b74b99b4783a0cd91" + }, + "stealthAddresses": [ + { + "ephemeralSeed": "b0126b5f4b7e2a6752281e977813c71e9e98832b4aeb98ae5e91a7bb87c37994", + "ephemeralPubKey": "471c22b4ce3805ab50cbbd880487fdf49b80c62d1e2d4cbe320f01a932c22332", + "stealthAddress": "GBPRSH57SFOQ5FY3TBWAHHW2YD27J3CCQ2XUYSHOGKKMGO3W6Z4JEOM5", + "viewTag": 51 + }, + { + "ephemeralSeed": "635b5072507169bb6eb2eea5d5ec48ca25cffb56ba11a6a77c49cbb18cc1630d", + "ephemeralPubKey": "8929a439bc8464aa0ef67c1a2e564c3d894a921143027a6842bc080f5137b30c", + "stealthAddress": "GCQETDXZEMWXMR3QG2PD35O4Q3RTTWFMSQPLLI43SHRPC23EEEFJQKOF", + "viewTag": 63 + } + ], + "rpc": { + "announcerContractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "latestLedger": 1990000, + "events": [ + { + "type": "contract", + "ledger": 1989900, + "ledgerClosedAt": "2026-10-01T12:00:00.000Z", + "contractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "id": "0008546555422310400-0000000000", + "txHash": "56f73ce7225c86bb063e074a5b1a874454b953615c30bdb435b9f06537949bcc", + "inSuccessfulContractCall": true, + "topic": [ + "AAAADwAAAAhhbm5vdW5jZQ==", + "AAAAAwAAAAI=", + "AAAAAwAAADM=", + "AAAAAwAAAAE=" + ], + "value": "AAAAEAAAAAEAAAADAAAAEgAAAAAAAAAAXxkfv5FdDpcbmGwDntrA9fTsQoavTEjuMpTDO3b2eJIAAAANAAAAIEccIrTOOAWrUMu9iASH/fSbgMYtHi1MvjIPAakywiMyAAAADQAAAAEzAAAA" + }, + { + "type": "contract", + "ledger": 1989901, + "ledgerClosedAt": "2026-10-01T12:00:05.000Z", + "contractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "id": "0008546559717277696-0000000001", + "txHash": "a1979ac06076b6412c21f7882e00bcf0baf3f790ea4db800ac36b70aeeb5289e", + "inSuccessfulContractCall": true, + "topic": [ + "AAAADwAAAAhhbm5vdW5jZQ==", + "AAAAAwAAAAI=", + "AAAAAwAAAKE=", + "AAAAAwAAAAE=" + ], + "value": "AAAAEAAAAAEAAAADAAAAEgAAAAAAAAAAALwNY/Qc+cTzHT/GARTAVrJPELsF2MfkCSgmLYt2uTsAAAANAAAAINu5i+jRxSbQ7j1QhFPW6LzCjumnZvozxe2jqPLlgea0AAAADQAAAAGhAAAA" + }, + { + "type": "contract", + "ledger": 1989902, + "ledgerClosedAt": "2026-10-01T12:00:10.000Z", + "contractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "id": "0008546564012244992-0000000002", + "txHash": "500ad5cf9c25e8f2d3a5212a24fe796da4d5610c9faa49721be50de88a60e937", + "inSuccessfulContractCall": true, + "topic": [ + "AAAADwAAAAhhbm5vdW5jZQ==", + "AAAAAwAAAAI=", + "AAAAAwAAAD8=", + "AAAAAwAAAAE=" + ], + "value": "AAAAEAAAAAEAAAADAAAAEgAAAAAAAAAAoEmO+SMtdkdwNp499dyG4znYrJQetaObkeLxa2QhCpgAAAANAAAAIIkppDm8hGSqDvZ8Gi5WTD2JSpIRQwJ6aEK8CA9RN7MMAAAADQAAAAE/AAAA" + }, + { + "type": "contract", + "ledger": 1989903, + "ledgerClosedAt": "2026-10-01T12:00:15.000Z", + "contractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "id": "0008546568307212288-0000000003", + "txHash": "60eb90252b68103311097c64fabfee713a9ba4a36176904f8b6d353775a89656", + "inSuccessfulContractCall": true, + "topic": [ + "AAAADwAAAAhhbm5vdW5jZQ==", + "AAAAAwAAAAI=", + "AAAAAwAAABY=", + "AAAAAwAAAAE=" + ], + "value": "AAAAEAAAAAEAAAADAAAAEgAAAAAAAAAAYHDKPKdmnK+1dQhnexzoxAfOIgefOuuGc6bi1yj2W6UAAAANAAAAIJompQty7o9izn46i6WNP2Gopg+kHRlxQ0ljHBgZYsv6AAAADQAAAAEWAAAA" + }, + { + "type": "contract", + "ledger": 1989904, + "ledgerClosedAt": "2026-10-01T12:00:20.000Z", + "contractId": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "id": "0008546572602179584-0000000004", + "txHash": "c2933ca05d68ddac1aaf121db0103c99c65eeddfd87ce88d16baf0a591176018", + "inSuccessfulContractCall": true, + "topic": [ + "AAAADwAAAAhhbm5vdW5jZQ==", + "AAAAAwAAAAI=", + "AAAAAwAAAPg=", + "AAAAAwAAAAE=" + ], + "value": "AAAAEAAAAAEAAAADAAAAEgAAAAAAAAAACYurZzTsdHKtBvydB9DnjmcRPtFE3DDYVrT/TwIxuNMAAAANAAAAIFz0doKcEKtpMZNCNZoTtq/ndYvhDYpUk7eQ8lbi6Z1yAAAADQAAAAH4AAAA" + } + ] + }, + "announcements": [ + { + "scheme_id": 2, + "stealth_address": "GBPRSH57SFOQ5FY3TBWAHHW2YD27J3CCQ2XUYSHOGKKMGO3W6Z4JEOM5", + "ephemeral_pub_key": "471c22b4ce3805ab50cbbd880487fdf49b80c62d1e2d4cbe320f01a932c22332", + "metadata": "33" + }, + { + "scheme_id": 2, + "stealth_address": "GAALYDLD6QOPTRHTDU74MAIUYBLLETYQXMC5RR7EBEUCMLMLO24TX4YL", + "ephemeral_pub_key": "dbb98be8d1c526d0ee3d508453d6e8bcc28ee9a766fa33c5eda3a8f2e581e6b4", + "metadata": "a1" + }, + { + "scheme_id": 2, + "stealth_address": "GCQETDXZEMWXMR3QG2PD35O4Q3RTTWFMSQPLLI43SHRPC23EEEFJQKOF", + "ephemeral_pub_key": "8929a439bc8464aa0ef67c1a2e564c3d894a921143027a6842bc080f5137b30c", + "metadata": "3f" + }, + { + "scheme_id": 2, + "stealth_address": "GBQHBSR4U5TJZL5VOUEGO6Y45DCAPTRCA6PTV24GOOTOFVZI6ZN2LOBT", + "ephemeral_pub_key": "9a26a50b72ee8f62ce7e3a8ba58d3f61a8a60fa41d19714349631c181962cbfa", + "metadata": "16" + }, + { + "scheme_id": 2, + "stealth_address": "GAEYXK3HGTWHI4VNA36J2B6Q46HGOEJ62FCNYMGYK22P6TYCGG4NHZNG", + "ephemeral_pub_key": "5cf476829c10ab69319342359a13b6afe7758be10d8a5493b790f256e2e99d72", + "metadata": "f8" + } + ], + "matches": [ + { + "index": 0, + "stealthAddress": "GBPRSH57SFOQ5FY3TBWAHHW2YD27J3CCQ2XUYSHOGKKMGO3W6Z4JEOM5", + "stealthPrivateScalar": "3437456400779383884853090559485828520176343062883920412031112698606428378328", + "stealthPubKey": "5f191fbf915d0e971b986c039edac0f5f4ec4286af4c48ee3294c33b76f67892" + }, + { + "index": 2, + "stealthAddress": "GCQETDXZEMWXMR3QG2PD35O4Q3RTTWFMSQPLLI43SHRPC23EEEFJQKOF", + "stealthPrivateScalar": "6640971120132170134333823985662900435482568511840634446737891804661085823421", + "stealthPubKey": "a0498ef9232d764770369e3df5dc86e339d8ac941eb5a39b91e2f16b64210a98" + } + ], + "scalarSignature": { + "message": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d", + "scalar": "3437456400779383884853090559485828520176343062883920412031112698606428378328", + "publicKey": "5f191fbf915d0e971b986c039edac0f5f4ec4286af4c48ee3294c33b76f67892", + "signature": "027adf8e4bfeab964c999b9e466aac3ee2259b9c35f4cc4fde54445f37c97b7cdebb35fb8bf64f38729b3e2b1cbce4b104ad3e2d3a269226960c502fa957520a" + } +}