diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml
index b787e48..a7e3af3 100644
--- a/.github/workflows/snippets.yml
+++ b/.github/workflows/snippets.yml
@@ -67,6 +67,9 @@ jobs:
- name: Check snippets
run: pnpm run check:snippets
+ - name: Check placeholders
+ run: pnpm run check:placeholders
+
- name: Check nav coverage
run: pnpm run check:nav-coverage
diff --git a/package.json b/package.json
index df9ea9c..5c04b7b 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
+ "check:placeholders": "tsx scripts/check-placeholders.ts",
"check:snippets": "tsx scripts/check-snippets.ts",
"check:nav-coverage": "node scripts/check-nav-coverage.mjs",
"check:contract-registry": "node scripts/check-contract-registry.mjs",
diff --git a/reference/contract-registry.mdx b/reference/contract-registry.mdx
index f299904..962bfa4 100644
--- a/reference/contract-registry.mdx
+++ b/reference/contract-registry.mdx
@@ -74,7 +74,7 @@ Mainnet deployment is gated on the security audit and remediation, per the contr
| `stealth-batch-sender` | Not deployed | v1 | — | — | — |
- Never substitute a guessed or placeholder contract address for a pending deployment.
+ Never substitute a guessed or placeholder contract address for a contract that has not yet been deployed.
`getDeployment("stellar")` throws for an unknown network, and calls against an
absent contract id fail on-chain.
@@ -126,7 +126,7 @@ console.log(getCkbDeployment("ckb").contracts);
Interface specs for stealth-announcer, stealth-registry, stealth-sender, and wraith-names
- Operator guide for the pending mainnet rollout
+ Operator guide for the mainnet rollout
Create an agent and send your first stealth payment against the live testnet contracts
diff --git a/reference/stellar-networks.mdx b/reference/stellar-networks.mdx
index 85663a6..496b601 100644
--- a/reference/stellar-networks.mdx
+++ b/reference/stellar-networks.mdx
@@ -12,7 +12,7 @@ Quick reference for every Stellar network supported by Wraith Protocol — what
|---|---|---|---|
| [Testnet](#testnet) | Development and integration testing | Live | ✓ |
| [Futurenet](#futurenet) | Experimental Soroban preview features | Not deployed | ✓ |
-| [Mainnet](#mainnet) | Production | Pending mainnet launch | ✗ |
+| [Mainnet](#mainnet) | Production | Not yet deployed | ✗ |
---
@@ -164,7 +164,7 @@ for the launch notification.
There are no mainnet contract addresses yet. Never substitute a guessed or
- placeholder address — use the [Contract Registry](/reference/contract-registry).
+ unverified address — use the [Contract Registry](/reference/contract-registry).
### Connection details
diff --git a/scripts/check-placeholders.ts b/scripts/check-placeholders.ts
new file mode 100644
index 0000000..27e7cd4
--- /dev/null
+++ b/scripts/check-placeholders.ts
@@ -0,0 +1,340 @@
+/**
+ * check-placeholders.ts
+ *
+ * Scans shipped MDX pages and docs.json for placeholder contract IDs,
+ * pending-deployment prose, and TODO markers that must not reach production.
+ *
+ * Exit codes
+ * 0 — clean
+ * 1 — one or more violations found (or the allowlist file is malformed)
+ *
+ * Allowlist
+ * scripts/placeholder-allowlist.json — relative paths (from repo root) of
+ * pages where placeholders are intentional, e.g. research / roadmap pages.
+ *
+ * Usage
+ * pnpm run check:placeholders
+ */
+
+import { readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+const repoRoot = process.cwd();
+
+/** Directories that are never shipped docs. */
+const ignoredDirs = new Set([
+ ".git",
+ ".github",
+ ".agents",
+ ".claude",
+ "node_modules",
+ ".next",
+ "dist",
+ "build",
+ "scripts", // the scripts themselves are not docs pages
+ "assets",
+ "docs", // /docs/ holds internal markdown (CONTRIBUTING etc.), not MDX pages
+]);
+
+/**
+ * Navigation groups whose pages must be clean.
+ * These match the `group` values in docs.json navigation tabs.
+ */
+const ENFORCED_GROUPS = new Set([
+ "Quickstarts",
+ "API Reference",
+ "Reference",
+ // Operations guides (the "Operations" group and ops/ sub-pages)
+ "Operations",
+]);
+
+/**
+ * Navigation tabs whose pages must be clean (all pages in the tab).
+ * Currently we enforce the API Reference tab entirely.
+ */
+const ENFORCED_TABS = new Set([
+ "API Reference",
+]);
+
+/**
+ * Patterns that indicate a placeholder or unresolved TODO.
+ *
+ * Each entry has:
+ * pattern — regex applied per line (case-sensitive unless `i` flag present)
+ * label — human-readable category shown in output
+ */
+const PLACEHOLDER_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
+ // Soroban contract ID placeholders — starts with C, all uppercase + underscores
+ {
+ pattern: /CPLACEHOLDER[A-Z0-9_]*/,
+ label: "placeholder contract ID",
+ },
+ // Generic placeholder sentinel text
+ {
+ pattern: /\bPLACEHOLDER\b/,
+ label: "PLACEHOLDER sentinel",
+ },
+ // Pending deployment prose
+ {
+ pattern: /pending\s+deployment/i,
+ label: "pending deployment text",
+ },
+ // "Pending mainnet" prose
+ {
+ pattern: /pending\s+mainnet/i,
+ label: "pending mainnet text",
+ },
+ // TODO markers in MDX content (not inside fenced code blocks — we check all lines anyway
+ // so devs are warned about code-fence TODOs too)
+ {
+ pattern: /\bTODO\b/,
+ label: "TODO marker",
+ },
+ // FIXME markers
+ {
+ pattern: /\bFIXME\b/,
+ label: "FIXME marker",
+ },
+ // "Coming soon" prose — common in integration tables
+ {
+ pattern: /\bcoming\s+soon\b/i,
+ label: "coming soon text",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface Violation {
+ file: string; // repo-relative path
+ line: number; // 1-based
+ column: number; // 1-based, start of match
+ label: string;
+ matchedText: string;
+}
+
+interface NavPage {
+ path: string; // repo-relative, no extension — e.g. "guides/quickstarts/python"
+ group: string;
+ tab: string;
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+async function main() {
+ // 1. Load allowlist
+ const allowlist = await loadAllowlist();
+
+ // 2. Parse docs.json to know which pages are enforced
+ const enforcedPaths = await resolveEnforcedPaths();
+
+ // 3. Find all MDX files
+ const mdxFiles = await findMdxFiles(repoRoot);
+
+ // 4. Scan enforced files
+ const violations: Violation[] = [];
+ let scanned = 0;
+ let skipped = 0;
+
+ for (const absPath of mdxFiles) {
+ const rel = path.relative(repoRoot, absPath);
+ const pageKey = rel.replace(/\.mdx$/, "");
+
+ if (allowlist.has(pageKey)) {
+ skipped += 1;
+ continue;
+ }
+
+ if (!enforcedPaths.has(pageKey)) {
+ // Not in an enforced navigation group — skip silently
+ continue;
+ }
+
+ const fileViolations = await scanFile(absPath, rel);
+ violations.push(...fileViolations);
+ scanned += 1;
+ }
+
+ // 5. Also scan docs.json itself for placeholder contract IDs
+ const docsJsonViolations = await scanFile(
+ path.join(repoRoot, "docs.json"),
+ "docs.json",
+ );
+ if (docsJsonViolations.length > 0) {
+ violations.push(...docsJsonViolations);
+ }
+
+ // 6. Report
+ const summary = [
+ `MDX files scanned : ${scanned}`,
+ `MDX files skipped (allowlist): ${skipped}`,
+ `docs.json violations: ${docsJsonViolations.length}`,
+ `Total violations : ${violations.length}`,
+ ].join("\n");
+
+ if (violations.length > 0) {
+ const report = violations
+ .map(
+ (v) =>
+ ` ${v.file}:${v.line}:${v.column} [${v.label}] ${v.matchedText.trim()}`,
+ )
+ .join("\n");
+
+ console.error(
+ `\nPlaceholder check FAILED\n\n${report}\n\n${summary}\n\n` +
+ `Fix the violations above, or add the page to scripts/placeholder-allowlist.json\n` +
+ `if the placeholder is intentional (e.g. a research or roadmap page).\n`,
+ );
+ process.exit(1);
+ }
+
+ console.log(`\nPlaceholder check passed.\n\n${summary}\n`);
+}
+
+// ---------------------------------------------------------------------------
+// Allowlist
+// ---------------------------------------------------------------------------
+
+async function loadAllowlist(): Promise> {
+ const allowlistPath = path.join(repoRoot, "scripts", "placeholder-allowlist.json");
+ try {
+ const raw = await readFile(allowlistPath, "utf8");
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ !Array.isArray(parsed) ||
+ !parsed.every((item): item is string => typeof item === "string")
+ ) {
+ console.error(
+ `placeholder-allowlist.json must be a JSON array of strings.\nGot: ${JSON.stringify(parsed, null, 2)}`,
+ );
+ process.exit(1);
+ }
+ return new Set(parsed);
+ } catch (err: unknown) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
+ // No allowlist file → treat as empty
+ return new Set();
+ }
+ throw err;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// docs.json navigation parsing
+// ---------------------------------------------------------------------------
+
+interface DocsJson {
+ navigation: {
+ tabs: Array<{
+ tab: string;
+ groups: Array<{
+ group: string;
+ pages: string[];
+ }>;
+ }>;
+ };
+}
+
+async function resolveEnforcedPaths(): Promise> {
+ const raw = await readFile(path.join(repoRoot, "docs.json"), "utf8");
+ const config: DocsJson = JSON.parse(raw);
+
+ const enforced = new Set();
+
+ for (const tabEntry of config.navigation.tabs) {
+ const tabName = tabEntry.tab;
+ const tabEnforced = ENFORCED_TABS.has(tabName);
+
+ for (const groupEntry of tabEntry.groups) {
+ const groupEnforced = ENFORCED_GROUPS.has(groupEntry.group);
+
+ if (!tabEnforced && !groupEnforced) {
+ continue;
+ }
+
+ for (const page of groupEntry.pages) {
+ enforced.add(page);
+ }
+ }
+ }
+
+ return enforced;
+}
+
+// ---------------------------------------------------------------------------
+// File discovery
+// ---------------------------------------------------------------------------
+
+async function findMdxFiles(dir: string): Promise {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const results = await Promise.all(
+ entries.map(async (entry) => {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ return ignoredDirs.has(entry.name) ? [] : findMdxFiles(fullPath);
+ }
+ if (entry.isFile() && entry.name.endsWith(".mdx")) {
+ return [fullPath];
+ }
+ return [];
+ }),
+ );
+ return results.flat().sort();
+}
+
+// ---------------------------------------------------------------------------
+// Per-file scanning
+// ---------------------------------------------------------------------------
+
+async function scanFile(absPath: string, relPath: string): Promise {
+ let content: string;
+ try {
+ content = await readFile(absPath, "utf8");
+ } catch {
+ return [];
+ }
+
+ const lines = content.split("\n");
+ const violations: Violation[] = [];
+
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
+ const lineText = lines[lineIdx];
+
+ for (const { pattern, label } of PLACEHOLDER_PATTERNS) {
+ // Reset lastIndex for global patterns between lines
+ pattern.lastIndex = 0;
+
+ const match = pattern.exec(lineText);
+ if (match === null) continue;
+
+ violations.push({
+ file: relPath,
+ line: lineIdx + 1,
+ column: match.index + 1,
+ label,
+ matchedText: lineText,
+ });
+
+ // Report at most one violation per pattern per line to avoid noise
+ }
+ }
+
+ return violations;
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/placeholder-allowlist.json b/scripts/placeholder-allowlist.json
new file mode 100644
index 0000000..5a21dac
--- /dev/null
+++ b/scripts/placeholder-allowlist.json
@@ -0,0 +1,3 @@
+[
+ "roadmap"
+]