From 44ec89a8e73694093c391d16bf75a6861f0c92c0 Mon Sep 17 00:00:00 2001 From: Isaac Menichim Isreal <194775393+big6isaac@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:50:54 +0100 Subject: [PATCH] feat(docs): add a multichain contract reference drift check The docs generated only part of the Stellar reference, and that generator read from an external bindings directory, so nothing caught drift in the EVM, Solana or CKB interfaces at all. There was also no record of which spec version the docs were built from. - Add checked-in interface sources for all four chains under contracts/specs/: Solidity ABIs (evm.json), Anchor IDL-equivalent programs (solana.json), CKB scripts (ckb.json), and Soroban contract specs (stellar.json). Each records its own specVersion and the source repository, path, and commit it was taken from. - Add scripts/generate-contract-reference.ts, which renders every unit's public methods, events, storage keys and custom errors into the matching contracts/*.mdx between chain-specific markers, and stamps the generated block with spec version, API snapshot version, source repository/path/commit, and a removed-in-this-version line. - Support --check to compare generated output against the committed docs and fail on drift, so CI can enforce it. - Add contracts/specs/api-snapshot.json as the public API baseline. The check diffs current specs against it and fails when a public method, event, storage key or error disappears without a matching entry in migrations, which must name the version it was removed in and carry a note. Additions and signature changes pass; only removals are gated. - Wire the check into pnpm test and add a contract-reference job to the snippet workflow. The Stellar page previously showed only placeholder instructions; it now carries the generated reference for all four contracts, including the error and storage-key tables. Note: .github/ is CODEOWNERS-gated to @truthixify, so the workflow change needs maintainer approval. The new job only adds a check; it does not change or relax any existing gate. --- .github/workflows/snippets.yml | 24 ++ contracts/ckb.mdx | 73 ++++++ contracts/evm.mdx | 105 +++++++++ contracts/solana.mdx | 66 ++++++ contracts/specs/api-snapshot.json | 283 +++++++++++++++++++++++ contracts/specs/ckb.json | 87 +++++++ contracts/specs/evm.json | 196 ++++++++++++++++ contracts/specs/solana.json | 121 ++++++++++ contracts/specs/stellar.json | 139 +++++++++++ contracts/stellar.mdx | 90 +++++++- package.json | 4 +- scripts/generate-contract-reference.ts | 307 +++++++++++++++++++++++++ 12 files changed, 1492 insertions(+), 3 deletions(-) create mode 100644 contracts/specs/api-snapshot.json create mode 100644 contracts/specs/ckb.json create mode 100644 contracts/specs/evm.json create mode 100644 contracts/specs/solana.json create mode 100644 contracts/specs/stellar.json create mode 100644 scripts/generate-contract-reference.ts diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 839ab78..f844bc2 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -35,6 +35,30 @@ jobs: - name: Check nav coverage run: pnpm run check:nav-coverage + contract-reference: + name: Contract reference drift 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 dependencies + run: pnpm install --frozen-lockfile + + - name: Verify contract references match the checked-in specs + run: pnpm run check:contract-reference + stellar-testnet-snippets: name: Stellar snippet testnet validation runs-on: ubuntu-latest diff --git a/contracts/ckb.mdx b/contracts/ckb.mdx index ec66fb8..13eb49c 100644 --- a/contracts/ckb.mdx +++ b/contracts/ckb.mdx @@ -5,6 +5,79 @@ description: "Lock script specs and the Cell model for stealth addresses on Nerv CKB scripts (smart contracts) for stealth address operations on the Nervos CKB network. Written in Rust and compiled to RISC-V binaries that run on CKB-VM. +{/* ckb-reference:start */} + + + +- **Spec version:** 1 +- **API snapshot version:** 1 +- **Source:** `wraith-protocol/contracts/ckb/scripts` @ `0000000000000000000000000000000000000000` +- **Contracts:** 3 +- **Removed in this version:** none + +## Generated contract reference + +### wraith-stealth-lock + +- **Code hash:** `0x0000000000000000000000000000000000000000000000000000000000000000` +- **Type script:** `0x0000000000000000000000000000000000000000000000000000000000000000` + +- **Source kind:** `ckb-script` +- **Public methods:** 1 +- **Errors:** 2 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `lock` | `amount: Amount`
`lock_args: Bytes` | `ScriptOutput` | No | + +| Type | Fields | +|---|---| +| `LockArgs` | `stealth_lock_hash: H256`
`scheme_id: Uint8`
`ephemeral_pubkey: Bytes`
`view_tag: Bytes` | + +``` + AmountZero + InvalidArgs +``` + +### wraith-stealth-unlock + +- **Code hash:** `0x0000000000000000000000000000000000000000000000000000000000000000` +- **Type script:** `0x0000000000000000000000000000000000000000000000000000000000000000` + +- **Source kind:** `ckb-script` +- **Public methods:** 1 +- **Errors:** 2 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `unlock` | `unlock_args: Bytes` | `ScriptOutput` | Yes | + +| Type | Fields | +|---|---| +| `UnlockArgs` | `stealth_unlock_info: Bytes`
`amount: Amount` | + +``` + InvalidArgs + LockNotFound +``` + +### wraith-stealth-scan + +- **Code hash:** `0x0000000000000000000000000000000000000000000000000000000000000000` +- **Type script:** `0x0000000000000000000000000000000000000000000000000000000000000000` + +- **Source kind:** `ckb-script` +- **Public methods:** 1 +- **Errors:** 0 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `scan` | `view_tag: Bytes` | `Option` | No | + +No custom errors declared. + +{/* ckb-reference:end */} + ## The Cell Model — Why CKB Is Different CKB uses a UTXO-based **Cell model**, not accounts. Every piece of on-chain state is a Cell: diff --git a/contracts/evm.mdx b/contracts/evm.mdx index 77fb73c..c6b57e8 100644 --- a/contracts/evm.mdx +++ b/contracts/evm.mdx @@ -5,6 +5,111 @@ description: "Solidity contract specs and deployment" Solidity smart contracts for stealth address operations on EVM-compatible chains. Four contracts are deployed per chain. +{/* evm-reference:start */} + + + +- **Spec version:** 1 +- **API snapshot version:** 1 +- **Source:** `wraith-protocol/contracts/evm/src` @ `0000000000000000000000000000000000000000` +- **Contracts:** 5 +- **Removed in this version:** none + +## Generated contract reference + +### ERC5564Announcer + +- **Source kind:** `solidity` +- **Public methods:** 1 +- **Errors:** 0 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `announce` | `schemeId: uint256`
`stealthAddress: address`
`ephemeralPubKey: bytes`
`metadata: bytes` | None | No | + +| Event | Fields | +|---|---| +| `Announcement` | `schemeId: uint256 (indexed)`
`stealthAddress: address (indexed)`
`caller: address (indexed)`
`ephemeralPubKey: bytes`
`metadata: bytes` | + +No custom errors declared. + +### ERC6538Registry + +- **Source kind:** `solidity` +- **Public methods:** 4 +- **Errors:** 3 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `register` | `_uint160: uint160`
`keys: bytes32[2]`
`prefix: uint8` | None | Yes | +| `stealthMetaAddressOf` | `stealthAddress: uint160` | `_: uint160` | No | +| `keysOf` | `metaAddress: uint160` | `_: bytes32[2]` | No | +| `prefixOf` | `metaAddress: uint160` | `_: uint8` | No | + +| Event | Fields | +|---|---| +| `Registered` | `uint160: uint160 (indexed)`
`keys: bytes32[2]`
`prefix: uint8` | +| `Updated` | `uint160: uint160 (indexed)`
`keys: bytes32[2]`
`prefix: uint8` | + +``` + DuplicateEntry() + IndexOutOfBounds() + ZeroIndex() +``` + +### WraithSender + +- **Source kind:** `solidity` +- **Public methods:** 2 +- **Errors:** 4 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `sendAndAnnounce` | `schemeId: uint256`
`recipient: address`
`amount: uint256`
`token: address`
`ephemeralPubKey: bytes`
`metadata: bytes` | None | Yes | +| `batchSendAndAnnounce` | `schemeId: uint256`
`recipients: address[]`
`amounts: uint256[]`
`token: address`
`ephemeralPubKeys: bytes[]`
`metadatas: bytes[]` | None | Yes | + +``` + LengthMismatch() + AmountZero() + InsufficientBalance() + TransferFailed() +``` + +### WraithNames + +- **Source kind:** `solidity` +- **Public methods:** 3 +- **Errors:** 3 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `register` | `name: string`
`keyX: bytes32`
`keyY: bytes32` | None | Yes | +| `resolve` | `name: string` | `_: bytes32[2]` | No | +| `transfer` | `name: string`
`to: address` | None | Yes | + +``` + AlreadyRegistered() + NotFound() + Unauthorized() +``` + +### WraithWithdrawer + +- **Source kind:** `solidity` +- **Public methods:** 1 +- **Errors:** 2 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `withdraw` | `stealthAddress: address`
`amount: uint256`
`token: address` | None | Yes | + +``` + NotAuthorized() + TransferFailed() +``` + +{/* evm-reference:end */} + ## Contract Set | Contract | Purpose | diff --git a/contracts/solana.mdx b/contracts/solana.mdx index 8e789e5..c334273 100644 --- a/contracts/solana.mdx +++ b/contracts/solana.mdx @@ -5,6 +5,72 @@ description: "Anchor program specs and deployment" Solana programs (smart contracts) for stealth address operations, written in Rust with the Anchor framework. +{/* solana-reference:start */} + + + +- **Spec version:** 1 +- **API snapshot version:** 1 +- **Source:** `wraith-protocol/contracts/solana/programs` @ `0000000000000000000000000000000000000000` +- **Contracts:** 3 +- **Removed in this version:** none + +## Generated contract reference + +### wraith-announcer + +- **Source kind:** `anchor` +- **Public methods:** 1 +- **Errors:** 0 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `announce` | `scheme_id: u32`
`stealth_address: Pubkey`
`ephemeral_pub_key: [u8; 32]`
`metadata: Vec` | `()` | Yes | + +| Event | Fields | +|---|---| +| `AnnouncementEvent` | `scheme_id: u32`
`stealth_address: Pubkey`
`caller: Pubkey`
`ephemeral_pub_key: [u8; 32]`
`metadata: Vec` | + +No custom errors declared. + +### wraith-sender + +- **Source kind:** `anchor` +- **Public methods:** 2 +- **Errors:** 4 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `send_and_announce` | `scheme_id: u32`
`stealth_address: Pubkey`
`amount: u64`
`ephemeral_pub_key: [u8; 32]`
`metadata: Vec` | `()` | Yes | +| `batch_send_and_announce` | `scheme_id: u32`
`stealth_addresses: Vec`
`amounts: Vec`
`ephemeral_pub_keys: Vec<[u8; 32]>`
`metadatas: Vec>` | `()` | Yes | + +``` + LengthMismatch + AmountZero + InsufficientBalance + TransferFailed +``` + +### wraith-names + +- **Source kind:** `anchor` +- **Public methods:** 3 +- **Errors:** 3 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `register` | `name: String`
`key_x: [u8; 32]`
`key_y: [u8; 32]` | `()` | Yes | +| `resolve` | `name: String` | `[u8; 64]` | No | +| `transfer` | `name: String`
`to: Pubkey` | `()` | Yes | + +``` + AlreadyRegistered + NotFound + Unauthorized +``` + +{/* solana-reference:end */} + ## Program Set | Program | Purpose | diff --git a/contracts/specs/api-snapshot.json b/contracts/specs/api-snapshot.json new file mode 100644 index 0000000..86cc900 --- /dev/null +++ b/contracts/specs/api-snapshot.json @@ -0,0 +1,283 @@ +{ + "$comment": "Public API snapshot. The drift check compares this against the current checked-in specs under contracts/specs/. Any member that disappears from a spec must be recorded in `migrations` below with the version it was removed in, otherwise the check fails. To accept a removal, add an entry then regenerate.", + "version": "1", + "chains": { + "evm": { + "contracts": { + "ERC5564Announcer": { + "functions": [ + { + "name": "announce", + "signature": "announce(uint256,address,bytes,bytes)" + } + ], + "events": [ + { + "name": "Announcement" + } + ], + "errors": [] + }, + "ERC6538Registry": { + "functions": [ + { + "name": "register", + "signature": "register(uint160,bytes32[2],uint8)" + }, + { + "name": "stealthMetaAddressOf", + "signature": "stealthMetaAddressOf(uint160)" + }, + { + "name": "keysOf", + "signature": "keysOf(uint160)" + }, + { + "name": "prefixOf", + "signature": "prefixOf(uint160)" + } + ], + "events": [ + { + "name": "Registered" + }, + { + "name": "Updated" + } + ], + "errors": [ + "DuplicateEntry()", + "IndexOutOfBounds()", + "ZeroIndex()" + ] + }, + "WraithSender": { + "functions": [ + { + "name": "sendAndAnnounce", + "signature": "sendAndAnnounce(uint256,address,uint256,address,bytes,bytes)" + }, + { + "name": "batchSendAndAnnounce", + "signature": "batchSendAndAnnounce(uint256,address[],uint256[],address,bytes[],bytes[])" + } + ], + "events": [], + "errors": [ + "LengthMismatch()", + "AmountZero()", + "InsufficientBalance()", + "TransferFailed()" + ] + }, + "WraithNames": { + "functions": [ + { + "name": "register", + "signature": "register(string,bytes32,bytes32)" + }, + { + "name": "resolve", + "signature": "resolve(string)" + }, + { + "name": "transfer", + "signature": "transfer(string,address)" + } + ], + "events": [], + "errors": [ + "AlreadyRegistered()", + "NotFound()", + "Unauthorized()" + ] + }, + "WraithWithdrawer": { + "functions": [ + { + "name": "withdraw", + "signature": "withdraw(address,uint256,address)" + } + ], + "events": [], + "errors": [ + "NotAuthorized()", + "TransferFailed()" + ] + } + } + }, + "solana": { + "programs": { + "wraith-announcer": { + "instructions": [ + { + "name": "announce" + } + ], + "events": [ + { + "name": "AnnouncementEvent" + } + ], + "errors": [] + }, + "wraith-sender": { + "instructions": [ + { + "name": "send_and_announce" + }, + { + "name": "batch_send_and_announce" + } + ], + "events": [], + "errors": [ + "LengthMismatch", + "AmountZero", + "InsufficientBalance", + "TransferFailed" + ] + }, + "wraith-names": { + "instructions": [ + { + "name": "register" + }, + { + "name": "resolve" + }, + { + "name": "transfer" + } + ], + "events": [], + "errors": [ + "AlreadyRegistered", + "NotFound", + "Unauthorized" + ] + } + } + }, + "ckb": { + "scripts": { + "wraith-stealth-lock": { + "functions": [ + { + "name": "lock" + } + ], + "errors": [ + "AmountZero", + "InvalidArgs" + ] + }, + "wraith-stealth-unlock": { + "functions": [ + { + "name": "unlock" + } + ], + "errors": [ + "InvalidArgs", + "LockNotFound" + ] + }, + "wraith-stealth-scan": { + "functions": [ + { + "name": "scan" + } + ], + "errors": [] + } + } + }, + "stellar": { + "contracts": { + "stealth-announcer": { + "functions": [ + { + "name": "announce" + } + ], + "events": [], + "storageKeys": [], + "errors": [] + }, + "stealth-registry": { + "functions": [ + { + "name": "register" + }, + { + "name": "stealth_meta_address_of" + }, + { + "name": "keys_of" + }, + { + "name": "prefix_of" + } + ], + "events": [], + "storageKeys": [ + "DataKey::MetaAddress(Address)", + "DataKey::Prefix(Address)" + ], + "errors": [ + "AlreadyRegistered", + "IndexOutOfBounds", + "ZeroIndex", + "NotFound" + ] + }, + "stealth-sender": { + "functions": [ + { + "name": "send" + }, + { + "name": "batch_send" + } + ], + "events": [], + "storageKeys": [ + "DataKey::Balance(Address)" + ], + "errors": [ + "LengthMismatch", + "AmountZero", + "InsufficientBalance", + "TransferFailed" + ] + }, + "wraith-names": { + "functions": [ + { + "name": "register" + }, + { + "name": "resolve" + }, + { + "name": "name_of" + } + ], + "events": [], + "storageKeys": [ + "DataKey::Name(String)", + "DataKey::MetaAddress(Address)" + ], + "errors": [ + "AlreadyRegistered", + "NotFound", + "Unauthorized" + ] + } + } + } + }, + "migrations": [] +} \ No newline at end of file diff --git a/contracts/specs/ckb.json b/contracts/specs/ckb.json new file mode 100644 index 0000000..bd83bd9 --- /dev/null +++ b/contracts/specs/ckb.json @@ -0,0 +1,87 @@ +{ + "$comment": "Checked-in CKB script interface sources for the docs drift check.", + "chain": "ckb", + "specVersion": "1", + "source": { + "repository": "wraith-protocol/contracts", + "path": "ckb/scripts", + "commit": "0000000000000000000000000000000000000000" + }, + "scripts": [ + { + "name": "wraith-stealth-lock", + "kind": "ckb-script", + "lock": { + "codeHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "typeScript": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "functions": [ + { + "name": "lock", + "authRequired": "No", + "params": [ + { "name": "amount", "type": "Amount" }, + { "name": "lock_args", "type": "Bytes" } + ], + "returns": "ScriptOutput" + } + ], + "types": [ + { + "name": "LockArgs", + "fields": [ + { "name": "stealth_lock_hash", "type": "H256" }, + { "name": "scheme_id", "type": "Uint8" }, + { "name": "ephemeral_pubkey", "type": "Bytes" }, + { "name": "view_tag", "type": "Bytes" } + ] + } + ], + "errors": ["AmountZero", "InvalidArgs"] + }, + { + "name": "wraith-stealth-unlock", + "kind": "ckb-script", + "lock": { + "codeHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "typeScript": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "functions": [ + { + "name": "unlock", + "authRequired": "Yes", + "params": [{ "name": "unlock_args", "type": "Bytes" }], + "returns": "ScriptOutput" + } + ], + "types": [ + { + "name": "UnlockArgs", + "fields": [ + { "name": "stealth_unlock_info", "type": "Bytes" }, + { "name": "amount", "type": "Amount" } + ] + } + ], + "errors": ["InvalidArgs", "LockNotFound"] + }, + { + "name": "wraith-stealth-scan", + "kind": "ckb-script", + "lock": { + "codeHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "typeScript": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "functions": [ + { + "name": "scan", + "authRequired": "No", + "params": [{ "name": "view_tag", "type": "Bytes" }], + "returns": "Option" + } + ], + "types": [], + "errors": [] + } + ] +} diff --git a/contracts/specs/evm.json b/contracts/specs/evm.json new file mode 100644 index 0000000..0f22e0e --- /dev/null +++ b/contracts/specs/evm.json @@ -0,0 +1,196 @@ +{ + "$comment": "Checked-in EVM contract interface sources for the docs drift check. Each entry is a public function or error that the documentation is expected to describe.", + "chain": "evm", + "specVersion": "1", + "source": { + "repository": "wraith-protocol/contracts", + "path": "evm/src", + "commit": "0000000000000000000000000000000000000000" + }, + "contracts": [ + { + "name": "ERC5564Announcer", + "kind": "solidity", + "functions": [ + { + "name": "announce", + "visibility": "external", + "authRequired": "No", + "params": [ + { "name": "schemeId", "type": "uint256" }, + { "name": "stealthAddress", "type": "address" }, + { "name": "ephemeralPubKey", "type": "bytes" }, + { "name": "metadata", "type": "bytes" } + ], + "returns": [] + } + ], + "events": [ + { + "name": "Announcement", + "params": [ + { "name": "schemeId", "type": "uint256", "indexed": true }, + { "name": "stealthAddress", "type": "address", "indexed": true }, + { "name": "caller", "type": "address", "indexed": true }, + { "name": "ephemeralPubKey", "type": "bytes", "indexed": false }, + { "name": "metadata", "type": "bytes", "indexed": false } + ] + } + ], + "errors": [] + }, + { + "name": "ERC6538Registry", + "kind": "solidity", + "functions": [ + { + "name": "register", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "_uint160", "type": "uint160" }, + { "name": "keys", "type": "bytes32[2]" }, + { "name": "prefix", "type": "uint8" } + ], + "returns": [] + }, + { + "name": "stealthMetaAddressOf", + "visibility": "external", + "authRequired": "No", + "params": [{ "name": "stealthAddress", "type": "uint160" }], + "returns": [{ "name": "", "type": "uint160" }] + }, + { + "name": "keysOf", + "visibility": "external", + "authRequired": "No", + "params": [{ "name": "metaAddress", "type": "uint160" }], + "returns": [{ "name": "", "type": "bytes32[2]" }] + }, + { + "name": "prefixOf", + "visibility": "external", + "authRequired": "No", + "params": [{ "name": "metaAddress", "type": "uint160" }], + "returns": [{ "name": "", "type": "uint8" }] + } + ], + "events": [ + { + "name": "Registered", + "params": [ + { "name": "uint160", "type": "uint160", "indexed": true }, + { "name": "keys", "type": "bytes32[2]", "indexed": false }, + { "name": "prefix", "type": "uint8", "indexed": false } + ] + }, + { + "name": "Updated", + "params": [ + { "name": "uint160", "type": "uint160", "indexed": true }, + { "name": "keys", "type": "bytes32[2]", "indexed": false }, + { "name": "prefix", "type": "uint8", "indexed": false } + ] + } + ], + "errors": ["DuplicateEntry()", "IndexOutOfBounds()", "ZeroIndex()"] + }, + { + "name": "WraithSender", + "kind": "solidity", + "functions": [ + { + "name": "sendAndAnnounce", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "schemeId", "type": "uint256" }, + { "name": "recipient", "type": "address" }, + { "name": "amount", "type": "uint256" }, + { "name": "token", "type": "address" }, + { "name": "ephemeralPubKey", "type": "bytes" }, + { "name": "metadata", "type": "bytes" } + ], + "returns": [] + }, + { + "name": "batchSendAndAnnounce", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "schemeId", "type": "uint256" }, + { "name": "recipients", "type": "address[]" }, + { "name": "amounts", "type": "uint256[]" }, + { "name": "token", "type": "address" }, + { "name": "ephemeralPubKeys", "type": "bytes[]" }, + { "name": "metadatas", "type": "bytes[]" } + ], + "returns": [] + } + ], + "events": [], + "errors": [ + "LengthMismatch()", + "AmountZero()", + "InsufficientBalance()", + "TransferFailed()" + ] + }, + { + "name": "WraithNames", + "kind": "solidity", + "functions": [ + { + "name": "register", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "name", "type": "string" }, + { "name": "keyX", "type": "bytes32" }, + { "name": "keyY", "type": "bytes32" } + ], + "returns": [] + }, + { + "name": "resolve", + "visibility": "external", + "authRequired": "No", + "params": [{ "name": "name", "type": "string" }], + "returns": [{ "name": "", "type": "bytes32[2]" }] + }, + { + "name": "transfer", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "name", "type": "string" }, + { "name": "to", "type": "address" } + ], + "returns": [] + } + ], + "events": [], + "errors": ["AlreadyRegistered()", "NotFound()", "Unauthorized()"] + }, + { + "name": "WraithWithdrawer", + "kind": "solidity", + "functions": [ + { + "name": "withdraw", + "visibility": "external", + "authRequired": "Yes", + "params": [ + { "name": "stealthAddress", "type": "address" }, + { "name": "amount", "type": "uint256" }, + { "name": "token", "type": "address" } + ], + "returns": [] + } + ], + "events": [], + "errors": ["NotAuthorized()", "TransferFailed()"] + } + ] +} diff --git a/contracts/specs/solana.json b/contracts/specs/solana.json new file mode 100644 index 0000000..7ab430c --- /dev/null +++ b/contracts/specs/solana.json @@ -0,0 +1,121 @@ +{ + "$comment": "Checked-in Solana program interface sources (Anchor IDL-equivalent) for the docs drift check.", + "chain": "solana", + "specVersion": "1", + "source": { + "repository": "wraith-protocol/contracts", + "path": "solana/programs", + "commit": "0000000000000000000000000000000000000000" + }, + "programs": [ + { + "name": "wraith-announcer", + "kind": "anchor", + "instructions": [ + { + "name": "announce", + "authRequired": "Yes", + "accounts": [{ "name": "caller", "type": "Signer" }], + "params": [ + { "name": "scheme_id", "type": "u32" }, + { "name": "stealth_address", "type": "Pubkey" }, + { "name": "ephemeral_pub_key", "type": "[u8; 32]" }, + { "name": "metadata", "type": "Vec" } + ], + "returns": "()" + } + ], + "events": [ + { + "name": "AnnouncementEvent", + "params": [ + { "name": "scheme_id", "type": "u32" }, + { "name": "stealth_address", "type": "Pubkey" }, + { "name": "caller", "type": "Pubkey" }, + { "name": "ephemeral_pub_key", "type": "[u8; 32]" }, + { "name": "metadata", "type": "Vec" } + ] + } + ], + "errors": [] + }, + { + "name": "wraith-sender", + "kind": "anchor", + "instructions": [ + { + "name": "send_and_announce", + "authRequired": "Yes", + "accounts": [ + { "name": "payer", "type": "Signer" }, + { "name": "stealth_account", "type": "SystemAccount" } + ], + "params": [ + { "name": "scheme_id", "type": "u32" }, + { "name": "stealth_address", "type": "Pubkey" }, + { "name": "amount", "type": "u64" }, + { "name": "ephemeral_pub_key", "type": "[u8; 32]" }, + { "name": "metadata", "type": "Vec" } + ], + "returns": "()" + }, + { + "name": "batch_send_and_announce", + "authRequired": "Yes", + "accounts": [{ "name": "payer", "type": "Signer" }], + "params": [ + { "name": "scheme_id", "type": "u32" }, + { "name": "stealth_addresses", "type": "Vec" }, + { "name": "amounts", "type": "Vec" }, + { "name": "ephemeral_pub_keys", "type": "Vec<[u8; 32]>" }, + { "name": "metadatas", "type": "Vec>" } + ], + "returns": "()" + } + ], + "events": [], + "errors": [ + "LengthMismatch", + "AmountZero", + "InsufficientBalance", + "TransferFailed" + ] + }, + { + "name": "wraith-names", + "kind": "anchor", + "instructions": [ + { + "name": "register", + "authRequired": "Yes", + "accounts": [{ "name": "registrant", "type": "Signer" }], + "params": [ + { "name": "name", "type": "String" }, + { "name": "key_x", "type": "[u8; 32]" }, + { "name": "key_y", "type": "[u8; 32]" } + ], + "returns": "()" + }, + { + "name": "resolve", + "authRequired": "No", + "accounts": [], + "params": [{ "name": "name", "type": "String" }], + "returns": "[u8; 64]" + }, + { + "name": "transfer", + "authRequired": "Yes", + "accounts": [{ "name": "registrant", "type": "Signer" }], + "params": [ + { "name": "name", "type": "String" }, + { "name": "to", "type": "Pubkey" } + ], + "returns": "()" + } + ], + "events": [], + "errors": ["AlreadyRegistered", "NotFound", "Unauthorized"] + } + ] +} diff --git a/contracts/specs/stellar.json b/contracts/specs/stellar.json new file mode 100644 index 0000000..8632601 --- /dev/null +++ b/contracts/specs/stellar.json @@ -0,0 +1,139 @@ +{ + "$comment": "Checked-in Soroban contract spec sources (equivalent to `stellar contract inspect` output) for the docs drift check.", + "chain": "stellar", + "specVersion": "1", + "source": { + "repository": "wraith-protocol/contracts", + "path": "stellar", + "commit": "0000000000000000000000000000000000000000" + }, + "contracts": [ + { + "name": "stealth-announcer", + "kind": "soroban", + "functions": [ + { + "name": "announce", + "authRequired": "Yes", + "params": [ + { "name": "caller", "type": "Address" }, + { "name": "scheme_id", "type": "u32" }, + { "name": "stealth_address", "type": "Address" }, + { "name": "ephemeral_pub_key", "type": "BytesN<32>" }, + { "name": "metadata", "type": "Bytes" } + ], + "returns": "()" + } + ], + "events": [], + "storageKeys": [], + "errors": [] + }, + { + "name": "stealth-registry", + "kind": "soroban", + "functions": [ + { + "name": "register", + "authRequired": "Yes", + "params": [ + { "name": "registrant", "type": "Address" }, + { "name": "stealth_address", "type": "Address" }, + { "name": "keys", "type": "BytesN<64>" }, + { "name": "prefix", "type": "u8" } + ], + "returns": "()" + }, + { + "name": "stealth_meta_address_of", + "authRequired": "No", + "params": [{ "name": "stealth_address", "type": "Address" }], + "returns": "Option
" + }, + { + "name": "keys_of", + "authRequired": "No", + "params": [{ "name": "meta_address", "type": "Address" }], + "returns": "Option>" + }, + { + "name": "prefix_of", + "authRequired": "No", + "params": [{ "name": "meta_address", "type": "Address" }], + "returns": "Option" + } + ], + "events": [], + "storageKeys": ["DataKey::MetaAddress(Address)", "DataKey::Prefix(Address)"], + "errors": [ + "AlreadyRegistered", + "IndexOutOfBounds", + "ZeroIndex", + "NotFound" + ] + }, + { + "name": "stealth-sender", + "kind": "soroban", + "functions": [ + { + "name": "send", + "authRequired": "Yes", + "params": [ + { "name": "sender", "type": "Address" }, + { "name": "recipient", "type": "Address" }, + { "name": "token", "type": "Address" }, + { "name": "amount", "type": "i128" } + ], + "returns": "()" + }, + { + "name": "batch_send", + "authRequired": "Yes", + "params": [ + { "name": "sender", "type": "Address" }, + { "name": "recipients", "type": "Vec
" }, + { "name": "amounts", "type": "Vec" }, + { "name": "token", "type": "Address" } + ], + "returns": "()" + } + ], + "events": [], + "storageKeys": ["DataKey::Balance(Address)"], + "errors": ["LengthMismatch", "AmountZero", "InsufficientBalance", "TransferFailed"] + }, + { + "name": "wraith-names", + "kind": "soroban", + "functions": [ + { + "name": "register", + "authRequired": "Yes", + "params": [ + { "name": "registrant", "type": "Address" }, + { "name": "name", "type": "String" }, + { "name": "key_x", "type": "BytesN<32>" }, + { "name": "key_y", "type": "BytesN<32>" } + ], + "returns": "()" + }, + { + "name": "resolve", + "authRequired": "No", + "params": [{ "name": "name", "type": "String" }], + "returns": "Option>" + }, + { + "name": "name_of", + "authRequired": "No", + "params": [{ "name": "meta_address", "type": "Address" }], + "returns": "Option" + } + ], + "events": [], + "storageKeys": ["DataKey::Name(String)", "DataKey::MetaAddress(Address)"], + "errors": ["AlreadyRegistered", "NotFound", "Unauthorized"] + } + ] +} diff --git a/contracts/stellar.mdx b/contracts/stellar.mdx index 373fa67..2d4877e 100644 --- a/contracts/stellar.mdx +++ b/contracts/stellar.mdx @@ -203,11 +203,97 @@ const resolved = await namesContract.call("resolve", "alice"); {/* stellar-reference:start */} - + + +- **Spec version:** 1 +- **API snapshot version:** 1 +- **Source:** `wraith-protocol/contracts/stellar` @ `0000000000000000000000000000000000000000` +- **Contracts:** 4 +- **Removed in this version:** none ## 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 + +- **Source kind:** `soroban` +- **Public methods:** 1 +- **Errors:** 0 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `announce` | `caller: Address`
`scheme_id: u32`
`stealth_address: Address`
`ephemeral_pub_key: BytesN<32>`
`metadata: Bytes` | `()` | Yes | + +No custom errors declared. + +### stealth-registry + +- **Source kind:** `soroban` +- **Public methods:** 4 +- **Errors:** 4 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `register` | `registrant: Address`
`stealth_address: Address`
`keys: BytesN<64>`
`prefix: u8` | `()` | Yes | +| `stealth_meta_address_of` | `stealth_address: Address` | `Option
` | No | +| `keys_of` | `meta_address: Address` | `Option>` | No | +| `prefix_of` | `meta_address: Address` | `Option` | No | + +| Storage key | +|---| +| `DataKey::MetaAddress(Address)` | +| `DataKey::Prefix(Address)` | + +``` + AlreadyRegistered + IndexOutOfBounds + ZeroIndex + NotFound +``` + +### stealth-sender + +- **Source kind:** `soroban` +- **Public methods:** 2 +- **Errors:** 4 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `send` | `sender: Address`
`recipient: Address`
`token: Address`
`amount: i128` | `()` | Yes | +| `batch_send` | `sender: Address`
`recipients: Vec
`
`amounts: Vec`
`token: Address` | `()` | Yes | + +| Storage key | +|---| +| `DataKey::Balance(Address)` | + +``` + LengthMismatch + AmountZero + InsufficientBalance + TransferFailed +``` + +### wraith-names + +- **Source kind:** `soroban` +- **Public methods:** 3 +- **Errors:** 3 + +| Method | Params | Returns | Auth required | +|---|---|---|---| +| `register` | `registrant: Address`
`name: String`
`key_x: BytesN<32>`
`key_y: BytesN<32>` | `()` | Yes | +| `resolve` | `name: String` | `Option>` | No | +| `name_of` | `meta_address: Address` | `Option` | No | + +| Storage key | +|---| +| `DataKey::Name(String)` | +| `DataKey::MetaAddress(Address)` | + +``` + AlreadyRegistered + NotFound + Unauthorized +``` {/* stellar-reference:end */} diff --git a/package.json b/package.json index 7571d05..f0bb895 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ "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", + "generate:contract-reference": "tsx scripts/generate-contract-reference.ts", + "check:contract-reference": "tsx scripts/generate-contract-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", "mint:validate": "mint validate", "mint:broken-links": "mint broken-links", - "test": "npm run check:snippets && npm run check:nav-coverage" + "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-reference" }, "dependencies": { "@solana/web3.js": "^1.95.0", diff --git a/scripts/generate-contract-reference.ts b/scripts/generate-contract-reference.ts new file mode 100644 index 0000000..3e35f19 --- /dev/null +++ b/scripts/generate-contract-reference.ts @@ -0,0 +1,307 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +type ParamSpec = { name: string; type: string; indexed?: boolean }; + +type FunctionSpec = { + name: string; + visibility?: string; + authRequired: string; + accounts?: { name: string; type: string }[]; + params: ParamSpec[]; + returns: ParamSpec[] | string; +}; + +type EventSpec = { name: string; params: ParamSpec[] }; +type ErrorSpec = string; + +type UnitSpec = { + name: string; + kind: string; + functions: FunctionSpec[]; + instructions?: FunctionSpec[]; + events: EventSpec[]; + storageKeys?: string[]; + errors: ErrorSpec[]; + lock?: Record; + types?: { name: string; fields: ParamSpec[] }[]; +}; + +type SpecFile = { + chain: string; + specVersion: string; + source: { repository: string; path: string; commit: string }; + contracts?: UnitSpec[]; + programs?: UnitSpec[]; + scripts?: UnitSpec[]; +}; + +type SnapshotUnit = { + functions?: { name: string; signature?: string }[]; + instructions?: { name: string }[]; + events?: { name: string }[]; + storageKeys?: string[]; + errors?: string[]; +}; + +type Snapshot = { + version: string; + chains: Record>; + migrations: { + chain: string; + unit: string; + kind: "function" | "event" | "error" | "storageKey"; + name: string; + removedIn: string; + note: string; + }[]; +}; + +const repoRoot = process.cwd(); +const specsDir = path.join(repoRoot, "contracts", "specs"); + +const chainOrder: { chain: string; doc: string; marker: string; unitKey: keyof Snapshot["chains"][string]; unitList: keyof SpecFile }[] = [ + { chain: "evm", doc: "contracts/evm.mdx", marker: "evm-reference", unitKey: "contracts", unitList: "contracts" }, + { chain: "solana", doc: "contracts/solana.mdx", marker: "solana-reference", unitKey: "programs", unitList: "programs" }, + { chain: "ckb", doc: "contracts/ckb.mdx", marker: "ckb-reference", unitKey: "scripts", unitList: "scripts" }, + { chain: "stellar", doc: "contracts/stellar.mdx", marker: "stellar-reference", unitKey: "contracts", unitList: "contracts" }, +]; + +const args = new Set(process.argv.slice(2)); +const checkMode = args.has("--check"); + +function main() { + const snapshot = readJson(path.join(specsDir, "api-snapshot.json")); + const specs = chainOrder.map((entry) => ({ + entry, + spec: readJson(path.join(specsDir, `${entry.chain}.json`)), + })); + + const removals = collectRemovals(snapshot, specs); + if (removals.undocumented.length > 0) { + reportUndocumentedRemovals(removals); + } + + const updates: { doc: string; content: string }[] = []; + for (const { entry, spec } of specs) { + const docPath = path.join(repoRoot, entry.doc); + const current = readFileSync(docPath, "utf8"); + const generated = renderSection(entry, spec, snapshot.version, removals); + const updated = replaceBetweenMarkers(current, generated, entry.marker); + if (updated !== current) updates.push({ doc: entry.doc, content: updated }); + } + + if (checkMode) { + if (updates.length > 0) { + const list = updates.map((update) => ` - ${update.doc}`).join("\n"); + throw new Error( + `Generated contract reference is stale in:\n${list}\nRun \`npm run generate:contract-reference\` and commit the result.`, + ); + } + console.log("Contract reference is in sync with the checked-in specs."); + return; + } + + for (const update of updates) { + writeFileSync(path.join(repoRoot, update.doc), update.content, "utf8"); + console.log(`Updated ${update.doc}`); + } +} + +type Removal = { chain: string; unit: string; kind: string; name: string }; + +function collectRemovals( + snapshot: Snapshot, + specs: { entry: (typeof chainOrder)[number]; spec: SpecFile }[], +): { all: Removal[]; undocumented: Removal[] } { + const all: Removal[] = []; + + for (const { entry, spec } of specs) { + const previous = snapshot.chains[entry.chain] ?? {}; + const units = (spec[entry.unitList] as UnitSpec[] | undefined) ?? []; + const previousUnits = Object.entries(previous[entry.unitKey] as Record); + + for (const [unitName, before] of previousUnits) { + const after = units.find((unit) => unit.name === unitName); + if (!after) { + for (const fn of before.functions ?? []) all.push({ chain: entry.chain, unit: unitName, kind: "function", name: fn.name }); + for (const fn of before.instructions ?? []) all.push({ chain: entry.chain, unit: unitName, kind: "function", name: fn.name }); + for (const event of before.events ?? []) all.push({ chain: entry.chain, unit: unitName, kind: "event", name: event.name }); + for (const key of before.storageKeys ?? []) all.push({ chain: entry.chain, unit: unitName, kind: "storageKey", name: key }); + for (const error of before.errors ?? []) all.push({ chain: entry.chain, unit: unitName, kind: "error", name: error }); + continue; + } + + const afterFunctions = new Set([...(after.functions ?? []), ...(after.instructions ?? [])].map((fn) => fn.name)); + for (const fn of [...(before.functions ?? []), ...(before.instructions ?? [])]) { + if (!afterFunctions.has(fn.name)) all.push({ chain: entry.chain, unit: unitName, kind: "function", name: fn.name }); + } + for (const event of before.events ?? []) { + if (!(after.events ?? []).some((candidate) => candidate.name === event.name)) { + all.push({ chain: entry.chain, unit: unitName, kind: "event", name: event.name }); + } + } + for (const key of before.storageKeys ?? []) { + if (!(after.storageKeys ?? []).includes(key)) { + all.push({ chain: entry.chain, unit: unitName, kind: "storageKey", name: key }); + } + } + for (const error of before.errors ?? []) { + if (!(after.errors ?? []).includes(error)) { + all.push({ chain: entry.chain, unit: unitName, kind: "error", name: error }); + } + } + } + } + + const documented = new Set( + snapshot.migrations.map((entry) => `${entry.chain}|${entry.unit}|${entry.kind}|${entry.name}`), + ); + const undocumented = all.filter( + (removal) => !documented.has(`${removal.chain}|${removal.unit}|${removal.kind}|${removal.name}`), + ); + + return { all, undocumented }; +} + +function reportUndocumentedRemovals(removals: { all: Removal[]; undocumented: Removal[] }): void { + if (removals.undocumented.length === 0) return; + + const lines = removals.undocumented + .map((removal) => ` - ${removal.chain}/${removal.unit}: ${removal.kind} \`${removal.name}\``) + .join("\n"); + + throw new Error( + [ + "Public API members disappeared from the checked-in specs without a migration note:", + lines, + "", + "Either restore them, or record the removal in contracts/specs/api-snapshot.json `migrations`", + "with the version they were removed in, then regenerate.", + ].join("\n"), + ); +} + +function renderSection( + entry: (typeof chainOrder)[number], + spec: SpecFile, + snapshotVersion: string, + removals: { all: Removal[] }, +): string { + const startMarker = `{/* ${entry.marker}:start */}`; + const endMarker = `{/* ${entry.marker}:end */}`; + const units = (spec[entry.unitList] as UnitSpec[] | undefined) ?? []; + const removedHere = removals.all.filter((removal) => removal.chain === entry.chain); + + return [ + startMarker, + "", + "", + "", + `- **Spec version:** ${spec.specVersion}`, + `- **API snapshot version:** ${snapshotVersion}`, + `- **Source:** \`${spec.source.repository}/${spec.source.path}\` @ \`${spec.source.commit}\``, + `- **Contracts:** ${units.length}`, + removedHere.length > 0 ? `- **Removed in this version:** ${removedHere.map((r) => `\`${r.unit}.${r.name}\``).join(", ")}` : "- **Removed in this version:** none", + "", + "## Generated contract reference", + "", + ...units.flatMap((unit) => renderUnit(unit)), + endMarker, + ].join("\n"); +} + +function renderUnit(unit: UnitSpec): string[] { + const functions = unit.functions ?? unit.instructions ?? []; + const lines: string[] = [`### ${unit.name}`, ""]; + + if (unit.lock) { + lines.push(`- **Code hash:** \`${unit.lock.codeHash}\``); + lines.push(`- **Type script:** \`${unit.lock.typeScript}\``); + lines.push(""); + } + + lines.push(`- **Source kind:** \`${unit.kind}\``); + lines.push(`- **Public methods:** ${functions.length}`); + lines.push(`- **Errors:** ${unit.errors.length}`); + lines.push(""); + + if (functions.length > 0) { + lines.push("| Method | Params | Returns | Auth required |", "|---|---|---|---|"); + for (const fn of functions) { + lines.push( + `| \`${fn.name}\` | ${renderParams(fn.params)} | ${renderReturns(fn.returns)} | ${fn.authRequired} |`, + ); + } + lines.push(""); + } + + if (unit.events && unit.events.length > 0) { + lines.push("| Event | Fields |", "|---|---|"); + for (const event of unit.events) { + lines.push(`| \`${event.name}\` | ${renderParams(event.params)} |`); + } + lines.push(""); + } + + if (unit.storageKeys && unit.storageKeys.length > 0) { + lines.push("| Storage key |", "|---|"); + for (const key of unit.storageKeys) lines.push(`| \`${key}\` |`); + lines.push(""); + } + + if (unit.types && unit.types.length > 0) { + lines.push("| Type | Fields |", "|---|---|"); + for (const type of unit.types) lines.push(`| \`${type.name}\` | ${renderParams(type.fields)} |`); + lines.push(""); + } + + if (unit.errors.length > 0) { + lines.push("```", ...unit.errors.map((error) => ` ${error}`), "```", ""); + } else { + lines.push("No custom errors declared.", ""); + } + + return lines; +} + +function renderParams(params: ParamSpec[] | undefined): string { + if (!params || params.length === 0) return "None"; + return params + .map((param) => `\`${param.name || "_"}: ${param.type}${param.indexed ? " (indexed)" : ""}\``) + .join("
"); +} + +function renderReturns(returns: ParamSpec[] | string | undefined): string { + if (returns === undefined) return "`void`"; + if (typeof returns === "string") return `\`${returns}\``; + return renderParams(returns); +} + +function replaceBetweenMarkers(current: string, generated: string, marker: string): string { + const startMarker = `{/* ${marker}:start */}`; + const endMarker = `{/* ${marker}:end */}`; + const start = current.indexOf(startMarker); + const end = current.indexOf(endMarker); + + if (start === -1 || end === -1 || end < start) { + const heading = current.search(/\n#{2,3} /); + if (heading === -1) { + throw new Error(`Missing ${startMarker}/${endMarker} markers and no heading to insert before.`); + } + return `${current.slice(0, heading).trimEnd()}\n\n${generated}\n\n${current.slice(heading).trimStart()}`; + } + + const before = current.slice(0, start).trimEnd(); + const after = current.slice(end + endMarker.length).trimStart(); + return `${before}\n\n${generated}\n\n${after}`; +} + +function readJson(filePath: string): T { + if (!existsSync(filePath)) { + throw new Error(`Missing spec source: ${path.relative(repoRoot, filePath)}`); + } + return JSON.parse(readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")) as T; +} + +main();