diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 772550b..d9e412a 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -247,3 +247,50 @@ jobs: - name: Run playground smoke tests run: pnpm run test:playground + + check-stellar-reference: + name: Stellar contract reference check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install Stellar CLI + # Pinned to the same minor used during the last registry verification. + # Update this pin when the CLI interface changes in a way that affects + # `stellar contract inspect` output parsing. + run: | + curl -sSfL https://github.com/stellar/stellar-cli/releases/download/v28.1.0/stellar-cli-28.1.0-x86_64-unknown-linux-gnu.tar.gz \ + | tar -xz -C /usr/local/bin stellar + stellar version + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Only the two contracts that currently have live testnet addresses are + # listed here (from scripts/contract-registry.json, stellar-testnet block). + # stealth-registry and stealth-sender are not yet deployed and are omitted + # until their addresses appear in the registry. + - name: Check Stellar generated reference + env: + STELLAR_CONTRACT_IDS: >- + { + "stealth-announcer": "CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL", + "wraith-names": "CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV" + } + STELLAR_NETWORK: testnet + run: >- + pnpm exec tsx scripts/generate-stellar-reference.ts + --check + --require-contracts=stealth-announcer,wraith-names diff --git a/contracts/stellar.mdx b/contracts/stellar.mdx index 73e4a82..13cb856 100644 --- a/contracts/stellar.mdx +++ b/contracts/stellar.mdx @@ -207,7 +207,54 @@ const resolved = await namesContract.call("resolve", "alice"); ## Generated contract reference -Set `STELLAR_BINDINGS_DIR` to the generated TypeScript bindings directory, then run `npm run generate:stellar-reference` to refresh this section. +### Stealth announcer + +#### Addresses + +| Network | Contract ID | +|---|---| +| testnet | `CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL` | + +#### Functions + +| Function | Params | Return | Auth required | +|---|---|---|---| +| `announce` | `scheme_id: u32`
`stealth_address: soroban_sdk::Address`
`ephemeral_pub_key: soroban_sdk::BytesN<32>`
`metadata: soroban_sdk::Bytes` | `void` | Yes | + +#### Events + +None found in generated bindings. + +#### Storage keys + +None found in generated bindings. + +### Wraith names + +#### Addresses + +| Network | Contract ID | +|---|---| +| testnet | `CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV` | + +#### Functions + +| Function | Params | Return | Auth required | +|---|---|---|---| +| `name_of` | `stealth_meta_address: soroban_sdk::Bytes` | `soroban_sdk::String` | No | +| `register` | `owner: soroban_sdk::Address`
`name: soroban_sdk::String`
`stealth_meta_address: soroban_sdk::Bytes` | `()` | Yes | +| `release` | `owner: soroban_sdk::Address`
`name: soroban_sdk::String` | `()` | Yes | +| `resolve` | `name: soroban_sdk::String` | `soroban_sdk::Bytes` | No | +| `update` | `owner: soroban_sdk::Address`
`name: soroban_sdk::String`
`new_meta_address: soroban_sdk::Bytes` | `()` | Yes | + +#### Events + +None found in generated bindings. + +#### Storage keys + +- `Name` +- `Reverse` {/* stellar-reference:end */} diff --git a/package.json b/package.json index 56929b7..81d81d2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:preview-redirect": "node --test scripts/check-preview-redirect.test.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", + "check:stellar-reference": "tsx scripts/generate-stellar-reference.ts --check", "generate:playground-fixtures": "node scripts/playground/generate-fixtures.mjs", "check:playground-fixtures": "node scripts/playground/generate-fixtures.mjs --check", "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ac50b9..e132f48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,8 +702,8 @@ packages: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} - toml@4.3.0: - resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + toml@5.0.1: + resolution: {integrity: sha512-EqYNA/YvUNndrA2B3O2ojzLQoTQrBxeZ9UIjQwk8t10oe6n7dwclBwd9IdZQTOKwe6vWX+AXtl6IntXRbepdxQ==} engines: {node: '>=20'} tr46@0.0.3: @@ -739,10 +739,6 @@ packages: resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} engines: {node: '>=6.14.2'} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -972,7 +968,7 @@ snapshots: eventsource: 2.0.2 feaxios: 0.0.23 randombytes: 2.1.0 - toml: 4.3.0 + toml: 5.0.1 urijs: 1.19.11 transitivePeerDependencies: - bare-url @@ -1311,7 +1307,7 @@ snapshots: isomorphic-ws: 4.0.1(ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 - uuid: 11.1.1 + uuid: 14.0.1 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -1431,7 +1427,7 @@ snapshots: safe-buffer: 5.2.1 typed-array-buffer: 1.0.3 - toml@4.3.0: {} + toml@5.0.1: {} tr46@0.0.3: {} @@ -1462,8 +1458,6 @@ snapshots: node-gyp-build: 4.8.4 optional: true - uuid@11.1.1: {} - uuid@14.0.1: {} viem@2.54.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6): diff --git a/scripts/generate-stellar-reference.ts b/scripts/generate-stellar-reference.ts index 09f13ab..0680df5 100644 --- a/scripts/generate-stellar-reference.ts +++ b/scripts/generate-stellar-reference.ts @@ -37,10 +37,35 @@ const contractTitles: Record = { const args = new Set(process.argv.slice(2)); const checkMode = args.has("--check"); -const allowMissing = args.has("--allow-missing"); +// --allow-missing is no longer accepted; the flag is rejected so stale CI +// invocations surface immediately rather than silently passing. +if (args.has("--allow-missing")) { + throw new Error( + "--allow-missing has been removed. Supply STELLAR_CONTRACT_IDS or STELLAR_BINDINGS_DIR so the check has real data.", + ); +} + +// --require-contracts=slug1,slug2 asserts that every listed slug appears in +// the generated reference. Use this in CI to catch gaps when a new contract +// is deployed but the docs section was never generated. +const requireContractsFlag = [...args].find((a) => a.startsWith("--require-contracts=")); +const requiredContracts: string[] = + requireContractsFlag ? requireContractsFlag.slice("--require-contracts=".length).split(",").filter(Boolean) : []; function main() { const references = loadReferencesOrExit(); + + if (requiredContracts.length > 0) { + const generatedSlugs = new Set(references.map((r) => r.slug)); + const missing = requiredContracts.filter((slug) => !generatedSlugs.has(slug)); + if (missing.length > 0) { + throw new Error( + `Required contract(s) missing from generated reference: ${missing.join(", ")}.\n` + + "Add the contract to STELLAR_CONTRACT_IDS or STELLAR_BINDINGS_DIR and re-run.", + ); + } + } + const generated = renderReference(references); const current = readFileSync(stellarDocPath, "utf8"); const updated = replaceBetweenMarkers(current, generated); @@ -61,11 +86,6 @@ function loadReferencesOrExit(): ContractReference[] { try { return loadReferences(); } catch (error) { - if (checkMode && allowMissing) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`Skipping Stellar reference check: ${message}`); - process.exit(0); - } throw error; } } @@ -304,18 +324,21 @@ function loadFromStellarInspect(): ContractReference[] { return Object.entries(configured).map(([slug, entry]) => { const contractId = typeof entry === "string" ? entry : entry[network] ?? entry.testnet ?? Object.values(entry)[0]; - const output = execFileSync(cli, ["contract", "inspect", "--id", contractId, "--network", network], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + // `stellar contract info interface` replaced the old `contract inspect --id` + // in CLI v28+. stdout is clean Rust trait output; info lines go to stderr. + const output = execFileSync( + cli, + ["contract", "info", "interface", "--contract-id", contractId, "--network", network, "--output", "rust"], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); return { slug, title: contractTitles[slug] ?? slug, addresses: { [network]: contractId }, - functions: parseInspectFunctions(output), - events: parseInspectList(output, "Events"), - storageKeys: parseInspectList(output, "Storage"), + functions: parseInfoFunctions(output), + events: parseInfoEvents(output), + storageKeys: parseInfoStorageKeys(output), }; }); } @@ -337,6 +360,92 @@ function readJsonFile(filePath: string): T { return JSON.parse(readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")) as T; } +// --------------------------------------------------------------------------- +// Parsers for `stellar contract info interface --output rust` +// +// The output is a Rust snippet with a `pub trait Contract { fn ... }` block +// followed by contracttype/contracterror enum and struct definitions. +// --------------------------------------------------------------------------- + +function parseInfoFunctions(output: string): FunctionReference[] { + // Extract the trait body between `pub trait Contract {` and the closing `}`. + const traitMatch = output.match(/pub\s+trait\s+Contract\s*\{([\s\S]*?)\n\}/); + if (!traitMatch) return []; + + const functions: FunctionReference[] = []; + // Each function: `fn name(\n env: ...,\n param: Type,\n) -> ReturnType;` + // The fn body may span multiple lines; params are separated by `,\n`. + const fnPattern = /\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*(?:->\s*([^;{]+))?;/g; + let match: RegExpExecArray | null; + + while ((match = fnPattern.exec(traitMatch[1]))) { + const name = match[1]; + const rawParams = match[2] + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .filter((p) => !p.startsWith("env:") && p !== "env: soroban_sdk::Env"); + + const params = rawParams.map((p) => { + const colonIdx = p.indexOf(":"); + if (colonIdx === -1) return { name: p, type: "unknown" }; + return { + name: p.slice(0, colonIdx).trim(), + type: normalizeType(p.slice(colonIdx + 1).trim()), + }; + }); + + const rawReturn = match[3]?.trim() ?? "void"; + // Strip Result wrapper — docs show the success type only. + const returnType = normalizeType(rawReturn.replace(/^Result<([^,>]+),\s*[^>]+>$/, "$1")); + + functions.push({ name, params, returnType, authRequired: inferAuth(name, params) }); + } + + return functions.sort((a, b) => a.name.localeCompare(b.name)); +} + +function parseInfoEvents(output: string): string[] { + // `stellar contract info interface` emits `#[soroban_sdk::contractevent]` + // annotated enums for contract events. Extract their variants. + const events = new Set(); + const eventEnumPattern = + /#\[soroban_sdk::contractevent\][\s\S]*?pub\s+enum\s+[A-Za-z0-9_]+\s*\{([\s\S]*?)\n\}/g; + let match: RegExpExecArray | null; + + while ((match = eventEnumPattern.exec(output))) { + match[1] + .split("\n") + .map((line) => line.trim().replace(/,$/, "")) + .filter((line) => line && !line.startsWith("//") && !line.startsWith("#")) + .forEach((variant) => events.add(toSnakeCase(variant.replace(/\s*\{[\s\S]*?\}/, "").trim()))); + } + + return [...events].sort(); +} + +function parseInfoStorageKeys(output: string): string[] { + // Storage key enums are tagged `#[soroban_sdk::contracttype]` and named + // DataKey or StorageKey by convention. + const keys = new Set(); + const keyEnumPattern = + /#\[soroban_sdk::contracttype\][\s\S]*?pub\s+enum\s+(?:[A-Za-z0-9_]*(?:DataKey|StorageKey)[A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/g; + let match: RegExpExecArray | null; + + while ((match = keyEnumPattern.exec(output))) { + match[1] + .split("\n") + .map((line) => line.trim().replace(/,$/, "").replace(/\(.*\)$/, "")) + .filter((line) => line && !line.startsWith("//") && !line.startsWith("#")) + .forEach((variant) => keys.add(variant)); + } + + return [...keys].sort(); +} + +// Keep the old inspect parsers for any caller that may still reference them +// (e.g. tests); they are no longer used by loadFromStellarInspect. +/** @deprecated Use parseInfoFunctions instead. */ function parseInspectFunctions(output: string): FunctionReference[] { const functions: FunctionReference[] = []; const fnPattern = /(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*(?:->\s*([^\n]+))?/g; @@ -364,6 +473,7 @@ function parseInspectFunctions(output: string): FunctionReference[] { return functions; } +/** @deprecated Use parseInfoStorageKeys / parseInfoEvents instead. */ function parseInspectList(output: string, heading: string): string[] { const match = output.match(new RegExp(`${heading}:?\\s*\\n([\\s\\S]*?)(?:\\n\\S|$)`, "i")); if (!match) return []; @@ -378,7 +488,7 @@ function renderReference(references: ContractReference[]): string { return [ startMarker, "", - "", + "{/* This section is generated by scripts/generate-stellar-reference.ts. Do not edit by hand. */}", "", "## Generated contract reference", "",