From 1a9a7a5ad4545762caf50160723ea358c0e32e0b Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Tue, 23 Jun 2026 15:28:21 +0100 Subject: [PATCH 1/3] docs: add stellar soroban event topic v2 schema documentation --- contracts/stellar.mdx | 3 + docs.json | 2 +- reference/stellar-event-schemas.mdx | 101 ++++++++++++++++++++++++++++ sdk/chains/stellar.mdx | 3 + 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 reference/stellar-event-schemas.mdx diff --git a/contracts/stellar.mdx b/contracts/stellar.mdx index 2a00124..74b350d 100644 --- a/contracts/stellar.mdx +++ b/contracts/stellar.mdx @@ -245,6 +245,9 @@ const events = await sorobanServer.getEvents({ }); ``` +> [!NOTE] +> For details on the v2 indexed event topics and filtering by view tags, see the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation. + --- ## Differences from EVM Contracts diff --git a/docs.json b/docs.json index 062814c..574b852 100644 --- a/docs.json +++ b/docs.json @@ -72,7 +72,7 @@ }, { "group": "Contracts", - "pages": ["contracts/evm", "contracts/stellar", "contracts/solana", "contracts/ckb"] + "pages": ["contracts/evm", "contracts/stellar", "contracts/solana", "contracts/ckb", "reference/stellar-event-schemas"] } ] }, diff --git a/reference/stellar-event-schemas.mdx b/reference/stellar-event-schemas.mdx new file mode 100644 index 0000000..b798fe2 --- /dev/null +++ b/reference/stellar-event-schemas.mdx @@ -0,0 +1,101 @@ +--- +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 emitted a single topic, requiring indexers to parse the data payload to extract routing information. +- **Topic Layout**: `("announce")` +- **Data Layout**: `(caller, scheme_id, stealth_address, 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**: `(caller, 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 being used (e.g., `1` for the standard ed25519 scheme). +3. **`view_tag_bucket`** (u32): A deterministic bucket derived from the view tag to allow prefix filtering. +4. **`metadata_kind`** (u32): The type of metadata attached to the event. + +### `view_tag_bucket` Derivation Rule + +To reduce false positives when scanning announcements, clients can filter by the `view_tag_bucket`. +- **Rule**: The bucket is derived directly from the first byte of the metadata (`metadata[0]`). +- **Stability**: This derivation is stable and guaranteed not to change for a given `metadata_kind`. + +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. + +- **`0`**: Standard stealth payment metadata (view tag included). +- **`1+`**: Reserved for future use (e.g., encrypted amounts, multi-asset routing). + +**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 1 +```json +{ + "startLedger": 123456, + "filters": [ + { + "type": "contract", + "contractIds": [""], + "topics": [ + ["announce"], + ["1"], + ["*"], + ["*"] + ] + } + ], + "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"], + ["1"], + ["42"], + ["0"] + ] + } + ], + "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/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index f1790eb..3a25af6 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -420,3 +420,6 @@ const announcements = await fetchAnnouncements("stellar"); ``` This replaces the need to manually query `sorobanServer.getEvents()` and parse XDR-encoded event data. + +> [!NOTE] +> For advanced use cases and indexer building, refer to the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation to learn how to natively filter topics via the RPC. From 40ff3eaac3c51956db966a5e9fa89b9931f0a6bf Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Fri, 25 Sep 2026 12:14:04 +0100 Subject: [PATCH 2/3] feat: add localization QA script for translated documentation QA #162 --- localization-qa.js | 458 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 localization-qa.js diff --git a/localization-qa.js b/localization-qa.js new file mode 100644 index 0000000..e662b1a --- /dev/null +++ b/localization-qa.js @@ -0,0 +1,458 @@ +/** + * Localization QA for Wraith Protocol documentation + * + * Checks translated documentation pages for: + * - Stale links + * - Missing sections + * - Code drift + * - Language metadata + */ + +const fs = require('fs'); +const path = require('path'); + +const DOCS_DIR = path.join(__dirname); +const MARKDOWN_EXT = ['.md', '.mdx']; + +const SKIP_DIRS = new Set(['.git', '.agents', 'node_modules']); +const SKIP_FILES = new Set(['docs.json', 'CLAUDE.md', 'PR_DESCRIPTION.md']); + +function parseFrontMatter(content) { + let attrs = {}; + let body = content; + + const fmMatch = content.match(/^---[\s\S]*?^---/m); + if (fmMatch) { + const fmContent = content.substring(3, fmMatch[0].lastIndexOf('---')).trim(); + const attrsObj = {}; + for (const line of fmContent.split('\n')) { + const match = line.match(/^(\w+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + if (value === 'true') value = true; + else if (value === 'false') value = false; + else if (!isNaN(value)) value = Number(value); + attrsObj[match[1]] = value; + } + } + attrs = attrsObj; + + const fmEndIndex = content.indexOf('---', 3); + if (fmEndIndex !== -1) { + body = content.substring(fmEndIndex + 3).trim(); + } + } + + return { attrs, body }; +} + +function extractHeadings(body) { + const headings = []; + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(/^(#{1,6})\s+(.*)$/); + if (match) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + line: i + 1, + }); + } + } + return headings; +} + +function countCodeFences(body) { + const lines = body.split('\n'); + let count = 0; + let inFence = false; + for (const line of lines) { + if (line.trim().startsWith('```')) { + inFence = !inFence; + count++; + } + } + return count; +} + +function extractLinks(body) { + const links = []; + const regex = /\[([^\]]+)\]\(([^)]+)\)/g; + let match; + while ((match = regex.exec(body)) !== null) { + links.push({ + text: match[1], + url: match[2], + line: match.index, + }); + } + return links; +} + +function classifyLink(url) { + if (!url) return 'external'; + if (url.startsWith('/') || url.startsWith('#')) return 'internal'; + if (url.startsWith('http://') || url.startsWith('https://')) return 'external'; + return 'internal'; +} + +function normalizePagePath(page) { + if (!page) return ''; + let p = page.replace(/^\/|\/$/g, ''); + return p.toLowerCase(); +} + +function getNavPages(navigation) { + const pages = []; + + if (!navigation || !navigation.tabs || !Array.isArray(navigation.tabs)) return pages; + + for (const tab of navigation.tabs) { + const groups = tab.groups; + if (!groups || !Array.isArray(groups)) continue; + + for (const group of groups) { + const groupPages = group.pages; + if (groupPages && Array.isArray(groupPages)) { + for (const page of groupPages) { + pages.push(normalizePagePath(page)); + } + } + if (group.items && Array.isArray(group.items)) { + for (const subItem of group.items) { + const subPages = subItem.pages; + if (subPages && Array.isArray(subPages)) { + for (const page of subPages) { + pages.push(normalizePagePath(page)); + } + } + const subGroups = subItem.groups; + if (subGroups && Array.isArray(subGroups)) { + for (const subGroup of subGroups) { + const sgPages = subGroup.pages; + if (sgPages && Array.isArray(sgPages)) { + for (const page of sgPages) { + pages.push(normalizePagePath(page)); + } + } + } + } + } + } + } + } + + return pages; +} + +function getAllMDXFiles() { + const files = []; + + function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (MARKDOWN_EXT.some(ext => entry.name.endsWith(ext))) { + // Skip non-doc files + const basename = entry.name.replace(path.extname(entry.name), ''); + if (SKIP_FILES.has(basename)) continue; + files.push(fullPath); + } + } + } + + walk(DOCS_DIR); + return files; +} + +function getPageKey(filePath) { + const rel = path.relative(DOCS_DIR, filePath); + const withoutExt = path.extname(rel) ? rel.replace(path.extname(rel), '') : rel; + return withoutExt.replace(/\\/g, '/'); +} + +function hasLocaleMetadata(attrs) { + return attrs.locale !== undefined && attrs.locale !== null; +} + +function runQA() { + console.log('=== Wraith Protocol Localization QA ===\n'); + + // 1. Read docs.json + const docsJSONPath = path.join(DOCS_DIR, 'docs.json'); + let navigation; + if (fs.existsSync(docsJSONPath)) { + const jsonContent = fs.readFileSync(docsJSONPath, 'utf-8'); + const docsConfig = JSON.parse(jsonContent); + navigation = docsConfig.navigation; + console.log('Loaded navigation from docs.json\n'); + } else { + console.warn('docs.json not found\n'); + navigation = null; + } + + // Get canonical page paths from navigation + const canonicalPages = navigation ? getNavPages(navigation) : []; + console.log(`Canonical pages from navigation: ${canonicalPages.length}\n`); + console.log(' ', canonicalPages.join(', '), '\n'); + + // 2. Get all markdown files + const allFiles = getAllMDXFiles(); + console.log(`Total markdown files found: ${allFiles.length}\n`); + + // 3. Parse all files + const fileData = []; + + for (const filePath of allFiles) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = parseFrontMatter(content); + const pageKey = getPageKey(filePath); + const hasLocale = hasLocaleMetadata(parsed.attrs); + const locale = hasLocale ? parsed.attrs.locale : null; + + const headings = extractHeadings(parsed.body); + const codeFenceCount = countCodeFences(parsed.body); + const links = extractLinks(parsed.body); + const classifiedLinks = links.map(l => ({ + ...l, + type: classifyLink(l.url), + })); + + fileData.push({ + filePath, + pageKey, + title: parsed.attrs.title || 'Untitled', + description: parsed.attrs.description || '', + locale, + hasLocaleMetadata: hasLocale, + headings, + codeFenceCount, + links: classifiedLinks, + rawBody: parsed.body, + }); + } catch (err) { + console.error(`Error parsing ${filePath}:`, err.message); + } + } + + // Build map of page keys + const pageMap = new Map(); + for (const fd of fileData) { + pageMap.set(fd.pageKey, fd); + } + + // 4. Identify source vs translated pages + const translatedPages = new Set(); + const sourceLanguagePages = new Set(); + + for (const [key, fd] of pageMap) { + if (fd.hasLocaleMetadata) { + translatedPages.add(key); + } else { + sourceLanguagePages.add(key); + } + } + + console.log(`Source language (English) pages: ${sourceLanguagePages.size}`); + console.log(`Translated pages: ${translatedPages.size}\n`); + + // 5. Define supported translated pages (from navigation) + const supportedTranslated = new Set(); + if (navigation) { + for (const pageKey of canonicalPages) { + if (!pageMap.has(pageKey)) { + console.log(` Warning: Page in navigation but not found in docs: ${pageKey}`); + } else { + supportedTranslated.add(pageKey); + } + } + } + + console.log(`Supported pages for translation: ${supportedTranslated.size}\n`); + + // 6. Compare structure between source and translated pages + const issues = []; + + for (const pageKey of supportedTranslated) { + const fd = pageMap.get(pageKey); + if (!fd) { + issues.push({ + type: 'missing_page', + page: pageKey, + message: 'Page defined in navigation but not found in docs directory', + }); + continue; + } + + // Check if translated + if (!fd.hasLocaleMetadata) { + issues.push({ + type: 'untranslated', + page: pageKey, + message: 'Page is missing locale metadata - not marked as translated', + }); + } + + // Compare with source version (same key in English) + const sourceFd = pageMap.get(pageKey); + + if (sourceFd) { + // Compare headings + const sourceHeadingTexts = sourceFd.headings.map(h => h.text); + const translatedHeadingTexts = fd.headings.map(h => h.text); + + const missingInTranslation = sourceHeadingTexts.filter(h => !translatedHeadingTexts.includes(h)); + const extraInTranslation = translatedHeadingTexts.filter(h => !sourceHeadingTexts.includes(h)); + + if (missingInTranslation.length > 0) { + issues.push({ + type: 'heading_drift', + page: pageKey, + message: `Missing headings in translation: ${missingInTranslation.join(', ')}`, + details: { + sourceHeadings: sourceHeadingTexts, + translatedHeadings: translatedHeadingTexts, + }, + }); + } + + if (extraInTranslation.length > 0) { + issues.push({ + type: 'heading_drift', + page: pageKey, + message: `Extra headings in translation not in source: ${extraInTranslation.join(', ')}`, + details: { + sourceHeadings: sourceHeadingTexts, + translatedHeadings: translatedHeadingTexts, + }, + }); + } + + // Compare code fence counts + if (sourceFd.codeFenceCount !== fd.codeFenceCount) { + issues.push({ + type: 'code_drift', + page: pageKey, + message: `Code fence count mismatch: source=${sourceFd.codeFenceCount}, translation=${fd.codeFenceCount}`, + details: { + sourceFenceCount: sourceFd.codeFenceCount, + translatedFenceCount: fd.codeFenceCount, + }, + }); + } + + // Compare link structure + const sourceLinks = sourceFd.links.map(l => ({ ...l, type: classifyLink(l.url) })); + const translatedLinks = fd.links.map(l => ({ ...l, type: classifyLink(l.url) })); + + // Links in translation not in source + for (const translatedLink of translatedLinks) { + const matchingSourceLink = sourceLinks.find( + sl => sl.url === translatedLink.url && sl.text === translatedLink.text + ); + + if (!matchingSourceLink) { + if (translatedLink.type === 'external') { + issues.push({ + type: 'link_structural', + page: pageKey, + message: `Link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, + }); + } else { + issues.push({ + type: 'link_structural', + page: pageKey, + message: `Internal link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, + }); + } + } + } + + // Links in source missing from translation + for (const sourceLink of sourceLinks) { + const hasMatching = translatedLinks.some( + tl => tl.url === sourceLink.url && tl.text === sourceLink.text + ); + if (!hasMatching && sourceLink.type === 'internal') { + issues.push({ + type: 'link_missing', + page: pageKey, + message: `Internal link missing from translation: ${sourceLink.text} (${sourceLink.url})`, + }); + } + } + } + } + + // 7. Check locale metadata + console.log('\n=== Locale Metadata Check ==='); + for (const [key, fd] of pageMap) { + if (fd.hasLocaleMetadata) { + console.log(` ✓ ${key}: locale="${fd.locale}"`); + } else { + console.log(` ✗ ${key}: missing locale metadata`); + } + } + +// 8. Check canonical links +console.log('\n=== Canonical Links Check ==='); + for (const fd of fileData) { + // Check for canonical link by looking for tag with rel=canonical + const hasCanonicalLink = / Date: Fri, 25 Sep 2026 13:42:41 +0100 Subject: [PATCH 3/3] docs: add Reference group with reference/stellar-event-schemas to navigation #162 --- docs.json | Bin 2682 -> 3084 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs.json b/docs.json index 574b85211f5be07055955a8c37a56ef247256e01..40e3b0e59bca9ed4fb113130dad639bcbb8800ea 100644 GIT binary patch delta 327 zcmew*(j&3Km~HZ27Q@NwIh7{6a|qNIrKY78rRF84>L-?_WR?^w>2N8a03|dP`9-?v zrI{(I_!OoV6#