diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 440ddca..a0b13f1 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -36,6 +36,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -76,6 +78,15 @@ jobs: - name: Check contract registry run: pnpm run check:contract-registry + - name: Check redirects and anchors + env: + DOCS_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + DOCS_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: pnpm run check:redirects-and-anchors + + - name: Test redirects and anchors check + run: pnpm run test:redirects-and-anchors + check-snippets-extended: name: Compile Rust / Python / shell / config snippets runs-on: ubuntu-latest diff --git a/docs.json b/docs.json index dd51c93..7bf261a 100644 --- a/docs.json +++ b/docs.json @@ -7,6 +7,9 @@ "light": "/assets/images/logo-black.png" }, "favicon": "/assets/images/logo-black.png", + "redirects": [ + { "source": "/README", "destination": "/introduction" } + ], "colors": { "primary": "#c6c6c7", "light": "#e6e1e5", @@ -102,6 +105,7 @@ "reference/error-codes", "reference/security-disclosure", "reference/sep-compatibility", + "reference/stellar-event-schemas", "reference/stellar-networks", "reference/threat-model" ] diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 66c83c2..0c8ad2d 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -95,4 +95,31 @@ the pages that quote it. Never ship a placeholder contract id such as Every pull request runs the snippet checker, the nav coverage check, and the contract registry check through GitHub Actions. A separate non-blocking Stellar testnet job is reserved for end-to-end snippet validation that depends on network -availability. +availability. The workflow also runs extended snippet validation and the +redirect and anchor checks, including their unit tests. + +## Mintlify preview redirect check + +After Mintlify publishes the pull request preview, run the smoke check from +this PR's checkout, supplying the actual preview base URL (for example, +`https://example.mintlify.app`). On macOS/Linux: + +```bash +MINTLIFY_PREVIEW_URL=https://example.mintlify.app \ +pnpm run test:preview-redirect +``` + +In PowerShell: + +```powershell +$env:MINTLIFY_PREVIEW_URL = "https://example.mintlify.app" +pnpm run test:preview-redirect +``` + +The check makes a real request to `/README`, does not follow redirects, and +requires a 3xx response with a `Location` resolving to `/introduction`. The +repository's GitHub Actions workflows do not expose the Mintlify preview URL. +GitHub also requires a `workflow_dispatch` workflow to exist on the default +branch before it can be manually dispatched, so this PR uses the documented +command against the preview URL instead of adding a workflow that cannot yet +be run for this PR. diff --git a/getting-started.mdx b/getting-started.mdx index d5f5cd5..6d180a6 100644 --- a/getting-started.mdx +++ b/getting-started.mdx @@ -151,6 +151,6 @@ try { - [Bring Your Own Model](guides/bring-your-own-model) — use OpenAI or Claude instead of Gemini - [Stellar Networks Reference](reference/stellar-networks) — passphrases, RPC endpoints, contract IDs, and reset cadence for every Stellar network - [Stellar Fee Estimation & Budgeting](guides/stellar-fees) — learn about inclusion fees, Soroban resource fees, and fee bumps -- [Stellar React Hooks](sdk/stellar-react-hooks) — React hooks for Stellar stealth address operations +- [Stellar SDK Primitives](/sdk/chains/stellar) — key derivation, stealth addresses, scanning, and signing - [Stellar Troubleshooting](guides/stellar-troubleshooting) — fixes for common Stellar, Soroban, and Stealth errors - [SDK Reference](sdk/agent-client) — full API documentation diff --git a/guides/stellar-mainnet-deployment.mdx b/guides/stellar-mainnet-deployment.mdx index dbca654..a7b5653 100644 --- a/guides/stellar-mainnet-deployment.mdx +++ b/guides/stellar-mainnet-deployment.mdx @@ -86,7 +86,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 afae19e..b8be05e 100644 --- a/guides/stellar-troubleshooting.mdx +++ b/guides/stellar-troubleshooting.mdx @@ -252,7 +252,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-errorcontract-n-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. @@ -310,4 +310,4 @@ const invocation = await myContract.myFunction({ nonce: nextNonce, // ... }); -``` \ No newline at end of file +``` diff --git a/guides/stellar/stellar-quickstart.mdx b/guides/stellar/stellar-quickstart.mdx index 378eb95..2c5ba39 100644 --- a/guides/stellar/stellar-quickstart.mdx +++ b/guides/stellar/stellar-quickstart.mdx @@ -15,7 +15,7 @@ By the end of this tutorial you will have: **Prerequisites:** Node.js 18+, a Wraith API key ([sign up at usewraith.xyz](https://usewraith.xyz)), and the Freighter browser extension installed ([get it here](https://www.freighter.app)). - Every stage below can be run interactively in your browser. The [Send](#send-a-stealth-payment) and [Withdraw](#withdraw-to-your-wallet) sections embed a client-side playground that runs the exact same flow against canned fixtures — no wallet or network calls needed. Continue the full guided flow on the [derive](/api-reference/stealth-keys) and [scan](/api-reference/fetch-announcements-stream) pages. + Every stage below can be run interactively in your browser. The [Send](#7-send-a-stealth-payment) and [Withdraw](#9-withdraw-to-your-wallet) sections embed a client-side playground that runs the exact same flow against canned fixtures — no wallet or network calls needed. Continue the full guided flow on the [derive](/api-reference/stealth-keys) and [scan](/api-reference/fetch-announcements-stream) pages. --- diff --git a/package.json b/package.json index a5176a1..fd9100c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "check:snippets-extended": "tsx scripts/check-snippets-extended.ts", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", "check:contract-registry": "node scripts/check-contract-registry.mjs", + "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", + "test:redirects-and-anchors": "node --test scripts/check-redirects-and-anchors.test.mjs", + "test:preview-redirect": "node scripts/check-preview-redirect.mjs", "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", @@ -16,7 +19,7 @@ "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", "mint:validate": "mint validate", "mint:broken-links": "mint broken-links", - "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry" + "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry && npm run test:redirects-and-anchors" }, "dependencies": { "@solana/web3.js": "^1.95.0", diff --git a/reference/auditor-guide.mdx b/reference/auditor-guide.mdx index 5a0a9c7..9319a46 100644 --- a/reference/auditor-guide.mdx +++ b/reference/auditor-guide.mdx @@ -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. @@ -87,7 +87,7 @@ We also provide **public credit** (with your permission) in advisories and the f [Security Disclosure Policy → Recognition and Rewards](/reference/security-disclosure#recognition-and-rewards). -## 5. PoC repository template +## 5. PoC repository template {#poc-repository-template} A finding is far more likely to be triaged quickly if it ships with a reproducible proof of concept. Use the template repository as a starting point: @@ -127,7 +127,7 @@ From the moment your email arrives, these are our commitments. If we cannot meet After a patch ships we coordinate the public disclosure date with you. If 90 days pass without a patch for reasons outside your control, you may disclose; we will not pursue legal or reputational action against you. -## 7. Submitting a report +## 7. Submitting a report {#submitting-a-report} Email **security@usewraith.xyz**. Do not open a public GitHub issue or post publicly until a fix ships and coordinated disclosure is agreed. Use PGP for sensitive PoC material — the public key is at [https://usewraith.xyz/.well-known/security.txt](https://usewraith.xyz/.well-known/security.txt). diff --git a/reference/error-codes.mdx b/reference/error-codes.mdx index 6b45c45..9b3cca5 100644 --- a/reference/error-codes.mdx +++ b/reference/error-codes.mdx @@ -21,7 +21,7 @@ Contract errors surface as `HostError: Error(Contract, #N)` in Soroban RPC respo | `#1` | `InvalidMetaAddressLength` | The meta-address payload is not exactly 64 bytes | Passing the human-readable `st:xlm:` prefix to `register_keys` instead of the raw 64-byte key material | Decode first: `decodeStealthMetaAddress("st:xlm:...")` returns raw bytes; pass those | | `#2` | `Unauthorized` | Caller is not the registered `registrant` | Invoking `register_keys` from a different keypair than the one in the auth envelope | Sign the transaction with the same keypair that you intend to register | -See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hostError-contract-trapped) in the troubleshooting guide. +See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hosterror-errorcontract-n-contract-trapped) in the troubleshooting guide. --- @@ -34,7 +34,7 @@ See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshoot | `#3` | `ArityMismatch` | `batch_send` vectors have different lengths | Passing `stealth_addresses`, `amounts`, `ephemeral_pub_keys`, or `metadatas` arrays of unequal length | Ensure all four arrays are the same length before building the invocation | | `#4` | `ZeroAmount` | `amount == 0` | Passing `0` as the SAC token amount (e.g. wrong decimal scaling for USDC) | USDC uses 7 decimals: 1 USDC = `10_000_000` in the `i128`; verify your scaling | -See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hostError-contract-trapped) and [`op_no_trust`](/guides/stellar-troubleshooting#18-op_no_trust--missing-trustline). +See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hosterror-errorcontract-n-contract-trapped) and [`op_no_trust`](/guides/stellar-troubleshooting#18-op_no_trust--missing-trustline). --- @@ -50,7 +50,7 @@ See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshoot | `#6` | `Unauthorized` | Caller is not the registered owner of the name | Attempting to `update` or `release` a name from a different keypair | Sign with the same keypair used during `register` | | `#7` | `NameAlreadyRegistered` | Calling `register` on a name that already exists | Race condition, or forgetting a previous registration | Call `resolve` first; if it returns data the name is taken | -See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hostError-contract-trapped) in the troubleshooting guide. +See also: [Soroban Contract Errors → `HostError`](/guides/stellar-troubleshooting#17-hosterror-errorcontract-n-contract-trapped) in the troubleshooting guide. --- @@ -122,13 +122,13 @@ The table below maps each numbered entry in [stellar-troubleshooting.mdx](/guide | 4 | `op_no_destination` | Stellar protocol error — see Horizon docs | | 5 | `429 Too Many Requests` | Network / rate limiting | | 6 | `502 / 504 Gateway` | Network / node availability | -| 7 | `retention window exceeded` | [`retention_window_exceeded`](#retention_window_exceeded) | +| 7 | `retention window exceeded` | [`retention_window_exceeded`](#soroban-rpc--indexer-errors) | | 8 | `tx_too_late` | Stellar protocol error — see Horizon docs | | 9 | `Freighter not installed` | Browser / wallet environment | | 10 | `Network mismatch` | Browser / wallet environment | | 11 | `User rejected signature` | Browser / wallet UX | | 12 | `tx_bad_auth` / `op_bad_auth` | Stellar protocol error — see Horizon docs | -| 13 | `Derived address matches recipient` | [`Point at infinity`](#point-at-infinity) | +| 13 | `Derived address matches recipient` | [`Point at infinity`](/guides/stellar-troubleshooting#13-derived-address-matches-recipient) | | 14 | `Zero-balance scan returning matches` | Scan logic / stale ledger state | | 15 | `Name resolution null` | Federation / DNS availability | | 16 | `Stealth payload too large for memo` | Stellar memo size constraint (32 bytes) | diff --git a/reference/stellar-event-schemas.mdx b/reference/stellar-event-schemas.mdx new file mode 100644 index 0000000..81d2650 --- /dev/null +++ b/reference/stellar-event-schemas.mdx @@ -0,0 +1,100 @@ +--- +title: "Stellar Event Schemas (v2)" +description: "Soroban event topic schemas for stealth address announcements" +--- + +The `stealth-announcer` contract emits events to notify indexers and clients about new stealth payments. In v2, the event topic schema has been updated to include indexed fields that allow clients to efficiently filter events before downloading the full metadata. + +This reference guide documents the v2 event schema, how it differs from v1, and how to query it. + +## v1 vs v2 Event Topic Comparison + +### v1 Schema (Legacy) +In v1, the event topics include the scheme and stealth address, while the data contains the caller and the remaining announcement fields. +- **Topic Layout**: `("announce", scheme_id, stealth_address)` +- **Data Layout**: `(caller, ephemeral_pub_key, metadata)` + +### v2 Schema (Current) +In v2, key routing fields have been moved to the event topics to enable native filtering via the Soroban RPC `getEvents` method. +- **Topic Layout**: `("announce", scheme_id, view_tag_bucket, metadata_kind)` +- **Data Layout**: `(stealth_address, ephemeral_pub_key, metadata)` + +## v2 Topic Layout Details + +The v2 event emits exactly four topics: + +1. **`"announce"`**: The literal string identifier for the event. +2. **`scheme_id`** (u32): The stealth address scheme. v2 accepts only `2`. +3. **`view_tag_bucket`** (u32): A deterministic bucket derived from the view tag to allow prefix filtering. +4. **`metadata_kind`** (u32): The metadata type. `METADATA_KIND_VIEW_TAG` is `1`. + +### `view_tag_bucket` Derivation Rule + +To reduce false positives when scanning announcements, clients can filter by the `view_tag_bucket`. +- **Rule**: Metadata must be non-empty. The bucket is `metadata[0] as u32`. + +When querying the RPC, indexers can specify their expected `view_tag_bucket` to dramatically reduce the number of events they need to fetch and process. + +### `metadata_kind` Values & Forward-Compat + +The `metadata_kind` field ensures forward compatibility for future upgrades to the announcement payload. + +- **`1` (`METADATA_KIND_VIEW_TAG`)**: Standard stealth payment metadata with a view tag. +- Other values are not accepted by the current v2 contract. + +**Forward-Compat Semantics**: Indexers and clients *must* gracefully ignore events with a `metadata_kind` they do not recognize. This allows new metadata formats to be deployed without breaking existing indexers. + +## Example `getEvents` Filter Queries + +You can use the Soroban RPC `getEvents` endpoint to filter for specific topics. + +### 1. Fetch all v2 announcements for Scheme 2 +```json +{ + "startLedger": 123456, + "filters": [ + { + "type": "contract", + "contractIds": [""], + "topics": [ + ["announce"], + ["2"], + ["*"], + ["*"] + ] + } + ], + "pagination": { "limit": 100 } +} +``` + +### 2. Filter by `view_tag_bucket` (e.g., Bucket 42) +This is the recommended query for clients looking for their own transactions. + +```json +{ + "startLedger": 123456, + "filters": [ + { + "type": "contract", + "contractIds": [""], + "topics": [ + ["announce"], + ["2"], + ["42"], + ["1"] + ] + } + ], + "pagination": { "limit": 100 } +} +``` + +## Migration & Indexer Recommendations + +The transition from v1 to v2 involves a new deployment of the `stealth-announcer` contract. + +- **Migration Timing**: v1 events remain readable and will not be deleted. v2 is a strictly new deployment with a new contract ID. +- **Indexer Recommendations**: During the transition period, indexers and wallets *must* listen to both the v1 and v2 contract IDs to ensure no announcements are missed. + +You can query both simultaneously by including both contract IDs in your `getEvents` filter, or by executing parallel queries for the different topic structures. diff --git a/scripts/check-preview-redirect.mjs b/scripts/check-preview-redirect.mjs new file mode 100644 index 0000000..a178de3 --- /dev/null +++ b/scripts/check-preview-redirect.mjs @@ -0,0 +1,41 @@ +const previewUrl = process.env.MINTLIFY_PREVIEW_URL; + +if (!previewUrl) { + console.error("Set MINTLIFY_PREVIEW_URL to the base URL of the Mintlify PR preview."); + process.exit(1); +} + +let baseUrl; +try { + baseUrl = new URL(previewUrl); +} catch { + console.error(`MINTLIFY_PREVIEW_URL is not a valid URL: ${previewUrl}`); + process.exit(1); +} + +if (!/^https?:$/.test(baseUrl.protocol)) { + console.error("MINTLIFY_PREVIEW_URL must use HTTP or HTTPS."); + process.exit(1); +} + +const requestUrl = new URL("/README", baseUrl); +const response = await fetch(requestUrl, { redirect: "manual" }); + +if (response.status < 300 || response.status >= 400) { + console.error(`Expected ${requestUrl} to return a 3xx redirect; received ${response.status}.`); + process.exit(1); +} + +const location = response.headers.get("location"); +if (!location) { + console.error(`Expected ${requestUrl} to include a Location header; received none.`); + process.exit(1); +} + +const destination = new URL(location, requestUrl); +if (destination.pathname !== "/introduction") { + console.error(`Expected ${requestUrl} to resolve to /introduction; Location was ${location}.`); + process.exit(1); +} + +console.log(`Verified ${requestUrl} returns ${response.status} redirect to ${destination.pathname}.`); diff --git a/scripts/check-redirects-and-anchors.mjs b/scripts/check-redirects-and-anchors.mjs new file mode 100644 index 0000000..bf01ac4 --- /dev/null +++ b/scripts/check-redirects-and-anchors.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** Check Git-detected documentation renames, redirects, and internal anchors. */ +import { execFileSync } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const root = process.cwd(); +const extensions = new Set([".md", ".mdx"]); +const shippedDirs = ["api-reference", "architecture", "concepts", "contracts", "docs", "guides", "reference", "sdk"]; +const excludedLinks = new Map([["docs/i18n.md", new Set(["/guides/my-guide"])]]); + +async function main() { + const docs = await readDocs(root); + const changedRenames = await getRenamesFromEnvironment(); + const pageFiles = await collectPages(root); + const changedPages = await getChangedPagesFromEnvironment(); + const pageByUrl = new Map(pageFiles.map((file) => [fileToUrl(file), file])); + const redirects = docs.redirects === undefined ? [] : docs.redirects; + const errors = []; + + if (!Array.isArray(redirects)) errors.push('docs.json "redirects" must be an array when present.'); + const redirectList = Array.isArray(redirects) ? redirects : []; + errors.push(...validateRedirects(redirectList, pageByUrl, changedRenames)); + + const pageAnchors = new Map(); + for (const file of pageFiles) pageAnchors.set(file, extractAnchors(await readFile(path.join(root, file), "utf8"))); + const scanFiles = getScanFiles(pageFiles, changedPages, changedRenames); + for (const file of scanFiles) { + const content = await readFile(path.join(root, file), "utf8"); + for (const link of extractLinks(content)) { + if (isExcludedLink(file, link)) continue; + const parsed = resolveLink(file, link, pageByUrl); + if (parsed.external) continue; + const stale = validateRenamedPageLink(file, link, changedRenames); + errors.push(...validateLinkAnchor(file, link, pageByUrl, pageAnchors)); + if (stale) errors.push(stale); + } + } + + if (errors.length) { + console.error(["Redirect and anchor check failed:", ...errors.map((e) => ` - ${e}`)].join("\n")); + process.exitCode = 1; + } else { + console.log(`Redirect and anchor check passed: ${changedRenames.length} Git rename(s), ${pageFiles.length} pages, ${redirectList.length} redirect(s).`); + } +} + +export function getScanFiles(pageFiles, changedPages, changedRenames = []) { + const scanFiles = changedPages.size ? new Set(pageFiles) : new Set(); + if (changedRenames.length) { + for (const file of pageFiles) scanFiles.add(file); + } + return scanFiles; +} + +export function isExcludedLink(source, link) { + return excludedLinks.get(source)?.has(link) ?? false; +} + +export function fileToUrl(file) { + return `/${file.replaceAll("\\", "/").replace(/\.(md|mdx)$/i, "")}`; +} + +export function parseRenameRecords(output) { + const lines = output.split(/\r?\n/); + const result = []; + for (let i = 0; i < lines.length; i++) { + const fields = lines[i].split("\t"); + if (/^R\d+$/.test(fields[0] ?? "") && isPage(fields[1]) && isPage(fields[2])) result.push([fields[1], fields[2]]); + } + return result; +} + +export function validateRedirects(redirects, pageByUrl, renames = []) { + const errors = []; + const sources = new Set(); + for (const [index, redirect] of redirects.entries()) { + if (!redirect || typeof redirect.source !== "string" || typeof redirect.destination !== "string") { + errors.push(`docs.json redirects[${index}] must have string source and destination fields.`); + continue; + } + if (sources.has(redirect.source)) errors.push(`Duplicate redirect source ${redirect.source} in docs.json.`); + sources.add(redirect.source); + if (!redirect.destination.startsWith("/") || redirect.destination.includes("#") || redirect.destination.includes("?")) { + errors.push(`Redirect ${redirect.source} has invalid destination ${redirect.destination}; use a root-relative page URL without an anchor or query.`); + } else if (!pageByUrl.has(redirect.destination)) { + errors.push(`Redirect ${redirect.source} points to missing page ${redirect.destination}.`); + } + } + for (const [oldPath, newPath] of renames) { + const source = fileToUrl(oldPath); + const destination = fileToUrl(newPath); + if (source === destination) continue; + if (!redirects.some((r) => r?.source === source && r?.destination === destination)) { + errors.push(`Renamed page ${oldPath} -> ${newPath} requires docs.json redirect { "source": "${source}", "destination": "${destination}" }.`); + } + if (!pageByUrl.has(destination)) errors.push(`Renamed page destination ${destination} does not resolve to a current documentation page.`); + } + return errors; +} + +export function validateLinkAnchor(source, link, pageByUrl, pageAnchors) { + const parsed = resolveLink(source, link, pageByUrl); + if (parsed.external) return []; + if (path.posix.extname(parsed.url) && !/\.(?:md|mdx)$/i.test(parsed.url)) return []; + if (!parsed.file || !pageAnchors.has(parsed.file)) return [`${source}: internal link "${link}" points to a missing documentation page.`]; + const anchors = pageAnchors.get(parsed.file); + if (parsed.anchor && !anchors.has(parsed.anchor) && !anchors.has(slugify(parsed.anchor))) return [`${source}: internal link "${link}" points to missing anchor "${parsed.anchor}" in ${parsed.file}.`]; + return []; +} + +export function validateRenamedPageLink(source, link, renames) { + const parsed = resolveLink(source, link, new Map()); + if (parsed.external) return null; + const moved = renames.find(([oldPath]) => fileToUrl(oldPath) === parsed.url); + return moved ? `${source}: link "${link}" still uses renamed page URL ${parsed.url}; update it to ${fileToUrl(moved[1])}.` : null; +} + +export function extractAnchors(markdown) { + const anchors = new Set(); + for (const match of markdown.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*(?:\{#([^}]+)\})?\s*$/gm)) { + const title = match[1].replace(/\s+\{#[^}]+\}\s*$/, ""); + const id = match[2]; + if (id) { anchors.add(id); continue; } + const base = slugify(title); + let slug = base; + let suffix = 1; + while (anchors.has(slug)) slug = `${base}-${suffix++}`; + anchors.add(slug); + } + for (const match of markdown.matchAll(/]*id=["']([^"']+)["'][^>]*>/gi)) anchors.add(match[1]); + return anchors; +} + +export function slugify(title) { + return title.toLowerCase().trim().replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "").replace(/\s+/g, "-").replace(/[^\p{L}\p{N}_-]/gu, ""); +} + +export function extractLinks(markdown) { + const links = []; + for (const m of markdown.matchAll(/!?\[[^\]]*\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+[^)]*)?\)/g)) links.push(m[1] ?? m[2]); + for (const m of markdown.matchAll(/\bhref=["']([^"']+)["']/gi)) links.push(m[1]); + return links; +} + +export function resolveLink(sourceFile, link, pageByUrl) { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(link)) return { external: true }; + const [pathname, rawAnchor] = link.split("#", 2); + const anchor = rawAnchor ? decodeFragment(rawAnchor) : ""; + let url; + if (!pathname) url = fileToUrl(sourceFile); + else if (pathname.startsWith("/")) url = pathname.replace(/\.(?:md|mdx)$/i, ""); + else url = fileToUrl(path.posix.normalize(path.posix.join(path.posix.dirname(sourceFile), pathname))); + let file = pageByUrl.get(url); + return { external: false, url, anchor, file }; +} + +function decodeFragment(fragment) { + try { return decodeURIComponent(fragment); } catch { return fragment; } +} +function isPage(file) { return typeof file === "string" && extensions.has(path.posix.extname(file).toLowerCase()); } +async function readDocs(directory) { return JSON.parse(await readFile(path.join(directory, "docs.json"), "utf8")); } +async function collectPages(directory) { + const result = []; + async function walk(dir) { + for (const entry of await readdir(path.join(directory, dir), { withFileTypes: true })) { + const rel = path.posix.join(dir, entry.name); + if (entry.isDirectory()) await walk(rel); + else if (isPage(rel)) result.push(rel); + } + } + for (const dir of shippedDirs) { + try { await walk(dir); } catch (error) { if (error.code !== "ENOENT") throw error; } + } + for (const entry of await readdir(directory, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".mdx")) result.push(entry.name); + return result; +} +async function getRenamesFromEnvironment() { + const base = process.env.DOCS_DIFF_BASE; + const head = process.env.DOCS_DIFF_HEAD; + if (!base || !head) return []; + const output = execFileSync("git", ["diff", "--find-renames", "--name-status", `${base}...${head}`, "--"], { encoding: "utf8" }); + return parseRenameRecords(output); +} + +async function getChangedPagesFromEnvironment() { + const base = process.env.DOCS_DIFF_BASE; + const head = process.env.DOCS_DIFF_HEAD; + if (!base || !head) return new Set(); + const output = execFileSync("git", ["diff", "--name-only", `${base}...${head}`, "--"], { encoding: "utf8" }); + const result = new Set(); + for (const file of output.split(/\r?\n/)) { + if (isPage(file) && await fileExists(path.join(root, file))) result.add(file.replaceAll("\\", "/")); + } + return result; +} + +async function fileExists(file) { + try { await readFile(file); return true; } catch { return false; } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`Redirect and anchor check could not complete: ${error.message}`); process.exitCode = 1; }); +} diff --git a/scripts/check-redirects-and-anchors.test.mjs b/scripts/check-redirects-and-anchors.test.mjs new file mode 100644 index 0000000..b519812 --- /dev/null +++ b/scripts/check-redirects-and-anchors.test.mjs @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractAnchors, extractLinks, fileToUrl, getScanFiles, isExcludedLink, parseRenameRecords, resolveLink, validateLinkAnchor, validateRedirects, validateRenamedPageLink } from "./check-redirects-and-anchors.mjs"; + +test("only actual Git rename records for Markdown pages are selected", () => { + assert.deepEqual(parseRenameRecords("R100\tREADME.mdx\tintroduction.mdx\nM\tdocs.json\nR090\told.txt\tnew.txt\nA\tnew.mdx\n"), [["README.mdx", "introduction.mdx"]]); +}); + +test("page paths map to root-relative extensionless URLs", () => { + assert.equal(fileToUrl("README.mdx"), "/README"); + assert.equal(fileToUrl("docs/i18n.md"), "/docs/i18n"); + assert.equal(fileToUrl("guides/old-page.md"), "/guides/old-page"); +}); + +test("extracts generated, duplicate, explicit, and HTML anchors", () => { + const anchors = extractAnchors("## Getting Started\n## Getting Started\n## User guide {#custom-id}\n"); + for (const anchor of ["getting-started", "getting-started-1", "custom-id", "legacy"]) assert.ok(anchors.has(anchor), anchor); +}); + +test("finds Markdown and HTML internal links", () => { + assert.deepEqual(extractLinks("[one](/guide#section) and intro"), ["/guide#section", "/intro"]); +}); + +test("resolves root-relative, relative, and page-local anchor links", () => { + const pages = new Map([["/guide", "guide.mdx"], ["/intro", "intro.mdx"], ["/section/child", "section/child.mdx"], ["/docs/i18n", "docs/i18n.md"]]); + assert.deepEqual(resolveLink("guide.mdx", "/intro#start", pages), { external: false, url: "/intro", anchor: "start", file: "intro.mdx" }); + assert.deepEqual(resolveLink("section/child.mdx", "../guide#part", pages), { external: false, url: "/guide", anchor: "part", file: "guide.mdx" }); + assert.deepEqual(resolveLink("guide.mdx", "#local", pages), { external: false, url: "/guide", anchor: "local", file: "guide.mdx" }); + assert.deepEqual(resolveLink("guide.mdx", "/docs/i18n.md#language-note", pages), { external: false, url: "/docs/i18n", anchor: "language-note", file: "docs/i18n.md" }); +}); + +test("requires an exact redirect for each renamed page and rejects invalid redirect targets", () => { + const pages = new Map([["/introduction", "introduction.mdx"]]); + assert.match(validateRedirects([], pages, [["README.mdx", "introduction.mdx"]])[0], /requires docs\.json redirect/); + assert.deepEqual(validateRedirects([{ source: "/README", destination: "/introduction" }], pages, [["README.mdx", "introduction.mdx"]]), []); + assert.ok(validateRedirects([{ source: "/old", destination: "/missing#anchor" }], pages).some((error) => /invalid destination/.test(error))); + assert.ok(validateRedirects([{ source: "/old", destination: "/missing" }], pages).some((error) => /points to missing page \/missing/.test(error))); + assert.ok(validateRedirects([{ source: "/old", destination: "/introduction" }, { source: "/old", destination: "/introduction" }], pages).some((error) => /Duplicate redirect source/.test(error))); +}); + +test("validates internal link anchors and reports stale fragments", () => { + const pages = new Map([["/target", "target.mdx"]]); + const anchors = new Map([["target.mdx", new Set(["valid-anchor"])]]); + assert.deepEqual(validateLinkAnchor("source.mdx", "/target#valid-anchor", pages, anchors), []); + assert.match(validateLinkAnchor("source.mdx", "/target#stale-anchor", pages, anchors)[0], /missing anchor/); + assert.deepEqual(validateLinkAnchor("source.mdx", "/target#Getting%20Started", pages, new Map([["target.mdx", extractAnchors("## Getting Started")]])), []); +}); + +test("reports inbound links that still use a renamed page URL", () => { + const links = extractLinks("[section](/README#section)"); + assert.match(validateRenamedPageLink("current.mdx", links[0], [["README.mdx", "introduction.mdx"]]), /still uses renamed page URL \/README; update it to \/introduction/); +}); + +test("scans unchanged inbound pages when documentation changes", () => { + const pageFiles = ["target.mdx", "source.mdx"]; + const changedPages = new Set(["target.mdx"]); + const scanFiles = getScanFiles(pageFiles, changedPages); + + assert.ok(scanFiles.has("target.mdx")); + assert.ok(scanFiles.has("source.mdx")); + assert.equal(scanFiles.size, 2); +}); + +test("excludes only the i18n guide's intentional starter-page link example", () => { + assert.equal(isExcludedLink("docs/i18n.md", "/guides/my-guide"), true); + assert.equal(isExcludedLink("docs/i18n.md", "/guides/my-guide.es"), false); + assert.equal(isExcludedLink("guides/example.mdx", "/guides/my-guide"), false); +}); diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index 9451331..78f7ac7 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. @@ -442,7 +442,7 @@ For detailed fee calculations, baseline resource metrics, and budgeting advice f - [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — build online, sign air-gapped, submit without the private key touching the internet ## See Also -- [Stellar React Hooks](/sdk/stellar-react-hooks) — React hooks for stealth address operations +- [React Native SDK integration](/guides/integrations/react-native) — use the Wraith SDK in React Native applications - [Wraith Names on Stellar](/guides/wraith-names-stellar) — core protocol identity lifecycle and subdomains - [Stellar Multisig Stealth Withdrawals](/guides/stellar-multisig-withdrawal) — coordinate N-of-M signers to authorize withdrawals from a multisig source account ## Federation Address Resolution