diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml
index b787e48..13de9e0 100644
--- a/.github/workflows/snippets.yml
+++ b/.github/workflows/snippets.yml
@@ -70,6 +70,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
+
- name: Check contract registry
run: pnpm run check:contract-registry
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 ff4765c..673f7ea 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 df9ea9c..9aba04d 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,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 && npm run check:contract-registry"
+ "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry && 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();