# Specter Protocol - Complete Documentation # Source: https://docs.specterchain.com # Note: Mermaid code blocks describe diagrams. Math uses LaTeX notation. ================================================================ SECTION: Introduction to Specter SOURCE: https://docs.specterchain.com/ ================================================================ # Introduction to Specter Specter is a purpose-built Layer 1 blockchain designed to power **Ghost Protocol**, a general-purpose commit/reveal privacy primitive founded on zero-knowledge cryptography. Where previous privacy systems operate as single-purpose mixers or shielded-transaction chains, Specter provides protocol-level privacy infrastructure that any application can leverage — for tokens, secrets, credentials, documents, or arbitrary data. ## What is Ghost Protocol? Ghost Protocol is a cryptographic framework built on two phases: **commit** and **reveal**. | Phase | What happens | Cryptography | |-------|-------------|--------------| | **Commit** | The user hashes private data into a commitment using the Poseidon hash function. That commitment is inserted into a Merkle tree stored on-chain. | Poseidon hash (circuit-friendly), append-only Merkle tree | | **Reveal** | The user later proves knowledge of a valid commitment inside the tree using a zero-knowledge proof — without revealing *which* commitment is theirs. | Groth16 proof over BN254, nullifier to prevent double-spending | The key insight is that the link between commit and reveal is severed. An observer can see that *someone* committed and that *someone* revealed, but cannot correlate the two events. This is the foundation of privacy on Specter. ### Not a Mixer Ghost Protocol is **not** a mixer. Mixers like Tornado Cash accept a fixed denomination of a single token, pool deposits together, and let users withdraw from the pool. Ghost Protocol is a commit/reveal primitive that operates at the protocol level: - **Any data type.** Tokens (native and ERC-20), secret messages, verifiable credentials, document hashes, and more can flow through the commit/reveal pipeline. - **Variable amounts.** Commitments encode arbitrary values — there is no fixed denomination requirement. - **Policy enforcement.** Reveal-time policy contracts can enforce compliance rules (e.g., sanctions screening, time locks, amount limits) without breaking privacy for non-flagged users. - **Physical key binding.** Commitments can be bound to NFC hardware cards (NTAG 424 DNA), enabling offline bearer instruments. - **Extensibility.** Developers deploy new vault contracts and policy modules without modifying the core protocol. ## Why a New Chain? Specter is built with the **Cosmos SDK** and runs a full **EVM execution environment**. It uses **CometBFT** for Byzantine Fault Tolerant consensus. The chain exists because Ghost Protocol has a requirement that no standard EVM chain can satisfy: > Smart contracts must be able to **mint and burn the native gas token**. On Ethereum, Base, Arbitrum, or any standard EVM chain, the native token (ETH) cannot be minted or burned by a smart contract. It can only be transferred. Ghost Protocol needs to destroy tokens on commit (so they leave circulation and cannot be traced) and mint fresh tokens on reveal (so the receiver obtains clean, unlinkable tokens). Specter solves this with: - **`x/ghostmint`** — a custom Cosmos SDK module that exposes an EVM precompile at address `0x0808`. This precompile bridges between the EVM execution layer and the Cosmos `x/bank` module, allowing authorized smart contracts to mint and burn the native `GHOST` token (`aghost`, 18 decimals). - **`NativeAssetHandler`** — the sole smart contract authorized to invoke the `ghostmint` precompile. All native token operations route through this contract. ### Chain Details | Property | Value | |----------|-------| | Chain ID | `5446` | | Native token | `GHOST` (base denom: `aghost`, 18 decimals) | | Consensus | CometBFT (BFT, ~5 second block time) | | Execution | EVM (Solidity smart contracts) | | Framework | Cosmos SDK | | Bech32 prefix | `umbra` (e.g., `umbra1abc...xyz`) | | Status | **Live testnet** | ## How Specter Compares The following table contrasts Specter's Ghost Protocol with the two most well-known privacy systems in cryptocurrency. | Feature | Tornado Cash | Zcash | Specter (Ghost Protocol) | |---------|-------------|-------|--------------------------| | **Scope** | Single-token mixer (ETH and select ERC-20s) | Shielded transactions for ZEC | General-purpose privacy primitive for any data type | | **Denomination** | Fixed denominations (0.1, 1, 10, 100 ETH) | Arbitrary amounts (shielded pool) | Arbitrary amounts and arbitrary data | | **Extensibility** | None — monolithic contract | Protocol-level only — no third-party extension | Modular vault + policy architecture; developers deploy custom contracts | | **Policy / Compliance** | None | Viewing keys (voluntary disclosure) | On-chain policy contracts enforce rules at reveal time | | **Physical keys** | Not supported | Not supported | NFC cards (NTAG 424 DNA) bind commitments to hardware bearer instruments | | **Cross-chain** | Single chain (Ethereum) | ZEC only | Hyperlane integration for Ethereum, Base, Arbitrum | | **Quantum resistance** | None | None | Keccak256 quantum defense layer; SP1 zkVM post-quantum proofs planned | | **Native token control** | Cannot mint/burn ETH | Built-in to protocol | `ghostmint` precompile mints/burns native GHOST from smart contracts | | **Data privacy** | Not applicable | Not applicable | OpenGhostVault for arbitrary secret data; PersistentKeyVault for reusable keys | ## What You Can Build Because Ghost Protocol is a primitive rather than a finished product, it enables a range of applications: - **Private payments.** Commit GHOST or wrapped ERC-20 tokens, reveal to a fresh address with no on-chain link. - **Credential verification.** Commit a hash of a credential (KYC attestation, diploma, license). Reveal proves possession without exposing the credential itself. - **Secret sharing.** Use OpenGhostVault to commit encrypted data on-chain. Reveal grants access to the decryption key without exposing sender identity. - **Bearer instruments.** Bind a commitment to an NFC card. The physical card holder can reveal — enabling cash-like digital assets. - **Compliant privacy.** Policy contracts let issuers enforce sanctions checks, geographic restrictions, or time-locked vesting while preserving privacy for all other users. ## Current Status Specter is running a **public testnet** (Chain ID `5446`). The core smart contracts (CommitRevealVault, CommitmentTree, NullifierRegistry, ProofVerifier, AssetGuard, NativeAssetHandler, GhostERC20Factory) are deployed and operational. The relayer infrastructure is live. The web application and mobile application are functional against the testnet. Continue to the [Architecture Overview](./architecture/overview.md) for a detailed look at how all the pieces fit together. ================================================================ SECTION: Architecture SOURCE: https://docs.specterchain.com/architecture/overview ================================================================ # Architecture Overview Specter is a vertically integrated system spanning from low-level cryptographic circuits to end-user mobile applications. This page presents the full architecture, layer by layer. ## System Diagram ```mermaid graph TB subgraph Clients["Client Layer"] Webapp["Webapp
(React + Vite)"] Mobile["Mobile App
(React Native + Expo)"] NFC["NFC Cards
(NTAG 424 DNA)"] end subgraph Relayers["Relayer Infrastructure"] direction TB CommitRelay["Commitment Relayer"] ProofRelay["Proof Relayer"] RootUpdater["Root Updater"] RevealRelay["Reveal Relayer"] BridgeRelay["Bridge Relayer"] GasRelay["Gas Relayer"] StatusRelay["Status Relayer"] CardRelay["Card Relayer"] CrossChainRelay["Cross-Chain Relayer"] IndexerRelay["Indexer"] HealthRelay["Health Monitor"] end subgraph Contracts["Smart Contract Layer"] direction TB CRV["CommitRevealVault
(Orchestrator)"] CT["CommitmentTree
(Merkle Tree)"] NR["NullifierRegistry"] PV["ProofVerifier
(Groth16)"] AG["AssetGuard
(Policy Engine)"] NAH["NativeAssetHandler"] GEF["GhostERC20Factory"] OGV["OpenGhostVault
(Data Privacy)"] PKV["PersistentKeyVault
(Reusable Keys)"] Policy["Policy Contracts"] end subgraph Blockchain["Blockchain Layer"] EVM["EVM Execution"] Precompile["ghostmint Precompile
(0x0808)"] GhostMint["x/ghostmint Module"] Bank["x/bank Module"] Staking["x/staking"] Gov["x/gov"] Dist["x/distribution"] Slash["x/slashing"] Evidence["x/evidence"] CometBFT["CometBFT Consensus"] end subgraph External["External Chains"] Ethereum["Ethereum"] Base["Base"] Arbitrum["Arbitrum"] Hyperlane["Hyperlane Bridge"] end subgraph Crypto["Cryptography"] Poseidon["Poseidon Hash
(2/4/7 inputs)"] Groth16["Groth16 Prover
(BN254)"] Keccak["Keccak256
(Quantum Defense)"] SP1["SP1 zkVM
(Planned)"] end Webapp --> CommitRelay Webapp --> ProofRelay Mobile --> CommitRelay Mobile --> ProofRelay NFC --> CardRelay CommitRelay --> CRV ProofRelay --> CRV RevealRelay --> CRV RootUpdater --> CT BridgeRelay --> Hyperlane GasRelay --> EVM CrossChainRelay --> Hyperlane CRV --> CT CRV --> NR CRV --> PV CRV --> AG CRV --> NAH AG --> Policy NAH --> Precompile Precompile --> GhostMint GhostMint --> Bank EVM --> CometBFT Hyperlane --> Ethereum Hyperlane --> Base Hyperlane --> Arbitrum Groth16 --> PV Poseidon --> CT Keccak --> CRV ``` ## Layer Breakdown ### 1. Blockchain Layer Specter runs on a Cosmos SDK application chain with an embedded EVM. CometBFT provides Byzantine Fault Tolerant consensus with approximately 5-second block finality. | Component | Role | |-----------|------| | **CometBFT** | Consensus engine. Orders transactions, produces blocks, provides BFT guarantees (tolerates up to 1/3 Byzantine validators). | | **Cosmos SDK** | Application framework. Manages state, module system, transaction routing, IBC compatibility. | | **EVM** | Ethereum-compatible execution environment. Runs Solidity smart contracts. Full EVM opcode support. | | **`x/ghostmint` module** | Custom Cosmos SDK module exposing an EVM precompile at address `0x0808`. Bridges EVM contract calls to Cosmos `x/bank` for native token mint/burn. | | **`x/bank`** | Standard Cosmos banking module. Manages all native token balances, transfers, supply tracking. | | **`x/staking`** | Proof-of-Stake validator management, delegation, unbonding. | | **`x/gov`** | On-chain governance for protocol upgrades and parameter changes. | | **`x/distribution`** | Distributes staking rewards and commission to validators and delegators. | | **`x/slashing`** | Penalizes validators for downtime or double-signing. | | **`x/evidence`** | Accepts and processes evidence of validator misbehavior. | **Chain identity:** - Chain ID: `5446` - Native denomination: `aghost` (18 decimals, displayed as `GHOST`) - Bech32 prefix: `umbra` ### 2. Smart Contract Layer All privacy logic lives in Solidity smart contracts deployed to Specter's EVM. The contracts follow a modular architecture where `CommitRevealVault` acts as the central orchestrator. #### Core Contracts | Contract | Purpose | |----------|---------| | **CommitRevealVault** | Orchestrator. Entry point for all commit and reveal operations. Coordinates calls to all other contracts. Manages commitment metadata, quantum commitments, and phantom key generation. | | **CommitmentTree** | On-chain Merkle tree storing Poseidon hash commitments. Supports incremental insertion. The root is used in ZK proofs to demonstrate membership. | | **NullifierRegistry** | Tracks spent nullifiers to prevent double-reveals. Each valid reveal consumes a nullifier; attempting to reuse one reverts the transaction. | | **ProofVerifier** | Verifies Groth16 zero-knowledge proofs on-chain. Validates that the prover knows a valid leaf in the Merkle tree without revealing which leaf. | | **AssetGuard** | Policy enforcement engine. Before a reveal completes, AssetGuard queries registered policy contracts to determine whether the operation is permitted. | | **NativeAssetHandler** | The sole contract authorized to call the `ghostmint` precompile at `0x0808`. Handles minting fresh GHOST on reveal and burning GHOST on commit. | | **GhostERC20Factory** | Factory contract for deploying wrapped ERC-20 tokens that integrate with the commit/reveal pipeline. Allows any ERC-20 to participate in Ghost Protocol. | #### Extended Contracts | Contract | Purpose | |----------|---------| | **OpenGhostVault** | Privacy vault for arbitrary data (not just tokens). Users commit encrypted data on-chain and reveal access to it later, enabling secret sharing, private messaging, and confidential document storage. | | **PersistentKeyVault** | Manages reusable phantom keys. Instead of generating a one-time key per commitment, users can maintain persistent keys that work across multiple commit/reveal cycles. Useful for recurring private interactions. | | **Policy contracts** | Pluggable modules registered with AssetGuard. Examples include sanctions screening, time-lock enforcement, amount caps, and geographic restrictions. Each policy contract implements a standard interface and is invoked at reveal time. | ### 3. Cryptography Specter uses a carefully selected cryptographic stack optimized for both ZK circuit efficiency and on-chain verification cost. #### Poseidon Hash Poseidon is an arithmetic-friendly hash function designed for use inside ZK circuits. Unlike Keccak256 or SHA-256, Poseidon operates natively over prime fields, making it orders of magnitude cheaper to prove in a SNARK circuit. Specter uses three Poseidon variants: | Variant | Inputs | Usage | |---------|--------|-------| | **Poseidon2** | 2 | Merkle tree internal node hashing (left child + right child) | | **Poseidon4** | 4 | Intermediate commitment construction | | **Poseidon7** | 7 | Full commitment hash: `Poseidon7(secret, nullifier, assetId, amount, chainId, phantom, aux)` | #### Groth16 on BN254 Zero-knowledge proofs are generated using the **Groth16** proving system over the **BN254** (alt_bn128) elliptic curve. This combination was chosen because: - BN254 has native EVM precompile support (`ecAdd`, `ecMul`, `ecPairing` at addresses `0x06`, `0x07`, `0x08`), making on-chain verification gas-efficient. - Groth16 produces constant-size proofs (~128 bytes) with fast verification time. - The trusted setup is circuit-specific; Specter's circuits have completed their setup ceremonies. #### Keccak256 Quantum Defense Each commitment includes a secondary **keccak256 hash** stored alongside the Poseidon commitment. At reveal time, the prover must supply the keccak256 preimage, which is verified on-chain. This provides a defense layer against future quantum attacks on the BN254 curve: - If BN254 is broken, an attacker could forge Groth16 proofs but would still need to supply a valid keccak256 preimage. - Keccak256 is a symmetric primitive resistant to Grover's algorithm (with sufficient output length). #### SP1 zkVM (Planned) Future iterations will integrate **Succinct's SP1 zkVM** for post-quantum proof generation. SP1 allows writing proof logic in Rust, compiled to a RISC-V target, and verified on-chain. This path enables migration away from BN254 to post-quantum-secure proof systems without rewriting circuit logic. ### 4. Relayer Infrastructure Specter runs **11 Node.js services** deployed on DigitalOcean, managed by **PM2** process manager, and fronted by a **Caddy** reverse proxy for automatic TLS. | Service | Role | |---------|------| | **Commitment Relayer** | Accepts commitment data from clients (especially mobile), computes server-side Poseidon hashes when the client cannot, and submits commit transactions to the chain. | | **Proof Relayer** | Generates Groth16 ZK proofs on behalf of constrained clients (mobile devices, low-power hardware). Runs the prover with the user's secret inputs and returns the proof. | | **Root Updater** | Watches the CommitmentTree contract for new insertions and keeps the cached Merkle root synchronized. Ensures proofs reference a current, valid root. | | **Reveal Relayer** | Submits reveal transactions to the chain on behalf of users. This allows users to reveal without needing GHOST for gas (the relayer pays gas and is reimbursed from the revealed amount). | | **Bridge Relayer** | Monitors Hyperlane message passing between Specter and external chains (Ethereum, Base, Arbitrum). Relays cross-chain commitment and reveal messages. | | **Gas Relayer** | Provides gas sponsorship for new users. Sends small amounts of GHOST to new addresses so they can submit their first transactions. | | **Status Relayer** | Tracks the status of pending commits and reveals. Provides a query API so clients can poll for confirmation. | | **Card Relayer** | Handles NFC card interactions. Validates NTAG 424 DNA signatures, maps card UIDs to commitments, and processes card-bound reveals. | | **Cross-Chain Relayer** | Manages the Hyperlane integration for multi-chain Ghost Protocol operations. Coordinates asset locking on source chains and minting on Specter. | | **Indexer** | Indexes on-chain events (commitments, reveals, nullifiers, policy actions) into a queryable database for fast client lookups. | | **Health Monitor** | Monitors all other relayer services, chain RPC endpoints, and contract state. Alerts on failures. | ### 5. Client Layer #### Web Application - **Stack:** React + Vite + TypeScript - **Capabilities:** Full commit/reveal flow, in-browser Poseidon hashing, in-browser Groth16 proof generation (via WASM), wallet connection (MetaMask, WalletConnect), NFC card management. - **Proof generation:** The webapp can generate proofs entirely client-side using a WASM build of the Groth16 prover. This means the user's secret inputs never leave the browser. #### Mobile Application - **Stack:** React Native + Expo + TypeScript - **Capabilities:** Commit and reveal flows, NFC card reading/writing, QR code scanning for phantom keys. - **Proof generation:** Mobile devices typically offload proof generation to the Proof Relayer due to memory and compute constraints. The secret inputs are sent over TLS to the relayer, which generates the proof and returns it. #### NFC Cards (NTAG 424 DNA) - **Hardware:** NXP NTAG 424 DNA chips with SUN (Secure Unique NFC) authentication. - **Usage:** Each card stores a phantom key bound to a specific commitment. Tapping the card reads the key and initiates a reveal flow. The card's cryptographic signature ensures the tap is authentic and the card has not been cloned. - **Use case:** Cash-like bearer instruments. Whoever holds the physical card can reveal the committed value. ### 6. External Chain Integration Specter connects to external EVM chains via **Hyperlane**, a modular interoperability protocol. | Chain | Integration | |-------|-------------| | **Ethereum** | Lock tokens on Ethereum, commit equivalent value on Specter. Reveal on Specter, unlock on Ethereum. | | **Base** | Same pattern as Ethereum. Optimized for lower-cost L2 operations. | | **Arbitrum** | Same pattern as Ethereum. Optimized for lower-cost L2 operations. | The cross-chain flow allows users to privately transfer value originating from any supported chain without the source or destination being linkable. ## Next Steps - [System Components](./system-components.md) — detailed data flow for commit and reveal operations - [Cosmos-EVM Integration](./cosmos-evm-integration.md) — deep dive into the ghostmint precompile and why Specter needs its own L1 ================================================================ SECTION: Architecture SOURCE: https://docs.specterchain.com/architecture/system-components ================================================================ # System Components This page details how each component in the Specter architecture connects to the others and walks through the complete data flow for both commit and reveal operations. ## Component Interaction Map ```mermaid sequenceDiagram participant User participant Client as Client (Web/Mobile) participant CR as Commitment Relayer participant CRV as CommitRevealVault participant CT as CommitmentTree participant NAH as NativeAssetHandler participant GM as ghostmint (0x0808) participant Bank as x/bank Note over User,Bank: COMMIT FLOW User->>Client: Provide secret, amount, asset Client->>Client: Compute Poseidon7 commitment Client->>Client: Compute keccak256 quantum commitment Client->>Client: Generate phantom key Client->>CRV: commit(commitment, amount, quantumCommitment, ...) CRV->>NAH: burn(user, amount) NAH->>GM: precompile call: burn aghost GM->>Bank: BurnCoins(amount) Bank-->>GM: OK GM-->>NAH: OK NAH-->>CRV: OK CRV->>CT: insert(commitment) CT-->>CRV: leafIndex CRV->>CRV: Store quantum commitment CRV-->>Client: CommitEvent(commitment, leafIndex) Client->>User: Phantom key + commitment receipt ``` ```mermaid sequenceDiagram participant User participant Client as Client (Web/Mobile) participant PR as Proof Relayer participant RR as Reveal Relayer participant CRV as CommitRevealVault participant PV as ProofVerifier participant AG as AssetGuard participant NR as NullifierRegistry participant NAH as NativeAssetHandler participant GM as ghostmint (0x0808) participant Bank as x/bank Note over User,Bank: REVEAL FLOW User->>Client: Load phantom key Client->>Client: Reconstruct secret, nullifier from key Client->>PR: Request proof generation (if constrained) PR->>PR: Generate Groth16 proof PR-->>Client: proof, publicSignals Client->>RR: Submit reveal request RR->>CRV: reveal(proof, nullifier, recipient, amount, root, quantumPreimage, ...) CRV->>PV: verifyProof(proof, publicSignals) PV-->>CRV: valid CRV->>CRV: Verify quantum preimage (keccak256) CRV->>NR: checkAndRecord(nullifier) NR-->>CRV: OK (not previously spent) CRV->>AG: checkPolicy(recipient, amount, asset, ...) AG-->>CRV: approved CRV->>NAH: mint(recipient, amount) NAH->>GM: precompile call: mint aghost GM->>Bank: MintCoins(amount) Bank-->>GM: OK GM-->>NAH: OK NAH-->>CRV: OK CRV-->>RR: RevealEvent(nullifier, recipient, amount) RR-->>Client: Reveal confirmed Client-->>User: Funds received at recipient address ``` ## Commit Flow — Step by Step The commit operation destroys tokens and records an on-chain commitment that can later be claimed by anyone who knows the corresponding secret. ### Step 1: Client-Side Computation The user's client (web browser or mobile app) performs all sensitive computation locally: 1. **Generate random secret** — a 256-bit random value known only to the user. 2. **Derive nullifier** — a deterministic value derived from the secret. This will be used at reveal time to prevent double-spending. 3. **Compute Poseidon7 commitment:** ``` commitment = Poseidon7(secret, nullifier, assetId, amount, chainId, phantom, aux) ``` The seven inputs encode everything about what is being committed: the secret (for ownership), the nullifier (for spend tracking), the asset type, the amount, the chain ID (for cross-chain safety), a phantom key component, and auxiliary data. 4. **Compute keccak256 quantum commitment:** ``` quantumCommitment = keccak256(abi.encodePacked(secret, nullifier, amount, ...)) ``` This redundant commitment uses a hash function resistant to quantum attacks, providing a second authentication factor at reveal time. 5. **Generate phantom key** — a compact representation of all the data needed to reconstruct the secret and perform a reveal. This key is what gets stored (on device, exported as QR code, or written to an NFC card). ### Step 2: On-Chain Commit Transaction The client (or the Commitment Relayer on behalf of the client) calls `CommitRevealVault.commit()`: ```solidity function commit( bytes32 commitment, uint256 amount, bytes32 quantumCommitment, address asset, bytes calldata auxData ) external payable; ``` The vault executes the following sequence: 1. **Token burn.** For native GHOST commits, the vault calls `NativeAssetHandler.burn()`, which invokes the `ghostmint` precompile at `0x0808` to destroy `amount` of `aghost` via the Cosmos `x/bank` module. For ERC-20 commits, the tokens are transferred to and locked in the vault. 2. **Tree insertion.** The vault calls `CommitmentTree.insert(commitment)`, which appends the commitment as a new leaf in the on-chain Merkle tree and returns the `leafIndex`. 3. **Quantum commitment storage.** The `quantumCommitment` is stored in a mapping indexed by the Poseidon commitment. It will be checked at reveal time. 4. **Event emission.** A `Commit` event is emitted containing the commitment hash, leaf index, asset, amount, and timestamp. Clients and the Indexer listen for this event. ### Step 3: Key Delivery After a successful commit, the user's phantom key is the sole credential needed to reveal. It can be: - **Stored on device** — encrypted in the webapp's local storage or the mobile app's secure enclave. - **Exported as QR code** — scanned by another device to transfer reveal capability. - **Written to NFC card** — the Card Relayer writes the key to an NTAG 424 DNA card, creating a physical bearer instrument. ## Reveal Flow — Step by Step The reveal operation proves knowledge of a valid commitment and mints fresh tokens to a designated recipient. ### Step 1: Key Loading The user loads their phantom key by one of: - Opening the webapp/mobile app where the key is stored. - Scanning a QR code. - Tapping an NFC card (the Card Relayer validates the NTAG 424 DNA SUN signature before extracting the key). From the phantom key, the client reconstructs the `secret`, `nullifier`, `amount`, `assetId`, and all other commitment parameters. ### Step 2: Proof Generation The client must generate a Groth16 zero-knowledge proof demonstrating: 1. **Knowledge of a valid commitment.** The prover knows values `(secret, nullifier, assetId, amount, chainId, phantom, aux)` such that `Poseidon7(...)` equals a leaf in the Merkle tree. 2. **Merkle membership.** The commitment exists in the tree under the current root. The prover supplies the Merkle path (sibling hashes) as private inputs. 3. **Correct nullifier derivation.** The nullifier in the public signals was correctly derived from the secret. **Public signals** (visible on-chain): `root`, `nullifier`, `recipient`, `amount`, `assetId`, `chainId`. **Private inputs** (never leave the prover): `secret`, `phantom`, `aux`, `merklePath`, `merklePathIndices`. #### Client-Side vs. Relayer-Side Proving | Environment | Proving approach | Trade-off | |-------------|-----------------|-----------| | **Web browser** | WASM Groth16 prover runs in-browser. All private inputs stay local. | Maximum privacy; slower (~15-30 seconds on modern hardware). | | **Mobile device** | Private inputs sent to the Proof Relayer over TLS. Relayer generates proof and returns it. | Faster; requires trusting the relayer with secret inputs during proof generation. | | **Server/CLI** | Native Groth16 prover. All private inputs stay local. | Fastest; full privacy. | ### Step 3: On-Chain Reveal Transaction The Reveal Relayer (or the client directly) calls `CommitRevealVault.reveal()`: ```solidity function reveal( bytes calldata proof, bytes32 root, bytes32 nullifier, address recipient, uint256 amount, address asset, bytes32 quantumPreimage, bytes calldata auxData ) external; ``` The vault executes the following sequence: 1. **Proof verification.** The vault calls `ProofVerifier.verifyProof(proof, publicSignals)`. The verifier checks the Groth16 proof against the verification key hardcoded in the contract. If invalid, the transaction reverts. 2. **Quantum preimage verification.** The vault computes `keccak256(quantumPreimage)` and checks it against the stored quantum commitment. If it does not match, the transaction reverts. This is the quantum defense: even if an attacker forges a Groth16 proof (hypothetically, via a quantum computer breaking BN254), they cannot supply the correct keccak256 preimage without knowing the original secret. 3. **Nullifier check.** The vault calls `NullifierRegistry.checkAndRecord(nullifier)`. If this nullifier has been seen before, the transaction reverts (preventing double-spend). Otherwise, the nullifier is recorded permanently. 4. **Policy enforcement.** The vault calls `AssetGuard.checkPolicy(recipient, amount, asset, auxData)`. AssetGuard iterates over registered policy contracts and calls each one. If any policy rejects the operation, the transaction reverts. Policies can enforce sanctions screening, time locks, amount limits, or custom business logic. 5. **Token minting.** For native GHOST reveals, the vault calls `NativeAssetHandler.mint(recipient, amount)`, which invokes the `ghostmint` precompile to mint fresh `aghost` via `x/bank`. For ERC-20 reveals, locked tokens are transferred from the vault to the recipient. 6. **Event emission.** A `Reveal` event is emitted containing the nullifier, recipient, amount, and timestamp. The link to the original commitment is not recorded — this is the privacy guarantee. ## Supporting Services ### Root Updater The Merkle root changes every time a new commitment is inserted into the CommitmentTree. The Root Updater service: 1. Listens for `LeafInserted` events from the CommitmentTree contract. 2. Recomputes the Merkle root from the full set of leaves. 3. Caches the latest root and recent historical roots (proofs may reference a slightly stale root if a new commitment was inserted between proof generation and reveal submission). 4. Serves the current root and Merkle path data to clients via a REST API. Without the Root Updater, clients would need to reconstruct the entire Merkle tree client-side to generate proofs — which is impractical for mobile devices. ### Commitment Relayer Mobile clients face two constraints that make direct commitment difficult: 1. **Poseidon computation.** The Poseidon hash function requires large prime field arithmetic that is expensive on mobile CPUs. The Commitment Relayer accepts the raw commitment inputs and computes `Poseidon7(...)` server-side. 2. **Gas payment.** New users may not have GHOST for gas. The Commitment Relayer can submit the commit transaction on behalf of the user and include the gas cost in the committed amount. The workflow: ``` Mobile → HTTPS POST to Commitment Relayer (secret, nullifier, amount, ...) Commitment Relayer → Compute Poseidon7 → Submit commit tx → Return commitment hash + leaf index ``` **Security note:** When using the Commitment Relayer, the user's secret inputs are transmitted to the relayer. The relayer sees the raw secret. This is a trust trade-off accepted for mobile usability. Users who require maximum privacy should use the web application with in-browser Poseidon computation. ### Proof Relayer The Proof Relayer generates Groth16 proofs for clients that cannot run the prover locally: ``` Client → HTTPS POST to Proof Relayer (secret, nullifier, amount, merklePath, ...) Proof Relayer → Run Groth16 prover → Return proof + publicSignals ``` The prover requires the full proving key (~50-100 MB) and significant memory. Mobile devices cannot efficiently run this. The Proof Relayer maintains the proving key in memory and generates proofs in 2-5 seconds. ### Reveal Relayer The Reveal Relayer submits reveal transactions so that users do not need GHOST in the recipient address for gas: 1. Client sends the proof, public signals, and recipient address to the Reveal Relayer. 2. The Reveal Relayer submits the `reveal()` transaction, paying gas from its own funded account. 3. The relayer is reimbursed by deducting a small fee from the revealed amount (the recipient receives `amount - relayerFee`). This is critical for the privacy model: if the user had to fund the recipient address with gas before revealing, the funding transaction would link the recipient to an existing address, potentially deanonymizing the reveal. ### Card Relayer The Card Relayer manages the NFC card lifecycle: | Operation | Description | |-----------|-------------| | **Card provisioning** | Writes a phantom key to a blank NTAG 424 DNA card. Configures the card's SUN authentication parameters. | | **Card reading** | When a user taps a card, the relayer receives the SUN message (a cryptographic MAC over a rolling counter and card UID). It validates the MAC against the card's known key, confirming the tap is authentic and the card has not been cloned. | | **Card-bound reveal** | After validating the card tap, the relayer extracts the phantom key, generates a proof (or delegates to the Proof Relayer), and initiates the reveal flow. | ## Data Flow Summary ```mermaid flowchart LR subgraph Commit A[User has tokens] --> B[Client computes hashes] B --> C[CommitRevealVault.commit] C --> D[Tokens burned] C --> E[Commitment in tree] C --> F[Quantum commitment stored] B --> G[Phantom key generated] end subgraph Reveal H[User loads phantom key] --> I[ZK proof generated] I --> J[CommitRevealVault.reveal] J --> K[Proof verified] J --> L[Quantum preimage checked] J --> M[Nullifier recorded] J --> N[Policy enforced] J --> O[Fresh tokens minted] end G -.->|"Phantom key (QR / NFC / stored)"| H ``` The fundamental privacy property is that the path from **G** to **H** is off-chain and unlinkable. There is no on-chain transaction connecting a specific commit to a specific reveal. The only on-chain evidence is that *some* commitment was made and *some* reveal was executed, with the ZK proof guaranteeing that the reveal corresponds to a valid (but unidentified) commitment. ================================================================ SECTION: Architecture SOURCE: https://docs.specterchain.com/architecture/cosmos-evm-integration ================================================================ # Cosmos-EVM Integration This page explains why Specter requires its own Layer 1 blockchain, how the Cosmos SDK and EVM execution environment work together, and the technical details of the `ghostmint` precompile that makes Ghost Protocol possible. ## The Problem Ghost Protocol's commit/reveal model requires two operations that are impossible on standard EVM chains: 1. **Burn native tokens from a smart contract.** On commit, the user's tokens must be destroyed — not transferred to a contract, not locked, but actually removed from total supply. If tokens are merely locked in a contract, the contract itself becomes a traceable pool (as with Tornado Cash). 2. **Mint native tokens from a smart contract.** On reveal, fresh tokens must be created and sent to the recipient. These tokens must be indistinguishable from any other native tokens — they are not wrapped, not synthetic, and not IOUs. On Ethereum and all standard EVM chains, the native token (ETH) exists outside the EVM's control. There is no opcode to mint or burn ETH. Only `CALL` with a value can transfer it, and the total supply is determined by block rewards and EIP-1559 burns at the protocol level. No smart contract can create or destroy ETH. This limitation means Ghost Protocol cannot operate as a set of smart contracts on Ethereum, Base, Arbitrum, or any other existing EVM chain — at least not for the native token. While ERC-20 tokens can be burned (by sending to a dead address or using a `burn()` function), this does not solve the problem for the native gas token, and creating a wrapped representation reintroduces the traceability problem. ## The Solution: x/ghostmint Specter solves this by running its own Cosmos SDK blockchain with a custom module called **`x/ghostmint`**. This module exposes an **EVM precompile** — a special contract-like interface available at a hardcoded address that executes native Go code instead of EVM bytecode. ### Architecture ```mermaid flowchart TD subgraph EVM["EVM Execution Environment"] Contract["NativeAssetHandler
(Solidity)"] Precompile["ghostmint Precompile
Address: 0x0808"] end subgraph Cosmos["Cosmos SDK"] GhostMintModule["x/ghostmint Module
(Go)"] BankKeeper["x/bank Keeper"] SupplyTracking["Total Supply Tracking"] end Contract -->|"CALL to 0x0808
with mint/burn params"| Precompile Precompile -->|"Go function call"| GhostMintModule GhostMintModule -->|"MintCoins / BurnCoins"| BankKeeper BankKeeper -->|"Update balances + supply"| SupplyTracking ``` ### How It Works #### 1. Precompile Registration At chain genesis, the `x/ghostmint` module registers an EVM precompile at address `0x0808`. This means any EVM contract can issue a `CALL` to `0x0808` with encoded parameters, and instead of executing EVM bytecode, the chain's Go runtime handles the call. The precompile implements the standard Ethereum precompile interface: ```go type GhostMintPrecompile struct { bankKeeper bankkeeper.Keeper authorized map[common.Address]bool } func (p *GhostMintPrecompile) Run(input []byte) ([]byte, error) { // Decode ABI-encoded input // Verify caller is authorized (NativeAssetHandler) // Execute mint or burn via bankKeeper // Return ABI-encoded result } ``` #### 2. Authorization Not every contract can mint or burn native tokens. The precompile maintains an allowlist of authorized callers. In practice, only **`NativeAssetHandler`** is authorized. This contract is deployed at chain initialization, and its address is registered with the precompile. If any other contract attempts to call `0x0808`, the precompile reverts. #### 3. Mint Operation When `CommitRevealVault.reveal()` needs to mint GHOST for the recipient: ``` CommitRevealVault → NativeAssetHandler.mint(recipient, amount) → NativeAssetHandler calls 0x0808 with ABI-encoded mint(recipient, amount) → ghostmint precompile decodes parameters → ghostmint calls bankKeeper.MintCoins(moduleName, sdk.NewCoin("aghost", amount)) → ghostmint calls bankKeeper.SendCoinsFromModuleToAccount(moduleName, recipientAddr, coins) → recipient's balance increases by amount → total supply of aghost increases by amount ``` The minted tokens are real native `aghost` — identical in every way to tokens earned from staking rewards or received in a normal transfer. There is no wrapper, no synthetic representation, and no way to distinguish minted-via-reveal tokens from any other tokens. #### 4. Burn Operation When `CommitRevealVault.commit()` needs to burn GHOST from the committer: ``` CommitRevealVault → NativeAssetHandler.burn(user, amount) → NativeAssetHandler calls 0x0808 with ABI-encoded burn(amount) → ghostmint precompile decodes parameters → ghostmint calls bankKeeper.SendCoinsFromAccountToModule(userAddr, moduleName, coins) → ghostmint calls bankKeeper.BurnCoins(moduleName, coins) → user's balance decreases by amount → total supply of aghost decreases by amount ``` The tokens are permanently destroyed. They do not sit in a contract. They do not exist in any account. The total supply decreases. This is what makes commits untraceable — there is no pool of locked tokens that can be analyzed. ### NativeAssetHandler Contract `NativeAssetHandler` is the choke point between the EVM world and the Cosmos native token world. Its role is strictly limited: ```solidity contract NativeAssetHandler { address constant GHOSTMINT_PRECOMPILE = 0x0000000000000000000000000000000000000808; address public vault; // CommitRevealVault address modifier onlyVault() { require(msg.sender == vault, "unauthorized"); _; } function mint(address recipient, uint256 amount) external onlyVault { // ABI-encode the mint call and invoke the precompile (bool success, ) = GHOSTMINT_PRECOMPILE.call( abi.encodeWithSignature("mint(address,uint256)", recipient, amount) ); require(success, "mint failed"); } function burn(uint256 amount) external payable onlyVault { // ABI-encode the burn call and invoke the precompile (bool success, ) = GHOSTMINT_PRECOMPILE.call( abi.encodeWithSignature("burn(uint256)", amount) ); require(success, "burn failed"); } } ``` This two-layer authorization (only vault can call `NativeAssetHandler`; only `NativeAssetHandler` can call the precompile) ensures that native token minting and burning can only happen through the Ghost Protocol pipeline. ## CometBFT Consensus Specter uses **CometBFT** (formerly Tendermint) as its consensus engine. ### Properties | Property | Value | |----------|-------| | **Block time** | ~5 seconds | | **Finality** | Instant (single-slot finality). Once a block is committed, it cannot be reverted. | | **Fault tolerance** | Tolerates up to 1/3 of validators being Byzantine (offline, malicious, or compromised). | | **Validator set** | Proof-of-Stake. Validators are selected by delegation weight. | ### Why CometBFT Matters for Privacy Instant finality is important for Ghost Protocol: - **No reorgs.** On probabilistic-finality chains (e.g., Ethereum pre-merge), a commitment could be included in a block that is later reorged out. This would require the user to recommit, potentially leaking information through the retry pattern. CometBFT guarantees that once a commitment is in a block, it stays. - **Fast confirmation.** Users know their commit is final in ~5 seconds, not 12+ minutes. This matters for physical card flows where a user taps an NFC card and expects immediate confirmation. ## Cosmos SDK Modules Beyond `x/ghostmint`, Specter uses the standard Cosmos SDK module set: ### x/bank Manages all native token balances. Every `aghost` balance, transfer, mint, and burn flows through `x/bank`. The module maintains: - Per-account balances - Total supply tracking - Denomination metadata (`aghost`, 18 decimals, display denom `GHOST`) ### x/staking Proof-of-Stake validator management: - Validators bond GHOST tokens to participate in consensus. - Delegators stake GHOST to validators and earn rewards. - Unbonding period protects against long-range attacks. ### x/gov On-chain governance: - Token holders submit and vote on proposals. - Proposals can modify chain parameters, upgrade software, or allocate community funds. - Governance controls critical parameters like the `ghostmint` authorized caller list. ### x/distribution Reward distribution: - Block rewards and transaction fees are collected by the distribution module. - Rewards are distributed to validators proportional to their voting power. - Validators share rewards with their delegators minus a commission. ### x/slashing Validator accountability: - Validators who go offline (miss too many blocks) are jailed and slashed. - Validators who double-sign are permanently jailed (tombstoned) and heavily slashed. - Slashing protects the network from unreliable or malicious validators. ### x/evidence Misbehavior processing: - Accepts evidence of double-signing or other protocol violations. - Triggers slashing and jailing through `x/slashing`. ## Address Formats Specter supports two address formats corresponding to its two execution environments: | Format | Example | Usage | |--------|---------|-------| | **Bech32** | `umbra1qpzm7v8rvf3hx5jg6nxm0dcqzwt...` | Cosmos SDK transactions, staking, governance, bank transfers | | **Hex (EIP-55)** | `0x742d35Cc6634C0532925a3b844...` | EVM transactions, smart contract interactions | Both formats refer to the same underlying account. The chain automatically converts between them. A user with a MetaMask wallet interacts via the hex address; the same account's staked GHOST appears under the bech32 address in Cosmos tooling. The bech32 prefix `umbra` is used for all Cosmos-side addresses: | Prefix | Usage | |--------|-------| | `umbra` | Account addresses | | `umbravaloper` | Validator operator addresses | | `umbravalcons` | Validator consensus addresses | | `umbrapub` | Account public keys | | `umbravaloperpub` | Validator operator public keys | | `umbravalconspub` | Validator consensus public keys | ## Why Not Just Deploy on an Existing Chain? A summary of the trade-offs: | Approach | Native mint/burn | Privacy model | Sovereignty | Trade-off | |----------|-----------------|---------------|-------------|-----------| | **Contracts on Ethereum** | No | Pool-based (traceable contract) | None (subject to Ethereum governance) | Cannot implement true commit/reveal for native token | | **L2 rollup** | No (inherits L1 native token) | Pool-based | Partial | Same native token limitation | | **Cosmos appchain (Specter)** | Yes (`ghostmint` precompile) | True mint/burn (no pool) | Full (own validators, governance) | Must bootstrap validator set and liquidity | Specter accepts the cost of running its own validator set and bootstrapping liquidity in exchange for the ability to implement Ghost Protocol correctly — with real native token destruction on commit and creation on reveal, no traceable token pools, and full sovereignty over protocol-level privacy infrastructure. ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/ghost-protocol ================================================================ # Ghost Protocol Reference Ghost Protocol is Specter's core cryptographic primitive — a commit/reveal privacy system built on zero-knowledge proofs, Poseidon hashing, and Groth16 verification over the BN254 elliptic curve. This page is the authoritative reference for every cryptographic component in the protocol. ## Finite Field All arithmetic in Ghost Protocol operates within the BN254 scalar field: ``` p = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` This is a 254-bit prime. Every value that enters a ZK circuit — secrets, nullifiers, token identifiers, policy hashes — must be a valid element of this field (i.e., in the range `[0, p)`). ### Field Reduction When Ghost Protocol derives circuit inputs from Ethereum-native operations (e.g., `keccak256`), the 256-bit hash output is reduced modulo `p` before use: ```solidity uint256 fieldElement = uint256(keccak256(abi.encodePacked(data))) % BN254_FIELD; ``` This reduction is critical. A raw `keccak256` output can exceed `p`, which would cause the circuit to reject the input or — worse — create a soundness gap between on-chain and off-chain computations. ## Poseidon Hash Variants Ghost Protocol uses three Poseidon hash configurations, each tuned to a specific input width. Poseidon is an algebraic hash function designed for efficiency inside arithmetic circuits (R1CS / Groth16), where it is orders of magnitude cheaper than SHA-256 or keccak256. | Variant | Inputs | Circuit Name | Primary Uses | |---------|--------|--------------|-------------| | **Poseidon2** | 2 | `PoseidonT3` | Merkle tree node hashing, nullifier derivation, access tags, token ID derivation | | **Poseidon4** | 4 | `PoseidonT5` | OpenGhost commitments | | **Poseidon7** | 7 | `PoseidonT8` | CommitRevealVault commitments with policy binding | ### Poseidon2 (PoseidonT3) The workhorse of the protocol. Two-input Poseidon is used whenever exactly two field elements need to be hashed together: ``` Poseidon2(a, b) → field element ``` **Applications:** - **Merkle tree nodes:** `node = Poseidon2(leftChild, rightChild)` - **Token ID derivation:** `tokenId = Poseidon2(tokenAddress, 0)` - **Nullifier (inner):** `innerNullifier = Poseidon2(nullifierSecret, commitment)` - **Nullifier (outer):** `nullifier = Poseidon2(innerNullifier, leafIndex)` - **Access tags:** `accessTag = Poseidon2(nullifierSecret, sessionNonce)` **On-chain deployment:** PoseidonT3 (the Poseidon2 implementation) is the only Poseidon variant deployed as an on-chain Solidity contract. A single invocation costs approximately **30,000 gas**. The on-chain contract is used for Merkle tree insertions and nullifier verification. ### Poseidon4 (PoseidonT5) Four-input Poseidon is used for OpenGhost commitments, which store arbitrary encrypted data rather than token values: ``` Poseidon4(secret, nullifierSecret, dataHash, blinding) → commitment ``` This variant is computed **off-chain only** — the commitment is generated in the client and submitted to the contract as a precomputed field element. No PoseidonT5 contract is deployed on-chain. ### Poseidon7 (PoseidonT8) Seven-input Poseidon is used for CommitRevealVault commitments, which bind token value and policy information into a single commitment: ``` Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash) → commitment ``` This is the most complex commitment structure in the protocol, encoding: | Input | Description | |-------|-------------| | `secret` | Random 31-byte secret known only to the key holder | | `nullifierSecret` | Separate random secret used in nullifier derivation | | `tokenId` | `Poseidon2(tokenAddress, 0)` — field-safe token identifier | | `amount` | Token amount in base units (e.g., `aghost` with 18 decimals) | | `blinding` | Random blinding factor for commitment uniqueness | | `policyId` | Identifier of the policy contract (0 for no policy) | | `policyParamsHash` | `keccak256(policyParams) % BN254_FIELD` (0 for no policy) | Like Poseidon4, this variant is computed **off-chain only**. The resulting commitment is a single field element submitted to the contract. ### Why Off-Chain for Higher Variants? Deploying PoseidonT5 or PoseidonT8 as Solidity contracts would be expensive — both in deployment cost and per-call gas. Since the ZK circuit already verifies the commitment preimage, the on-chain contract only needs to store and compare the commitment hash. The commitment is computed in the client (JavaScript/TypeScript), and the ZK proof guarantees it was computed correctly. ## Merkle Tree Ghost Protocol stores all commitments in an append-only Merkle tree that provides set membership proofs without revealing which specific leaf a user is proving knowledge of. ### Tree Parameters | Parameter | Value | |-----------|-------| | **Depth** | 20 | | **Capacity** | 2^20 = 1,048,576 commitments | | **Hash function** | Poseidon2 (PoseidonT3) | | **Zero value** | `keccak256("ghost_protocol") % BN254_FIELD` | | **Root history** | 100-root ring buffer on-chain | ### Structure ``` Root / \ H(0,1) H(2,3) ← Poseidon2(left, right) / \ / \ L0 L1 L2 L3 ← Leaf commitments ``` Each internal node is computed as: ``` node = Poseidon2(leftChild, rightChild) ``` Empty leaves are initialized with the zero value. As new commitments are appended, the tree is updated from the leaf to the root, recalculating only the nodes along the insertion path (20 hashes per insertion). ### On-Chain vs Off-Chain | Component | Location | Purpose | |-----------|----------|---------| | **Root history** | On-chain (ring buffer) | Stores the last 100 Merkle roots for proof verification | | **Current root** | On-chain | The most recent root, updated on each insertion | | **Next leaf index** | On-chain | Counter tracking the next available leaf position | | **Filled subtrees** | On-chain | Array of 20 hashes enabling O(20) root recomputation | | **Full tree** | Off-chain | Complete tree maintained by the relayer and client for proof generation | The ring buffer of 100 historical roots is critical for concurrency. If the tree updates between when a user generates their proof and when the proof is verified on-chain, the proof's root may no longer be the current root — but it will still be valid if it matches any root in the history buffer. ### Insertion When a new commitment is inserted: 1. The commitment is placed at position `nextLeafIndex` 2. The path from the new leaf to the root is recomputed (20 Poseidon2 hashes) 3. The new root is pushed into the ring buffer 4. `nextLeafIndex` is incremented 5. An event is emitted with the commitment and leaf index ```solidity // Simplified on-chain insertion function _insert(uint256 commitment) internal returns (uint256 index) { uint256 currentHash = commitment; for (uint256 i = 0; i < DEPTH; i++) { if (index % 2 == 0) { filledSubtrees[i] = currentHash; currentHash = PoseidonT3.hash([currentHash, zeros[i]]); } else { currentHash = PoseidonT3.hash([filledSubtrees[i], currentHash]); } index /= 2; } roots[currentRootIndex] = currentHash; currentRootIndex = (currentRootIndex + 1) % ROOT_HISTORY_SIZE; } ``` ## Nullifiers Nullifiers prevent double-spending. Each commitment has exactly one valid nullifier, but an observer cannot link a nullifier to its commitment without knowing the secret preimage. ### Two-Level Derivation Ghost Protocol uses a two-level nullifier derivation scheme: ``` Step 1: innerNullifier = Poseidon2(nullifierSecret, commitment) Step 2: nullifier = Poseidon2(innerNullifier, leafIndex) ``` **Why two levels?** - **Level 1** binds the nullifier to the commitment content via the `nullifierSecret`. Without knowing `nullifierSecret`, an observer cannot predict the nullifier for a given commitment. - **Level 2** binds the nullifier to the commitment's position in the tree. This ensures that even if the same commitment value appears in two different leaves (unlikely but possible), each instance has a distinct nullifier. ### Nullifier Registry The `NullifierRegistry` contract maintains a mapping of spent nullifiers: ```solidity mapping(uint256 => bool) public nullifiers; ``` During reveal, the contract checks `nullifiers[nullifier] == false`, then sets it to `true`. Once spent, a nullifier can never be reused. This is the mechanism that prevents double-spending of committed assets. ### Nullifier Privacy The nullifier is computed inside the ZK circuit and output as a public signal. The verifier checks that it was correctly derived from the commitment, but since the commitment itself is hidden behind the Merkle proof, an observer learns only that *some* commitment was spent — not which one. ## Quantum Resistance Layer Ghost Protocol includes an optional quantum defense mechanism that protects committed assets against future quantum computers capable of breaking elliptic curve cryptography. ### Threat Model A sufficiently powerful quantum computer could: 1. Break BN254 elliptic curve operations (Groth16 proofs) 2. Recover secret preimages from Poseidon hashes (algebraic attacks) If this happens, an attacker could forge ZK proofs and drain committed assets. The quantum layer adds a second authentication factor based on hash preimage knowledge, which remains secure against known quantum algorithms (Grover's algorithm provides only quadratic speedup against 256-bit hashes). ### Mechanism At commit time, the user optionally generates a `quantumSecret` (32 random bytes) and stores its keccak256 hash: ``` quantumCommitment = keccak256(quantumSecret) ``` This commitment is stored on-chain alongside the Poseidon commitment. At reveal time, if a quantum commitment exists for the nullified leaf, the user must provide the `quantumSecret` preimage: ```solidity if (quantumCommitments[commitment] != bytes32(0)) { require( keccak256(abi.encodePacked(quantumSecret)) == quantumCommitments[commitment], "Invalid quantum preimage" ); } ``` ### Properties | Property | Detail | |----------|--------| | **Optional** | Users can commit without a quantum secret | | **Hash function** | keccak256 (256-bit, quantum-resistant against preimage attacks) | | **Storage** | On-chain, indexed by commitment | | **Verification** | Preimage check at reveal time | | **Key format** | V4 phantom key format includes the quantum secret | ### Limitations The quantum layer protects against preimage forgery — an attacker cannot reveal without knowing the quantum secret. However, it does not upgrade the ZK proof system itself. Full post-quantum ZK proofs (via SP1 zkVM with post-quantum signature schemes) are planned for a future protocol version. ## Cryptographic Parameter Summary | Parameter | Value / Algorithm | |-----------|-------------------| | Curve | BN254 | | Scalar field | `p = 21888...5617` (254 bits) | | Proof system | Groth16 | | Hash (circuit) | Poseidon (T3, T5, T8 variants) | | Hash (quantum) | keccak256 | | Merkle depth | 20 | | Merkle capacity | ~1,048,576 leaves | | Root buffer | 100 entries | | Nullifier derivation | Two-level Poseidon2 | | Field reduction | `keccak256(x) % p` | | On-chain Poseidon gas | ~30,000 per invocation | ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/commit-reveal-flow ================================================================ # Commit / Reveal Flow This page describes the complete lifecycle of a Ghost Protocol transaction — from commitment creation through on-chain commit, ZK proof generation, and on-chain reveal. Every step is covered, including the cryptographic operations, smart contract logic, and data flow. ## Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ COMMIT PHASE │ │ │ │ 1. Generate secrets (secret, nullifierSecret, blinding) │ │ 2. Derive tokenId = Poseidon2(tokenAddress, 0) │ │ 3. Compute commitment = Poseidon7(secret, nullifierSecret, │ │ tokenId, amount, blinding, policyId, policyParamsHash) │ │ 4. Save phantom key (PNG/PDF/QR) │ │ 5. Submit commitment on-chain → tokens burned │ │ │ ├──────────────────── time passes ────────────────────────────────┤ │ │ │ REVEAL PHASE │ │ │ │ 6. Generate Merkle proof (off-chain tree) │ │ 7. Compute nullifier = Poseidon2(Poseidon2(nullifierSecret, │ │ commitment), leafIndex) │ │ 8. Generate Groth16 proof (WASM/native prover) │ │ 9. Submit proof on-chain → tokens minted to recipient │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ## Commitment Structure The CommitRevealVault commitment encodes seven field elements into a single Poseidon hash: ``` commitment = Poseidon7( secret, // Random 31-byte secret nullifierSecret, // Random 31-byte nullifier secret tokenId, // Poseidon2(tokenAddress, 0) amount, // Token amount in base units blinding, // Random blinding factor policyId, // Policy contract identifier (0 = none) policyParamsHash // keccak256(policyParams) % BN254_FIELD (0 = none) ) ``` ### Token ID Derivation Ethereum addresses are 160 bits, which fit comfortably within the BN254 scalar field. However, to maintain a consistent hashing scheme and avoid domain separation issues, token addresses are converted to field-safe token IDs: ``` tokenId = Poseidon2(uint256(tokenAddress), 0) ``` The second input is fixed at `0`, making this a one-to-one mapping from addresses to field elements. The on-chain contract stores the mapping and verifies it during both commit and reveal: ```solidity mapping(uint256 => address) public tokenIdToAddress; ``` ## Commit Phase (On-Chain) ### Inputs The user submits the following to the `commit()` function: | Parameter | Type | Description | |-----------|------|-------------| | `commitment` | `uint256` | Poseidon7 hash of the preimage | | `amount` | `uint256` | Token amount in base units | | `tokenAddress` | `address` | ERC-20 token address (or sentinel for native GHOST) | | `quantumCommitment` | `bytes32` | `keccak256(quantumSecret)` or `0x0` if unused | | `policyId` | `uint256` | Policy contract ID (0 for no policy) | | `policyParamsHash` | `uint256` | `keccak256(policyParams) % BN254_FIELD` (0 for no policy) | ### Validation Steps The contract executes the following checks in order: ``` 1. VALIDATE FIELD ELEMENT └─ require(commitment < BN254_FIELD) └─ require(commitment != 0) 2. VALIDATE AMOUNT └─ require(amount > 0) 3. CHECK ASSET GUARD └─ AssetGuard.checkCommit(tokenAddress, amount, msg.sender) └─ Enforces token allowlists and per-token limits 4. RATE LIMIT └─ require(block.timestamp >= lastCommitTime[msg.sender] + 5 seconds) └─ Prevents spam and front-running attacks └─ Updates lastCommitTime[msg.sender] 5. BURN TOKENS └─ For native GHOST: NativeAssetHandler.burn(amount) via ghostmint precompile └─ For ERC-20: transferFrom(msg.sender, address(this), amount) then burn └─ Tokens are destroyed — they leave circulation entirely 6. RECORD COMMITMENT └─ CommitmentTree.insert(commitment) → returns leafIndex └─ Merkle tree updated, new root pushed to history buffer 7. STORE QUANTUM COMMITMENT (if provided) └─ quantumCommitments[commitment] = quantumCommitment 8. STORE POLICY BINDING (if provided) └─ commitmentPolicies[commitment] = PolicyBinding(policyId, policyParamsHash) 9. EMIT EVENT └─ emit Committed(commitment, leafIndex, tokenId, amount, timestamp) ``` ### Rate Limiting The 5-second cooldown per sender address prevents: - **Transaction spam:** Flooding the Merkle tree with commitments - **Front-running:** Rapidly committing/revealing to exploit timing - **Denial of service:** Exhausting the tree's 1M leaf capacity ```solidity mapping(address => uint256) public lastCommitTime; modifier rateLimited() { require( block.timestamp >= lastCommitTime[msg.sender] + COMMIT_COOLDOWN, "Rate limited" ); lastCommitTime[msg.sender] = block.timestamp; _; } ``` ### Token Burning When tokens are committed, they are **burned** — not locked or escrowed. This is the fundamental mechanism that severs the on-chain link between commit and reveal: ```solidity // Native GHOST token function _burnNative(uint256 amount) internal { // Calls the ghostmint precompile (0x0808) via NativeAssetHandler // The precompile instructs x/bank to destroy aghost from the contract's account nativeAssetHandler.burn{value: amount}(); } // ERC-20 tokens function _burnERC20(address token, uint256 amount) internal { IERC20(token).transferFrom(msg.sender, address(this), amount); IERC20Burnable(token).burn(amount); } ``` ## Proof Generation (Off-Chain) Between commit and reveal, the user generates a Groth16 zero-knowledge proof. This happens entirely off-chain (in the browser via WASM, or via a native prover). ### Circuit Inputs The ZK circuit takes the following inputs: **Private inputs** (known only to the prover): | Input | Description | |-------|-------------| | `secret` | The random secret from the commitment | | `nullifierSecret` | The nullifier secret from the commitment | | `tokenId` | Token ID (verified against public input) | | `amount` | Full committed amount | | `blinding` | Blinding factor | | `policyId` | Policy ID (verified against public input) | | `policyParamsHash` | Policy params hash (verified against public input) | | `leafIndex` | Position of the commitment in the Merkle tree | | `pathElements[20]` | Sibling hashes along the Merkle path | | `pathIndices[20]` | Left/right indicators for the Merkle path | **Public inputs** (visible to everyone, verified on-chain): | Index | Input | Description | |-------|-------|-------------| | 0 | `root` | Merkle root the proof is generated against | | 1 | `nullifier` | Unique nullifier derived from the commitment | | 2 | `withdrawAmount` | Amount to withdraw (may be less than committed) | | 3 | `recipient` | Address receiving the revealed tokens | | 4 | `changeCommitment` | Commitment for the remaining balance (or 0) | | 5 | `tokenId` | Token ID being withdrawn | | 6 | `policyId` | Policy contract ID | | 7 | `policyParamsHash` | Hash of policy parameters | ### Circuit Constraints The circuit enforces the following: ``` 1. COMMITMENT INTEGRITY └─ Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash) == computed commitment 2. MERKLE MEMBERSHIP └─ MerkleProof(commitment, leafIndex, pathElements, pathIndices) == root 3. NULLIFIER DERIVATION └─ innerNullifier = Poseidon2(nullifierSecret, commitment) └─ nullifier = Poseidon2(innerNullifier, leafIndex) 4. AMOUNT BALANCE └─ withdrawAmount <= amount └─ withdrawAmount > 0 5. CHANGE COMMITMENT (if partial withdrawal) └─ changeAmount = amount - withdrawAmount └─ changeCommitment = Poseidon7(secret, nullifierSecret, tokenId, changeAmount, newBlinding, policyId, policyParamsHash) 6. TOKEN ID BINDING └─ tokenId == public tokenId input 7. POLICY BINDING └─ policyId == public policyId input └─ policyParamsHash == public policyParamsHash input ``` ## Reveal Phase (On-Chain) ### Inputs The user (or relayer) submits the following to the `reveal()` function: | Parameter | Type | Description | |-----------|------|-------------| | `proof` | `uint256[8]` | Groth16 proof (a, b, c points) | | `root` | `uint256` | Merkle root used in proof generation | | `nullifier` | `uint256` | Computed nullifier | | `withdrawAmount` | `uint256` | Amount to withdraw | | `recipient` | `address` | Receiving address | | `changeCommitment` | `uint256` | Change commitment (0 for full withdrawal) | | `tokenId` | `uint256` | Token ID | | `policyId` | `uint256` | Policy contract ID | | `policyParamsHash` | `uint256` | Policy parameters hash | | `policyParams` | `bytes` | Raw policy parameters (for policy contract) | | `quantumSecret` | `bytes` | Quantum secret preimage (if quantum-protected) | ### Verification Steps ``` 1. VERIFY TOKEN ID └─ require(tokenIdToAddress[tokenId] != address(0)) └─ tokenAddress = tokenIdToAddress[tokenId] 2. VERIFY ROOT IN HISTORY └─ require(isKnownRoot(root)) └─ Checks the 100-entry ring buffer of historical roots └─ Allows proofs generated against recent (but not current) roots 3. CHECK NULLIFIER NOT SPENT └─ require(!NullifierRegistry.isSpent(nullifier)) └─ If spent, the commitment has already been revealed (double-spend attempt) 4. VERIFY GROTH16 PROOF └─ publicInputs = [root, nullifier, withdrawAmount, recipient, changeCommitment, tokenId, policyId, policyParamsHash] └─ require(ProofVerifier.verify(proof, publicInputs)) └─ The proof cryptographically guarantees all circuit constraints hold 5. ENFORCE POLICY └─ if (policyId != 0): │ policyContract = PolicyRegistry.getPolicy(policyId) │ (bool success, bytes memory result) = policyContract.staticcall{gas: 100000}( │ abi.encodeCall(IRevealPolicy.validate, │ (commitment, nullifier, recipient, withdrawAmount, tokenAddress, policyParams)) │ ) │ require(success && abi.decode(result, (bool))) └─ Policy cannot modify state (staticcall) and has a 100k gas cap 6. VERIFY QUANTUM PREIMAGE (if applicable) └─ if (quantumCommitments[commitment] != bytes32(0)): │ require(keccak256(abi.encodePacked(quantumSecret)) │ == quantumCommitments[commitment]) └─ Provides post-quantum authentication 7. MARK NULLIFIER SPENT └─ NullifierRegistry.markSpent(nullifier) 8. MINT TOKENS TO RECIPIENT └─ For native GHOST: NativeAssetHandler.mint(recipient, withdrawAmount) └─ For ERC-20: ERC20.mint(recipient, withdrawAmount) └─ Fresh tokens are created — no link to the burned tokens 9. INSERT CHANGE COMMITMENT (if partial withdrawal) └─ if (changeCommitment != 0): │ CommitmentTree.insert(changeCommitment) │ emit Committed(changeCommitment, newLeafIndex, tokenId, │ amount - withdrawAmount, timestamp) └─ The change commitment is a new leaf in the Merkle tree ``` ### The 8 Public Inputs The public inputs array is the bridge between the off-chain proof and the on-chain verification. Each element serves a specific purpose: ```solidity uint256[8] memory publicInputs = [ root, // [0] Which Merkle root the proof was generated against nullifier, // [1] Unique spend tag — prevents double-reveal withdrawAmount, // [2] How many tokens to mint to recipient uint256(uint160(recipient)), // [3] Who receives the tokens changeCommitment, // [4] Commitment for leftover balance (0 = full) tokenId, // [5] Which token is being withdrawn policyId, // [6] Which policy governs this reveal policyParamsHash // [7] Hash of the policy parameters ]; ``` All 8 values are verified by the Groth16 proof. An attacker cannot modify any of these values without invalidating the proof. ## Partial Withdrawals Ghost Protocol supports partial withdrawals — revealing less than the full committed amount and generating a new commitment for the remainder. ### How It Works When `withdrawAmount < amount`, the circuit computes a **change commitment**: ``` changeAmount = amount - withdrawAmount changeCommitment = Poseidon7( secret, // Same secret as original nullifierSecret, // Same nullifier secret as original tokenId, // Same token changeAmount, // Reduced amount newBlinding, // NEW random blinding factor policyId, // Same policy (carried forward) policyParamsHash // Same policy params (carried forward) ) ``` ### What Stays the Same | Field | Carried Forward? | Reason | |-------|-----------------|--------| | `secret` | Yes | The same phantom key controls the change | | `nullifierSecret` | Yes | Needed for the change commitment's future nullifier | | `tokenId` | Yes | Change must be the same token | | `policyId` | Yes | Policy is inescapable — cannot be removed by partial withdrawal | | `policyParamsHash` | Yes | Policy parameters are bound to the commitment | ### What Changes | Field | Changed? | Reason | |-------|----------|--------| | `amount` | Yes | Reduced by `withdrawAmount` | | `blinding` | Yes | New random blinding prevents linkability | | `leafIndex` | Yes | Change commitment gets a new position in the tree | ### Change Commitment Lifecycle ``` Original commitment (leaf #42, 100 GHOST) │ ▼ Partial reveal: withdraw 30 GHOST │ ├──► 30 GHOST minted to recipient │ └──► Change commitment (leaf #1057, 70 GHOST) inserted into Merkle tree same secret, nullifierSecret, tokenId, policy new blinding, new leafIndex │ ▼ Later: full reveal of 70 GHOST │ └──► 70 GHOST minted to recipient No change commitment (full withdrawal) ``` ### Blinding Uniqueness The new blinding factor for the change commitment is critical. If the same blinding were reused, the change commitment would be identical to a predictable transformation of the original, potentially allowing an observer to link the original commit to the partial reveal. A fresh random blinding ensures the change commitment is indistinguishable from any other commitment in the tree. ## Transaction Flow Diagram ``` User (Browser/Mobile) Specter Chain ───────────────────── ───────────── │ │ │ 1. Generate secrets │ │ 2. Compute commitment │ │ 3. Save phantom key │ │ │ │──── commit(commitment, amount) ────►│ │ │ 4. Validate inputs │ │ 5. Burn tokens │ │ 6. Insert into Merkle tree │ │ 7. Store quantum commitment │◄──── tx receipt + leafIndex ────────│ 8. Store policy binding │ │ 9. Emit event │ │ │ ... time passes ... │ │ │ │ 10. Fetch Merkle proof │ │ 11. Generate ZK proof │ │ │ │──── reveal(proof, inputs) ─────────►│ │ │ 12. Verify root │ │ 13. Check nullifier │ │ 14. Verify Groth16 proof │ │ 15. Enforce policy │ │ 16. Verify quantum preimage │ │ 17. Mark nullifier spent │ │ 18. Mint tokens │◄──── tx receipt ───────────────────│ 19. Insert change (if partial) │ │ ``` ## Error Conditions | Error | Cause | Resolution | |-------|-------|------------| | `"Invalid field element"` | Commitment >= BN254_FIELD or == 0 | Recompute commitment with valid inputs | | `"Rate limited"` | Less than 5 seconds since last commit | Wait and retry | | `"Asset not allowed"` | Token not in AssetGuard allowlist | Use an allowed token | | `"Root not known"` | Proof generated against a root older than 100 insertions | Regenerate proof with current tree state | | `"Nullifier already spent"` | Commitment has already been revealed | Cannot reveal twice — this is by design | | `"Invalid proof"` | Groth16 verification failed | Regenerate proof (likely stale inputs) | | `"Policy validation failed"` | Policy contract rejected the reveal | Check policy requirements (time, recipient, etc.) | | `"Invalid quantum preimage"` | Quantum secret does not match stored hash | Provide correct quantum secret from phantom key | ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/phantom-keys ================================================================ # Phantom Keys A phantom key is the sole proof of ownership for a Ghost Protocol commitment. It contains all the secret values needed to generate a ZK proof and reveal committed assets. There is no recovery mechanism, no admin override, and no account abstraction — if you lose the key, the committed assets are permanently inaccessible. ## Core Principle > **The key IS the proof of ownership.** A phantom key is not an access credential for a remote system. It is the cryptographic material itself. The secrets inside the key are the private inputs to the ZK circuit. Without them, it is computationally infeasible to generate a valid proof — even with unlimited access to the smart contracts and Merkle tree data. There is no "forgot your password" flow. There is no multisig recovery. There is no admin key. This is a deliberate design choice: the same property that makes Ghost Protocol private (no on-chain link between commit and reveal) also makes phantom keys unrecoverable. ## Key Formats ### ghostchain-v2 (CommitRevealVault) The standard format for token commitments through the CommitRevealVault: ```json { "version": "ghostchain-v2", "token": "0x0000000000000000000000000000000000000000", "seed": "a1b2c3d4e5f67890a1b2c3d4e5f67890", "secret": "1234567890abcdef...", "nullifierSecret": "fedcba0987654321...", "blinding": "1122334455667788...", "amount": "1000000000000000000", "commitment": "8765432109876543...", "leafIndex": 42, "tokenIdHash": "abcdef1234567890...", "quantumSecret": "deadbeef01234567...", "policyId": "0", "policyParamsHash": "0", "policyParams": "" } ``` **Field descriptions:** | Field | Type | Description | |-------|------|-------------| | `version` | string | Format identifier. Always `"ghostchain-v2"` | | `token` | string | Token contract address (zero address for native GHOST) | | `seed` | string | 16-byte random seed used during key generation (hex) | | `secret` | string | 31-byte random secret — primary circuit input (field element, decimal) | | `nullifierSecret` | string | 31-byte random secret for nullifier derivation (field element, decimal) | | `blinding` | string | Random blinding factor for commitment uniqueness (field element, decimal) | | `amount` | string | Token amount in base units (e.g., `10^18` for 1 GHOST) | | `commitment` | string | Poseidon7 hash of all preimage values (field element, decimal) | | `leafIndex` | number | Position of the commitment in the Merkle tree | | `tokenIdHash` | string | `Poseidon2(tokenAddress, 0)` (field element, decimal) | | `quantumSecret` | string | 32-byte quantum secret for post-quantum protection (hex). Empty string if unused | | `policyId` | string | Policy contract identifier (field element, decimal). `"0"` for no policy | | `policyParamsHash` | string | `keccak256(policyParams) % BN254_FIELD` (field element, decimal). `"0"` for no policy | | `policyParams` | string | ABI-encoded policy parameters (hex). Empty string for no policy | ### open-ghost-persistent-v1 (PersistentKeyVault) The format for persistent phantom keys, which can be accessed multiple times without spending: ```json { "version": "open-ghost-persistent-v1", "persistent": true, "contentType": "application/json", "encryptedSecret": "0xaabbccdd...", "encKeyPartA": "0x11223344...", "keyVaultId": "9876543210...", "revokePolicy": "BEARER", "secret": "1234567890abcdef...", "nullifierSecret": "fedcba0987654321...", "dataHash": "5566778899aabbcc...", "blinding": "1122334455667788...", "commitment": "8765432109876543...", "leafIndex": 107, "createdAt": "2026-01-15T08:30:00.000Z" } ``` **Field descriptions:** | Field | Type | Description | |-------|------|-------------| | `version` | string | Format identifier. Always `"open-ghost-persistent-v1"` | | `persistent` | boolean | Always `true` — distinguishes from one-time keys | | `contentType` | string | MIME type of the encrypted payload | | `encryptedSecret` | string | AES-encrypted payload (hex) | | `encKeyPartA` | string | First half of the AES key — stored in this file | | `keyVaultId` | string | On-chain identifier for the key's vault entry | | `revokePolicy` | string | `"BEARER"` or `"ISSUER_ONLY"` — who can revoke | | `secret` | string | ZK circuit secret (field element, decimal) | | `nullifierSecret` | string | Nullifier derivation secret (field element, decimal) | | `dataHash` | string | Hash of the encrypted data (field element, decimal) | | `blinding` | string | Blinding factor (field element, decimal) | | `commitment` | string | Poseidon4 hash of the preimage (field element, decimal) | | `leafIndex` | number | Position in the Merkle tree | | `createdAt` | string | ISO 8601 timestamp of key creation | ## Export Formats Phantom keys must be durably stored outside the browser. Ghost Protocol supports multiple export formats, each optimized for different use cases. ### PNG Image The primary export format. The phantom key JSON is embedded in the PNG file's metadata using steganographic encoding: ``` ┌──────────────────────────────────┐ │ │ │ Visual card design │ │ (amount, token, timestamp) │ │ │ │ ┌────────────────────────────┐ │ │ │ Embedded metadata: │ │ │ │ - Full phantom key JSON │ │ │ │ - Key version │ │ │ │ - Integrity checksum │ │ │ └────────────────────────────┘ │ │ │ └──────────────────────────────────┘ ``` **Properties:** - File appears as a normal image — does not look like a cryptographic key - Can be saved to camera roll, AirDrop, cloud storage - Integrity checksum verifies the key has not been corrupted - The import flow reads the embedded metadata from the PNG to reconstruct the key ### PDF Voucher A printable document containing the phantom key as both human-readable data and machine-readable QR codes: - Full phantom key JSON encoded as a QR code - Human-readable fields: amount, token, commitment (truncated), creation date - Instructions for redemption - Suitable for physical distribution (gift cards, paper wallets) ### QR Code A standalone QR code encoding the phantom key JSON: - Used for screen-to-screen transfers - Displayed temporarily in the app for scanning - Not recommended for long-term storage (no visual context, easy to confuse with other QR codes) ### NFC (NTAG 424 DNA) Phantom keys can be written to NFC hardware cards using the NTAG 424 DNA chip: ``` ┌─────────────────────────────┐ │ NTAG 424 DNA Card │ │ │ │ NDEF Record: │ │ ├── Type: application/json│ │ ├── Payload: phantom key │ │ └── SUN authentication │ │ │ │ Hardware features: │ │ ├── Tamper detection │ │ ├── AES-128 encryption │ │ ├── Rolling SUN counter │ │ └── One-tap NFC read │ │ │ └─────────────────────────────┘ ``` **NTAG 424 DNA features:** | Feature | Description | |---------|-------------| | **SUN (Secure Unique NFC)** | Each tap generates a unique authentication code, preventing relay attacks | | **AES-128** | On-chip encryption protects the stored key data | | **Tap counter** | Monotonic counter increments on each read — detects cloning attempts | | **Tamper detect** | Physical tamper loop can detect if the card has been opened | NFC cards turn phantom keys into physical bearer instruments — the person holding the card can reveal the committed assets by tapping their phone. ### Numeric Code A human-typeable representation of the phantom key, designed for phone dictation or manual entry: ``` Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX └──────────── V4 with quantum secret ────────────┘ ``` The V4 numeric format includes the quantum secret, ensuring that manually entered keys retain full quantum protection. The code uses a restricted character set (digits only) with checksum segments for error detection. ## Security Properties ### No Recovery | Scenario | Outcome | |----------|---------| | Lost phantom key (all copies) | Assets permanently locked — no one can generate the ZK proof | | Corrupted phantom key | Assets permanently locked — incorrect secrets produce invalid proofs | | Stolen phantom key | Thief can reveal the assets — the key is the only authentication | | Copied phantom key | First to reveal wins — nullifier prevents second reveal | | Forgotten password | N/A — phantom keys are not password-protected by default | ### Bearer Property Phantom keys are bearer instruments. Whoever possesses the key can reveal the assets. This is analogous to physical cash: - **Possession = ownership.** There is no identity verification, no KYC check, no wallet signature required at reveal time. The ZK proof itself is the authentication. - **Transferability.** Sending someone a phantom key (via AirDrop, email, NFC tap) transfers the ability to reveal. This is how Ghost Protocol enables cash-like digital assets. - **Risk.** If a key is intercepted, the interceptor can reveal before the intended recipient. For high-value transfers, policies (e.g., `DestinationRestriction`) can mitigate this risk. ### Commitment Binding The phantom key is cryptographically bound to its commitment. Every field in the key is either a direct input to the Poseidon hash or derived from one: ``` commitment = Poseidon7( key.secret, key.nullifierSecret, key.tokenIdHash, ← derived from key.token key.amount, key.blinding, key.policyId, key.policyParamsHash ) ``` If any field is modified, the resulting commitment will not match the on-chain commitment, and the Merkle proof will fail. Phantom keys cannot be forged or tampered with. ## Key Lifecycle ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Generate │───►│ Export │───►│ Store │───►│ Import │ │ │ │ │ │ │ │ │ │ secrets │ │ PNG/PDF/ │ │ camera │ │ scan/ │ │ compute │ │ QR/NFC/ │ │ roll, │ │ upload/ │ │ commit │ │ numeric │ │ cloud, │ │ tap NFC │ └──────────┘ └──────────┘ │ NFC card │ └────┬─────┘ └──────────┘ │ ▼ ┌──────────┐ │ Reveal │ │ │ │ generate │ │ ZK proof │ │ submit │ └──────────┘ ``` 1. **Generate:** Random secrets are generated client-side. The commitment is computed. The phantom key JSON is assembled. 2. **Export:** The key is rendered into one or more export formats (PNG, PDF, QR, NFC, numeric). 3. **Store:** The user saves the exported key. This is the critical step — the key must survive browser closure. 4. **Import:** When ready to reveal, the user imports the key by scanning, uploading, or tapping. 5. **Reveal:** The imported key provides all the private inputs needed to generate the ZK proof and submit the reveal transaction. ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/programmable-policies ================================================================ # Programmable Policies Programmable policies are reveal-time enforcement contracts that govern how, when, and to whom committed assets can be revealed. Policies are cryptographically bound to commitments at commit time and cannot be removed, changed, or bypassed after the fact — not by the user, not by an admin, and not by a partial withdrawal. ## Design Principles 1. **Bound at commit time.** The `policyId` and `policyParamsHash` are two of the seven inputs to the Poseidon7 commitment hash. They are inside the ZK circuit. 2. **Tamper-proof.** Changing the policy would change the commitment, which would invalidate the Merkle proof. The ZK circuit enforces that the public `policyId` and `policyParamsHash` match the committed values. 3. **Inescapable.** Change commitments (from partial withdrawals) carry the same `policyId` and `policyParamsHash` forward. You cannot split your way out of a policy. 4. **Stateless enforcement.** Policy contracts are called via `staticcall` — they cannot modify state. They receive inputs and return a boolean. 5. **Gas-capped.** Policy execution is limited to 100,000 gas to prevent denial-of-service via expensive policy logic. 6. **Permissionless.** Anyone can deploy a policy contract. The PolicyRegistry is informational only. ## How Policy Binding Works ### At Commit Time When a user creates a commitment with a policy, the policy information becomes part of the commitment preimage: ``` commitment = Poseidon7( secret, nullifierSecret, tokenId, amount, blinding, policyId, ← 6th input policyParamsHash ← 7th input ) ``` The `policyParamsHash` is derived from the raw policy parameters: ```solidity uint256 policyParamsHash = uint256(keccak256(abi.encodePacked(policyParams))) % BN254_FIELD; ``` The field reduction (`% BN254_FIELD`) is necessary because `keccak256` produces a 256-bit output that may exceed the BN254 scalar field prime `p`. ### Inside the ZK Circuit The circuit constrains: ``` // The public policyId must match the committed policyId signal input policyId; // private signal input pub_policyId; // public policyId === pub_policyId; // The public policyParamsHash must match the committed policyParamsHash signal input policyParamsHash; // private signal input pub_policyParamsHash; // public policyParamsHash === pub_policyParamsHash; ``` This means the on-chain verifier receives `policyId` and `policyParamsHash` as public inputs and the ZK proof guarantees they match the values inside the commitment. An attacker cannot submit a different policy ID or different parameters — the proof would be invalid. ### At Reveal Time After the Groth16 proof is verified, the contract enforces the policy: ```solidity if (policyId != 0) { address policyContract = policyRegistry.getPolicy(policyId); require(policyContract != address(0), "Policy not found"); (bool success, bytes memory result) = policyContract.staticcall{gas: 100000}( abi.encodeCall( IRevealPolicy.validate, (commitment, nullifier, recipient, amount, tokenAddress, policyParams) ) ); require(success, "Policy call failed"); require(abi.decode(result, (bool)), "Policy validation failed"); } ``` ### Parameter Integrity The contract verifies that the `policyParams` submitted at reveal time match the `policyParamsHash` that was committed: ```solidity uint256 computedHash = uint256(keccak256(abi.encodePacked(policyParams))) % BN254_FIELD; require(computedHash == policyParamsHash, "Policy params mismatch"); ``` This prevents an attacker from substituting different policy parameters at reveal time. The parameters are locked at commit time via the hash, and the hash is locked inside the ZK proof. ## IRevealPolicy Interface Every policy contract must implement the `IRevealPolicy` interface: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IRevealPolicy { /** * @notice Validates whether a reveal should be allowed * @param commitment The original commitment being revealed * @param nullifier The nullifier for this reveal * @param recipient The address that will receive the tokens * @param amount The amount being withdrawn * @param token The token address being withdrawn * @param policyParams ABI-encoded parameters specific to this policy * @return valid True if the reveal is allowed, false to reject */ function validate( uint256 commitment, uint256 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view returns (bool valid); } ``` **Key constraints:** | Constraint | Mechanism | Reason | |------------|-----------|--------| | Read-only | `staticcall` | Policy cannot modify state, mint tokens, or trigger side effects | | Gas limit | 100,000 gas cap | Prevents DoS via expensive computation | | Return type | `bool` | Simple accept/reject — no partial amounts or redirects | | Parameters | Verified by hash | Cannot be tampered with at reveal time | ## Reference Policies Specter ships three reference policy implementations that cover common use cases. ### TimelockExpiry Restricts reveals to a specific time window. ```solidity contract TimelockExpiry is IRevealPolicy { function validate( uint256, /* commitment */ uint256, /* nullifier */ address, /* recipient */ uint256, /* amount */ address, /* token */ bytes calldata policyParams ) external view override returns (bool) { (uint256 notBefore, uint256 notAfter) = abi.decode(policyParams, (uint256, uint256)); if (notBefore > 0 && block.timestamp < notBefore) { return false; // Too early } if (notAfter > 0 && block.timestamp > notAfter) { return false; // Too late (expired) } return true; } } ``` **Policy parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `notBefore` | `uint256` | Earliest allowed reveal time (Unix timestamp). 0 = no lower bound | | `notAfter` | `uint256` | Latest allowed reveal time (Unix timestamp). 0 = no upper bound | **Use cases:** - Vesting schedules: `notBefore = vestingCliffTimestamp, notAfter = 0` - Expiring vouchers: `notBefore = 0, notAfter = expirationTimestamp` - Time-boxed access: `notBefore = startTime, notAfter = endTime` ### DestinationRestriction Restricts which addresses can receive the revealed tokens. ```solidity contract DestinationRestriction is IRevealPolicy { function validate( uint256, /* commitment */ uint256, /* nullifier */ address recipient, uint256, /* amount */ address, /* token */ bytes calldata policyParams ) external view override returns (bool) { // Check encoding mode: single address or Merkle allowlist if (policyParams.length == 32) { // Single address mode address allowed = abi.decode(policyParams, (address)); return recipient == allowed; } else { // Merkle allowlist mode (bytes32 merkleRoot, bytes32[] memory proof) = abi.decode(policyParams, (bytes32, bytes32[])); bytes32 leaf = keccak256(abi.encodePacked(recipient)); return _verifyMerkleProof(proof, merkleRoot, leaf); } } function _verifyMerkleProof( bytes32[] memory proof, bytes32 root, bytes32 leaf ) internal pure returns (bool) { bytes32 computedHash = leaf; for (uint256 i = 0; i < proof.length; i++) { if (computedHash <= proof[i]) { computedHash = keccak256(abi.encodePacked(computedHash, proof[i])); } else { computedHash = keccak256(abi.encodePacked(proof[i], computedHash)); } } return computedHash == root; } } ``` **Two modes:** | Mode | Parameter Encoding | Description | |------|-------------------|-------------| | **Single address** | `abi.encode(address)` (32 bytes) | Only the specified address can receive tokens | | **Merkle allowlist** | `abi.encode(bytes32, bytes32[])` | Recipient must be in the allowlist Merkle tree | **Use cases:** - Payroll: commit with `DestinationRestriction(employeeAddress)` — only the employee can reveal - Compliance: commit with a Merkle root of KYC-verified addresses - Escrow: commit with the counterparty's address, release only to them ### ThresholdWitness Requires M-of-N signatures from designated witnesses before reveal is allowed. ```solidity contract ThresholdWitness is IRevealPolicy { function validate( uint256, /* commitment */ uint256 nullifier, address, /* recipient */ uint256, /* amount */ address, /* token */ bytes calldata policyParams ) external view override returns (bool) { ( uint256 threshold, address[] memory witnesses, bytes[] memory signatures ) = abi.decode(policyParams, (uint256, address[], bytes[])); require(signatures.length >= threshold, "Not enough signatures"); bytes32 message = keccak256(abi.encodePacked( "SPECTER_WITNESS", nullifier )); bytes32 ethSignedHash = keccak256(abi.encodePacked( "\x19Ethereum Signed Message:\n32", message )); uint256 validCount = 0; for (uint256 i = 0; i < signatures.length; i++) { address signer = _recover(ethSignedHash, signatures[i]); if (_isWitness(signer, witnesses)) { validCount++; } } return validCount >= threshold; } } ``` **Policy parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `threshold` | `uint256` | Minimum number of valid signatures required | | `witnesses` | `address[]` | Addresses of authorized witnesses | | `signatures` | `bytes[]` | EIP-191 signatures over the nullifier | **How it works:** 1. At commit time, the `policyParams` encode the `threshold` and `witnesses` list. The `signatures` array is empty (or a placeholder) — only the hash of the full params matters at commit time. 2. Before reveal, the revealer contacts M witnesses and asks them to sign the nullifier. 3. At reveal time, the revealer submits the signed messages as `policyParams`. The hash must still match `policyParamsHash`. **Important:** Because `policyParamsHash` is committed at commit time, the witness list and threshold are fixed. However, the signatures are part of the params and change per reveal. This requires careful parameter construction: the committed hash must cover the threshold and witness list but use a placeholder for signatures. The policy contract then extracts and verifies accordingly. **Use cases:** - Multi-sig custody: 2-of-3 witnesses must approve any withdrawal - Escrow release: arbiter + buyer must both sign - Corporate treasury: board members must co-sign ## PolicyRegistry The `PolicyRegistry` is an on-chain directory that maps policy IDs to contract addresses: ```solidity contract PolicyRegistry { mapping(uint256 => address) public policies; uint256 public nextPolicyId; event PolicyRegistered(uint256 indexed policyId, address indexed policyContract); function registerPolicy(address policyContract) external returns (uint256 policyId) { policyId = nextPolicyId++; policies[policyId] = policyContract; emit PolicyRegistered(policyId, policyContract); } function getPolicy(uint256 policyId) external view returns (address) { return policies[policyId]; } } ``` **Properties:** | Property | Detail | |----------|--------| | **Permissionless** | Anyone can register a policy contract | | **Informational** | The registry is for discoverability, not access control | | **Immutable mappings** | Once registered, a policy ID always points to the same contract | | **No upgrades** | Policy contracts are not upgradeable — the commitment hash locks the policy | ## Writing a Custom Policy ### Step 1: Define Your Validation Logic Decide what conditions must be met at reveal time. Your policy contract has access to: - `commitment` — the commitment being revealed - `nullifier` — the unique spend tag - `recipient` — who will receive the tokens - `amount` — how many tokens are being withdrawn - `token` — which token address - `policyParams` — arbitrary bytes you define (ABI-encoded) ### Step 2: Implement IRevealPolicy ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract MyCustomPolicy is IRevealPolicy { function validate( uint256 commitment, uint256 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool) { // Decode your parameters (uint256 maxAmount, address requiredToken) = abi.decode(policyParams, (uint256, address)); // Enforce your rules if (amount > maxAmount) return false; if (token != requiredToken) return false; return true; } } ``` ### Step 3: Test Within the Gas Cap Your `validate` function must execute within **100,000 gas**. Common operations and their approximate costs: | Operation | Gas Cost | |-----------|----------| | `abi.decode` (2 params) | ~500 | | Integer comparison | ~3 | | Address comparison | ~3 | | `keccak256` (32 bytes) | ~36 | | `ecrecover` | ~3,000 | | Storage read (`SLOAD`) | ~2,100 (cold) / ~100 (warm) | | Merkle proof (depth 10) | ~5,000 | **Warning:** Avoid external calls from within your policy. Since the policy is already called via `staticcall`, nested calls consume gas from the 100k cap and can fail unpredictably. ### Step 4: Deploy and Register ```bash # Deploy the policy contract forge create src/policies/MyCustomPolicy.sol:MyCustomPolicy --rpc-url $RPC_URL --private-key $KEY # Register with PolicyRegistry (get the policyId from the event) cast send $POLICY_REGISTRY "registerPolicy(address)" $MY_POLICY_ADDRESS --rpc-url $RPC_URL --private-key $KEY ``` ### Step 5: Use in Commitments When creating a commitment, set: ```javascript const policyId = 42; // Your registered policy ID const policyParams = ethers.AbiCoder.defaultAbiCoder().encode( ['uint256', 'address'], [maxAmount, requiredToken] ); const policyParamsHash = BigInt( ethers.keccak256(policyParams) ) % BN254_FIELD; // Include policyId and policyParamsHash in the Poseidon7 commitment const commitment = poseidon7([ secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash ]); ``` ### Step 6: Supply Parameters at Reveal At reveal time, provide the raw `policyParams`: ```javascript await vault.reveal( proof, root, nullifier, withdrawAmount, recipient, changeCommitment, tokenId, policyId, policyParamsHash, policyParams, // Raw ABI-encoded bytes quantumSecret ); ``` The contract will verify `keccak256(policyParams) % BN254_FIELD == policyParamsHash` and then call your policy's `validate` function. ## Change Commitments and Policy Persistence When a partial withdrawal creates a change commitment, the policy is carried forward: ``` Original commitment: Poseidon7(secret, nullifierSecret, tokenId, 100, blinding1, policyId=5, paramsHash=0xabc) Partial reveal: withdraw 30 Change commitment: Poseidon7(secret, nullifierSecret, tokenId, 70, blinding2, policyId=5, paramsHash=0xabc) ^^^^^^^^ ^^^ ^^^^^ new blinding SAME SAME ``` The ZK circuit enforces this constraint. If the prover attempts to generate a change commitment with a different `policyId` or `policyParamsHash`, the proof will be invalid. This makes policies **inescapable** — once bound, they govern every fragment of the original commitment. ### Why Policies Are Inescapable Consider a scenario: a compliance officer commits 1000 GHOST with a `DestinationRestriction` policy limiting reveals to KYC-verified addresses. Without policy persistence: 1. The user partially reveals 1 GHOST to a KYC-verified address (passes policy) 2. The change commitment (999 GHOST) has no policy 3. The user reveals 999 GHOST to any address (bypasses policy) With policy persistence, the change commitment inherits the same `DestinationRestriction`, and every subsequent reveal must also satisfy the policy. There is no escape hatch. ## Policy Composition For complex requirements, compose multiple conditions within a single policy contract rather than chaining multiple policies: ```solidity contract CompositePolicy is IRevealPolicy { function validate( uint256 commitment, uint256 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool) { ( uint256 notBefore, uint256 notAfter, address allowedRecipient, uint256 maxAmount ) = abi.decode(policyParams, (uint256, uint256, address, uint256)); // Timelock check if (block.timestamp < notBefore || block.timestamp > notAfter) return false; // Destination check if (recipient != allowedRecipient) return false; // Amount cap if (amount > maxAmount) return false; return true; } } ``` Each commitment supports exactly one `policyId`. For multiple independent conditions, combine them in a single policy contract. ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/persistent-phantom-keys ================================================================ # Persistent Phantom Keys Standard phantom keys are one-time instruments: you reveal once and the nullifier is spent forever. Persistent phantom keys are fundamentally different — the nullifier is **not** spent during access. The commitment remains live in the Merkle tree and can be accessed repeatedly until explicitly revoked. This enables a category of applications impossible with one-time keys: API keys, software licenses, subscription credentials, and team-shared secrets. ## One-Time vs Persistent | Property | One-Time Key | Persistent Key | |----------|-------------|----------------| | **Nullifier spent on use** | Yes — single use | No — only on revocation | | **Proof circuit** | CommitReveal (8 public inputs) | AccessProof (4 public inputs) | | **Vault** | CommitRevealVault | PersistentKeyVault | | **Data model** | Token amount + secrets | Encrypted payload + split key | | **Revocation** | Implicit (nullifier spent) | Explicit (revoke transaction) | | **Format** | `ghostchain-v2` | `open-ghost-persistent-v1` | ## Access Proof Circuit The AccessProof circuit proves that the user knows a valid commitment in the Merkle tree without spending it. It outputs 4 public inputs (compared to 8 for the standard CommitReveal circuit). ### Public Inputs | Index | Input | Description | |-------|-------|-------------| | 0 | `root` | Merkle root the proof is verified against | | 1 | `dataHash` | Hash of the encrypted data (proves which key is being accessed) | | 2 | `sessionNonce` | Fresh random nonce provided by the verifier | | 3 | `accessTag` | `Poseidon2(nullifierSecret, sessionNonce)` — anti-replay identifier | ### Circuit Constraints ``` 1. COMMITMENT INTEGRITY └─ Poseidon4(secret, nullifierSecret, dataHash, blinding) == commitment 2. MERKLE MEMBERSHIP └─ MerkleProof(commitment, leafIndex, pathElements, pathIndices) == root 3. DATA HASH BINDING └─ dataHash == public dataHash input 4. ACCESS TAG DERIVATION └─ accessTag = Poseidon2(nullifierSecret, sessionNonce) └─ accessTag == public accessTag input ``` ### Why No Nullifier? In the standard CommitReveal circuit, the nullifier is a public output that gets marked as spent on-chain. The AccessProof circuit replaces the nullifier with an `accessTag` — a value that is unique per session but does not get permanently recorded. This is what makes repeated access possible. ## Access Tag Anti-Replay The access tag prevents replay attacks without spending the nullifier: ``` accessTag = Poseidon2(nullifierSecret, sessionNonce) ``` | Component | Source | Purpose | |-----------|--------|---------| | `nullifierSecret` | From the phantom key (private) | Binds the tag to the key holder | | `sessionNonce` | From the verifier (public) | Ensures each access produces a unique tag | ### How Anti-Replay Works ``` Session 1: Verifier provides: sessionNonce = 0xaaa... Prover computes: accessTag = Poseidon2(nullifierSecret, 0xaaa...) Verifier records: accessTag 0x123... for session 0xaaa... Session 2: Verifier provides: sessionNonce = 0xbbb... (fresh nonce) Prover computes: accessTag = Poseidon2(nullifierSecret, 0xbbb...) Verifier records: accessTag 0x456... for session 0xbbb... Replay attempt: Attacker replays proof from Session 1 against sessionNonce 0xbbb... ✗ Proof is invalid — it was generated with nonce 0xaaa... Attacker replays proof from Session 1 with nonce 0xaaa... ✗ Verifier rejects — nonce 0xaaa... already used ``` The verifier maintains a set of used session nonces. Each access requires a fresh nonce, and the ZK proof binds the access tag to that specific nonce. Replaying an old proof with a new nonce fails because the circuit constraints would not hold. ## PersistentKeyVault Contract The `PersistentKeyVault` manages the on-chain state for persistent phantom keys. ### Core Functions #### storeKeyPartB Stores the second half of the split encryption key on-chain: ```solidity function storeKeyPartB( uint256 commitment, bytes calldata encryptedKeyPartB, uint256 policyId, uint256 policyParamsHash, bytes32 quantumCommitment ) external; ``` | Parameter | Description | |-----------|-------------| | `commitment` | The Poseidon4 commitment for this key | | `encryptedKeyPartB` | Second half of the AES key, encrypted with the commitment | | `policyId` | Revocation policy ID (0 for default BEARER) | | `policyParamsHash` | Policy parameters hash | | `quantumCommitment` | Optional quantum protection | Called during the seal flow. The commitment is inserted into the Merkle tree and Part B is stored on-chain. #### accessKeyPartB Retrieves Part B after verifying an AccessProof — **non-destructive**: ```solidity function accessKeyPartB( uint256[8] calldata proof, uint256 root, uint256 dataHash, uint256 sessionNonce, uint256 accessTag ) external view returns (bytes memory encryptedKeyPartB); ``` | Parameter | Description | |-----------|-------------| | `proof` | Groth16 AccessProof | | `root` | Merkle root | | `dataHash` | Hash of the encrypted data | | `sessionNonce` | Fresh session nonce | | `accessTag` | `Poseidon2(nullifierSecret, sessionNonce)` | This function is `view` — it does not modify state. The commitment remains in the tree. The key can be accessed again with a new session nonce. #### revokeKey Permanently invalidates the key by spending its nullifier: ```solidity function revokeKey( uint256[8] calldata proof, uint256 root, uint256 nullifier, uint256 dataHash, bytes calldata policyParams, bytes calldata quantumSecret ) external; ``` Revocation uses a standard CommitReveal proof (not an AccessProof). It spends the nullifier, which makes the commitment permanently unusable. The on-chain encrypted key data is also cleared. ## Split Encryption Persistent phantom keys use split-key encryption to ensure that neither the key file alone nor the chain alone is sufficient to access the protected data. ``` AES Key (256 bits) ───────────────── / \ Part A (128 bits) Part B (128 bits) │ │ ▼ ▼ Stored in the Stored on-chain phantom key file (PersistentKeyVault) ``` ### Encryption Flow (Seal) ``` 1. Generate random AES-256 key 2. Encrypt the secret payload with AES-256-GCM 3. Split the AES key into Part A (first 16 bytes) and Part B (last 16 bytes) 4. Store Part A in the phantom key file (encKeyPartA field) 5. Encrypt Part B with a key derived from the commitment 6. Store encrypted Part B on-chain via storeKeyPartB() ``` ### Decryption Flow (Access) ``` 1. Import the phantom key file (obtain Part A) 2. Generate AccessProof 3. Call accessKeyPartB() with the proof (obtain encrypted Part B) 4. Decrypt Part B using the commitment-derived key 5. Reconstruct the full AES key: Part A || Part B 6. Decrypt the secret payload with AES-256-GCM ``` ### Why Split? | Threat | Protection | |--------|-----------| | **Key file stolen** | Attacker has Part A but not Part B. Cannot decrypt. | | **Chain data scraped** | Observer has encrypted Part B but not Part A. Cannot decrypt. | | **Key file + chain data** | Need ZK proof to retrieve Part B. Without `nullifierSecret`, proof cannot be generated. | Both pieces are required, and retrieving Part B requires a valid ZK proof — which requires knowing the secrets in the key file. This creates a circular dependency that protects against each individual attack vector. ## Revocation Policies Each persistent phantom key has a revocation policy that controls who can revoke it. ### BEARER (Default) ``` Anyone who possesses the phantom key file can revoke. ``` This is the default policy. It mirrors the behavior of one-time keys: possession equals control. The person holding the key file can generate the revocation proof and spend the nullifier. **Use cases:** Personal API keys, individual licenses, self-managed credentials. ### ISSUER_ONLY ``` Only the wallet that sealed the key can revoke. ``` The sealer's address is stored on-chain at seal time. Revocation requires both the ZK proof (from the key file) and a transaction from the sealer's wallet. ```solidity function revokeKey(...) external { // ... verify ZK proof ... if (revokePolicy == RevokePolicy.ISSUER_ONLY) { require(msg.sender == keyMetadata[commitment].sealer, "Only issuer can revoke"); } // ... spend nullifier, clear data ... } ``` **Use cases:** Employer-issued credentials (employee has key, but only employer can revoke), subscription providers (user has access key, but only provider can terminate). ## Cryptographic Flows ### Seal Flow The sealer creates a persistent phantom key and stores it on-chain: ``` Sealer Specter Chain ────── ───────────── │ │ │ 1. Generate secrets │ │ 2. Encrypt payload with AES-256-GCM │ │ 3. Split AES key → Part A + Part B │ │ 4. Compute dataHash │ │ 5. Compute commitment = │ │ Poseidon4(secret, nullifierSecret, │ │ dataHash, blinding) │ │ 6. Assemble phantom key file │ │ (includes Part A) │ │ 7. Export phantom key (PNG/PDF/NFC) │ │ │ │──── storeKeyPartB(commitment, ...) ──────►│ │ │ 8. Insert commitment into Merkle tree │ │ 9. Store encrypted Part B │ │ 10. Store revocation policy │◄──── tx receipt + leafIndex ─────────────│ 11. Emit event │ │ │ 12. Update phantom key with leafIndex │ │ 13. Re-export phantom key │ │ │ ``` ### Access Flow (Connect) The key holder accesses the encrypted payload without spending the commitment: ``` Key Holder Verifier Specter Chain ────────── ──────── ───────────── │ │ │ │◄── sessionNonce ────────│ │ │ │ │ │ 1. Import phantom key │ │ │ 2. Fetch Merkle proof │ │ │ 3. Compute accessTag = │ │ │ Poseidon2( │ │ │ nullifierSecret, │ │ │ sessionNonce) │ │ │ 4. Generate AccessProof │ │ │ │ │ │──── proof + accessTag ──►│ │ │ │ │ │ │── accessKeyPartB() ─►│ │ │ │ 5. Verify proof │ │◄── encrypted Part B ─│ 6. Return Part B │ │ │ │◄── encrypted Part B ────│ │ │ │ │ │ 7. Decrypt Part B │ │ │ 8. Reconstruct AES key │ │ │ 9. Decrypt payload │ │ │ │ │ ``` ### Revoke Flow The authorized party permanently invalidates the key: ``` Revoker Specter Chain ─────── ───────────── │ │ │ 1. Import phantom key │ │ 2. Generate CommitReveal proof │ │ (standard nullifier derivation) │ │ │ │──── revokeKey(proof, nullifier, ...) ────►│ │ │ 3. Verify proof │ │ 4. Check revocation policy │ │ 5. Verify quantum preimage (if set) │ │ 6. Spend nullifier permanently │ │ 7. Clear on-chain encrypted data │◄──── tx receipt ────────────────────────│ 8. Emit Revoked event │ │ ``` After revocation: - The nullifier is spent — no further access proofs can be generated - The encrypted Part B is cleared from on-chain storage - The phantom key file becomes useless - Anyone attempting to access will fail at the Merkle membership check (commitment is logically removed) ## Use Cases ### API Keys ``` ┌─────────────┐ seal ┌──────────────┐ │ API Provider │──────────────►│ Phantom Key │ │ │ │ (PNG file) │ │ Encrypts: │ │ │ │ - API token │ │ Contains: │ │ - Rate limit│ access │ - Part A │ │ - Scope │◄─────────────│ - ZK secrets │ └─────────────┘ └──────────────┘ ``` The API provider seals an API token as a persistent phantom key. The developer imports the key and generates an AccessProof on each API call. The provider verifies the proof, retrieves Part B, decrypts the API token, and services the request. The developer never exposes the raw API token. ### Software Licensing A software vendor seals a license key as a persistent phantom key with `ISSUER_ONLY` revocation: 1. Vendor seals the license with the customer's entitlements encrypted inside 2. Customer imports the phantom key into the software 3. On launch, the software generates an AccessProof and verifies the license 4. If the customer violates terms, the vendor revokes the key on-chain 5. Next license check fails — the commitment's nullifier is spent ### Subscription Content Content providers can gate access behind persistent phantom keys: 1. Publisher seals content encryption keys as persistent phantom keys 2. Subscribers receive phantom key files 3. On each access, the subscriber's client generates an AccessProof 4. The content is decrypted with the reconstructed AES key 5. The publisher can revoke access at any time (ISSUER_ONLY policy) 6. Each access generates a unique `accessTag`, providing an anonymized access log ### Team Credentials A team lead seals shared credentials (database password, service account key) as a persistent phantom key: 1. The same phantom key file is distributed to all team members 2. Each member can independently generate AccessProofs (different access tags) 3. If a team member leaves, the key is revoked and re-sealed for remaining members 4. The access tag log shows how many times the credential was accessed, without revealing which team member accessed it ================================================================ SECTION: Protocol SOURCE: https://docs.specterchain.com/protocol/zero-loss-system ================================================================ # Zero-Loss Phantom Key System Ghost Protocol's fundamental property — the phantom key is the only proof of ownership — creates a critical engineering challenge: if the key is lost before the on-chain transaction confirms, the committed assets are permanently inaccessible. The zero-loss system is a set of design patterns that ensure a user **never** loses assets due to application or infrastructure failures. ## Core Principle: Save Before Transaction ``` ┌─────────────────────────────────────┐ │ THE IRON RULE │ │ │ │ The phantom key must be durably │ │ saved BEFORE any on-chain │ │ transaction is submitted. │ │ │ │ If the save fails, the transaction │ │ is never sent. │ └─────────────────────────────────────┘ ``` This is not a best practice or a recommendation — it is a hard gate enforced by the application architecture. The transaction submission button is disabled until the key export step is confirmed complete. ## Full Vanish Flow The full vanish (commit) flow converts tokens into a phantom key. This is the highest-risk operation because the user is about to burn tokens irreversibly. ### Step-by-Step ``` Step 1: GENERATE KEY │ │ Generate random secrets (secret, nullifierSecret, blinding) │ Compute commitment = Poseidon7(...) │ Assemble phantom key JSON │ Key exists only in memory at this point │ ▼ Step 2: RENDER PNG │ │ Pre-generate the phantom key PNG image │ Embed key JSON into PNG metadata │ PNG is rendered in memory (canvas → blob) │ The PNG is ready for download but NOT YET SAVED │ ▼ Step 3: GATE — USER SAVES KEY │ │ Display the PNG with a download button │ Display confirmation checkbox: │ ☐ "I have saved my phantom key and understand │ it cannot be recovered" │ The "Vanish" button is DISABLED until: │ 1. The PNG download has been triggered │ 2. The checkbox is checked │ ▼ Step 4: SUBMIT TRANSACTION │ │ Call commit() on CommitRevealVault │ Wait for transaction confirmation │ Update key with leafIndex from event │ Offer to re-download updated PNG (with leafIndex) │ ▼ Step 5: DONE ``` ### Why Pre-Generate? The PNG is generated **before** the user clicks "Vanish" — not after. This matters because: 1. **Canvas rendering can fail.** If the PNG generation fails after tokens are burned, the user has no key. By pre-generating, we catch rendering failures before any irreversible action. 2. **Browser tab can close.** If the user accidentally closes the tab after the transaction but before PNG generation, the key is lost. Pre-generation ensures the key is already saved. 3. **Memory pressure.** On mobile devices, canvas rendering under memory pressure can produce corrupt output. Pre-generation allows integrity verification before proceeding. ## Partial Summon Flow The partial summon (partial reveal) flow is more complex because it involves two keys: the original key being consumed and the change key being created. ### Step-by-Step ``` Step 1: IMPORT ORIGINAL KEY │ │ User imports existing phantom key (PNG/QR/NFC) │ Validate key integrity │ Compute original commitment, verify it exists in tree │ ▼ Step 2: GENERATE PROOF + CHANGE KEY │ │ Compute changeAmount = originalAmount - withdrawAmount │ Generate new blinding for change commitment │ Compute changeCommitment = Poseidon7( │ secret, nullifierSecret, tokenId, changeAmount, │ newBlinding, policyId, policyParamsHash) │ Assemble change phantom key JSON │ Generate Groth16 proof (includes change commitment) │ ▼ Step 3: RENDER CHANGE KEY PNG │ │ Pre-generate the change key PNG image │ Embed change key JSON into PNG metadata │ PNG is rendered in memory, ready for download │ ▼ Step 4: GATE — USER SAVES CHANGE KEY │ │ Display the change key PNG with download button │ Display confirmation checkbox: │ ☐ "I have saved my change key and understand │ it cannot be recovered" │ The "Summon" button is DISABLED until: │ 1. The change key PNG download has been triggered │ 2. The checkbox is checked │ ▼ Step 5: SUBMIT TRANSACTION │ │ Call reveal() on CommitRevealVault │ Wait for transaction confirmation │ Update change key with leafIndex from event │ Offer to re-download updated change key PNG │ ▼ Step 6: DONE │ │ withdrawAmount tokens minted to recipient │ changeAmount tokens committed under change key │ Original key is now spent (nullifier consumed) ``` ### Why Gate the Change Key? The change key represents the unspent portion of the original commitment. If the user reveals 30 out of 100 GHOST, the change key holds the remaining 70 GHOST. Losing the change key means losing 70 GHOST permanently. The gating mechanism ensures the change key is saved before the reveal transaction is submitted. ## Failure Scenario Analysis The following table enumerates every failure point and its outcome under the zero-loss system: | Failure Point | Timing | Assets at Risk? | Outcome | |---------------|--------|-----------------|---------| | **Browser crash before PNG save** | Before Step 3 gate | No | No transaction was sent. Secrets existed only in memory. User restarts and tries again. No tokens were burned. | | **Browser crash after PNG save, before tx** | After Step 3, before Step 4 | No | User has the PNG but no transaction was sent. The key contains a valid commitment but no `leafIndex`. The user can either retry the commit or discard the key. No tokens were burned. | | **Browser crash after tx sent, before tx confirms** | During Step 4 | No | The PNG was saved (Step 3 gate passed). If the tx confirms, the key is valid — the user can re-derive the `leafIndex` from chain events. If the tx fails/reverts, no tokens were burned. | | **Browser crash after tx confirms** | After Step 4 | No | The PNG was saved. The key may lack the `leafIndex`, but it can be recovered by scanning chain events for the commitment. All secrets are in the saved PNG. | | **MetaMask rejection** | Step 4 | No | User declined the transaction. No tokens burned. Key PNG was already saved but is inert (no on-chain commitment). User can retry or discard. | | **Network failure during tx** | Step 4 | No | Transaction was not broadcast or did not confirm. Same as MetaMask rejection — retry with the same key. | | **PNG rendering fails** | Step 2 | No | The gate (Step 3) is never reached. The "Vanish" button remains disabled. User cannot submit the transaction. Alert the user to retry or switch browsers. | | **PNG download fails** | Step 3 | No | The download trigger did not fire. The checkbox cannot be checked. The "Vanish" button remains disabled. User retries the download. | | **Partial reveal: change key PNG fails** | Step 3 (summon) | No | The gate blocks the reveal transaction. User cannot proceed without saving the change key. | | **Partial reveal: crash after tx, before change key re-download** | After Step 5 | No | The change key PNG was saved (gate passed in Step 4). The saved PNG may lack the new `leafIndex`, but it can be recovered from chain events. | ### Key Insight In every failure scenario, the answer to "are assets at risk?" is **No**. The zero-loss system achieves this through a single invariant: **the gate never opens until the key is saved**. ## Technical Implementation ### Ref-Based Context Stashing The application uses React refs (not state) to hold the phantom key data during the commit/reveal flow. This is deliberate: ```typescript // React state would trigger re-renders and risk losing data during unmount const phantomKeyRef = useRef(null); const pngBlobRef = useRef(null); const proofRef = useRef(null); ``` **Why refs instead of state?** | Approach | Risk | |----------|------| | React state (`useState`) | Re-renders during proof generation can cause flickering, race conditions, or loss of intermediate state if a parent component unmounts | | Context API | Context value changes trigger re-renders in all consumers — same risks as state | | Refs (`useRef`) | Values persist across renders without triggering re-renders. No risk of loss during re-render cycles. Accessible synchronously. | The refs hold the key data from generation through PNG rendering, gating, and transaction submission. At no point is the key data stored only in a closure or callback that could be garbage collected. ### PNG Pre-Generation The PNG is generated eagerly — as soon as the commitment is computed, before the user interacts with any UI: ```typescript async function prepareVanish(amount: bigint, token: address) { // 1. Generate all secrets const key = generatePhantomKey(amount, token); phantomKeyRef.current = key; // 2. Pre-render PNG immediately const pngBlob = await renderPhantomKeyPNG(key); pngBlobRef.current = pngBlob; // 3. Verify PNG integrity const reimported = await extractKeyFromPNG(pngBlob); if (reimported.commitment !== key.commitment) { throw new Error("PNG integrity check failed"); } // 4. Enable the download button (gate is still closed) setReadyToSave(true); } ``` The integrity check (step 3) re-extracts the key from the generated PNG and verifies the commitment matches. This catches encoding errors, canvas corruption, and metadata embedding failures before the user ever sees the download button. ### Gated Progression The transaction button is controlled by two boolean flags, both of which must be `true`: ```typescript const [downloadTriggered, setDownloadTriggered] = useState(false); const [checkboxChecked, setCheckboxChecked] = useState(false); const canSubmit = downloadTriggered && checkboxChecked; function handleDownload() { // Trigger browser download of the pre-generated PNG const url = URL.createObjectURL(pngBlobRef.current!); const a = document.createElement('a'); a.href = url; a.download = `phantom-key-${Date.now()}.png`; a.click(); URL.revokeObjectURL(url); setDownloadTriggered(true); } ``` ```jsx ``` The checkbox is disabled until the download has been triggered. The submit button is disabled until both conditions are met. This creates a strict linear progression: download, confirm, then transact. ### Null LeafIndex Tolerance The `leafIndex` is assigned by the Merkle tree contract during the commit transaction. This means the phantom key is generated **before** the `leafIndex` is known. The system handles this gracefully: ```typescript interface PhantomKeyData { // ... other fields ... leafIndex: number | null; // null until transaction confirms } ``` A phantom key with `leafIndex: null` is valid — it contains all the secrets needed to generate a proof. The `leafIndex` can be recovered by: 1. Scanning `Committed` events for the matching commitment value 2. Querying the relayer's off-chain tree for the commitment's position The import flow handles null-index keys by performing this recovery automatically: ```typescript async function importPhantomKey(key: PhantomKeyData): Promise { if (key.leafIndex === null || key.leafIndex === undefined) { // Recover leafIndex from chain events const events = await vault.queryFilter( vault.filters.Committed(key.commitment) ); if (events.length > 0) { key.leafIndex = events[0].args.leafIndex.toNumber(); } // If no event found, the commitment was never submitted on-chain // The key is valid but uncommitted — user can retry the commit } return key; } ``` ## Flow Diagrams ### Full Vanish — Success Path ``` Time ──────────────────────────────────────────────────► ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐ │ Gen │ │Render│ │ Save │ │ Confirm │ │ TX │ │ Key │─►│ PNG │─►│ PNG │─►│ Checkbox │─►│ Send │ │ │ │ │ │ ↓ │ │ ↓ │ │ │ └──────┘ └──────┘ │ gate │ │ gate │ └──┬───┘ │ open │ │ open │ │ └──────┘ └──────────┘ │ ▼ ┌──────────┐ │ Confirm │ │ + update │ │ leafIdx │ └──────────┘ ``` ### Full Vanish — Failure at Each Stage ``` Gen Key fails → No secrets generated. Retry. No risk. │ Render PNG fails → Gate stays closed. Cannot transact. No risk. │ Save PNG fails → Gate stays closed. Cannot transact. No risk. │ Checkbox skipped → Gate stays closed. Cannot transact. No risk. │ TX rejected → Key saved but no on-chain commitment. No risk. │ TX reverts → Key saved but tokens not burned. No risk. │ TX confirms → Key saved + tokens burned. SUCCESS. │ Browser crash → Key already saved (gate passed). Recover leafIndex from chain events. No risk. ``` ### Partial Summon — Success Path ``` Time ──────────────────────────────────────────────────────────────► ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐ │Import│ │ Gen │ │Render│ │ Save │ │Confirm│ │ TX │ │Update│ │ Orig │─►│Proof │─►│Change│─►│Change│─►│Chkbox │─►│ Send │─►│Change│ │ Key │ │+ Chg │ │ PNG │ │ PNG │ │ │ │ │ │ Idx │ └──────┘ │ Key │ └──────┘ │ ↓ │ │ ↓ │ └────────┘ └──────┘ └──────┘ │ gate │ │ gate │ │ open │ │ open │ └──────┘ └───────┘ ``` ## Edge Cases ### Multiple Browser Tabs If the user opens multiple tabs and attempts concurrent commits, each tab independently generates keys and gates transactions. Since each key has unique secrets, there is no collision risk. The rate limiter (5-second cooldown) prevents the second tab's transaction from being front-run by the first. ### Mobile Safari Restrictions iOS Safari restricts programmatic downloads. The PNG is presented as an inline image that the user long-presses to save to their camera roll. The `downloadTriggered` flag is set when the user taps the "Save to Photos" button, which triggers the native iOS share sheet. ### Relayer-Submitted Transactions When transactions are submitted via a relayer (for gas abstraction), the same gating applies. The client generates the proof and key, saves the key, then sends the signed proof to the relayer. If the relayer fails to submit, the user retries with the same proof — the key is already saved. ### Stale PNG After LeafIndex Update The initial PNG (saved before the transaction) does not contain the `leafIndex`. After the transaction confirms, the application offers to re-download an updated PNG with the `leafIndex` included. If the user declines, the original PNG is still valid — the `leafIndex` can be recovered from chain events during import. ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/overview ================================================================ # Specter Blockchain Overview Specter is a privacy-focused blockchain built on the Cosmos SDK with full EVM compatibility. It combines CometBFT consensus with an integrated Ethereum Virtual Machine, enabling Solidity smart contracts to operate alongside native Cosmos modules — including the custom `x/ghostmint` module that powers native-asset privacy. ## Chain Parameters | Parameter | Value | |---|---| | **Chain ID** | `5446` (`0x1546`) | | **Chain ID (Cosmos)** | `umbraline_5446-1` | | **Native Token** | GHOST | | **Smallest Unit** | aghost (1 GHOST = 10^18 aghost) | | **Decimals** | 18 | | **Consensus** | CometBFT (Tendermint) | | **Block Time** | ~5 seconds | | **Framework** | Cosmos SDK + EVM (Ethermint-derived) | | **Bech32 Prefix** | `umbra` | | **Address Format** | Both `umbra1...` (Cosmos) and `0x...` (EVM) | ## Genesis Allocation The total genesis supply of GHOST is **1,000,000,000** (1 billion), distributed as follows: | Allocation | Amount (GHOST) | Percentage | Purpose | |---|---|---|---| | **Treasury** | 900,000,000 | 90% | Protocol development, ecosystem grants, long-term sustainability | | **Operator** | 50,000,000 | 5% | Operational expenses, infrastructure, team | | **Faucet** | 49,000,000 | 4.9% | Testnet distribution, developer onboarding | | **Validator** | 1,000,000 | 0.1% | Initial validator stake for genesis block production | | **Total** | 1,000,000,000 | 100% | | ## Architecture Specter's architecture layers EVM execution on top of the Cosmos SDK, giving it access to both ecosystems: ``` ┌─────────────────────────────────────────────┐ │ EVM Smart Contracts │ │ (Solidity, ERC-20s, Privacy Contracts) │ ├─────────────────────────────────────────────┤ │ EVM Module (Ethermint) │ │ JSON-RPC / Web3 Interface │ ├──────────────┬──────────────────────────────┤ │ x/ghostmint │ x/bank │ x/staking │ ...│ │ (native │ (token │ (PoS │ │ │ minting) │ xfers) │ consensus) │ │ ├──────────────┴──────────┴─────────────┴────┤ │ Cosmos SDK Core │ ├─────────────────────────────────────────────┤ │ CometBFT Consensus Engine │ └─────────────────────────────────────────────┘ ``` ### Key Design Decisions - **CometBFT Consensus**: Provides instant finality (no block reorganizations), which is critical for privacy protocols where reorgs could leak information. - **EVM Compatibility**: Allows deployment of standard Solidity contracts, making the privacy protocol accessible to the existing Ethereum developer ecosystem. - **Custom `x/ghostmint` Module**: Bridges EVM contract execution with Cosmos-native token minting, enabling privacy-preserving transactions at the protocol level rather than as a token wrapper. - **Bech32 `umbra` Prefix**: Cosmos-side addresses use the `umbra` prefix (e.g., `umbra1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5a5dkq`), while EVM-side addresses use standard `0x` format. Both address formats refer to the same underlying account. ## Network Endpoints ### Testnet | Service | URL | |---|---| | **JSON-RPC (EVM)** | `https://testnet.specterchain.com` | | **JSON-RPC (EVM, alt)** | `https://testnet.umbraline.com` | | **Tendermint RPC** | `https://testnet.specterchain.com:26657` | | **REST API (Cosmos)** | `https://testnet.specterchain.com:1317` | | **gRPC** | `testnet.specterchain.com:9090` | ### Connecting with MetaMask To add the Specter testnet to MetaMask: | Field | Value | |---|---| | Network Name | Specter Testnet | | RPC URL | `https://testnet.specterchain.com` | | Chain ID | `5446` | | Currency Symbol | GHOST | | Block Explorer URL | *(coming soon)* | ### Connecting with Cosmos Tooling For Cosmos-native tooling (e.g., `umbralined` CLI, Keplr wallet), use the Tendermint RPC and REST API endpoints. The chain ID in Cosmos format is `umbraline_5446-1`. ## Token Denomination GHOST follows the standard 18-decimal convention used by Ethereum: | Unit | Relation | Typical Use | |---|---|---| | **GHOST** | 1 GHOST = 10^18 aghost | Display unit, staking, governance | | **aghost** | Smallest indivisible unit | Gas prices, raw transaction amounts | When interacting via the Cosmos CLI, amounts are specified in `aghost`. When interacting via EVM/Web3, amounts are in wei-equivalent (also `aghost`). ```bash # Cosmos CLI — send 1 GHOST umbralined tx bank send 1000000000000000000aghost --chain-id umbraline_5446-1 # EVM — 1 GHOST in wei const amount = ethers.parseEther("1.0"); // 1000000000000000000n ``` ## What Makes Specter Different Specter is not a general-purpose L1. It is purpose-built for **privacy-preserving asset transfers** using zero-knowledge proofs. The chain exists to support the GHOST privacy protocol, which allows users to deposit GHOST tokens into a shielded pool and withdraw them to a fresh address with no on-chain link between deposit and withdrawal. The key enabling technology is the `x/ghostmint` module, which allows EVM smart contracts to trigger native Cosmos-level minting of GHOST tokens. This means the privacy protocol operates on the native gas token itself — not on a wrapped or synthetic asset — providing the strongest possible privacy guarantees. See [GhostMint Module](./ghostmint-module.md) for the full technical deep dive. ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/node-setup ================================================================ # Node Setup This guide walks through setting up a Specter full node from scratch. A full node validates all transactions and blocks, maintains a complete copy of the blockchain state, and can serve RPC queries. Validators should start here before proceeding to the [Validator Guide](./validator-guide.md). ## Hardware Requirements | Resource | Minimum | Recommended | |---|---|---| | **CPU** | 4 cores | 8+ cores | | **RAM** | 16 GB | 32 GB | | **Storage** | 500 GB SSD | 1 TB NVMe SSD | | **Network** | 100 Mbps | 1 Gbps | | **OS** | Ubuntu 22.04 LTS / macOS 13+ | Ubuntu 22.04 LTS | Storage requirements grow over time as the chain accumulates state. NVMe SSDs are strongly recommended for validators due to the I/O demands of CometBFT consensus and EVM state access. ## Software Prerequisites | Software | Version | Purpose | |---|---|---| | **Go** | 1.21+ | Building `umbralined` from source | | **Git** | 2.x | Cloning the repository | | **Make** | GNU Make | Build automation | | **jq** | 1.6+ | JSON processing for scripts | ### Install Go ```bash # Download and install Go 1.21+ wget https://go.dev/dl/go1.21.6.linux-amd64.tar.gz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf go1.21.6.linux-amd64.tar.gz # Add to PATH (add to ~/.bashrc or ~/.zshrc for persistence) export PATH=$PATH:/usr/local/go/bin export GOPATH=$HOME/go export PATH=$PATH:$GOPATH/bin # Verify go version # go version go1.21.6 linux/amd64 ``` ## Build from Source ```bash # Clone the repository git clone https://github.com/specter-chain/umbraline-cosmos.git cd umbraline-cosmos # Build the binary make install # Verify installation umbralined version ``` The `make install` command compiles the `umbralined` binary and places it in `$GOPATH/bin`. ## Initialize the Node ```bash # Set your node's moniker (human-readable name) MONIKER="my-specter-node" # Initialize the node umbralined init $MONIKER --chain-id umbraline_5446-1 ``` This creates the following directory structure at `~/.umbralined/`: ``` ~/.umbralined/ ├── config/ │ ├── app.toml # Application configuration (EVM, API, gas) │ ├── config.toml # CometBFT configuration (P2P, RPC, consensus) │ ├── genesis.json # Genesis state (will be replaced) │ ├── node_key.json # P2P identity key │ └── priv_validator_key.json # Validator signing key (BACK THIS UP) └── data/ └── priv_validator_state.json # Validator state tracking ``` ## Configure Genesis Replace the auto-generated genesis file with the official Specter genesis: ```bash # Download the official genesis file curl -L https://raw.githubusercontent.com/specter-chain/network-config/main/testnet/genesis.json \ -o ~/.umbralined/config/genesis.json # Verify the genesis hash sha256sum ~/.umbralined/config/genesis.json # Expected: ``` The genesis file contains the initial chain state, including the genesis allocation of 1 billion GHOST tokens distributed across the treasury, operator, faucet, and validator accounts. ## Configure Peers Your node needs peers to discover the network. Add seed nodes and persistent peers to your configuration. ### Seeds Seeds are nodes that provide initial peer discovery. Your node contacts them once to learn about other peers, then disconnects. ```bash # Set seed nodes SEEDS="@seeds.specterchain.com:26656" sed -i "s/^seeds =.*/seeds = \"$SEEDS\"/" ~/.umbralined/config/config.toml ``` ### Persistent Peers Persistent peers are nodes your node maintains a constant connection to. ```bash # Set persistent peers PEERS="@peer1.specterchain.com:26656,@peer2.specterchain.com:26656" sed -i "s/^persistent_peers =.*/persistent_peers = \"$PEERS\"/" ~/.umbralined/config/config.toml ``` :::tip Check the Specter Discord or the network-config repository for the latest peer list. ::: ## Configure Minimum Gas Prices Set a minimum gas price to protect against spam transactions: ```bash sed -i 's/^minimum-gas-prices =.*/minimum-gas-prices = "0.001aghost"/' ~/.umbralined/config/app.toml ``` ## Enable the JSON-RPC Server If you want your node to serve EVM JSON-RPC queries (required for Web3 applications): ```bash # In app.toml, enable the JSON-RPC server sed -i '/\[json-rpc\]/,/\[/ s/^enable =.*/enable = true/' ~/.umbralined/config/app.toml sed -i '/\[json-rpc\]/,/\[/ s/^address =.*/address = "0.0.0.0:8545"/' ~/.umbralined/config/app.toml sed -i '/\[json-rpc\]/,/\[/ s/^ws-address =.*/ws-address = "0.0.0.0:8546"/' ~/.umbralined/config/app.toml ``` ## Start the Node ### Direct Execution ```bash umbralined start ``` ### Systemd Service (Recommended for Production) Create a systemd service file for automatic restarts and log management: ```bash sudo tee /etc/systemd/system/umbralined.service > /dev/null <&1 | jq '.SyncInfo.catching_up' # Returns: true (still syncing) or false (fully synced) ``` ### Check Connected Peers ```bash # View the number of connected peers umbralined status 2>&1 | jq '.NodeInfo.network' curl -s http://localhost:26657/net_info | jq '.result.n_peers' ``` ## State Sync (Fast Sync) For faster initial sync, you can use state sync to download a recent snapshot of the chain state instead of replaying all blocks from genesis. ```bash # Get a recent trusted block height and hash from a trusted RPC node LATEST_HEIGHT=$(curl -s https://testnet.specterchain.com:26657/block | jq -r '.result.block.header.height') TRUST_HEIGHT=$((LATEST_HEIGHT - 2000)) TRUST_HASH=$(curl -s "https://testnet.specterchain.com:26657/block?height=$TRUST_HEIGHT" | jq -r '.result.block_id.hash') echo "Trust Height: $TRUST_HEIGHT" echo "Trust Hash: $TRUST_HASH" # Configure state sync in config.toml sed -i '/\[statesync\]/,/\[/ s/^enable =.*/enable = true/' ~/.umbralined/config/config.toml sed -i "/\[statesync\]/,/\[/ s|^rpc_servers =.*|rpc_servers = \"https://testnet.specterchain.com:26657,https://testnet.specterchain.com:26657\"|" ~/.umbralined/config/config.toml sed -i "/\[statesync\]/,/\[/ s/^trust_height =.*/trust_height = $TRUST_HEIGHT/" ~/.umbralined/config/config.toml sed -i "/\[statesync\]/,/\[/ s/^trust_hash =.*/trust_hash = \"$TRUST_HASH\"/" ~/.umbralined/config/config.toml ``` After configuring state sync, restart the node: ```bash # Reset the data directory (preserves config) umbralined tendermint unsafe-reset-all --home ~/.umbralined --keep-addr-book # Start the node sudo systemctl restart umbralined ``` :::caution State sync only provides a snapshot of the recent state. If you need full historical data (e.g., for an indexer or archive node), you must sync from genesis. ::: ## Troubleshooting ### Node won't start ```bash # Check for port conflicts sudo lsof -i :26656 # P2P sudo lsof -i :26657 # Tendermint RPC sudo lsof -i :8545 # EVM JSON-RPC sudo lsof -i :1317 # REST API sudo lsof -i :9090 # gRPC ``` ### No peers found - Verify your firewall allows inbound/outbound on port `26656`. - Confirm seed/peer addresses are correct and reachable. - Check that your `config.toml` has the correct `external_address` set if behind NAT. ```bash # Set external address if behind NAT EXTERNAL_IP=$(curl -s https://ifconfig.me) sed -i "s/^external_address =.*/external_address = \"tcp:\/\/$EXTERNAL_IP:26656\"/" ~/.umbralined/config/config.toml ``` ### Node is stuck at a block height This typically indicates a consensus issue. Check logs for error messages: ```bash sudo journalctl -u umbralined -n 200 --no-pager | grep -i "error\|ERR\|panic" ``` If the node is stuck due to an app hash mismatch, you may need to reset state and resync: ```bash umbralined tendermint unsafe-reset-all --home ~/.umbralined --keep-addr-book sudo systemctl restart umbralined ``` ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/configuration ================================================================ # Configuration Specter nodes are configured through three primary files located in `~/.umbralined/config/`. This guide covers the key settings in each file and recommendations for different node roles (full node, validator, RPC provider). ## Configuration Files | File | Purpose | |---|---| | `config.toml` | CometBFT settings: consensus, P2P networking, Tendermint RPC | | `app.toml` | Application settings: EVM config, Cosmos REST API, gRPC, gas prices | | `genesis.json` | Chain genesis state — do not modify after chain launch | ## config.toml This is the CometBFT configuration file. It controls consensus behavior, peer-to-peer networking, and the Tendermint RPC server. ### General ```toml # Human-readable name for this node (shown to peers) moniker = "my-specter-node" # Database backend: "goleveldb" | "rocksdb" | "pebbledb" # goleveldb is default; pebbledb offers better compaction for large state db_backend = "goleveldb" # Log level: "info" | "debug" | "warn" | "error" # Use "info" for production, "debug" for troubleshooting log_level = "info" # Log format: "plain" | "json" # Use "json" if feeding logs into a structured logging system log_format = "plain" ``` ### RPC Server Controls the Tendermint RPC endpoint (default: port 26657). This is the Cosmos-side RPC, distinct from the EVM JSON-RPC. ```toml [rpc] # Listen address for Tendermint RPC laddr = "tcp://127.0.0.1:26657" # CORS allowed origins (set to ["*"] for public RPC nodes) cors_allowed_origins = [] # Maximum number of simultaneous connections max_open_connections = 900 # Maximum number of unique client subscriptions per connection max_subscriptions_per_client = 5 # Timeout for broadcast_tx_commit (should be longer than block time) timeout_broadcast_tx_commit = "10s" # Maximum request body size (bytes) max_body_bytes = 1000000 # Maximum header size (bytes) max_header_bytes = 1048576 ``` :::tip For public-facing RPC nodes, bind to `0.0.0.0:26657` and set appropriate `cors_allowed_origins`. For validators, keep the default `127.0.0.1` binding to minimize attack surface. ::: ### P2P Networking ```toml [p2p] # Listen address for P2P connections laddr = "tcp://0.0.0.0:26656" # External address to advertise to peers # Required if behind NAT or a reverse proxy external_address = "" # Seed nodes for initial peer discovery (comma-separated) seeds = "" # Persistent peers to maintain constant connections to (comma-separated) persistent_peers = "" # Maximum number of inbound peers max_num_inbound_peers = 40 # Maximum number of outbound peers max_num_outbound_peers = 10 # Toggle peer exchange reactor (PEX) # Set to false for private/sentry node architectures pex = true # Seed mode — node operates as a seed, crawls the network, and disconnects seed_mode = false # Comma-separated list of peer IDs to keep private (will not be gossiped) private_peer_ids = "" # Handshake and dial timeouts handshake_timeout = "20s" dial_timeout = "3s" ``` ### Consensus ```toml [consensus] # Propose timeout — how long the proposer waits before proposing an empty block timeout_propose = "3s" # Prevote timeout timeout_prevote = "1s" # Precommit timeout timeout_precommit = "1s" # Commit timeout — delay after committing a block before starting the next height # This directly affects block time. Specter targets ~5s blocks. timeout_commit = "3s" # Make empty blocks (set to false to only produce blocks when there are transactions) create_empty_blocks = true # Interval between empty blocks (if create_empty_blocks is true) create_empty_blocks_interval = "0s" ``` ### Mempool ```toml [mempool] # Maximum number of transactions in the mempool size = 5000 # Maximum size of the mempool in bytes max_txs_bytes = 1073741824 # 1 GB # Maximum size of a single transaction (bytes) max_tx_bytes = 1048576 # 1 MB # TTL for transactions in the mempool (number of blocks; 0 = no limit) ttl-num-blocks = 0 # TTL for transactions in the mempool (duration; 0s = no limit) ttl-duration = "0s" ``` ### State Sync ```toml [statesync] # Enable state sync for fast initial node bootstrapping enable = false # Trusted RPC endpoints (comma-separated, at least 2) rpc_servers = "" # Trusted block height and hash (from a trusted RPC node) trust_height = 0 trust_hash = "" # Trust period — how far back in time the trust anchor can be trust_period = "168h0m0s" # 7 days # Time to wait for a state sync chunk before retrying chunk_request_timeout = "10s" ``` ## app.toml This file controls the Cosmos application layer, including the EVM module, API servers, and gas pricing. ### Base Configuration ```toml # Minimum gas prices — transactions with gas prices below this are rejected # Critical for spam prevention minimum-gas-prices = "0.001aghost" # Pruning strategy: "default" | "nothing" | "everything" | "custom" # "default" — keep recent 100 states + every 500th state # "nothing" — keep all states (archive node) # "everything" — keep only the current state (minimal storage) pruning = "default" # Custom pruning (only when pruning = "custom") pruning-keep-recent = "100" pruning-interval = "10" # Halt height — node shuts down at this block height (0 = disabled) # Useful for coordinated upgrades halt-height = 0 # Halt time — node shuts down at this Unix timestamp (0 = disabled) halt-time = 0 ``` ### EVM Configuration ```toml [evm] # EVM transaction tracer: "" | "json" | "struct" | "access-list" tracer = "" # Maximum gas allowed in a block (0 = unlimited) max-tx-gas-wanted = 0 ``` ### JSON-RPC (EVM) Controls the Ethereum-compatible JSON-RPC server. ```toml [json-rpc] # Enable the JSON-RPC server enable = true # Listen address for JSON-RPC HTTP address = "0.0.0.0:8545" # Listen address for JSON-RPC WebSocket ws-address = "0.0.0.0:8546" # API namespaces to enable # Available: eth, txpool, personal, net, debug, web3, miner api = "eth,net,web3,txpool" # Maximum number of logs returned by eth_getLogs max-log-limit = 10000 # Maximum gas allowed for eth_call gas-cap = 25000000 # EVM timeout for eth_call (duration) evm-timeout = "5s" # Transaction fee cap (in GHOST) for RPC methods txfee-cap = 1 # HTTP request timeout http-timeout = "30s" # HTTP idle timeout http-idle-timeout = "120s" # Maximum open HTTP connections (0 = unlimited) max-open-connections = 0 # Enable indexing of EVM transactions enable-indexer = false ``` :::caution Be aware that `eth_getLogs` has known limitations on Cosmos EVM chains. See [Cosmos EVM Workarounds](./cosmos-evm-workarounds.md) for details. ::: ### Cosmos REST API ```toml [api] # Enable the Cosmos REST API enable = true # Listen address address = "tcp://0.0.0.0:1317" # Enable Swagger documentation at /swagger/ swagger = false # Maximum number of simultaneous connections (0 = unlimited) max-open-connections = 1000 # Read and write timeouts rpc-read-timeout = 10 # seconds rpc-write-timeout = 0 # seconds (0 = no timeout) ``` ### gRPC ```toml [grpc] # Enable the gRPC server enable = true # Listen address address = "0.0.0.0:9090" # Maximum receive message size (bytes) max-recv-msg-size = 10485760 # 10 MB # Maximum send message size (bytes) max-send-msg-size = 2147483647 # ~2 GB ``` ### Telemetry ```toml [telemetry] # Enable Prometheus metrics enabled = false # Prefix for all emitted metrics global-labels = [] # Prometheus retention time (seconds) prometheus-retention-time = 0 # Enable CPU and memory profiling enable-hostname = false enable-hostname-label = false enable-service-label = false ``` ## genesis.json The genesis file defines the initial state of the blockchain. It is set once at chain launch and must be identical across all nodes. ### Key Sections | Section | Purpose | |---|---| | `chain_id` | Network identifier (`umbraline_5446-1`) | | `genesis_time` | Timestamp of the first block | | `consensus_params` | Block size limits, evidence rules, validator parameters | | `app_state.bank` | Initial token balances | | `app_state.staking` | Staking parameters (bond denom, unbonding time) | | `app_state.slashing` | Slashing parameters (downtime, double-sign penalties) | | `app_state.gov` | Governance parameters (voting period, quorum, threshold) | | `app_state.evm` | EVM chain config (chain ID, homestead block, etc.) | | `app_state.ghostmint` | GhostMint module parameters | ### Consensus Parameters in Genesis ```json { "consensus_params": { "block": { "max_bytes": "22020096", "max_gas": "-1" }, "evidence": { "max_age_num_blocks": "100000", "max_age_duration": "172800000000000", "max_bytes": "1048576" }, "validator": { "pub_key_types": ["ed25519"] } } } ``` :::warning Never modify `genesis.json` after the chain has launched. All nodes must use the identical genesis file. A mismatched genesis will cause your node to fail at block 1 with an app hash mismatch. ::: ## Recommended Configurations by Node Role ### Validator Validators prioritize security and consensus performance. ```toml # config.toml [rpc] laddr = "tcp://127.0.0.1:26657" # Local-only RPC [p2p] max_num_inbound_peers = 40 max_num_outbound_peers = 10 pex = true # app.toml minimum-gas-prices = "0.001aghost" pruning = "default" [json-rpc] enable = false # Disable EVM RPC on validators [api] enable = false # Disable REST API on validators ``` ### Public RPC Node RPC nodes serve queries from users and applications. ```toml # config.toml [rpc] laddr = "tcp://0.0.0.0:26657" cors_allowed_origins = ["*"] max_open_connections = 900 # app.toml pruning = "nothing" # Keep all state for queries [json-rpc] enable = true address = "0.0.0.0:8545" ws-address = "0.0.0.0:8546" api = "eth,net,web3,txpool" [api] enable = true address = "tcp://0.0.0.0:1317" swagger = true ``` ### Archive Node Archive nodes retain complete historical state. ```toml # app.toml pruning = "nothing" [json-rpc] enable = true enable-indexer = true ``` ## Applying Configuration Changes After modifying configuration files, restart the node: ```bash # If running as a systemd service sudo systemctl restart umbralined # If running directly # Stop the running process (Ctrl+C) and restart umbralined start ``` Most configuration changes take effect on restart. Changes to `genesis.json` after chain initialization require a full chain reset (`umbralined tendermint unsafe-reset-all`), which should only be done on testnets or if you are willing to resync from scratch. ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/ghostmint-module ================================================================ # GhostMint Module The `x/ghostmint` module is the core protocol-level primitive that makes Specter's native GHOST privacy possible. It is a custom Cosmos SDK module that bridges EVM smart contract execution with Cosmos-native token minting, allowing authorized EVM contracts to mint native GHOST tokens directly through a precompiled contract. ## Why GhostMint Exists In a typical privacy protocol on Ethereum, users deposit ETH or tokens into a shielded pool contract. When they withdraw, the contract transfers tokens from its own balance back to the user. This means the contract must hold a pool of tokens, and the pool balance itself leaks information about the privacy set. Specter takes a fundamentally different approach. When a user deposits GHOST into the privacy pool, the tokens are **burned**. When they withdraw, new GHOST tokens are **minted** at the protocol level. There is no pool balance to observe. The minting happens through the Cosmos SDK's `x/bank` module, making it indistinguishable from any other native token operation. This is only possible because Specter controls the full stack — the EVM execution environment, the Cosmos application layer, and the consensus engine. ## Architecture ``` ┌──────────────────────────────────┐ │ NativeAssetHandler.sol │ ← Authorized EVM contract │ (calls precompile at 0x808) │ └──────────────┬───────────────────┘ │ mintNativeTo(address, uint256) ▼ ┌──────────────────────────────────┐ │ GhostMint Precompile │ ← EVM precompile at 0x0...0808 │ (0x0000...000000000808) │ │ Validates caller == NAH │ └──────────────┬───────────────────┘ │ MintNativeTo(ctx, recipient, amount) ▼ ┌──────────────────────────────────┐ │ x/ghostmint Keeper │ ← Cosmos SDK module │ Wraps x/bank module │ └──────────────┬───────────────────┘ │ MintCoins → SendCoinsFromModuleToAccount ▼ ┌──────────────────────────────────┐ │ x/bank Module │ ← Native Cosmos token ledger │ (aghost balance updated) │ └──────────────────────────────────┘ ``` ## Module Types ```go package types const ( // ModuleName defines the module name ModuleName = "ghostmint" // StoreKey defines the primary module store key StoreKey = ModuleName // RouterKey defines the module's message routing key RouterKey = ModuleName ) // NativeDenom is the denomination of the native token that can be minted const NativeDenom = "aghost" ``` ## Keeper The GhostMint keeper is a thin wrapper around the `x/bank` module's keeper. It exposes a single operation: mint native tokens and send them to a recipient address. ### `MintNativeTo` ```go // MintNativeTo mints native GHOST tokens and sends them to the recipient. // This is the only way new GHOST enters circulation post-genesis. func (k Keeper) MintNativeTo( ctx sdk.Context, recipient sdk.AccAddress, amount sdk.Int, ) error { // 1. Validate amount is positive if !amount.IsPositive() { return ErrInvalidAmount } // 2. Validate recipient address if recipient.Empty() { return ErrInvalidRecipient } // 3. Create the coin to mint coins := sdk.NewCoins(sdk.NewCoin(NativeDenom, amount)) // 4. Mint coins to the ghostmint module account if err := k.bankKeeper.MintCoins(ctx, ModuleName, coins); err != nil { return err } // 5. Send coins from module account to recipient if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, ModuleName, recipient, coins); err != nil { return err } return nil } ``` ### Execution Flow 1. **Validate amount** — The amount must be a positive integer. Zero or negative amounts are rejected with `ErrInvalidAmount`. 2. **Validate recipient** — The recipient address must be a valid, non-empty Cosmos account address. Invalid recipients are rejected with `ErrInvalidRecipient`. 3. **Mint to module account** — Coins are first minted to the `ghostmint` module's own account using `x/bank.MintCoins`. This increases the total supply. 4. **Transfer to recipient** — Coins are then transferred from the module account to the recipient using `x/bank.SendCoinsFromModuleToAccount`. ## Errors | Error | Code | Description | |---|---|---| | `ErrInvalidAmount` | 2 | Amount is zero, negative, or otherwise invalid | | `ErrInvalidRecipient` | 3 | Recipient address is empty or malformed | | `ErrUnauthorized` | 4 | Caller is not the authorized NativeAssetHandler contract | ```go var ( ErrInvalidAmount = errorsmod.Register(ModuleName, 2, "invalid amount") ErrInvalidRecipient = errorsmod.Register(ModuleName, 3, "invalid recipient") ErrUnauthorized = errorsmod.Register(ModuleName, 4, "unauthorized") ) ``` ## EVM Precompile The GhostMint module exposes its functionality to the EVM through a **precompiled contract** — a special contract that exists at a fixed address and executes native Go code rather than EVM bytecode. ### Precompile Address ``` 0x0000000000000000000000000000000000000808 ``` This is a reserved address in the EVM address space. Any `CALL` to this address triggers the GhostMint precompile rather than executing EVM bytecode. ### Interface The precompile exposes a single function: ```solidity // Solidity interface for the GhostMint precompile interface IGhostMint { /// @notice Mints native GHOST tokens and sends them to the recipient. /// @param recipient The EVM address to receive the minted tokens. /// @param amount The amount of aghost (smallest unit) to mint. /// @dev Only callable by the authorized NativeAssetHandler contract. function mintNativeTo(address recipient, uint256 amount) external; } ``` ### ABI Encoding The function selector for `mintNativeTo(address,uint256)` is computed as: ``` keccak256("mintNativeTo(address,uint256)") = 0x... (first 4 bytes) ``` A call to the precompile is ABI-encoded as: ``` [4-byte selector][32-byte address (left-padded)][32-byte uint256 amount] ``` ### Authorization The precompile enforces a strict authorization check: **only the NativeAssetHandler contract is allowed to call `mintNativeTo`**. This is enforced at the protocol level — the precompile checks `msg.sender` against the authorized address before executing. ```go func (p *Precompile) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error) { // Reject static calls (read-only context) if readonly { return nil, vm.ErrWriteProtection } // Verify caller is the authorized NativeAssetHandler if contract.CallerAddress != p.authorizedCaller { return nil, ErrUnauthorized } // Parse ABI-encoded arguments recipient, amount, err := parseArgs(contract.Input) if err != nil { return nil, err } // Convert EVM address to Cosmos address cosmosAddr := sdk.AccAddress(recipient.Bytes()) // Execute the mint if err := p.keeper.MintNativeTo(evm.StateDB.GetContext(), cosmosAddr, amount); err != nil { return nil, err } return nil, nil } ``` This means: - Random EOAs (externally owned accounts) cannot call the precompile. - Arbitrary smart contracts cannot call the precompile. - Only the single, protocol-designated NativeAssetHandler contract can trigger minting. - The authorized caller address is set at chain initialization and cannot be changed without a governance upgrade. ## NativeAssetHandler Contract The NativeAssetHandler is a Solidity smart contract deployed on the Specter EVM. It is the sole contract authorized to interact with the GhostMint precompile. It serves as the bridge between the GHOST privacy protocol (which handles zero-knowledge proof verification, commitment tracking, and nullifier management) and the native minting layer. When a user completes a valid withdrawal from the privacy pool: 1. The privacy protocol contract verifies the zero-knowledge proof. 2. It calls the NativeAssetHandler to release funds. 3. The NativeAssetHandler calls `mintNativeTo` on the GhostMint precompile. 4. The precompile invokes the `x/ghostmint` keeper, which mints native GHOST via `x/bank`. 5. The newly minted GHOST arrives in the recipient's account as native tokens. ## Security Considerations ### Single Point of Authorization The entire minting capability is gated behind a single authorized contract. This is a deliberate design choice: - **Pro**: Minimal attack surface. Only one contract can trigger minting, and that contract's logic is auditable. - **Pro**: No governance overhead for individual mints — the authorization is baked into the protocol. - **Con**: If the NativeAssetHandler contract has a vulnerability, it could be exploited to mint arbitrary amounts. This is mitigated by extensive auditing and the simplicity of the handler's logic. ### Supply Integrity Every mint operation goes through `x/bank.MintCoins`, which properly updates the chain's total supply tracking. This means: - The total supply reported by the chain is always accurate. - Block explorers and analytics tools can track minting events. - There is no hidden or unaccounted-for token creation. ### Immutability The authorized caller address for the precompile is set at the protocol level. Changing it requires a coordinated software upgrade, not just a transaction. This prevents any single actor from redirecting minting authority. ## Querying GhostMint State ### Total Minted Supply Since GhostMint uses `x/bank` under the hood, you can query the total supply of GHOST: ```bash # Query total supply of aghost umbralined query bank total --denom aghost # Query the ghostmint module account balance (should be 0 if all mints are sent) umbralined query bank balances $(umbralined keys show ghostmint-module -a) ``` ### EVM-Side Queries From the EVM side, the minting is transparent — the recipient simply sees an increased native balance: ```javascript const { ethers } = require("ethers"); const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com"); // Check balance after a withdrawal (mint) const balance = await provider.getBalance("0xRecipientAddress"); console.log("Balance:", ethers.formatEther(balance), "GHOST"); ``` ## Relationship to the Privacy Protocol The GhostMint module is one half of the privacy equation: | Operation | Module | Direction | |---|---|---| | **Deposit** (enter privacy pool) | `x/bank` burn (via EVM) | GHOST leaves circulation | | **Withdrawal** (exit privacy pool) | `x/ghostmint` mint (via precompile) | GHOST enters circulation | The total circulating supply of GHOST remains constant (assuming equal deposits and withdrawals). The module does not create inflation — it re-materializes tokens that were previously burned during deposits. ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/validator-guide ================================================================ # Validator Guide This guide covers everything needed to run a Specter validator, from initial setup to ongoing maintenance, slashing parameters, and key operational commands. Validators are responsible for proposing and signing blocks, and they earn staking rewards in return for securing the network. ## Prerequisites Before becoming a validator, you must have a fully synced Specter full node. Complete the [Node Setup](./node-setup.md) guide first and confirm your node is caught up: ```bash umbralined status 2>&1 | jq '.SyncInfo.catching_up' # Must return: false ``` ### Hardware Requirements Validators have stricter requirements than full nodes due to the real-time demands of consensus: | Resource | Minimum | Recommended | |---|---|---| | **CPU** | 4 cores | 8+ cores (high single-thread performance) | | **RAM** | 16 GB | 32 GB | | **Storage** | 500 GB NVMe SSD | 1 TB NVMe SSD | | **Network** | 100 Mbps, low latency | 1 Gbps, under 50ms to peers | | **Uptime** | 99%+ | 99.9%+ | ### Software Requirements - Go 1.21+ - `umbralined` binary (built from source or official release) - `jq` for JSON processing ### Stake Requirements To create a validator, you need a minimum self-delegation of GHOST tokens. The exact minimum depends on the current active set size, but you need enough stake to be in the top N validators by total stake (including delegations). ## Create a Validator ### Step 1: Create or Import a Key ```bash # Create a new key umbralined keys add my-validator-key # OR import an existing mnemonic umbralined keys add my-validator-key --recover ``` **Save the mnemonic phrase securely.** This is the only way to recover your key. Loss of the mnemonic means permanent loss of access to your validator and all staked funds. ```bash # Verify your key was created umbralined keys show my-validator-key -a # Returns: umbra1... # Show the validator's public key (needed for create-validator tx) umbralined tendermint show-validator # Returns: {"@type":"/cosmos.crypto.ed25519.PubKey","key":"..."} ``` ### Step 2: Fund Your Account Your validator account needs GHOST to cover the self-delegation and transaction fees. ```bash # Check your balance umbralined query bank balances $(umbralined keys show my-validator-key -a) ``` On testnet, use the faucet at the Specter Discord or request tokens from the faucet address. ### Step 3: Create the Validator ```bash umbralined tx staking create-validator \ --amount=1000000000000000000000aghost \ --pubkey=$(umbralined tendermint show-validator) \ --moniker="my-validator" \ --chain-id=umbraline_5446-1 \ --commission-rate="0.10" \ --commission-max-rate="0.20" \ --commission-max-change-rate="0.01" \ --min-self-delegation="1" \ --gas="auto" \ --gas-adjustment=1.5 \ --gas-prices="0.001aghost" \ --from=my-validator-key ``` **Parameter breakdown:** | Parameter | Value | Description | |---|---|---| | `--amount` | `1000000000000000000000aghost` | Initial self-delegation (1000 GHOST) | | `--commission-rate` | `0.10` | 10% commission on delegator rewards | | `--commission-max-rate` | `0.20` | Maximum commission rate (can never exceed this) | | `--commission-max-change-rate` | `0.01` | Maximum daily commission change (1%/day) | | `--min-self-delegation` | `1` | Minimum self-delegation in GHOST | :::warning `--commission-max-rate` and `--commission-max-change-rate` are set at validator creation and **cannot be changed later**. Choose values carefully. ::: ### Step 4: Verify Your Validator ```bash # Check validator status umbralined query staking validator $(umbralined keys show my-validator-key --bech val -a) # Check if your validator is in the active set umbralined query staking validators --status bonded | grep "my-validator" # Check your validator's signing status umbralined query slashing signing-info $(umbralined tendermint show-validator) ``` ## Managing Your Validator ### Edit Validator Update your validator's description, commission rate, or other mutable fields: ```bash umbralined tx staking edit-validator \ --moniker="new-moniker" \ --details="Specter validator operated by Example Corp" \ --website="https://example.com" \ --identity="KEYBASE_ID" \ --commission-rate="0.12" \ --from=my-validator-key \ --chain-id=umbraline_5446-1 \ --gas="auto" \ --gas-prices="0.001aghost" ``` The `--identity` field should be a Keybase.io PGP key fingerprint (16 characters), which links your validator to a verifiable identity. ### Delegate Additional Stake ```bash umbralined tx staking delegate \ $(umbralined keys show my-validator-key --bech val -a) \ 500000000000000000000aghost \ --from=my-validator-key \ --chain-id=umbraline_5446-1 \ --gas="auto" \ --gas-prices="0.001aghost" ``` ### Withdraw Rewards ```bash # Withdraw staking rewards umbralined tx distribution withdraw-rewards \ $(umbralined keys show my-validator-key --bech val -a) \ --from=my-validator-key \ --chain-id=umbraline_5446-1 \ --gas="auto" \ --gas-prices="0.001aghost" # Withdraw rewards AND commission umbralined tx distribution withdraw-rewards \ $(umbralined keys show my-validator-key --bech val -a) \ --commission \ --from=my-validator-key \ --chain-id=umbraline_5446-1 \ --gas="auto" \ --gas-prices="0.001aghost" ``` ### Unjail a Validator If your validator is jailed (due to downtime or other infractions), you can unjail after the jail period expires: ```bash # Check jail status umbralined query slashing signing-info $(umbralined tendermint show-validator) # Unjail (after the minimum jail duration has passed) umbralined tx slashing unjail \ --from=my-validator-key \ --chain-id=umbraline_5446-1 \ --gas="auto" \ --gas-prices="0.001aghost" ``` ## Slashing Slashing is the protocol's mechanism for penalizing validators that act against network interests. There are two slashing conditions: ### Downtime (Liveness Fault) A validator is slashed for downtime if they fail to sign a sufficient number of blocks within a rolling window. | Parameter | Value | |---|---| | **Slash Fraction** | 1% of staked tokens | | **Jail Duration** | 10 minutes | | **Signed Blocks Window** | 100 blocks | | **Minimum Signed Per Window** | 50% (50 of 100 blocks) | **How it works:** 1. The chain tracks the last 100 blocks. 2. If a validator misses more than 50 of those 100 blocks, they are automatically jailed. 3. 1% of the validator's total stake (including delegator stake) is slashed. 4. After 10 minutes, the validator can submit an `unjail` transaction to rejoin the active set. **Common causes of downtime:** - Node crash or out-of-memory - Network partition or connectivity issues - Disk full - Misconfigured firewall blocking P2P port ### Double Signing (Equivocation) Double signing occurs when a validator signs two different blocks at the same height. This is a severe offense because it can lead to chain forks. | Parameter | Value | |---|---| | **Slash Fraction** | 5% of staked tokens | | **Jail Duration** | Permanent (tombstoned) | **How it works:** 1. CometBFT's evidence module detects that a validator produced two conflicting signatures for the same block height. 2. The validator is immediately jailed and **tombstoned** — permanently banned from the active validator set. 3. 5% of the validator's total stake is slashed. 4. The validator can never unjail. A new validator must be created with a new key. **Common causes of double signing:** - Running two instances of the same validator simultaneously (e.g., during migration without stopping the old node first) - Restoring a validator from backup without ensuring the old instance is fully stopped - Using the same `priv_validator_key.json` on multiple machines :::danger Double signing results in **permanent removal** from the validator set (tombstoning). There is no recovery. Always ensure only one instance of your validator is running at any time. ::: ### Evidence Module The evidence module is responsible for detecting and processing equivocation (double-sign) evidence. When a CometBFT node receives conflicting votes from the same validator for the same height/round, it packages this as evidence and includes it in the next block. Key parameters: | Parameter | Value | Description | |---|---|---| | `max_age_num_blocks` | 100,000 | Evidence older than this is discarded | | `max_age_duration` | 48 hours | Time-based evidence expiry | | `max_bytes` | 1,048,576 | Maximum evidence size per block (1 MB) | Evidence is processed at `BeginBlock` — before any transactions in the block are executed. This ensures slashing happens promptly. ## Monitoring ### Essential Metrics to Monitor | Metric | What to Watch | Alert Threshold | |---|---|---| | **Block signing** | Consecutive missed blocks | >10 consecutive misses | | **Peer count** | Number of connected peers | Fewer than 5 peers | | **Disk usage** | Available storage | Under 20% remaining | | **Memory usage** | RAM consumption | >80% utilization | | **Block height** | Chain sync status | >10 blocks behind | | **Process status** | `umbralined` running | Process not found | ### Prometheus Metrics Enable Prometheus metrics in `config.toml`: ```toml [instrumentation] prometheus = true prometheus_listen_addr = ":26660" ``` Key Prometheus metrics to track: ``` # Consensus height cometbft_consensus_height # Number of connected peers cometbft_p2p_peers # Missed blocks cometbft_consensus_missing_validators # Block processing time cometbft_consensus_block_interval_seconds # Transaction count cometbft_consensus_num_txs ``` ### Health Check Script ```bash #!/bin/bash # validator-health-check.sh NODE_STATUS=$(umbralined status 2>&1) CATCHING_UP=$(echo "$NODE_STATUS" | jq -r '.SyncInfo.catching_up') LATEST_HEIGHT=$(echo "$NODE_STATUS" | jq -r '.SyncInfo.latest_block_height') PEER_COUNT=$(curl -s http://localhost:26657/net_info | jq -r '.result.n_peers') echo "Height: $LATEST_HEIGHT" echo "Catching Up: $CATCHING_UP" echo "Peers: $PEER_COUNT" if [ "$CATCHING_UP" = "true" ]; then echo "WARNING: Node is still syncing!" fi if [ "$PEER_COUNT" -lt 5 ]; then echo "WARNING: Low peer count ($PEER_COUNT)" fi # Check if validator is jailed VALIDATOR_ADDR=$(umbralined keys show my-validator-key --bech val -a) JAILED=$(umbralined query staking validator "$VALIDATOR_ADDR" -o json 2>/dev/null | jq -r '.jailed') if [ "$JAILED" = "true" ]; then echo "CRITICAL: Validator is JAILED!" fi ``` ## Backup and Recovery ### Critical Files to Back Up | File | Location | Purpose | |---|---|---| | `priv_validator_key.json` | `~/.umbralined/config/` | Validator signing key (**most critical**) | | `node_key.json` | `~/.umbralined/config/` | P2P identity key | | Mnemonic phrase | Offline storage | Account recovery | ### Backup Procedure ```bash # Back up validator key (store securely, preferably offline/encrypted) cp ~/.umbralined/config/priv_validator_key.json /secure/backup/location/ # Back up node key cp ~/.umbralined/config/node_key.json /secure/backup/location/ ``` ### Recovery Procedure ```bash # 1. Set up a new node (follow Node Setup guide) umbralined init recovery-node --chain-id umbraline_5446-1 # 2. STOP the old validator completely (critical to prevent double signing!) # Verify the old instance is fully stopped before proceeding # 3. Restore the validator key cp /secure/backup/location/priv_validator_key.json ~/.umbralined/config/ # 4. Download genesis and configure peers # (follow Node Setup guide) # 5. Sync the node (state sync recommended for speed) # 6. Verify sync, then the validator will resume signing automatically ``` :::danger **Never run two instances with the same `priv_validator_key.json` simultaneously.** This will cause double signing and result in permanent tombstoning. Always fully stop the old instance before starting a new one. ::: ## Key Reference Commands ### Validator Status ```bash # View your validator info umbralined query staking validator $(umbralined keys show my-validator-key --bech val -a) # View all active validators umbralined query staking validators --status bonded # View signing info (missed blocks, jail status) umbralined query slashing signing-info $(umbralined tendermint show-validator) # View your outstanding rewards umbralined query distribution rewards $(umbralined keys show my-validator-key -a) # View your validator's commission umbralined query distribution commission $(umbralined keys show my-validator-key --bech val -a) ``` ### Staking Operations ```bash # Delegate to your own validator umbralined tx staking delegate aghost \ --from=my-validator-key --chain-id=umbraline_5446-1 --gas=auto --gas-prices=0.001aghost # Redelegate from one validator to another (no unbonding period) umbralined tx staking redelegate aghost \ --from=my-validator-key --chain-id=umbraline_5446-1 --gas=auto --gas-prices=0.001aghost # Unbond (start unbonding, subject to unbonding period) umbralined tx staking unbond aghost \ --from=my-validator-key --chain-id=umbraline_5446-1 --gas=auto --gas-prices=0.001aghost ``` ### Node Operations ```bash # Check node sync status umbralined status | jq '.SyncInfo' # View connected peers curl -s http://localhost:26657/net_info | jq '.result.peers[] | {id: .node_info.id, moniker: .node_info.moniker, remote_ip: .remote_ip}' # View the latest block umbralined query block # Export state (for debugging or migration) umbralined export --height > state_export.json ``` ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/governance ================================================================ # Governance Specter uses the Cosmos SDK `x/gov` module for on-chain governance. Any GHOST token holder can submit proposals, deposit tokens to bring proposals to a vote, and vote on active proposals. Validators vote on behalf of their delegators by default, but delegators can override their validator's vote at any time. ## Proposal Types | Type | Description | Use Case | |---|---|---| | **Text** | A signaling proposal with no automatic on-chain execution | Community sentiment, non-binding resolutions, RFC-style discussions | | **Parameter Change** | Modifies one or more on-chain module parameters | Adjusting gas prices, staking parameters, slashing thresholds | | **Software Upgrade** | Schedules a coordinated binary upgrade at a specified block height | Chain upgrades, new module deployments | | **Community Pool Spend** | Transfers funds from the community pool to a recipient address | Grants, ecosystem funding, bounties | ## Proposal Lifecycle Every proposal goes through the following stages: ``` Submit Proposal → Deposit Period → Voting Period → Tallying → Execution (48 hours) (48 hours) ``` ### 1. Submit Proposal Any account can submit a proposal by paying a transaction fee. The proposal enters the **deposit period** immediately. ```bash # Submit a text proposal umbralined tx gov submit-proposal \ --type text \ --title "Proposal Title" \ --description "Detailed description of the proposal and its rationale." \ --deposit 5000000000000000000aghost \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` ### 2. Deposit Period (48 Hours) After submission, the proposal must accumulate the **minimum deposit** before the voting period begins. If the minimum deposit is not reached within the deposit period, the proposal is removed and all deposits are burned. | Parameter | Value | |---|---| | **Minimum Deposit** | 10 GHOST (10,000,000,000,000,000,000 aghost) | | **Deposit Period** | 48 hours | | **Deposit Refund** | Deposits are refunded if the proposal is not vetoed | | **Deposit Burn** | Deposits are burned if the proposal is vetoed (>33.4% NoWithVeto) or fails to reach minimum deposit | ```bash # Deposit additional tokens to an existing proposal umbralined tx gov deposit 5000000000000000000aghost \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` Multiple accounts can contribute to the deposit. Once the total reaches 10 GHOST, the proposal immediately enters the voting period. ### 3. Voting Period (48 Hours) During the voting period, all staked GHOST holders (validators and their delegators) can cast a vote. #### Vote Options | Option | Effect | |---|---| | **Yes** | Support the proposal | | **No** | Oppose the proposal | | **Abstain** | Decline to vote for/against, but count toward quorum | | **NoWithVeto** | Oppose the proposal and signal it is harmful/spam. If >33.4% of votes are NoWithVeto, deposits are burned | ```bash # Cast a vote umbralined tx gov vote yes \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` Valid vote options: `yes`, `no`, `abstain`, `no_with_veto`. ### 4. Tallying At the end of the voting period, votes are tallied according to these rules: | Criterion | Threshold | What Happens if Not Met | |---|---|---| | **Quorum** | 33.4% of staked GHOST must vote | Proposal fails, deposits are burned | | **Threshold** | >50% of non-abstain votes must be Yes | Proposal fails, deposits are refunded | | **Veto** | Under 33.4% of total votes must be NoWithVeto | Proposal fails, deposits are **burned** | The tallying logic (in order): 1. **Check quorum**: If fewer than 33.4% of all staked tokens voted (including Abstain), the proposal fails and deposits are burned. 2. **Check veto**: If more than 33.4% of total votes are NoWithVeto, the proposal fails and deposits are burned. 3. **Check threshold**: Among non-abstain votes, if more than 50% are Yes, the proposal passes. Otherwise it fails and deposits are refunded. ### 5. Execution - **Text proposals**: No automatic execution. They serve as a recorded signal of community intent. - **Parameter change proposals**: The parameter change is applied immediately at the end of the voting period. - **Software upgrade proposals**: The upgrade is scheduled for the specified block height. Validators must upgrade their binary before that height. - **Community pool spend proposals**: Funds are transferred from the community pool to the specified recipient immediately. ## Governance Parameters | Parameter | Value | Module | |---|---|---| | `min_deposit` | 10 GHOST | `x/gov` | | `max_deposit_period` | 48 hours | `x/gov` | | `voting_period` | 48 hours | `x/gov` | | `quorum` | 0.334 (33.4%) | `x/gov` | | `threshold` | 0.500 (50%) | `x/gov` | | `veto_threshold` | 0.334 (33.4%) | `x/gov` | | `burn_vote_quorum` | true | `x/gov` | | `burn_vote_veto` | true | `x/gov` | These parameters are themselves governable — they can be changed through a parameter change proposal. ### Query Current Parameters ```bash # View all governance parameters umbralined query gov params # View deposit parameters umbralined query gov params --subspace deposit # View voting parameters umbralined query gov params --subspace voting # View tallying parameters umbralined query gov params --subspace tallying ``` ## Delegator Voting Delegators inherit their validator's vote by default. However, delegators can cast their own vote to override this default at any time during the voting period. **How vote weight works:** - A delegator's vote weight equals the amount of GHOST they have staked. - If a delegator votes, their vote overrides the validator's vote for their staked amount. - If a delegator does not vote, their staked tokens are counted according to their validator's vote. - If a delegator's tokens are split across multiple validators, and only some validators vote, only the portions with voting validators are counted. ```bash # As a delegator, override your validator's vote umbralined tx gov vote no \ --from my-delegator-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` :::tip Delegators are encouraged to vote directly on all proposals. This is your mechanism for participating in the governance of the Specter network, and it ensures your voice is heard even if your validator votes differently than you prefer. ::: ## Submitting Specific Proposal Types ### Parameter Change Proposal ```bash umbralined tx gov submit-proposal param-change proposal.json \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` Where `proposal.json` contains: ```json { "title": "Increase Minimum Gas Price", "description": "This proposal increases the recommended minimum gas price from 0.001aghost to 0.01aghost to better protect against spam transactions during periods of high network load.", "changes": [ { "subspace": "evm", "key": "MinGasPrice", "value": "\"0.01\"" } ], "deposit": "10000000000000000000aghost" } ``` ### Software Upgrade Proposal ```bash umbralined tx gov submit-proposal software-upgrade v2.0.0 \ --title "Upgrade to v2.0.0" \ --description "This upgrade introduces improved EVM gas metering and a new x/ghostmint audit trail feature." \ --upgrade-height 500000 \ --upgrade-info '{"binaries":{"linux/amd64":"https://github.com/specter-chain/umbraline-cosmos/releases/download/v2.0.0/umbralined-v2.0.0-linux-amd64"}}' \ --deposit 10000000000000000000aghost \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` ### Community Pool Spend Proposal ```bash umbralined tx gov submit-proposal community-pool-spend proposal.json \ --from my-key \ --chain-id umbraline_5446-1 \ --gas auto \ --gas-prices 0.001aghost ``` Where `proposal.json` contains: ```json { "title": "Fund Privacy Research Grant", "description": "Allocate 50,000 GHOST from the community pool to fund ZK circuit optimization research by the Specter Labs team.", "recipient": "umbra1recipient...", "amount": "50000000000000000000000aghost", "deposit": "10000000000000000000aghost" } ``` ## Querying Proposals ```bash # List all proposals umbralined query gov proposals # View a specific proposal umbralined query gov proposal # View the deposit status of a proposal umbralined query gov deposits # View all votes on a proposal umbralined query gov votes # View the current tally of a proposal umbralined query gov tally # View your vote on a proposal umbralined query gov vote $(umbralined keys show my-key -a) ``` ## Best Practices ### For Proposal Authors 1. **Discuss first.** Before submitting an on-chain proposal, discuss it in the Specter governance forum or Discord. Gather feedback and refine the proposal text. 2. **Be specific.** Clearly state what the proposal does, why it is needed, and what the expected impact is. For parameter changes, include before/after values. 3. **Provide context.** Link to relevant discussions, research, audits, or technical documentation. 4. **Start with a text proposal.** For contentious changes, submit a non-binding text proposal first to gauge community sentiment before submitting the binding version. 5. **Fund the deposit yourself** (if possible) to demonstrate conviction in the proposal. ### For Validators 1. **Vote on every proposal.** Your delegators trust you to represent their interests. If you abstain or do not vote, their tokens may not be counted. 2. **Communicate your rationale.** Publish your voting rationale to delegators before the voting period ends so they can override if they disagree. 3. **Monitor the deposit period.** Deposit on proposals you believe deserve a vote, even if you plan to vote No. ### For Delegators 1. **Check active proposals regularly.** Use `umbralined query gov proposals --status voting_period` to see what is up for vote. 2. **Vote directly** when you have a strong opinion. Your vote overrides your validator's vote. 3. **Use NoWithVeto carefully.** This option is reserved for proposals that are spam or fundamentally harmful. If the veto threshold is reached, the proposer's deposit is burned. ================================================================ SECTION: Blockchain SOURCE: https://docs.specterchain.com/blockchain/cosmos-evm-workarounds ================================================================ # Cosmos EVM Workarounds Specter is built on a Cosmos SDK + EVM architecture (Ethermint-derived). While this provides the benefits of both ecosystems, there are known compatibility gaps where standard Ethereum JSON-RPC methods do not behave as expected. These are **not Specter bugs** — they are inherent limitations of running an EVM inside a Cosmos consensus engine. This page documents the known issues and the recommended workarounds. ## eth_getLogs Returns Empty Arrays ### The Problem The standard Ethereum JSON-RPC method `eth_getLogs` is unreliable on Cosmos EVM chains. Queries that would return results on Ethereum or other EVM chains frequently return empty arrays, even when matching events were clearly emitted in the specified block range. ```javascript // This often returns [] even when events exist const logs = await provider.getLogs({ address: contractAddress, topics: [commitmentEventTopic], fromBlock: depositBlockNumber, toBlock: depositBlockNumber, }); console.log(logs); // [] — empty, even though the event was emitted ``` This affects any code that relies on event log queries, including: - Fetching deposit `Commitment` events from the privacy pool contract - Monitoring contract events via polling - Historical event indexing using standard Web3 libraries ### The Root Cause Cosmos EVM implementations store EVM logs differently than native Ethereum clients. The Tendermint block structure and transaction indexing do not map cleanly to the Ethereum log filter API. The EVM module's log indexing is best-effort and may miss events, especially during periods of high throughput or when querying historical ranges. ### The Workaround Use a dedicated indexer service instead of `eth_getLogs`. The Specter indexer provides a `POST /commitment` endpoint that reliably returns commitment data: ```javascript // Instead of eth_getLogs, use the indexer API const response = await fetch("https://indexer.specterchain.com/commitment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contractAddress: poolContractAddress, fromBlock: startBlock, toBlock: endBlock, }), }); const commitments = await response.json(); ``` For non-commitment events, run your own indexer that processes blocks sequentially using `eth_getBlockByNumber` with full transaction receipts, rather than relying on log filters. ### Alternative: Block-by-Block Scanning If you cannot use the indexer, you can scan blocks individually: ```javascript async function getEventsFromBlock(provider, blockNumber, contractAddress, eventTopic) { const block = await provider.getBlockWithTransactions(blockNumber); const events = []; for (const tx of block.transactions) { if (tx.to?.toLowerCase() === contractAddress.toLowerCase()) { const receipt = await provider.getTransactionReceipt(tx.hash); if (receipt && receipt.logs) { for (const log of receipt.logs) { if (log.topics[0] === eventTopic) { events.push(log); } } } } } return events; } ``` This is slower but more reliable than `eth_getLogs`. --- ## eth_getTransactionReceipt Parse Errors ### The Problem Calling `eth_getTransactionReceipt` for certain transactions returns parse errors or malformed data instead of a valid receipt object. This is particularly common with transactions that involve Cosmos-level operations (e.g., transactions that trigger `x/ghostmint` minting via the precompile). ```javascript try { const receipt = await provider.getTransactionReceipt(txHash); // May throw: "could not decode transaction receipt" // Or return a receipt with missing/malformed fields } catch (error) { console.error(error.message); // "missing response for request" or similar parse error } ``` ### The Root Cause When an EVM transaction triggers a Cosmos-level state change (such as minting via the `x/ghostmint` precompile), the resulting transaction receipt may contain fields that standard Ethereum client libraries cannot parse. The Cosmos SDK processes these transactions differently from pure EVM transactions, and the receipt encoding does not always conform to the Ethereum receipt RLP specification. ### The Workaround Instead of relying on transaction receipts to obtain contract addresses or event data, compute the expected values directly. #### Computing Contract Addresses If you need the address of a contract deployed in a transaction, compute it from the deployer address and nonce: ```javascript const { ethers } = require("ethers"); // Compute the contract address deterministically function getDeployedContractAddress(deployerAddress, nonce) { return ethers.getCreateAddress({ from: deployerAddress, nonce: nonce, }); } // Example: deployer's first contract deployment (nonce = 0) const contractAddress = getDeployedContractAddress( "0xDeployerAddress...", 0 ); console.log("Contract deployed at:", contractAddress); ``` For `CREATE2` deployments, compute the address using the factory, salt, and init code hash: ```javascript const contractAddress = ethers.getCreate2Address( factoryAddress, salt, initCodeHash ); ``` #### Verifying Transaction Success If you cannot parse the receipt, verify transaction inclusion by checking the transaction itself: ```javascript // Check if the transaction was included in a block const tx = await provider.getTransaction(txHash); if (tx && tx.blockNumber) { console.log("Transaction included in block:", tx.blockNumber); // Verify the expected state change occurred const balance = await provider.getBalance(recipientAddress); console.log("Recipient balance:", ethers.formatEther(balance)); } ``` --- ## leafIndex Must Be Obtained On-Chain Before Vanish ### The Problem In the GHOST privacy protocol, when a user deposits tokens into the shielded pool, a `Commitment` event is emitted containing a `leafIndex` — the position of the commitment in the Merkle tree. This `leafIndex` is required to later construct a valid withdrawal proof. On standard Ethereum, you would retrieve the `leafIndex` from the transaction receipt's logs. On Specter's Cosmos EVM, this is unreliable due to the `eth_getLogs` and `eth_getTransactionReceipt` issues described above. ```javascript // This approach is UNRELIABLE on Cosmos EVM const receipt = await provider.getTransactionReceipt(depositTxHash); const leafIndex = parseLeafIndexFromReceipt(receipt); // May fail or return wrong value ``` ### Why This Is Critical The `leafIndex` is essential for constructing the Merkle proof needed for withdrawal. If you lose the `leafIndex`, you cannot prove that your commitment exists in the tree, and your deposited funds become unrecoverable. The term "vanish" refers to the commitment data becoming difficult to retrieve after the fact — once the transaction is buried deep enough in the chain history, reconstructing the `leafIndex` from event logs becomes increasingly unreliable. ### The Workaround Query the `leafIndex` from the contract's on-chain state **immediately after the deposit transaction is confirmed**, before relying on event logs. ```javascript // Read leafIndex directly from contract state const pool = new ethers.Contract(poolAddress, poolABI, provider); // Option 1: Query the next leaf index before and after deposit const leafIndexBefore = await pool.nextIndex(); // ... submit deposit transaction and wait for confirmation ... const leafIndexAfter = await pool.nextIndex(); const myLeafIndex = leafIndexBefore; // Your commitment's index // Option 2: Use the indexer API const response = await fetch("https://indexer.specterchain.com/commitment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contractAddress: poolAddress, leafIndex: expectedLeafIndex, }), }); const commitmentData = await response.json(); ``` ### Best Practice: Store Deposit Data Immediately Always persist the following data locally immediately after a deposit is confirmed: ```javascript const depositRecord = { txHash: depositTx.hash, blockNumber: depositTx.blockNumber, leafIndex: myLeafIndex, // Queried on-chain immediately commitment: commitmentHash, // Computed locally nullifierHash: nullifierHash, // Computed locally secret: secret, // User's secret (store securely!) timestamp: Date.now(), }; // Save to local storage, encrypted file, etc. saveDepositRecord(depositRecord); ``` Do not rely on being able to reconstruct this data later from chain queries. --- ## Summary of Workarounds | Standard Method | Issue on Cosmos EVM | Workaround | |---|---|---| | `eth_getLogs` | Returns empty arrays | Use indexer `POST /commitment` endpoint or block-by-block scanning | | `eth_getTransactionReceipt` | Parse errors / malformed data | Compute contract addresses from deployer+nonce; verify state changes directly | | leafIndex from receipt logs | Unreliable retrieval | Query on-chain state (`nextIndex`) immediately after deposit; persist locally | ## General Recommendations 1. **Do not assume Ethereum JSON-RPC parity.** Cosmos EVM implements the Ethereum JSON-RPC specification on a best-effort basis. Always test your integrations against a Specter node, not just Hardhat or Ganache. 2. **Use the indexer for event data.** The Specter indexer processes blocks at the Cosmos level and provides reliable event data. Prefer it over `eth_getLogs` for any production use case. 3. **Persist critical data client-side.** For privacy protocol interactions (deposits, commitments, leaf indices), store all necessary data locally at transaction time. Do not assume you can recover it later from the chain. 4. **Verify state changes, not receipts.** After submitting a transaction, verify the expected state change occurred (e.g., balance changed, commitment exists in the tree) rather than parsing the receipt. 5. **Handle errors gracefully.** Wrap all JSON-RPC calls in try/catch blocks and implement retry logic with exponential backoff. Transient failures are more common on Cosmos EVM than on native Ethereum clients. ```javascript async function reliableCall(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (i === maxRetries - 1) throw error; await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i))); } } } // Usage const balance = await reliableCall(() => provider.getBalance(address) ); ``` 6. **Report new issues.** If you encounter a Cosmos EVM compatibility issue not documented here, report it in the Specter Discord or GitHub repository. These gaps are tracked and addressed in chain upgrades where possible. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/overview ================================================================ # Contract Architecture The Specter protocol is composed of interconnected smart contracts deployed on the Specter chain (Chain ID `5446`). The architecture follows a modular design: a central vault manages commit-reveal flows, while peripheral contracts handle Merkle state, nullifier tracking, token management, policy enforcement, and native asset bridging. ## Architecture Diagram ```mermaid graph TD subgraph Core CRV[CommitRevealVault] CT[CommitmentTree] NR[NullifierRegistry] end subgraph Asset Layer NAH[NativeAssetHandler] AG[AssetGuard] GF[GhostERC20Factory] GT[GhostERC20 Tokens] end subgraph Policy Layer PR[PolicyRegistry] TE[TimelockExpiry] DR[DestinationRestriction] TW[ThresholdWitness] end subgraph Cryptographic Primitives GRV[GhostRedemptionVerifier] PT3[PoseidonT3] end subgraph Dead Man's Switch DMS[DMSRegistry] end CRV -->|recordCommitment| CT CRV -->|checkAndRecord| NR CRV -->|verifyProof| GRV CRV -->|burnNative / mintNativeTo| NAH CRV -->|isAuthorized| AG CRV -->|staticcall validateReveal| TE CRV -->|staticcall validateReveal| DR CRV -->|staticcall validateReveal| TW GF -->|deploys| GT GF -->|recordDeployment| AG GT -->|enableGhost| AG GT -->|Poseidon2 hash| PT3 PR -->|register| TE PR -->|register| DR PR -->|register| TW NAH -->|burn to 0xdead| NAH NAH -->|ghostmint 0x0808| NAH DMS -->|verifyProof| GRV DMS -->|isKnownRoot| CT ``` ## Contract Relationships | Contract | Role | Depends On | |---|---|---| | **CommitRevealVault** | Central privacy vault; accepts deposits (commits) and processes withdrawals (reveals) via ZK proofs | CommitmentTree, NullifierRegistry, GhostRedemptionVerifier, NativeAssetHandler, AssetGuard, Policy contracts | | **GhostRedemptionVerifier** | Verifies Groth16 ZK-SNARK proofs on-chain (BN254 curve) | None (pure verifier) | | **CommitmentTree** | Maintains an incremental Merkle tree of deposit commitments with a root history ring buffer | PoseidonT3 | | **NullifierRegistry** | Tracks spent nullifiers to prevent double-withdrawals | None | | **NativeAssetHandler** | Bridges native GHOST token between EVM and Cosmos layers via burn/mint mechanics | 0x0808 ghostmint precompile | | **AssetGuard** | Token whitelist; validates which ERC-20 tokens can enter the vault | None | | **GhostERC20Factory** | Deterministic CREATE2 deployment of GhostERC20 tokens | AssetGuard | | **GhostERC20** | Privacy-compatible ERC-20 with Poseidon-based token ID hashing | PoseidonT3, AssetGuard | | **PolicyRegistry** | Informational registry of available reveal policies | None | | **TimelockExpiry** | Policy: enforces time-window constraints on reveals | None | | **DestinationRestriction** | Policy: restricts reveal recipient to a single address or Merkle allowlist | None | | **ThresholdWitness** | Policy: requires M-of-N witness signatures to authorize a reveal | None | | **DMSRegistry** | Dead Man's Switch registry for key recovery and inheritance | GhostRedemptionVerifier, CommitmentTree | | **PoseidonT3** | Poseidon hash function (t=3, 2 inputs) used for commitment and token ID computation | None | ## Deployed Addresses (v4.5) Deployed on **March 4, 2026** to the Specter chain (Chain ID `5446`). | Contract | Address | |---|---| | CommitRevealVault | `0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a` | | GhostRedemptionVerifier | `0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee` | | CommitmentTree | `0xE29DD14998f6FE8e7862571c883090d14FE29475` | | NullifierRegistry | `0xaadb9c3394835B450023daA91Ad5a46beA6e43a1` | | NativeAssetHandler | `0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3` | | AssetGuard | `0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1` | | GhostERC20Factory | `0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95` | | PoseidonT3 | `0xacaef99b13d5846e3309017586de9f777da41548` | | PolicyRegistry | `0x2DC1641d5A32D6788264690D42710edC843Cb1db` | | TimelockExpiry | `0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c` | | DestinationRestriction | `0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1` | | ThresholdWitness | `0x5814e4755C0D98218ddb752D26dD03feba428c80` | | DMSRegistry | `0x14d5629136edAc7ef2b2E5956838b9Bb0211eB9d` | ## Commit-Reveal Flow 1. **Commit phase** -- A depositor calls `commit()` or `commitNative()` on the `CommitRevealVault`, providing a Pedersen commitment (and quantum-safe commitment). The vault checks token authorization via `AssetGuard`, transfers tokens, and calls `CommitmentTree.recordCommitment()` to insert the commitment leaf. 2. **Merkle root update** -- An off-chain operator computes the new Merkle root from the updated tree and calls `CommitmentTree.updateRoot()`. 3. **Reveal phase** -- A withdrawer generates a ZK-SNARK proof demonstrating knowledge of a valid commitment in the tree without revealing which one. The vault verifies the proof via `GhostRedemptionVerifier`, checks the nullifier via `NullifierRegistry`, enforces any attached policies, and releases funds. ## Policy Enforcement Policies are optional constraints attached at commit time. During reveal, the vault calls the policy contract via `staticcall` with a 100,000 gas cap. The policy must implement the `IRevealPolicy` interface and return `true` for the reveal to succeed. See the [Policy Overview](./policies/overview.md) for details. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/commit-reveal-vault ================================================================ # CommitRevealVault The `CommitRevealVault` is the central contract in the Specter protocol. It accepts token deposits (commits) paired with cryptographic commitments and processes privacy-preserving withdrawals (reveals) validated by ZK-SNARK proofs. **Deployed address:** `0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a` ## Constants ```solidity uint256 public constant BN254_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; ``` The BN254 scalar field modulus. All commitments and nullifiers must be less than this value. A **5-second cooldown** is enforced between consecutive operations from the same address to mitigate front-running and replay attacks. ## Functions ### commit ```solidity function commit( address token, uint256 amount, bytes32 commitment, bytes32 quantumCommitment ) external ``` Deposits ERC-20 tokens into the vault and records a commitment. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | ERC-20 token contract address. Must be authorized in `AssetGuard`. | | `amount` | `uint256` | Number of tokens to deposit (in token's smallest unit). | | `commitment` | `bytes32` | Pedersen commitment hash = Poseidon2(secret, nullifier). Must be < `BN254_SCALAR_FIELD`. | | `quantumCommitment` | `bytes32` | Quantum-safe commitment (e.g., hash-based) for future post-quantum migration. | **Behavior:** 1. Validates `token` is authorized via `AssetGuard.isAuthorized(token)`. 2. Transfers `amount` tokens from `msg.sender` to the vault via `transferFrom` (requires prior approval). 3. Calls `CommitmentTree.recordCommitment(commitment)` to insert the leaf. 4. Stores `quantumCommitments[commitment] = quantumCommitment`. 5. Emits `Committed`. **Reverts if:** - Token is not authorized. - Commitment is zero or >= `BN254_SCALAR_FIELD`. - Cooldown has not elapsed since the caller's last operation. - Token transfer fails. --- ### commitNative ```solidity function commitNative( bytes32 commitment, bytes32 quantumCommitment ) external payable ``` Deposits native GHOST tokens into the vault. The deposited GHOST is burned via `NativeAssetHandler.burnNative()`. **Parameters:** | Name | Type | Description | |---|---|---| | `commitment` | `bytes32` | Pedersen commitment hash. Must be < `BN254_SCALAR_FIELD`. | | `quantumCommitment` | `bytes32` | Quantum-safe commitment. | **Behavior:** 1. Forwards `msg.value` to `NativeAssetHandler.burnNative{value: msg.value}()`. 2. Records the commitment in the `CommitmentTree`. 3. Stores the quantum commitment. 4. Emits `Committed` with `token = address(0)`. --- ### commitWithPolicy ```solidity function commitWithPolicy( address token, uint256 amount, bytes32 commitment, bytes32 quantumCommitment, address policyId, bytes32 policyParamsHash ) external ``` Deposits ERC-20 tokens with an attached reveal policy. The policy will be enforced during the reveal phase. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | ERC-20 token contract address. | | `amount` | `uint256` | Number of tokens to deposit. | | `commitment` | `bytes32` | Pedersen commitment hash. | | `quantumCommitment` | `bytes32` | Quantum-safe commitment. | | `policyId` | `address` | Address of the policy contract implementing `IRevealPolicy`. | | `policyParamsHash` | `bytes32` | `keccak256` hash of the policy parameters that will be supplied at reveal time. | **Behavior:** 1. Performs all checks and transfers from `commit()`. 2. Stores `commitmentPolicies[commitment] = policyId`. 3. Stores `commitmentPolicyParamsHashes[commitment] = policyParamsHash`. 4. Emits `Committed`. --- ### commitNativeWithPolicy ```solidity function commitNativeWithPolicy( bytes32 commitment, bytes32 quantumCommitment, address policyId, bytes32 policyParamsHash ) external payable ``` Deposits native GHOST with an attached reveal policy. Combines the behavior of `commitNative` and `commitWithPolicy`. **Parameters:** | Name | Type | Description | |---|---|---| | `commitment` | `bytes32` | Pedersen commitment hash. | | `quantumCommitment` | `bytes32` | Quantum-safe commitment. | | `policyId` | `address` | Policy contract address. | | `policyParamsHash` | `bytes32` | Hash of policy parameters. | --- ### reveal ```solidity function reveal( address token, bytes calldata proof, uint256[8] calldata publicInputs, bytes32 commitment, bytes calldata quantumProof, bytes32 changeQuantumCommitment, bytes calldata policyParams ) external ``` Withdraws funds from the vault by providing a valid ZK-SNARK proof. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | Token to withdraw (`address(0)` for native GHOST). | | `proof` | `bytes` | Groth16 proof bytes (BN254 curve). | | `publicInputs` | `uint256[8]` | Public inputs to the proof circuit: `[root, nullifier, recipient, amount, tokenIdHash, policyId, policyParamsHash, changeCommitment]`. | | `commitment` | `bytes32` | The original commitment being spent. | | `quantumProof` | `bytes` | Quantum-safe proof data (reserved for future use). | | `changeQuantumCommitment` | `bytes32` | Quantum commitment for any change output. | | `policyParams` | `bytes` | ABI-encoded policy parameters; must hash to the stored `policyParamsHash`. | **Behavior:** 1. Validates the Merkle `root` from `publicInputs[0]` via `CommitmentTree.isKnownRoot()`. 2. Extracts the `nullifier` from `publicInputs[1]` and checks/records it via `NullifierRegistry.checkAndRecord()`. 3. Verifies the ZK proof via `GhostRedemptionVerifier.verifyProof(proof, publicInputs)`. 4. If a policy is attached (`commitmentPolicies[commitment] != address(0)`): - Validates `keccak256(policyParams) == commitmentPolicyParamsHashes[commitment]`. - Calls the policy via `staticcall` with a 100,000 gas cap. 5. Transfers tokens to `recipient` (from `publicInputs[2]`): - For native GHOST: calls `NativeAssetHandler.mintNativeTo(recipient, amount)`. - For ERC-20: transfers from vault balance. 6. Emits `Revealed`. **Reverts if:** - Root is not known. - Nullifier already spent. - Proof verification fails. - Policy validation fails. - Token transfer fails. ## Events ### Committed ```solidity event Committed( bytes32 indexed commitment, uint256 leafIndex, uint256 amount, address indexed token ); ``` Emitted when a deposit is recorded. | Parameter | Type | Description | |---|---|---| | `commitment` | `bytes32` | The Pedersen commitment hash (indexed). | | `leafIndex` | `uint256` | Position in the Merkle tree. | | `amount` | `uint256` | Deposited amount. | | `token` | `address` | Token address; `address(0)` for native GHOST (indexed). | ### Revealed ```solidity event Revealed( bytes32 indexed nullifier, address indexed recipient, uint256 amount, address indexed token ); ``` Emitted when a withdrawal is processed. | Parameter | Type | Description | |---|---|---| | `nullifier` | `bytes32` | The spent nullifier (indexed). | | `recipient` | `address` | Withdrawal destination (indexed). | | `amount` | `uint256` | Withdrawn amount. | | `token` | `address` | Token address; `address(0)` for native GHOST (indexed). | ## Storage ### commitmentPolicies ```solidity mapping(bytes32 => address) public commitmentPolicies; ``` Maps a commitment to its attached policy contract address. Returns `address(0)` if no policy is attached. ### commitmentPolicyParamsHashes ```solidity mapping(bytes32 => bytes32) public commitmentPolicyParamsHashes; ``` Maps a commitment to the `keccak256` hash of the policy parameters that must be provided at reveal time. ### quantumCommitments ```solidity mapping(bytes32 => bytes32) public quantumCommitments; ``` Maps a commitment to its quantum-safe counterpart, stored for future post-quantum migration. ## Usage Examples ### Depositing ERC-20 Tokens ```solidity // 1. Approve the vault to spend tokens IERC20(tokenAddress).approve(vaultAddress, amount); // 2. Compute commitment off-chain: commitment = Poseidon2(secret, nullifier) bytes32 commitment = 0x...; // computed off-chain bytes32 quantumCommitment = 0x...; // quantum-safe hash // 3. Commit CommitRevealVault(vaultAddress).commit( tokenAddress, amount, commitment, quantumCommitment ); ``` ### Depositing Native GHOST ```solidity CommitRevealVault(vaultAddress).commitNative{value: 1 ether}( commitment, quantumCommitment ); ``` ### Depositing with a Timelock Policy ```solidity // Encode policy params and compute hash bytes memory policyParams = abi.encode(lockUntil, expiresAt); bytes32 paramsHash = keccak256(policyParams); CommitRevealVault(vaultAddress).commitWithPolicy( tokenAddress, amount, commitment, quantumCommitment, timelockExpiryAddress, // policy contract paramsHash ); ``` ### Revealing (Withdrawing) ```solidity // Generate proof off-chain using the Specter SDK // proof, publicInputs are produced by the prover CommitRevealVault(vaultAddress).reveal( tokenAddress, proof, publicInputs, // [root, nullifier, recipient, amount, tokenIdHash, policyId, paramsHash, changeCommitment] commitment, quantumProof, changeQuantumCommitment, policyParams ); ``` ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/commitment-tree ================================================================ # CommitmentTree The `CommitmentTree` contract maintains an incremental Merkle tree of deposit commitments. It stores a ring buffer of historical roots so that proofs generated against recent states remain valid even after new deposits are recorded. **Deployed address:** `0xE29DD14998f6FE8e7862571c883090d14FE29475` ## Constants ```solidity uint256 public constant TREE_DEPTH = 20; ``` The Merkle tree has a fixed depth of 20, supporting up to 2^20 (1,048,576) commitment leaves. ```solidity uint256 public constant ROOT_HISTORY_SIZE = 100; ``` The contract maintains a ring buffer of the last 100 Merkle roots. Any proof referencing one of these roots is considered valid, providing a window for concurrent deposits and reveals. ## Functions ### recordCommitment ```solidity function recordCommitment(bytes32 commitment) external returns (uint256 leafIndex) ``` Inserts a new commitment leaf into the Merkle tree. **Parameters:** | Name | Type | Description | |---|---|---| | `commitment` | `bytes32` | The Pedersen commitment to record as a leaf in the tree. | **Returns:** | Name | Type | Description | |---|---|---| | `leafIndex` | `uint256` | The zero-based index of the newly inserted leaf. | **Access control:** Only the `CommitRevealVault` contract can call this function. **Behavior:** 1. Assigns the commitment to the next available leaf slot. 2. Increments the internal commitment counter. 3. Returns the leaf index for use in the `Committed` event. **Reverts if:** - Caller is not the authorized vault. - Tree is full (all 2^20 leaves occupied). --- ### updateRoot ```solidity function updateRoot(bytes32 newRoot) external ``` Pushes a new Merkle root into the ring buffer. Called by the off-chain operator after recomputing the tree root from the latest leaves. **Parameters:** | Name | Type | Description | |---|---|---| | `newRoot` | `bytes32` | The newly computed Merkle root. | **Access control:** Only the designated operator address can call this function. **Behavior:** 1. Writes `newRoot` into the ring buffer at position `currentIndex % ROOT_HISTORY_SIZE`. 2. Advances the ring buffer index. --- ### isKnownRoot ```solidity function isKnownRoot(bytes32 root) external view returns (bool) ``` Checks whether a given root exists in the root history ring buffer. **Parameters:** | Name | Type | Description | |---|---|---| | `root` | `bytes32` | The Merkle root to check. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the root is found in the ring buffer, `false` otherwise. | **Usage:** Called by `CommitRevealVault.reveal()` to verify that the proof's Merkle root is recent and valid. --- ### getLastRoot ```solidity function getLastRoot() external view returns (bytes32) ``` Returns the most recently stored Merkle root. **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bytes32` | The latest Merkle root in the ring buffer. | --- ### getCommitmentCount ```solidity function getCommitmentCount() external view returns (uint256) ``` Returns the total number of commitments recorded in the tree. **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `uint256` | The number of leaves inserted so far (0-indexed next slot). | ## Storage Layout | Slot | Type | Description | |---|---|---| | `roots` | `bytes32[ROOT_HISTORY_SIZE]` | Ring buffer of historical Merkle roots. | | `currentRootIndex` | `uint256` | Current write position in the ring buffer. | | `nextLeafIndex` | `uint256` | Index of the next leaf to be inserted. | | `commitments` | `mapping(uint256 => bytes32)` | Maps leaf index to commitment value. | ## Ring Buffer Mechanics The root history ring buffer works as follows: ``` Index: 0 1 2 ... 99 0 1 ... Root: R0 R1 R2 ... R99 R100 R101 ... ``` When a new root is stored, it overwrites the oldest entry once the buffer wraps. This means: - The most recent 100 roots are always available for proof verification. - Proofs generated against a root older than 100 updates will fail `isKnownRoot` and cannot be used. - Users should generate and submit proofs promptly to avoid root expiration. ## Usage Example ```solidity // Check if a root is valid (called internally by CommitRevealVault) bool valid = CommitmentTree(treeAddress).isKnownRoot(proofRoot); require(valid, "Unknown merkle root"); // Query the current state uint256 totalDeposits = CommitmentTree(treeAddress).getCommitmentCount(); bytes32 latestRoot = CommitmentTree(treeAddress).getLastRoot(); ``` ## Integration Notes - The tree uses Poseidon hashing (via `PoseidonT3`) for internal node computation, matching the ZK circuit's hash function. - `recordCommitment` only stores the leaf; it does **not** recompute the Merkle root on-chain. Root updates are performed by the operator via `updateRoot` after off-chain tree recomputation. This design saves gas while maintaining verifiability. - The operator must call `updateRoot` before reveals referencing the newly inserted leaves can succeed. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/nullifier-registry ================================================================ # NullifierRegistry The `NullifierRegistry` is a simple but critical contract that tracks spent nullifiers. Every withdrawal (reveal) in the Specter protocol produces a unique nullifier derived from the depositor's secret. Recording nullifiers prevents double-spending -- the same commitment cannot be withdrawn twice. **Deployed address:** `0xaadb9c3394835B450023daA91Ad5a46beA6e43a1` ## Storage ```solidity mapping(bytes32 => bool) public nullifiers; ``` Maps each nullifier hash to a boolean indicating whether it has been spent. Once set to `true`, it is never reset. ## Functions ### recordNullifier ```solidity function recordNullifier(bytes32 nullifier) external ``` Records a nullifier as spent. **Parameters:** | Name | Type | Description | |---|---|---| | `nullifier` | `bytes32` | The nullifier hash to mark as spent. | **Access control:** Only authorized contracts (the `CommitRevealVault`) can call this function. **Reverts if:** - Caller is not authorized. - Nullifier has already been recorded (already spent). --- ### checkAndRecord ```solidity function checkAndRecord(bytes32 nullifier) external returns (bool) ``` Atomically checks whether a nullifier is unspent and, if so, records it as spent. This is the primary function used during the reveal flow. **Parameters:** | Name | Type | Description | |---|---|---| | `nullifier` | `bytes32` | The nullifier hash to check and record. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the nullifier was successfully recorded (was unspent). `false` if it was already spent. | **Access control:** Only authorized contracts can call this function. **Behavior:** 1. Checks `nullifiers[nullifier]`. 2. If `false` (unspent): sets `nullifiers[nullifier] = true` and returns `true`. 3. If `true` (already spent): returns `false` (or reverts, depending on implementation). --- ### isSpent ```solidity function isSpent(bytes32 nullifier) external view returns (bool) ``` Checks whether a single nullifier has been spent. **Parameters:** | Name | Type | Description | |---|---|---| | `nullifier` | `bytes32` | The nullifier hash to query. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the nullifier has been recorded (spent), `false` otherwise. | --- ### batchIsSpent ```solidity function batchIsSpent(bytes32[] calldata nullifiers_) external view returns (bool[] memory) ``` Checks the spent status of multiple nullifiers in a single call. **Parameters:** | Name | Type | Description | |---|---|---| | `nullifiers_` | `bytes32[]` | Array of nullifier hashes to query. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool[]` | Array of booleans, each `true` if the corresponding nullifier is spent. | **Usage:** Useful for wallet UIs and indexers that need to check the status of many nullifiers efficiently without making multiple RPC calls. ## Usage Examples ### Checking Nullifier Status ```solidity // Single check bool spent = NullifierRegistry(registryAddress).isSpent(nullifierHash); // Batch check bytes32[] memory toCheck = new bytes32[](3); toCheck[0] = nullifier1; toCheck[1] = nullifier2; toCheck[2] = nullifier3; bool[] memory results = NullifierRegistry(registryAddress).batchIsSpent(toCheck); // results[0] = true/false, results[1] = true/false, etc. ``` ### Internal Usage by CommitRevealVault ```solidity // During reveal(), the vault calls: bool success = NullifierRegistry(registryAddress).checkAndRecord(nullifier); require(success, "Nullifier already spent"); ``` ## Security Notes - The nullifier is derived from the depositor's secret inside the ZK circuit: `nullifier = Poseidon2(secret, leafIndex)`. This binding ensures that each commitment produces exactly one nullifier, and the nullifier cannot be predicted without knowledge of the secret. - The registry is append-only. There is no mechanism to "unspend" a nullifier. - The `batchIsSpent` function is read-only and can be called by anyone. Only write functions are access-controlled. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/native-asset-handler ================================================================ # NativeAssetHandler The `NativeAssetHandler` bridges native GHOST tokens between the EVM execution layer and the Cosmos `x/bank` module. Deposits burn GHOST by sending it to a dead address, and withdrawals mint new GHOST via a custom chain precompile. **Deployed address:** `0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3` ## How It Works The Specter chain is a Cosmos SDK chain with an EVM module. The native GHOST token exists in both layers: - **Cosmos side:** managed by `x/bank` as the native staking/gas denomination. - **EVM side:** represented as the native `msg.value` currency. To move GHOST into the privacy vault, the handler **burns** it by transferring to a dead address. To release GHOST from the vault, the handler **mints** it via a chain-level precompile. This ensures the total supply remains consistent across both layers. ### Burn Address ``` 0x000000000000000000000000000000000000dEaD ``` GHOST sent to this address is effectively destroyed. The Cosmos `x/bank` module recognizes transfers to this address as burns. ### Ghostmint Precompile ``` 0x0000000000000000000000000000000000000808 ``` The `0x0808` address is a stateful precompile built into the Specter chain. When called, it instructs the Cosmos `x/bank` module to mint new GHOST tokens to the specified recipient. This precompile is only callable by the `NativeAssetHandler` contract. ## Functions ### burnNative ```solidity function burnNative() external payable ``` Burns native GHOST by forwarding `msg.value` to the `0xdead` burn address. **Parameters:** None (the amount is determined by `msg.value`). **Access control:** Only the `CommitRevealVault` can call this function. **Behavior:** 1. Receives GHOST as `msg.value`. 2. Transfers the full `msg.value` to `0x000000000000000000000000000000000000dEaD`. 3. The Cosmos layer detects the burn and adjusts supply accounting. **Reverts if:** - Caller is not the authorized vault. - `msg.value` is zero. - Transfer to the burn address fails. --- ### mintNativeTo ```solidity function mintNativeTo(address recipient, uint256 amount) external ``` Mints native GHOST to a recipient by calling the `0x0808` ghostmint precompile. **Parameters:** | Name | Type | Description | |---|---|---| | `recipient` | `address` | The address to receive minted GHOST. | | `amount` | `uint256` | The amount of GHOST to mint (in wei). | **Access control:** Only the `CommitRevealVault` can call this function. **Behavior:** 1. Encodes the mint call: `abi.encodeWithSignature("mint(address,uint256)", recipient, amount)`. 2. Calls the `0x0808` precompile with the encoded data. 3. The precompile instructs `x/bank` to credit `amount` GHOST to `recipient`. **Reverts if:** - Caller is not the authorized vault. - Amount is zero. - Precompile call fails (e.g., insufficient chain permissions). ## Precompile Details The `0x0808` ghostmint precompile is a custom stateful precompile registered in the Specter chain's EVM module configuration. It has the following properties: | Property | Value | |---|---| | Address | `0x0000000000000000000000000000000000000808` | | Type | Stateful precompile | | Access | Restricted to `NativeAssetHandler` | | Gas cost | Fixed 10,000 gas | | Effect | Calls `x/bank` keeper's `MintCoins` + `SendCoinsFromModuleToAccount` | The precompile accepts a single function signature: ```solidity function mint(address to, uint256 amount) external; ``` ## Usage Example ```solidity // During a native GHOST commit (called internally by CommitRevealVault): NativeAssetHandler(handlerAddress).burnNative{value: depositAmount}(); // During a native GHOST reveal (called internally by CommitRevealVault): NativeAssetHandler(handlerAddress).mintNativeTo(recipientAddress, withdrawAmount); ``` ## Security Considerations - Both `burnNative` and `mintNativeTo` are restricted to the `CommitRevealVault`. No other contract or EOA can trigger mints or burns through this handler. - The `0x0808` precompile itself is restricted at the chain level to only accept calls originating from the `NativeAssetHandler` contract address. - The burn-and-mint pattern ensures that GHOST entering the privacy pool is removed from circulation, and GHOST leaving the pool is freshly minted. This breaks any on-chain linkage between depositor and withdrawer. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/asset-guard ================================================================ # AssetGuard The `AssetGuard` contract maintains a whitelist of ERC-20 tokens that are permitted to enter the Specter privacy vault. It prevents unauthorized or malicious tokens from being deposited and tracks tokens deployed by authorized factories. **Deployed address:** `0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1` ## Functions ### authorizeToken ```solidity function authorizeToken(address token) external ``` Adds a token to the whitelist, allowing it to be deposited into the `CommitRevealVault`. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | The ERC-20 token contract address to authorize. | **Access control:** Only the contract admin can call this function. **Reverts if:** - Caller is not the admin. - Token is already authorized. - `token` is the zero address. --- ### deauthorizeToken ```solidity function deauthorizeToken(address token) external ``` Removes a token from the whitelist. Tokens already deposited in the vault remain withdrawable, but no new deposits of this token will be accepted. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | The ERC-20 token contract address to deauthorize. | **Access control:** Only the contract admin can call this function. **Reverts if:** - Caller is not the admin. - Token is not currently authorized. --- ### isAuthorized ```solidity function isAuthorized(address token) external view returns (bool) ``` Checks whether a token is on the whitelist. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | The ERC-20 token contract address to check. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the token is authorized, `false` otherwise. | --- ### recordDeployment ```solidity function recordDeployment(address token, address factory) external ``` Records that a token was deployed by an authorized factory. This function is called by `GhostERC20Factory` during token creation to automatically register the new token. **Parameters:** | Name | Type | Description | |---|---|---| | `token` | `address` | The newly deployed token address. | | `factory` | `address` | The factory contract that deployed the token. | **Access control:** Only authorized factory contracts can call this function. **Behavior:** 1. Validates that `factory` is an authorized factory. 2. Marks `token` as authorized. 3. Records the `token => factory` mapping for provenance tracking. **Reverts if:** - Caller is not an authorized factory. - Token is already registered. ## Storage ```solidity mapping(address => bool) public authorized; ``` Maps token addresses to their authorization status. ```solidity mapping(address => address) public deployedBy; ``` Maps token addresses to the factory that deployed them (zero if manually authorized by admin). ```solidity mapping(address => bool) public authorizedFactories; ``` Maps factory addresses to their authorization status. ## Usage Examples ### Admin: Authorizing an External Token ```solidity // Authorize an existing ERC-20 token for vault deposits AssetGuard(guardAddress).authorizeToken(usdcAddress); // Later, if needed, remove it AssetGuard(guardAddress).deauthorizeToken(usdcAddress); ``` ### Checking Authorization (used by CommitRevealVault) ```solidity // The vault checks before accepting a deposit require( AssetGuard(guardAddress).isAuthorized(tokenAddress), "Token not authorized" ); ``` ### Factory: Automatic Registration ```solidity // GhostERC20Factory calls this during deployToken(): AssetGuard(guardAddress).recordDeployment(newTokenAddress, address(this)); // The token is now authorized for vault deposits without admin intervention. ``` ## Security Notes - Deauthorizing a token does not affect existing deposits. Users can still reveal (withdraw) previously committed funds for that token. - The `recordDeployment` pathway allows the `GhostERC20Factory` to register tokens without requiring a separate admin transaction, streamlining the token creation flow. - Only the admin can directly authorize or deauthorize tokens. Factory-based registration is restricted to pre-approved factory contracts. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/ghost-erc20 ================================================================ # GhostERC20 `GhostERC20` is a privacy-compatible ERC-20 token designed for use within the Specter protocol. It extends the standard OpenZeppelin ERC-20 implementation with Specter-specific features: a Poseidon-based token ID hash for use inside ZK circuits, ghost registration with `AssetGuard`, and authorized mint/burn capabilities. ## Token ID Hash Each `GhostERC20` token computes a unique identifier used inside ZK-SNARK circuits: ```solidity bytes32 public tokenIdHash; ``` The hash is computed as: ``` tokenIdHash = Poseidon2(uint256(uint160(address(this))), 0) ``` This uses the `PoseidonT3` library (Poseidon with t=3, meaning 2 inputs and 1 output). The token's address is cast to `uint256` and hashed with a zero constant. This deterministic hash is included in ZK proofs to bind withdrawals to specific token types without revealing on-chain which token is involved. ## Functions ### enableGhost ```solidity function enableGhost() external ``` Registers this token with the `AssetGuard` contract, authorizing it for deposits into the `CommitRevealVault`. **Access control:** Only the token deployer (owner) can call this function. Typically called immediately after deployment by the `GhostERC20Factory`. **Behavior:** 1. Calls `AssetGuard.recordDeployment(address(this), factory)`. 2. Computes and stores the `tokenIdHash` via `PoseidonT3`. 3. Marks the token as ghost-enabled (prevents double-registration). **Reverts if:** - Already ghost-enabled. - Caller is not the owner. --- ### mint ```solidity function mint(address to, uint256 amount) external ``` Mints new tokens to the specified address. **Parameters:** | Name | Type | Description | |---|---|---| | `to` | `address` | Recipient of the minted tokens. | | `amount` | `uint256` | Number of tokens to mint (in the token's smallest unit). | **Access control:** Only authorized minters can call this function. The `CommitRevealVault` is typically the primary authorized minter. **Reverts if:** - Caller is not an authorized minter. - `to` is the zero address. --- ### burn ```solidity function burn(address from, uint256 amount) external ``` Burns tokens from the specified address. **Parameters:** | Name | Type | Description | |---|---|---| | `from` | `address` | Address whose tokens will be burned. | | `amount` | `uint256` | Number of tokens to burn. | **Access control:** Only authorized minters (e.g., the vault) can call this function. **Reverts if:** - Caller is not an authorized minter. - `from` has insufficient balance. ## Inherited ERC-20 Functions `GhostERC20` inherits the full OpenZeppelin ERC-20 interface: | Function | Description | |---|---| | `name()` | Returns the token name. | | `symbol()` | Returns the token symbol. | | `decimals()` | Returns the number of decimals. | | `totalSupply()` | Returns the total supply. | | `balanceOf(address)` | Returns the balance of an account. | | `transfer(address, uint256)` | Transfers tokens to a recipient. | | `allowance(address, address)` | Returns the remaining allowance. | | `approve(address, uint256)` | Sets an allowance for a spender. | | `transferFrom(address, address, uint256)` | Transfers tokens using an allowance. | ## Constructor ```solidity constructor( string memory name_, string memory symbol_, uint8 decimals_, address assetGuard_, address poseidonT3_ ) ``` | Parameter | Description | |---|---| | `name_` | Human-readable token name (e.g., "Ghost USDC"). | | `symbol_` | Token ticker symbol (e.g., "gUSDC"). | | `decimals_` | Number of decimal places (e.g., 18). | | `assetGuard_` | Address of the `AssetGuard` contract. | | `poseidonT3_` | Address of the deployed `PoseidonT3` library. | ## Usage Example ### Deploying and Enabling a GhostERC20 ```solidity // Normally done via GhostERC20Factory, but shown here for clarity: GhostERC20 token = new GhostERC20( "Ghost USDC", "gUSDC", 6, assetGuardAddress, poseidonT3Address ); // Register with AssetGuard token.enableGhost(); // Now the token can be deposited into CommitRevealVault // token.tokenIdHash() returns the Poseidon hash for ZK circuits ``` ### Using tokenIdHash in Proofs ```javascript // Off-chain (JavaScript/TypeScript SDK): const tokenAddress = "0x..."; const tokenIdHash = poseidon2(BigInt(tokenAddress), 0n); // This value is used as a public input to the ZK proof circuit // and must match the on-chain token.tokenIdHash() ``` ## Security Notes - The `tokenIdHash` is deterministic and public. It does not leak any private information; it simply provides a field-element representation of the token address for use in arithmetic circuits. - Mint and burn functions are access-controlled to prevent unauthorized supply manipulation. The vault is the expected caller during reveal operations. - Standard ERC-20 transfers remain fully functional. Users can hold, transfer, and trade `GhostERC20` tokens outside the privacy system. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/ghost-erc20-factory ================================================================ # GhostERC20Factory The `GhostERC20Factory` deploys new `GhostERC20` token contracts using the `CREATE2` opcode, ensuring deterministic and predictable addresses. Deployed tokens are automatically registered with `AssetGuard`. **Deployed address:** `0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95` ## Functions ### deployToken ```solidity function deployToken( string calldata name, string calldata symbol, uint8 decimals, bytes32 salt ) external returns (address) ``` Deploys a new `GhostERC20` token contract via `CREATE2`. **Parameters:** | Name | Type | Description | |---|---|---| | `name` | `string` | Human-readable token name (e.g., "Ghost USDC"). | | `symbol` | `string` | Token ticker symbol (e.g., "gUSDC"). | | `decimals` | `uint8` | Number of decimal places (e.g., 6 for USDC-like, 18 for ETH-like). | | `salt` | `bytes32` | A unique salt for deterministic address generation via `CREATE2`. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `address` | The address of the newly deployed `GhostERC20` contract. | **Behavior:** 1. Computes the `CREATE2` address from the factory address, `salt`, and the `GhostERC20` bytecode + constructor arguments. 2. Deploys the `GhostERC20` contract at the deterministic address. 3. Calls `enableGhost()` on the newly deployed token, which registers it with `AssetGuard`. 4. Calls `AssetGuard.recordDeployment(tokenAddress, address(this))` to record provenance. 5. Returns the deployed token address. **Reverts if:** - A contract already exists at the computed `CREATE2` address (salt collision). - The `AssetGuard` registration fails. ## CREATE2 Address Derivation The deployed address is deterministic and can be computed before deployment: ``` address = keccak256( 0xff, factoryAddress, salt, keccak256(creationCode) )[12:] ``` Where `creationCode` is: ``` abi.encodePacked( type(GhostERC20).creationCode, abi.encode(name, symbol, decimals, assetGuardAddress, poseidonT3Address) ) ``` ## Precomputing the Address You can compute the deployment address off-chain before submitting the transaction: ```solidity // On-chain helper (if available): function computeAddress( string calldata name, string calldata symbol, uint8 decimals, bytes32 salt ) external view returns (address) ``` ```javascript // Off-chain (ethers.js): const { ethers } = require("ethers"); const factoryAddress = "0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95"; const salt = ethers.utils.id("my-unique-salt"); const creationCode = ethers.utils.solidityPack( ["bytes", "bytes"], [ GhostERC20Bytecode, ethers.utils.defaultAbiCoder.encode( ["string", "string", "uint8", "address", "address"], [name, symbol, decimals, assetGuardAddress, poseidonT3Address] ), ] ); const deployedAddress = ethers.utils.getCreate2Address( factoryAddress, salt, ethers.utils.keccak256(creationCode) ); ``` ## Usage Example ### Deploying a New Privacy Token ```solidity // Deploy a new GhostERC20 via the factory address ghostUSDC = GhostERC20Factory(factoryAddress).deployToken( "Ghost USDC", "gUSDC", 6, keccak256("ghost-usdc-v1") // salt ); // The token is now: // 1. Deployed at a deterministic address // 2. Registered with AssetGuard // 3. Ready for deposits into CommitRevealVault // Users can now approve and commit: IERC20(ghostUSDC).approve(vaultAddress, amount); CommitRevealVault(vaultAddress).commit(ghostUSDC, amount, commitment, qCommitment); ``` ### Deploying Multiple Tokens ```solidity address ghostDAI = factory.deployToken("Ghost DAI", "gDAI", 18, keccak256("ghost-dai-v1")); address ghostWBTC = factory.deployToken("Ghost WBTC", "gWBTC", 8, keccak256("ghost-wbtc-v1")); address ghostUSDT = factory.deployToken("Ghost USDT", "gUSDT", 6, keccak256("ghost-usdt-v1")); ``` ## Security Notes - The `CREATE2` salt must be unique per deployment. Reusing a salt with different constructor arguments will produce a different bytecode hash and thus a different address. Reusing a salt with identical arguments will revert because the address is already occupied. - The factory must be registered as an authorized factory in `AssetGuard` for `recordDeployment` to succeed. - Only the factory owner or authorized callers can deploy tokens (depending on the access control configuration). ================================================================ SECTION: Contracts > Policies SOURCE: https://docs.specterchain.com/contracts/policies/overview ================================================================ # Policy System Overview Specter's policy system allows depositors to attach programmable constraints to their commitments. These constraints are enforced at reveal (withdrawal) time, enabling features like timelocks, destination restrictions, and multi-signature requirements -- all without breaking the privacy guarantees of the ZK proof system. ## IRevealPolicy Interface Every policy contract must implement the `IRevealPolicy` interface: ```solidity interface IRevealPolicy { /// @notice Validates whether a reveal operation should be permitted. /// @param commitment The original commitment being revealed. /// @param nullifier The nullifier derived from the commitment. /// @param recipient The address receiving the withdrawn funds. /// @param amount The amount being withdrawn. /// @param token The token address (address(0) for native GHOST). /// @param policyParams ABI-encoded parameters specific to this policy. /// @return valid True if the reveal satisfies the policy constraints. function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view returns (bool valid); } ``` **Key constraint:** The `validateReveal` function must be a `view` function. The `CommitRevealVault` invokes it via `staticcall`, meaning the policy cannot modify state. This prevents policies from being used as attack vectors for reentrancy or state manipulation. ## Enforcement Mechanism During `CommitRevealVault.reveal()`, if a policy is attached to the commitment, the vault executes: ```solidity (bool success, bytes memory result) = policyAddress.staticcall{gas: 100_000}( abi.encodeWithSelector( IRevealPolicy.validateReveal.selector, commitment, nullifier, recipient, amount, token, policyParams ) ); require(success && abi.decode(result, (bool)), "Policy validation failed"); ``` ### Enforcement Properties | Property | Value | Rationale | |---|---|---| | Call type | `staticcall` | Prevents state modification; policies are pure validators. | | Gas cap | 100,000 | Prevents griefing via unbounded computation. Policies must be gas-efficient. | | Parameter binding | `keccak256(policyParams)` must match stored hash | Ensures the revealer provides the exact parameters committed to at deposit time. | ### Flow ```mermaid sequenceDiagram participant User participant Vault as CommitRevealVault participant Policy as IRevealPolicy User->>Vault: reveal(token, proof, publicInputs, ..., policyParams) Vault->>Vault: Verify ZK proof Vault->>Vault: Check nullifier Vault->>Vault: Verify keccak256(policyParams) == stored hash Vault->>Policy: staticcall validateReveal(commitment, nullifier, recipient, amount, token, policyParams) Policy-->>Vault: returns true/false alt Policy returns true Vault->>User: Transfer funds else Policy returns false Vault->>User: Revert end ``` ## Policy Lifecycle ### 1. Commit Phase (Depositor Sets Policy) When committing funds, the depositor specifies: - `policyId`: The address of the policy contract. - `policyParamsHash`: The `keccak256` hash of the parameters that will govern the reveal. ```solidity // Example: Timelock policy requiring reveal between two timestamps bytes memory params = abi.encode(lockUntil, expiresAt); bytes32 paramsHash = keccak256(params); vault.commitWithPolicy(token, amount, commitment, qCommitment, timelockAddress, paramsHash); ``` The actual `params` are **not stored on-chain** -- only their hash is. This preserves privacy: observers cannot determine the policy parameters by inspecting the commitment transaction. ### 2. Reveal Phase (Policy Enforced) The revealer must provide the original `policyParams`. The vault: 1. Verifies `keccak256(policyParams) == commitmentPolicyParamsHashes[commitment]`. 2. Calls `policyId.validateReveal(...)` with the decoded parameters. 3. Requires the call to succeed and return `true`. ## Building a Custom Policy To create a custom policy: ### Step 1: Implement the Interface ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract MyCustomPolicy is IRevealPolicy { function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool valid) { // Decode your custom parameters (uint256 minAmount, address requiredToken) = abi.decode( policyParams, (uint256, address) ); // Enforce constraints if (amount < minAmount) return false; if (token != requiredToken) return false; return true; } } ``` ### Step 2: Gas Budget Your `validateReveal` function must execute within 100,000 gas. Guideline budgets: | Operation | Approximate Gas | |---|---| | ABI decode (2 params) | ~500 | | Storage read (SLOAD) | ~2,100 (cold) / ~100 (warm) | | Keccak256 (32 bytes) | ~36 | | ECDSA ecrecover | ~3,000 | | Comparison operations | ~3 each | Since the function is called via `staticcall`, you cannot read storage that was written in the same transaction. All reads must reference pre-existing state. ### Step 3: Deploy and Register ```solidity // Deploy the policy MyCustomPolicy policy = new MyCustomPolicy(); // Optionally register in PolicyRegistry for discoverability PolicyRegistry(registryAddress).register(address(policy)); ``` ### Step 4: Use with Commitments ```solidity bytes memory params = abi.encode(minAmount, requiredToken); bytes32 paramsHash = keccak256(params); vault.commitWithPolicy(token, amount, commitment, qCommitment, address(policy), paramsHash); ``` ## Built-in Policies The Specter protocol ships with three built-in policies: | Policy | Address | Description | |---|---|---| | [TimelockExpiry](./timelock-expiry.md) | `0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c` | Time-window enforcement: funds can only be revealed within a specified time range. | | [DestinationRestriction](./destination-restriction.md) | `0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1` | Restricts the reveal recipient to a single address or a Merkle allowlist. | | [ThresholdWitness](./threshold-witness.md) | `0x5814e4755C0D98218ddb752D26dD03feba428c80` | Requires M-of-N witness signatures to authorize a reveal. | ## Security Considerations - **staticcall isolation:** Policies cannot modify state, emit events, or transfer tokens. They can only read existing state and return a boolean. - **Gas cap protection:** The 100,000 gas limit prevents denial-of-service attacks where a malicious policy consumes unbounded gas. - **Parameter commitment:** Policy parameters are committed at deposit time via their hash. The revealer cannot alter the parameters; they must provide the exact bytes that hash to the stored value. - **No upgrade path:** Once a commitment is made with a policy, that policy is immutable for that commitment. If a policy contract is upgraded or destroyed, commitments bound to it may become unredeemable. Use upgradeable proxies cautiously. ================================================================ SECTION: Contracts > Policies SOURCE: https://docs.specterchain.com/contracts/policies/timelock-expiry ================================================================ # TimelockExpiry The `TimelockExpiry` policy enforces a time window during which a reveal (withdrawal) is permitted. Funds can only be withdrawn after a lock period has elapsed and before an expiration deadline. This enables use cases like vesting schedules, delayed withdrawals, and time-limited redemption windows. **Deployed address:** `0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c` ## Interface ```solidity contract TimelockExpiry is IRevealPolicy { function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool valid); } ``` ## Policy Parameters The `policyParams` must be ABI-encoded as: ```solidity bytes memory policyParams = abi.encode(lockUntil, expiresAt); ``` | Parameter | Type | Description | |---|---|---| | `lockUntil` | `uint256` | Unix timestamp (seconds). The reveal is blocked until `block.timestamp >= lockUntil`. | | `expiresAt` | `uint256` | Unix timestamp (seconds). The reveal is blocked after `block.timestamp > expiresAt`. | ## Validation Logic ```solidity function validateReveal( bytes32, // commitment (unused) bytes32, // nullifier (unused) address, // recipient (unused) uint256, // amount (unused) address, // token (unused) bytes calldata policyParams ) external view override returns (bool valid) { (uint256 lockUntil, uint256 expiresAt) = abi.decode( policyParams, (uint256, uint256) ); return block.timestamp >= lockUntil && block.timestamp <= expiresAt; } ``` The function returns `true` if and only if: ``` lockUntil <= block.timestamp <= expiresAt ``` ## Time Window Visualization ``` Time ──────────────────────────────────────────────────────► ◄── LOCKED ──►◄──── VALID WINDOW ────►◄── EXPIRED ──► │ │ lockUntil expiresAt ``` - **Before `lockUntil`:** Reveal is rejected. Funds remain locked. - **Between `lockUntil` and `expiresAt`:** Reveal is permitted. - **After `expiresAt`:** Reveal is rejected. Funds are effectively frozen unless another recovery mechanism exists. ## Usage Examples ### Commit with a 7-Day Lock and 30-Day Expiry ```solidity uint256 lockUntil = block.timestamp + 7 days; uint256 expiresAt = block.timestamp + 30 days; bytes memory params = abi.encode(lockUntil, expiresAt); bytes32 paramsHash = keccak256(params); vault.commitWithPolicy( tokenAddress, amount, commitment, quantumCommitment, 0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c, // TimelockExpiry address paramsHash ); ``` ### Reveal After Lock Period ```solidity // Must be called when: lockUntil <= block.timestamp <= expiresAt bytes memory params = abi.encode(lockUntil, expiresAt); vault.reveal( tokenAddress, proof, publicInputs, commitment, quantumProof, changeQuantumCommitment, params // policyParams ); ``` ### Immediate-Available with Expiry Only ```solidity // Set lockUntil to 0 (or any past timestamp) for immediate availability uint256 lockUntil = 0; uint256 expiresAt = block.timestamp + 90 days; bytes memory params = abi.encode(lockUntil, expiresAt); ``` ### Permanent Lock (No Expiry) ```solidity // Set expiresAt to type(uint256).max for no practical expiry uint256 lockUntil = block.timestamp + 365 days; uint256 expiresAt = type(uint256).max; bytes memory params = abi.encode(lockUntil, expiresAt); ``` ## Gas Usage This policy is extremely gas-efficient: | Operation | Gas | |---|---| | ABI decode (2 uint256) | ~400 | | Two comparisons | ~6 | | **Total** | **~406** | Well within the 100,000 gas cap. ## Security Considerations - **Expiry is final:** Once `expiresAt` passes, funds committed with this policy cannot be revealed. There is no admin override. Ensure the expiry window is sufficiently generous. - **Block timestamp manipulation:** Validators can manipulate `block.timestamp` by a few seconds. For policies requiring precise timing, add a small buffer (e.g., set `lockUntil` a few minutes after the desired time). - **Parameter privacy:** The `lockUntil` and `expiresAt` values are not stored on-chain in plaintext. Only the `keccak256` hash is stored at commit time. The actual timestamps are revealed only when the withdrawal is executed. ================================================================ SECTION: Contracts > Policies SOURCE: https://docs.specterchain.com/contracts/policies/destination-restriction ================================================================ # DestinationRestriction The `DestinationRestriction` policy restricts which address can receive funds during a reveal. It supports two modes: a **single-address** restriction and a **Merkle allowlist** for multiple permitted destinations. The mode is determined by the length of the `policyParams`. **Deployed address:** `0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1` ## Interface ```solidity contract DestinationRestriction is IRevealPolicy { function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool valid); } ``` ## Modes ### Mode 1: Single Address (policyParams length == 32 bytes) When `policyParams` is exactly 32 bytes, it is interpreted as a single allowed destination address (left-padded to `bytes32`). ```solidity bytes memory policyParams = abi.encode(allowedAddress); ``` **Validation:** ```solidity address allowed = abi.decode(policyParams, (address)); return recipient == allowed; ``` The reveal succeeds only if the `recipient` in the ZK proof's public inputs matches the committed address. ### Mode 2: Merkle Allowlist (policyParams length > 32 bytes) When `policyParams` is longer than 32 bytes, it is interpreted as a Merkle proof that the `recipient` is a member of a pre-committed allowlist. ```solidity bytes memory policyParams = abi.encode(merkleRoot, merkleProof); ``` Where: - `merkleRoot` (`bytes32`): Root of the allowlist Merkle tree. This root must match what was committed (i.e., the `policyParamsHash` at commit time is `keccak256(abi.encode(merkleRoot))`... see details below). - `merkleProof` (`bytes32[]`): Merkle inclusion proof for the `recipient`. **Validation:** ```solidity (bytes32 merkleRoot, bytes32[] memory proof) = abi.decode( policyParams, (bytes32, bytes32[]) ); // Verify recipient is in the allowlist bytes32 leaf = keccak256(abi.encodePacked(recipient)); return MerkleProof.verify(proof, merkleRoot, leaf); ``` The reveal succeeds only if a valid Merkle proof is provided demonstrating that the `recipient` is a leaf in the allowlist tree. ## Parameter Encoding ### Single Address Mode ```solidity // At commit time: address allowedRecipient = 0x1234...; bytes memory params = abi.encode(allowedRecipient); bytes32 paramsHash = keccak256(params); vault.commitWithPolicy(token, amount, commitment, qCommitment, policyAddress, paramsHash); // At reveal time: vault.reveal(token, proof, publicInputs, commitment, qProof, changeQCommitment, params); ``` ### Merkle Allowlist Mode ```solidity // Off-chain: build a Merkle tree from allowed addresses // Leaves: keccak256(abi.encodePacked(address)) bytes32 merkleRoot = computeMerkleRoot(allowedAddresses); // At commit time: bytes memory params = abi.encode(merkleRoot, new bytes32[](0)); // placeholder proof bytes32 paramsHash = keccak256(abi.encode(merkleRoot)); // hash only the root // IMPORTANT: paramsHash commits to the root only, not the proof. // The actual proof varies per recipient. vault.commitWithPolicy(token, amount, commitment, qCommitment, policyAddress, paramsHash); // At reveal time (for a specific recipient): bytes32[] memory proof = generateMerkleProof(allowedAddresses, recipient); bytes memory revealParams = abi.encode(merkleRoot, proof); vault.reveal(token, proof_, publicInputs, commitment, qProof, changeQCommitment, revealParams); ``` :::caution For the Merkle allowlist mode, the `policyParamsHash` stored at commit time must be `keccak256(abi.encode(merkleRoot))` -- hashing only the root, not the proof. The proof is variable per recipient and is validated at reveal time against the committed root. ::: ## Usage Examples ### Restricting to a Single Withdrawal Address ```solidity // Only allow withdrawal to a specific cold wallet address coldWallet = 0xAbCd...1234; bytes memory params = abi.encode(coldWallet); bytes32 paramsHash = keccak256(params); vault.commitWithPolicy( tokenAddress, amount, commitment, quantumCommitment, 0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1, // DestinationRestriction paramsHash ); ``` ### Allowing Withdrawal to Any of N Addresses ```javascript // Off-chain: build the Merkle tree const { MerkleTree } = require("merkletreejs"); const { keccak256 } = require("ethers/lib/utils"); const allowedAddresses = [address1, address2, address3, address4]; const leaves = allowedAddresses.map((a) => keccak256(ethers.utils.solidityPack(["address"], [a])) ); const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); const merkleRoot = tree.getHexRoot(); // Commit with the Merkle root const paramsHash = keccak256(ethers.utils.defaultAbiCoder.encode(["bytes32"], [merkleRoot])); ``` ```solidity // At reveal time, generate proof for the chosen recipient bytes32[] memory proof = getMerkleProof(recipient); bytes memory params = abi.encode(merkleRoot, proof); vault.reveal(token, zkProof, publicInputs, commitment, qProof, changeQCommitment, params); ``` ## Gas Usage | Mode | Operations | Approximate Gas | |---|---|---| | Single address | ABI decode + address comparison | ~500 | | Merkle allowlist (depth 10) | ABI decode + 10 hashes + comparisons | ~5,000 | | Merkle allowlist (depth 20) | ABI decode + 20 hashes + comparisons | ~9,000 | Both modes are well within the 100,000 gas cap. ## Security Considerations - **Single address mode** provides the strongest guarantee: funds can only go to one specific address. This is useful for self-transfers to a cold wallet or pre-arranged payments. - **Merkle allowlist mode** provides flexibility while maintaining control. The set of allowed recipients is committed at deposit time and cannot be changed afterward. - **Recipient binding:** The `recipient` validated by this policy is extracted from the ZK proof's public inputs. It cannot be spoofed because it is verified as part of the proof circuit. - **Allowlist privacy:** The full list of allowed addresses is not stored on-chain. Only the Merkle root (via its hash) is committed. Individual addresses are revealed only when a withdrawal is made to them. ================================================================ SECTION: Contracts > Policies SOURCE: https://docs.specterchain.com/contracts/policies/threshold-witness ================================================================ # ThresholdWitness The `ThresholdWitness` policy requires M-of-N witness signatures to authorize a reveal. This enables multi-party approval for withdrawals -- useful for institutional custody, social recovery, and governance-controlled funds. **Deployed address:** `0x5814e4755C0D98218ddb752D26dD03feba428c80` ## Interface ```solidity contract ThresholdWitness is IRevealPolicy { function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool valid); } ``` ## Message Format Each witness signs a message constructed as: ```solidity bytes32 message = keccak256( abi.encodePacked(commitment, nullifier, recipient, amount, token) ); ``` This binds each signature to the specific reveal operation. A signature valid for one reveal cannot be replayed for a different commitment, recipient, or amount. Witnesses sign the **Ethereum signed message hash** (EIP-191): ```solidity bytes32 ethSignedMessage = keccak256( abi.encodePacked("\x19Ethereum Signed Message:\n32", message) ); ``` ## Policy Parameters The `policyParams` are ABI-encoded as: ```solidity bytes memory policyParams = abi.encode(threshold, witnesses, signatures); ``` | Parameter | Type | Description | |---|---|---| | `threshold` | `uint256` | Minimum number of valid signatures required (M). | | `witnesses` | `address[]` | Ordered list of all possible witness addresses (N). | | `signatures` | `bytes[]` | Array of ECDSA signatures, one per witness. Use empty bytes (`""`) for witnesses that did not sign. | ### Parameter Hash at Commit Time At commit time, only the `threshold` and `witnesses` are committed (not the signatures, which are produced at reveal time): ```solidity bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses)); ``` ## Validation Logic ```solidity function validateReveal( bytes32 commitment, bytes32 nullifier, address recipient, uint256 amount, address token, bytes calldata policyParams ) external view override returns (bool valid) { ( uint256 threshold, address[] memory witnesses, bytes[] memory signatures ) = abi.decode(policyParams, (uint256, address[], bytes[])); require(signatures.length == witnesses.length, "Length mismatch"); bytes32 message = keccak256( abi.encodePacked(commitment, nullifier, recipient, amount, token) ); bytes32 ethSignedMessage = keccak256( abi.encodePacked("\x19Ethereum Signed Message:\n32", message) ); uint256 validCount = 0; for (uint256 i = 0; i < witnesses.length; i++) { if (signatures[i].length == 65) { address signer = ecrecover(ethSignedMessage, v, r, s); // decomposed from signatures[i] if (signer == witnesses[i]) { validCount++; } } } return validCount >= threshold; } ``` The function: 1. Decodes `threshold`, `witnesses`, and `signatures` from `policyParams`. 2. Reconstructs the message hash from the reveal parameters. 3. Iterates through each witness, verifying their signature via `ecrecover`. 4. Returns `true` if at least `threshold` valid signatures are present. ## Usage Examples ### Setting Up a 2-of-3 Multisig Policy ```solidity // Define witnesses address[] memory witnesses = new address[](3); witnesses[0] = 0xAlice...; witnesses[1] = 0xBob...; witnesses[2] = 0xCarol...; uint256 threshold = 2; // Commit time: hash only threshold and witnesses bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses)); vault.commitWithPolicy( tokenAddress, amount, commitment, quantumCommitment, 0x5814e4755C0D98218ddb752D26dD03feba428c80, // ThresholdWitness paramsHash ); ``` ### Collecting Signatures and Revealing ```javascript // Off-chain: compute the message const message = ethers.utils.solidityKeccak256( ["bytes32", "bytes32", "address", "uint256", "address"], [commitment, nullifier, recipient, amount, token] ); // Each witness signs the message const aliceSig = await alice.signMessage(ethers.utils.arrayify(message)); const bobSig = await bob.signMessage(ethers.utils.arrayify(message)); // Carol did not sign const carolSig = "0x"; ``` ```solidity // At reveal time: bytes[] memory signatures = new bytes[](3); signatures[0] = aliceSig; // valid signatures[1] = bobSig; // valid signatures[2] = ""; // Carol didn't sign bytes memory params = abi.encode(threshold, witnesses, signatures); vault.reveal( tokenAddress, proof, publicInputs, commitment, quantumProof, changeQuantumCommitment, params ); // Succeeds: 2 valid signatures >= threshold of 2 ``` ### 1-of-1 (Single Approver) ```solidity address[] memory witnesses = new address[](1); witnesses[0] = approverAddress; uint256 threshold = 1; bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses)); ``` ### N-of-N (Unanimous) ```solidity uint256 threshold = witnesses.length; // all must sign bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses)); ``` ## Gas Usage | N (witnesses) | Approximate Gas | |---|---| | 1 | ~4,500 | | 3 | ~12,000 | | 5 | ~19,500 | | 7 | ~27,000 | | 10 | ~38,000 | Each `ecrecover` costs approximately 3,000 gas. With ABI decoding and loop overhead, the practical maximum within the 100,000 gas cap is approximately **25-30 witnesses**. ## Security Considerations - **Signature binding:** Signatures are bound to the specific `(commitment, nullifier, recipient, amount, token)` tuple. They cannot be reused across different reveals. - **Witness ordering:** The `witnesses` array must be provided in the same order at both commit and reveal time, since the `policyParamsHash` includes the witness array encoding. - **Empty signatures:** Non-signing witnesses must provide empty bytes. The contract skips signatures that are not exactly 65 bytes long. - **No replay:** Each reveal has a unique nullifier, so a signature set valid for one reveal is not valid for any other. - **Signer recovery:** The contract uses `ecrecover`, which returns `address(0)` for invalid signatures. Since `address(0)` is never a valid witness, invalid signatures are safely ignored. ================================================================ SECTION: Contracts > Policies SOURCE: https://docs.specterchain.com/contracts/policies/policy-registry ================================================================ # PolicyRegistry The `PolicyRegistry` is an informational on-chain registry of reveal policy contracts. It provides a discovery mechanism for wallets, UIs, and indexers to find available policies. Registration is **not required** for a policy to function -- any contract implementing `IRevealPolicy` can be used with `commitWithPolicy` regardless of registry status. **Deployed address:** `0x2DC1641d5A32D6788264690D42710edC843Cb1db` ## Functions ### register ```solidity function register(address policy) external ``` Registers a policy contract in the registry. **Parameters:** | Name | Type | Description | |---|---|---| | `policy` | `address` | The address of the policy contract to register. | **Behavior:** 1. Verifies the address contains code (is a contract). 2. Adds the policy to the internal registry. 3. Records the registrant (`msg.sender`) and registration timestamp. **Reverts if:** - `policy` is the zero address. - `policy` has no code (is an EOA). - `policy` is already registered. --- ### isRegistered ```solidity function isRegistered(address policy) external view returns (bool) ``` Checks whether a policy address is registered. **Parameters:** | Name | Type | Description | |---|---|---| | `policy` | `address` | The address to check. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the policy is registered, `false` otherwise. | --- ### getPolicy ```solidity function getPolicy(address policy) external view returns ( address registrant, uint256 registeredAt, bool active ) ``` Returns metadata about a registered policy. **Parameters:** | Name | Type | Description | |---|---|---| | `policy` | `address` | The policy address to query. | **Returns:** | Name | Type | Description | |---|---|---| | `registrant` | `address` | The address that registered the policy. | | `registeredAt` | `uint256` | Block timestamp when the policy was registered. | | `active` | `bool` | Whether the policy is currently active in the registry. | --- ### getPolicies ```solidity function getPolicies() external view returns (address[] memory) ``` Returns an array of all registered policy addresses. **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `address[]` | Array of all registered policy contract addresses. | ## Storage ```solidity struct PolicyInfo { address registrant; uint256 registeredAt; bool active; } mapping(address => PolicyInfo) public policies; address[] public policyList; ``` ## Usage Examples ### Registering a Custom Policy ```solidity // After deploying a custom policy contract MyCustomPolicy policy = new MyCustomPolicy(); // Register it for discoverability PolicyRegistry(registryAddress).register(address(policy)); ``` ### Querying Available Policies ```solidity // Get all registered policies address[] memory allPolicies = PolicyRegistry(registryAddress).getPolicies(); // Check a specific policy bool registered = PolicyRegistry(registryAddress).isRegistered(policyAddress); // Get policy details (address registrant, uint256 registeredAt, bool active) = PolicyRegistry(registryAddress).getPolicy(policyAddress); ``` ### Front-End Integration ```javascript const registry = new ethers.Contract(registryAddress, registryABI, provider); // List all available policies for a UI dropdown const policies = await registry.getPolicies(); for (const addr of policies) { const info = await registry.getPolicy(addr); console.log(`Policy: ${addr}, Registered by: ${info.registrant}, Active: ${info.active}`); } ``` ## Notes - The registry is **informational only**. It does not enforce any behavior in the `CommitRevealVault`. A policy does not need to be registered to be used with `commitWithPolicy`. - Registration is permissionless. Anyone can register a policy contract. The `registrant` field provides attribution but does not imply endorsement. - The built-in Specter policies (`TimelockExpiry`, `DestinationRestriction`, `ThresholdWitness`) are pre-registered at deployment time. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/persistent-key-vault ================================================================ # PersistentKeyVault The `PersistentKeyVault` provides on-chain encrypted key storage with privacy-preserving access control. Key holders can store encrypted key material that can be accessed via ZK proofs without revealing the accessor's identity, and optionally revoked under configurable policies. This contract supports Dead Man's Switch (DMS) key recovery and inheritance workflows. ## Enums ### RevokePolicy ```solidity enum RevokePolicy { BEARER, // 0 - Anyone with the nullifier can revoke ISSUER_ONLY // 1 - Only the original key storer can revoke } ``` | Value | Meaning | |---|---| | `BEARER` (0) | Any party that can produce a valid nullifier for the key can revoke it. Used for bearer-instrument-style keys. | | `ISSUER_ONLY` (1) | Only the address that originally called `storeKeyPartB` can revoke the key. Used when the issuer retains sole revocation authority. | ## Structs ### KeyEntry ```solidity struct KeyEntry { bytes32 commitment; bytes encKeyPartB; address issuer; RevokePolicy revokePolicy; bool revoked; uint256 storedAt; } ``` | Field | Type | Description | |---|---|---| | `commitment` | `bytes32` | The Pedersen commitment associated with this key entry. Links to the Merkle tree. | | `encKeyPartB` | `bytes` | The encrypted key material (Part B). Encrypted client-side before storage. | | `issuer` | `address` | The address that stored the key entry. | | `revokePolicy` | `RevokePolicy` | Determines who can revoke this key. | | `revoked` | `bool` | Whether the key has been revoked. | | `storedAt` | `uint256` | Block timestamp when the key was stored. | ## Storage ### usedAccessTags ```solidity mapping(bytes32 => bool) public usedAccessTags; ``` Maps access tags to their usage status. Each access tag can only be used once, preventing replay of access proofs. An access tag is a unique identifier derived from the accessor's session to ensure one-time retrieval. ## Functions ### storeKeyPartB ```solidity function storeKeyPartB( bytes32 keyId, bytes32 commitment, bytes calldata encKeyPartB, RevokePolicy revokePolicy ) external ``` Stores encrypted key material on-chain. **Parameters:** | Name | Type | Description | |---|---|---| | `keyId` | `bytes32` | Unique identifier for this key entry. Typically a hash of key metadata. | | `commitment` | `bytes32` | Pedersen commitment that anchors this key in the Merkle tree. | | `encKeyPartB` | `bytes` | Encrypted key material. Encrypted off-chain; the contract stores it opaquely. | | `revokePolicy` | `RevokePolicy` | The revocation policy for this key (BEARER or ISSUER_ONLY). | **Behavior:** 1. Validates `keyId` is not already in use. 2. Creates a `KeyEntry` struct with the provided parameters. 3. Sets `issuer = msg.sender` and `storedAt = block.timestamp`. 4. Stores the entry in the `keys` mapping. **Reverts if:** - `keyId` is already occupied. - `commitment` is zero. - `encKeyPartB` is empty. --- ### accessKeyPartB ```solidity function accessKeyPartB( bytes32 keyId, bytes calldata proof, bytes32 root, bytes32 dataHash, bytes32 sessionNonce, bytes32 accessTag ) external returns (bytes memory encKeyPartB) ``` Retrieves encrypted key material by providing a valid ZK proof of authorization. **Parameters:** | Name | Type | Description | |---|---|---| | `keyId` | `bytes32` | The key entry to access. | | `proof` | `bytes` | Groth16 ZK proof demonstrating the caller is authorized to access this key. | | `root` | `bytes32` | Merkle root used in the proof. Must be a known root in `CommitmentTree`. | | `dataHash` | `bytes32` | Hash of the data being accessed, included as a public input to prevent proof reuse across different keys. | | `sessionNonce` | `bytes32` | A nonce binding the proof to a specific session, preventing replay. | | `accessTag` | `bytes32` | A unique tag derived from the proof that is marked as used after access. | **Returns:** | Name | Type | Description | |---|---|---| | `encKeyPartB` | `bytes` | The encrypted key material stored for this `keyId`. | **Behavior:** 1. Validates the key exists and is not revoked. 2. Verifies `root` via `CommitmentTree.isKnownRoot(root)`. 3. Verifies `accessTag` has not been used: `!usedAccessTags[accessTag]`. 4. Verifies the ZK proof via `GhostRedemptionVerifier`. 5. Marks `usedAccessTags[accessTag] = true`. 6. Returns the stored `encKeyPartB`. **Reverts if:** - Key does not exist or is revoked. - Root is not known. - Access tag already used. - ZK proof is invalid. --- ### revokeKey ```solidity function revokeKey(bytes32 keyId, bytes32 nullifier) external ``` Revokes a stored key entry, making it permanently inaccessible. **Parameters:** | Name | Type | Description | |---|---|---| | `keyId` | `bytes32` | The key entry to revoke. | | `nullifier` | `bytes32` | The nullifier proving authorization to revoke. | **Behavior:** Depending on the key's `revokePolicy`: - **BEARER:** Validates the `nullifier` against the `NullifierRegistry` and revokes. - **ISSUER_ONLY:** Requires `msg.sender == keyEntry.issuer` (nullifier may still be checked for record-keeping). Sets `keyEntry.revoked = true`. **Reverts if:** - Key does not exist. - Key is already revoked. - Caller is not authorized per the `revokePolicy`. --- ### getKeyInfo ```solidity function getKeyInfo(bytes32 keyId) external view returns ( bytes32 commitment, address issuer, RevokePolicy revokePolicy, bool revoked, uint256 storedAt ) ``` Returns metadata about a key entry (excluding the encrypted key material). **Parameters:** | Name | Type | Description | |---|---|---| | `keyId` | `bytes32` | The key entry to query. | **Returns:** | Name | Type | Description | |---|---|---| | `commitment` | `bytes32` | The associated commitment. | | `issuer` | `address` | The address that stored the key. | | `revokePolicy` | `RevokePolicy` | The revocation policy. | | `revoked` | `bool` | Whether the key is revoked. | | `storedAt` | `uint256` | Storage timestamp. | --- ### isKeyAvailable ```solidity function isKeyAvailable(bytes32 keyId) external view returns (bool) ``` Checks whether a key entry exists and is not revoked. **Parameters:** | Name | Type | Description | |---|---|---| | `keyId` | `bytes32` | The key entry to check. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the key exists and has not been revoked. | --- ### isAccessTagUsed ```solidity function isAccessTagUsed(bytes32 accessTag) external view returns (bool) ``` Checks whether an access tag has been consumed. **Parameters:** | Name | Type | Description | |---|---|---| | `accessTag` | `bytes32` | The access tag to check. | **Returns:** | Name | Type | Description | |---|---|---| | (unnamed) | `bool` | `true` if the tag has been used, `false` otherwise. | --- ### getStats ```solidity function getStats() external view returns ( uint256 totalKeys, uint256 activeKeys, uint256 revokedKeys, uint256 totalAccesses ) ``` Returns aggregate statistics about the vault. **Returns:** | Name | Type | Description | |---|---|---| | `totalKeys` | `uint256` | Total number of key entries ever stored. | | `activeKeys` | `uint256` | Number of keys that are not revoked. | | `revokedKeys` | `uint256` | Number of revoked keys. | | `totalAccesses` | `uint256` | Total number of successful `accessKeyPartB` calls. | ## Usage Examples ### Storing a Key for DMS Recovery ```solidity // Off-chain: encrypt key Part B with the beneficiary's public key bytes memory encKeyPartB = encryptForBeneficiary(keyPartB, beneficiaryPubKey); // Compute a unique key ID bytes32 keyId = keccak256(abi.encodePacked("dms-recovery", userId, nonce)); // Store on-chain PersistentKeyVault(vaultAddress).storeKeyPartB( keyId, commitment, // links to the Merkle tree encKeyPartB, PersistentKeyVault.RevokePolicy.ISSUER_ONLY ); ``` ### Accessing a Key with a ZK Proof ```solidity // Generate proof off-chain demonstrating authorization // proof, root, dataHash, sessionNonce, accessTag are produced by the prover bytes memory encryptedKey = PersistentKeyVault(vaultAddress).accessKeyPartB( keyId, proof, root, dataHash, sessionNonce, accessTag ); // Off-chain: decrypt encryptedKey with the beneficiary's private key bytes memory keyPartB = decrypt(encryptedKey, beneficiaryPrivKey); ``` ### Checking Key Availability ```solidity bool available = PersistentKeyVault(vaultAddress).isKeyAvailable(keyId); if (available) { // Key exists and can be accessed } // Check if a specific access has already occurred bool used = PersistentKeyVault(vaultAddress).isAccessTagUsed(accessTag); ``` ### Revoking a Key (Issuer Only) ```solidity // Only the original storer can revoke when RevokePolicy is ISSUER_ONLY PersistentKeyVault(vaultAddress).revokeKey(keyId, nullifier); ``` ## Security Considerations - **Encrypted storage:** The contract stores `encKeyPartB` opaquely. It never decrypts or interprets the key material. All encryption and decryption happens client-side. - **One-time access tags:** Each `accessTag` can only be used once. This prevents replay attacks where a proof is resubmitted to retrieve the key material multiple times from different sessions. - **ZK-proof authorization:** Accessors prove their authorization via ZK proofs without revealing their identity on-chain. The proof circuit validates membership in the commitment tree. - **Revocation finality:** Once a key is revoked, it cannot be un-revoked. The encrypted data remains on-chain (in event logs/storage) but `accessKeyPartB` will revert for revoked keys. - **Two-part key design:** The key is split into Part A (held by the user) and Part B (stored on-chain encrypted). Neither part alone is sufficient to reconstruct the full key, providing defense in depth. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/sp1-verifier ================================================================ # SP1ProofVerifier The `SP1ProofVerifier` contract performs on-chain verification of Groth16 zero-knowledge proofs over the BN254 (alt_bn128) elliptic curve. It is the cryptographic gatekeeper of Ghost Protocol: every reveal operation must pass through this contract, which validates that the prover possesses knowledge of a valid commitment in the Merkle tree without learning anything about which commitment was used. ## Overview | Property | Value | |----------|-------| | **Proof system** | Groth16 | | **Curve** | BN254 (alt_bn128) | | **Verification gas cost** | ~220,000 gas | | **Proof size** | 256 bytes (constant) | | **Public inputs** | 8 field elements | | **EVM precompile** | EIP-197 BN254 pairing check (`0x08`) | Groth16 was selected for Specter because of its constant-size proofs, fast verification, and native EVM support via the BN254 pairing precompile. Every proof is exactly 256 bytes regardless of the circuit complexity, and verification cost is fixed at approximately 220k gas regardless of the number of constraints in the circuit. ## Contract Interface ```solidity interface ISP1ProofVerifier { /// @notice Verifies a Groth16 proof against the verification key /// @param proof The serialized Groth16 proof (256 bytes: 3 G1/G2 points) /// @param publicInputs Array of 8 public input field elements /// @return bool True if the proof is valid function verifyProof( bytes calldata proof, uint256[8] calldata publicInputs ) external view returns (bool); } ``` The contract is stateless and `view` -- it performs pure computation with no state writes. This means verification can be called by any contract or off-chain process without gas cost when used in a static call context. ## Public Inputs Layout The 8 public inputs encode everything the verifier needs to check without revealing the private commitment data. Each input is a BN254 scalar field element (254 bits, range `[0, p)` where `p = 21888242871839275222246405745257275088548364400416034343698204186575808495617`). | Index | Name | Description | |-------|------|-------------| | 0 | `root` | Merkle tree root at the time of proof generation. Must exist in the CommitmentTree's 100-root ring buffer. | | 1 | `nullifier` | Unique spend tag derived from `Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex)`. Registered in NullifierRegistry to prevent double-spending. | | 2 | `withdrawAmount` | The amount being withdrawn from the commitment. May be less than or equal to the committed amount (partial withdrawals produce change). | | 3 | `recipient` | The address receiving the withdrawn funds, reduced to a field element. Binding the recipient inside the proof prevents front-running attacks. | | 4 | `changeCommitment` | A new commitment for the remaining balance (`amount - withdrawAmount`). If the full amount is withdrawn, this is a commitment to zero. | | 5 | `tokenId` | Token identifier derived as `Poseidon2(tokenAddress, 0)`. Ensures the proof is bound to a specific asset type. | | 6 | `policyId` | Identifier of the policy contract governing this commitment. `0` indicates no policy. | | 7 | `policyParamsHash` | `keccak256(policyParams) % BN254_FIELD`. Binds the proof to specific policy parameters. `0` when no policy is set. | ### Input Encoding Public inputs are passed as a fixed-size array of `uint256` values. Each value must be a valid BN254 scalar field element. The circuit enforces field membership, and any input outside the range `[0, p)` will cause verification to fail. ```solidity uint256[8] memory publicInputs = [ merkleRoot, // from CommitmentTree.roots[] nullifier, // derived in-circuit withdrawAmount, // user-specified uint256(uint160(recipient)), // address cast to uint256 changeCommitment, // computed by client tokenId, // Poseidon2(tokenAddress, 0) policyId, // from commitment metadata policyParamsHash // keccak256(params) % BN254_FIELD ]; ``` ## Groth16 Proof Structure A Groth16 proof consists of three elliptic curve points: | Point | Curve Group | Size | Description | |-------|-------------|------|-------------| | **A** | G1 | 64 bytes | First proof element (2 x 32-byte coordinates) | | **B** | G2 | 128 bytes | Second proof element (2 x 2 x 32-byte coordinates, on the twist curve) | | **C** | G1 | 64 bytes | Third proof element (2 x 32-byte coordinates) | **Total: 256 bytes** (constant regardless of circuit size). The proof bytes are serialized as: ``` proof = A.x || A.y || B.x[1] || B.x[0] || B.y[1] || B.y[0] || C.x || C.y ``` Note the reversed coordinate ordering for the G2 point `B` -- this matches the EVM precompile's expected format for the twisted curve representation. ## Verification Algorithm The verifier checks the Groth16 pairing equation: ``` e(A, B) = e(alpha, beta) * e(sum_of_public_inputs, gamma) * e(C, delta) ``` Where `e` is the BN254 optimal Ate pairing, and `alpha`, `beta`, `gamma`, `delta` are elements of the verification key generated during the trusted setup. ### Step-by-Step Verification 1. **Deserialize the proof** into curve points `A` (G1), `B` (G2), `C` (G1). 2. **Compute the public input accumulator** by performing a multi-scalar multiplication over the verification key's IC (input commitment) points: ``` vk_x = IC[0] + publicInputs[0] * IC[1] + publicInputs[1] * IC[2] + ... + publicInputs[7] * IC[8] ``` This is computed using the `ecMul` (0x07) and `ecAdd` (0x06) precompiles. 3. **Execute the pairing check** using the `ecPairing` precompile at address `0x08`: ``` ecPairing( negate(A), B, alpha1, beta2, vk_x, gamma2, C, delta2 ) == 1 ``` The pairing precompile returns `1` if the pairing equation holds, `0` otherwise. ### EIP-197 Precompile Usage The contract relies on three EVM precompiles defined in EIP-196 and EIP-197: | Address | Precompile | Operation | Gas Cost | |---------|------------|-----------|----------| | `0x06` | `ecAdd` | BN254 G1 point addition | 150 gas | | `0x07` | `ecMul` | BN254 G1 scalar multiplication | 6,000 gas | | `0x08` | `ecPairing` | BN254 pairing check | 45,000 + 34,000 per pair | The total verification cost breaks down approximately as: | Operation | Count | Gas | |-----------|-------|-----| | `ecMul` (public input accumulation) | 8 | 48,000 | | `ecAdd` (public input accumulation) | 8 | 1,200 | | `ecPairing` (4 pairs) | 1 | 181,000 | | Calldata + overhead | -- | ~10,000 | | **Total** | | **~220,000** | ### Solidity Implementation Pattern ```solidity function verifyProof( bytes calldata proof, uint256[8] calldata publicInputs ) external view returns (bool) { // 1. Compute the linear combination of public inputs with IC points // vk_x = IC[0] + sum(publicInputs[i] * IC[i+1]) uint256[2] memory vk_x = [IC_0_X, IC_0_Y]; for (uint256 i = 0; i < 8; i++) { // ecMul: publicInputs[i] * IC[i+1] (uint256 mx, uint256 my) = ecMul(IC[i+1], publicInputs[i]); // ecAdd: accumulate into vk_x (vk_x[0], vk_x[1]) = ecAdd(vk_x[0], vk_x[1], mx, my); } // 2. Decode proof points (uint256 ax, uint256 ay) = decodeG1(proof, 0); // B is G2 - decoded as 4 uint256 values (uint256 bx1, uint256 bx0, uint256 by1, uint256 by0) = decodeG2(proof, 64); (uint256 cx, uint256 cy) = decodeG1(proof, 192); // 3. Negate A (flip y-coordinate: y -> p - y) uint256 nay = (BN254_P - ay) % BN254_P; // 4. Pairing check: e(-A,B) * e(alpha,beta) * e(vk_x,gamma) * e(C,delta) == 1 return ecPairing([ ax, nay, bx1, bx0, by1, by0, // -A, B ALPHA_X, ALPHA_Y, BETA_X1, BETA_X0, BETA_Y1, BETA_Y0, // alpha, beta vk_x[0], vk_x[1], GAMMA_X1, GAMMA_X0, GAMMA_Y1, GAMMA_Y0, // vk_x, gamma cx, cy, DELTA_X1, DELTA_X0, DELTA_Y1, DELTA_Y0 // C, delta ]); } ``` ## Verification Key The verification key is embedded as immutable constants in the contract. It is generated during the trusted setup ceremony and is specific to the GhostRedemption circuit. The key consists of: | Component | Type | Description | |-----------|------|-------------| | `alpha1` | G1 point | Trusted setup element | | `beta2` | G2 point | Trusted setup element | | `gamma2` | G2 point | Trusted setup element | | `delta2` | G2 point | Trusted setup element | | `IC[0..8]` | G1 points (9 total) | Input commitment points: one base point plus one per public input | The verification key is hardcoded rather than stored in storage to minimize gas costs. Since these values never change after deployment, there is no reason to pay SLOAD costs on every verification. ## Trusted Setup The Groth16 proving system requires a **circuit-specific trusted setup** to generate the proving key and verification key. Specter's trusted setup has been completed for the GhostRedemption circuit. ### Setup Properties - **Ceremony type**: Powers-of-tau (Phase 1) + circuit-specific contribution (Phase 2) - **Circuit**: GhostRedemption with tree depth 20 - **Toxic waste**: Destroyed after ceremony completion -- if any single participant in the ceremony is honest, the setup is secure - **Output artifacts**: `proving_key.zkey`, `verification_key.json`, and the on-chain verifier contract ### Security Assumption Groth16's security relies on the assumption that the toxic waste (the secret randomness used during the trusted setup) was properly destroyed. If an attacker obtains the toxic waste, they can forge proofs for arbitrary statements. The multi-party ceremony ensures this cannot happen as long as at least one participant behaved honestly. ## Integration with CommitRevealVault The `SP1ProofVerifier` is called by `CommitRevealVault` during every reveal operation: ```solidity // Inside CommitRevealVault.reveal() function reveal( bytes calldata proof, uint256 root, uint256 nullifier, uint256 withdrawAmount, address recipient, uint256 changeCommitment, uint256 tokenId, uint256 policyId, uint256 policyParamsHash ) external { // 1. Verify the Merkle root is known require(commitmentTree.isKnownRoot(root), "Unknown root"); // 2. Verify the nullifier has not been spent require(!nullifierRegistry.isSpent(nullifier), "Nullifier already spent"); // 3. Construct public inputs array uint256[8] memory publicInputs = [ root, nullifier, withdrawAmount, uint256(uint160(recipient)), changeCommitment, tokenId, policyId, policyParamsHash ]; // 4. Verify the ZK proof require(proofVerifier.verifyProof(proof, publicInputs), "Invalid proof"); // 5. Register the nullifier as spent nullifierRegistry.spend(nullifier); // 6. Process the withdrawal (mint/transfer to recipient) // 7. Insert change commitment into the tree (if non-zero withdrawal) // ... } ``` ## Client-Side Proof Generation Proofs are generated on the client side using **snarkjs** compiled to WebAssembly. The client loads the proving key and WASM circuit, provides the private inputs (secret, nullifierSecret, amount, blinding, Merkle path), and snarkjs produces the 256-byte proof along with the 8 public inputs. ```typescript async function generateRedemptionProof( secret: bigint, nullifierSecret: bigint, amount: bigint, blinding: bigint, pathElements: bigint[], pathIndices: number[], newBlinding: bigint, withdrawAmount: bigint, recipient: string, tokenId: bigint, policyId: bigint, policyParamsHash: bigint ) { const input = { // Private inputs secret, nullifierSecret, amount, blinding, pathElements, pathIndices, newBlinding, // Public inputs (also provided to circuit) withdrawAmount, recipient: BigInt(recipient), tokenId, policyId, policyParamsHash, }; const { proof, publicSignals } = await snarkjs.groth16.fullProve( input, "/circuits/GhostRedemption.wasm", "/circuits/GhostRedemption.zkey" ); // Format proof for on-chain submission const calldata = await snarkjs.groth16.exportSolidityCallData( proof, publicSignals ); return { proof: calldata.proof, publicInputs: calldata.inputs }; } ``` ## Gas Optimization Notes The ~220k gas verification cost is a fixed overhead on every reveal transaction. Several design decisions minimize this cost: 1. **Hardcoded verification key**: All VK points are contract constants, avoiding SLOAD operations (~2,100 gas each). 2. **Assembly-level precompile calls**: The ecMul, ecAdd, and ecPairing calls use inline assembly with `staticcall` to avoid Solidity's ABI encoding overhead. 3. **Single pairing check**: The pairing equation is batched into a single `ecPairing` call with 4 point pairs, rather than multiple separate pairing calls. 4. **No proof deserialization storage**: Proof points are decoded from calldata directly into memory without intermediate storage writes. ## Comparison: Groth16 vs SP1 Specter currently uses Groth16 for production verification and plans to add SP1 as an alternative proving backend: | Property | Groth16 (Current) | SP1 (Planned) | |----------|-------------------|---------------| | **Proof size** | 256 bytes (constant) | ~1-10 KB (varies) | | **Verification gas** | ~220k | ~300-500k (estimated) | | **Trusted setup** | Required (circuit-specific) | None (transparent) | | **Prover language** | Circom (DSL) | Rust (general-purpose) | | **Quantum resistance** | None (BN254 is vulnerable) | Possible with PQ signature schemes | | **Status** | Live in production | In development | The SP1 path eliminates the trusted setup requirement and opens the door to post-quantum proof systems, at the cost of larger proofs and higher verification gas. Both systems will coexist, with Groth16 serving as the default for gas-sensitive operations and SP1 available for users requiring stronger security assumptions. ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/open-ghost-vault ================================================================ # OpenGhostVault The `OpenGhostVault` contract provides **data privacy** -- the ability to commit arbitrary data on-chain and later prove knowledge of it without revealing the data itself or linking the commit and reveal operations. Unlike `CommitRevealVault`, which handles token privacy (committing and revealing fungible assets), OpenGhostVault deals exclusively with data: encrypted messages, secret keys, document hashes, credentials, and any other information that benefits from a commit/reveal privacy pattern. ## Overview | Property | Value | |----------|-------| | **Purpose** | Data privacy (not token privacy) | | **Commitment hash** | `Poseidon4(secret, nullifierSecret, dataHash, blinding)` | | **Fee model** | Native GHOST fee per commit (no token burning) | | **Merkle tree** | OpenCommitmentTree (separate from token CommitmentTree) | | **Nullifier registry** | OpenNullifierRegistry (separate from token NullifierRegistry) | | **Reveal contract** | OpenGhostReveal | | **Tree depth** | 20 | ## Why a Separate Vault? OpenGhostVault exists as a distinct system from CommitRevealVault for several reasons: 1. **No token flow.** Data commitments do not burn or mint tokens. There is no `amount` field, no `withdrawAmount`, no change commitment. The entire token lifecycle is absent. 2. **Different commitment structure.** Token commitments use Poseidon7 (7 inputs including amount, tokenId, policyId). Data commitments use Poseidon4 (4 inputs) because they only need to encode the data hash, not financial parameters. 3. **Separate state trees.** Mixing data commitments and token commitments in the same Merkle tree would create a larger anonymity set, but it would also mean data operations compete with financial operations for tree space. Separate trees ensure neither system can bottleneck the other. 4. **Different security model.** Token reveals require nullifiers to prevent double-spending. Data reveals use nullifiers to prevent replay, but the semantics differ -- "spending" data is not the same as spending a token. ## Commitment Structure An OpenGhost commitment is a Poseidon4 hash of four field elements: ``` commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding) ``` | Input | Size | Description | |-------|------|-------------| | `secret` | 31 bytes | Random secret known only to the committer. Proves ownership of the commitment. | | `nullifierSecret` | 31 bytes | Separate random secret used to derive the nullifier. Separating this from `secret` enables delegation patterns. | | `dataHash` | 32 bytes (field element) | Hash of the data being committed. Typically `keccak256(data) % BN254_FIELD`. This is the payload. | | `blinding` | 31 bytes | Random blinding factor ensuring commitment uniqueness even if the same data is committed twice. | ### Commitment Computation (Client-Side) ```typescript const BN254_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; function computeOpenGhostCommitment( secret: bigint, nullifierSecret: bigint, data: Uint8Array, blinding: bigint ): bigint { // Hash the data payload into a field element const dataHash = BigInt(ethers.keccak256(data)) % BN254_FIELD; // Compute the Poseidon4 commitment return poseidon4([secret, nullifierSecret, dataHash, blinding]); } ``` ### Why Poseidon4 (Not Poseidon7)? Token commitments require 7 inputs because they encode financial metadata: `secret`, `nullifierSecret`, `tokenId`, `amount`, `blinding`, `policyId`, `policyParamsHash`. Data commitments strip away all financial fields, leaving only the 4 inputs needed for a generic data commitment. Using a smaller Poseidon variant reduces circuit complexity, proof generation time, and the number of constraints. ## Contract Interface ```solidity interface IOpenGhostVault { /// @notice Commit data by inserting a Poseidon4 commitment into the OpenCommitmentTree /// @param commitment The Poseidon4(secret, nullifierSecret, dataHash, blinding) hash /// @dev Requires msg.value >= commitFee (paid in native GHOST) function commit(uint256 commitment) external payable; /// @notice Get the current commit fee /// @return The fee in aghost (native GHOST, 18 decimals) function commitFee() external view returns (uint256); /// @notice Update the commit fee (governance only) /// @param newFee The new fee in aghost function setCommitFee(uint256 newFee) external; /// @notice Emitted when a new data commitment is inserted event DataCommitted( uint256 indexed commitment, uint256 leafIndex, uint256 timestamp ); } ``` ## Commit Flow The commit operation is straightforward compared to token commits -- there is no token burning, no asset guard check, and no policy evaluation. The flow is: ```mermaid sequenceDiagram participant Client participant OGV as OpenGhostVault participant OCT as OpenCommitmentTree Client->>Client: Generate secret, nullifierSecret, blinding Client->>Client: Compute dataHash = keccak256(data) % BN254_FIELD Client->>Client: commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding) Client->>OGV: commit(commitment) {value: fee} OGV->>OGV: Verify msg.value >= commitFee OGV->>OCT: insert(commitment) OCT->>OCT: Update Merkle tree (20-level path recomputation) OCT-->>OGV: Return leafIndex OGV-->>Client: Emit DataCommitted(commitment, leafIndex, timestamp) ``` ### Fee Model OpenGhostVault charges a flat fee in native GHOST for each commit operation. This serves two purposes: 1. **Spam prevention.** Without a fee, an attacker could fill the tree with garbage commitments, consuming tree capacity (2^20 = 1,048,576 leaves) and degrading the anonymity set with known-empty commitments. 2. **Operational sustainability.** The fees fund protocol operations and can be directed to validators or a protocol treasury via governance. The fee is denominated in `aghost` (18 decimals) and is configurable via governance. There is no token burning -- the fee is a transfer, not a destruction event. This is fundamentally different from `CommitRevealVault`, where the committed token amount is burned on commit and minted on reveal. ```solidity function commit(uint256 commitment) external payable { require(msg.value >= commitFee, "Insufficient fee"); // Insert into the separate OpenCommitmentTree uint256 leafIndex = openCommitmentTree.insert(commitment); // Refund excess fee if (msg.value > commitFee) { payable(msg.sender).transfer(msg.value - commitFee); } emit DataCommitted(commitment, leafIndex, block.timestamp); } ``` ## Separate State Infrastructure OpenGhostVault uses its own Merkle tree and nullifier registry, entirely separate from the token privacy system: | Component | Token Privacy | Data Privacy | |-----------|--------------|--------------| | **Vault** | CommitRevealVault | OpenGhostVault | | **Merkle tree** | CommitmentTree | OpenCommitmentTree | | **Nullifier registry** | NullifierRegistry | OpenNullifierRegistry | | **Reveal contract** | (within CommitRevealVault) | OpenGhostReveal | | **Commitment hash** | Poseidon7 | Poseidon4 | | **Tree depth** | 20 | 20 | The `OpenCommitmentTree` is structurally identical to `CommitmentTree` -- same depth (20), same Poseidon2 internal hashing, same 100-root ring buffer. It is a separate deployment with its own state. Similarly, `OpenNullifierRegistry` is a separate mapping from the token `NullifierRegistry`. A nullifier spent in one registry has no effect on the other. ## OpenGhostReveal The `OpenGhostReveal` contract handles the reveal side of data commitments. During a reveal, the user proves they know the preimage of a commitment in the OpenCommitmentTree without revealing which commitment is theirs. ### Reveal Verification ```solidity interface IOpenGhostReveal { /// @notice Verify a data reveal proof /// @param proof The Groth16 proof bytes /// @param root The Merkle root used during proof generation /// @param nullifier The nullifier for this commitment /// @param dataHash The hash of the data being revealed function reveal( bytes calldata proof, uint256 root, uint256 nullifier, uint256 dataHash ) external; event DataRevealed( uint256 indexed nullifier, uint256 dataHash, uint256 timestamp ); } ``` The reveal flow verifies: 1. The `root` exists in the OpenCommitmentTree's root ring buffer 2. The `nullifier` has not been spent in OpenNullifierRegistry 3. The ZK proof is valid -- the prover knows `(secret, nullifierSecret, blinding)` such that `Poseidon4(secret, nullifierSecret, dataHash, blinding)` is a leaf in the tree at the given root 4. The nullifier was correctly derived from the commitment Upon successful verification, the nullifier is marked as spent in OpenNullifierRegistry and a `DataRevealed` event is emitted. ## Use Cases ### Revels (Permissionless Secret Sharing) Revels are Specter's permissionless secret sharing primitive. A user commits encrypted data to OpenGhostVault, then shares the decryption key through a separate channel. The reveal proves the data was committed without exposing the committer's identity. ```mermaid sequenceDiagram participant Alice participant OGV as OpenGhostVault participant Bob Alice->>Alice: Encrypt message with symmetric key K Alice->>Alice: dataHash = keccak256(encryptedMessage) % BN254_FIELD Alice->>Alice: commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding) Alice->>OGV: commit(commitment) {value: fee} Note over OGV: Commitment stored in OpenCommitmentTree Alice->>Bob: Share K and encrypted message (off-chain) Bob->>Bob: Decrypt message using K Bob->>Bob: Verify: keccak256(encryptedMessage) matches on-chain dataHash Note over Bob: Bob confirms the message was committed on-chain
without learning Alice's identity ``` The key property is that Bob can verify the data was committed at a specific time (block timestamp) without any on-chain link to Alice. Alice's commit transaction could have come from any address -- potentially a fresh address funded by the Gas Relayer. ### PersistentKeyVault The `PersistentKeyVault` contract builds on OpenGhostVault to provide **reusable encryption keys**. Instead of generating a new key for each interaction, a user commits a long-lived key to OpenGhostVault and proves knowledge of it repeatedly using different session nonces. The data flow for PersistentKeyVault: 1. User generates a persistent key pair 2. User commits `Poseidon4(secret, nullifierSecret, keyHash, blinding)` to OpenGhostVault 3. For each session, the user generates an access proof (see [Access Proof Circuit](../circuits/access-proof-circuit.md)) that proves knowledge of the committed key without spending the nullifier 4. The access proof uses a `sessionNonce` to produce a unique `accessTag` per session, ensuring sessions are unlinkable This pattern enables recurring private interactions -- such as authenticated access to a private API, repeated decryption of a data stream, or ongoing participation in a private channel -- without creating a new commitment for each interaction. ### Confidential Document Attestation Organizations can commit document hashes to OpenGhostVault, later revealing specific documents when needed (e.g., for audits or legal proceedings) while proving the document existed at the commit timestamp. The commit/reveal unlinkability means the organization's document inventory remains private until selective disclosure is required. ```typescript // Commit a document hash const documentBytes = new TextEncoder().encode(documentContent); const dataHash = BigInt(ethers.keccak256(documentBytes)) % BN254_FIELD; const commitment = poseidon4([secret, nullifierSecret, dataHash, blinding]); const tx = await openGhostVault.commit(commitment, { value: ethers.parseEther("0.01"), // commit fee }); ``` ## Architecture Diagram ```mermaid graph TD subgraph Data Privacy System OGV[OpenGhostVault] OCT[OpenCommitmentTree] ONR[OpenNullifierRegistry] OGR[OpenGhostReveal] end subgraph Token Privacy System CRV[CommitRevealVault] CT[CommitmentTree] NR[NullifierRegistry] end subgraph Applications Revels[Revels
Secret Sharing] PKV[PersistentKeyVault
Reusable Keys] DocAttest[Document
Attestation] end OGV -->|insert| OCT OGR -->|check root| OCT OGR -->|spend| ONR Revels --> OGV Revels --> OGR PKV --> OGV DocAttest --> OGV DocAttest --> OGR style OGV fill:#1a1a2e,stroke:#e94560,color:#fff style CRV fill:#1a1a2e,stroke:#0f3460,color:#fff ``` The two systems (data privacy and token privacy) share no on-chain state. They are completely independent deployments with separate trees, separate nullifier registries, and separate verification circuits. ## Comparison: OpenGhostVault vs CommitRevealVault | Feature | CommitRevealVault | OpenGhostVault | |---------|-------------------|----------------| | **Purpose** | Token privacy | Data privacy | | **Commitment** | `Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash)` | `Poseidon4(secret, nullifierSecret, dataHash, blinding)` | | **On commit** | Burns committed token amount | Collects flat GHOST fee | | **On reveal** | Mints tokens to recipient | Emits data reveal event | | **Change commitment** | Yes (partial withdrawals) | No (no amounts to split) | | **Policy enforcement** | Yes (AssetGuard + policy contracts) | No | | **Token binding** | Yes (tokenId in commitment) | No | | **Nullifier purpose** | Prevent double-spending | Prevent replay | | **Merkle tree** | CommitmentTree | OpenCommitmentTree | | **Circuit** | GhostRedemption(20) | OpenGhost circuit | ================================================================ SECTION: Contracts SOURCE: https://docs.specterchain.com/contracts/bridge-hyperlane ================================================================ # HypGhostERC20Synthetic (Hyperlane Bridge) Specter uses **Hyperlane** warp routes to bridge tokens from external EVM chains (Ethereum, Base, Arbitrum) into the Specter privacy layer. The `HypGhostERC20Synthetic` contract is a synthetic token implementation that mints wrapped "g-tokens" on Specter when the corresponding tokens are locked on the source chain. These g-tokens can then enter the Ghost Protocol commit/reveal pipeline for private transactions. ## Overview | Property | Value | |----------|-------| | **Bridge protocol** | Hyperlane (modular interoperability) | | **Token model** | Lock-and-mint / burn-and-unlock | | **Base class** | `TokenRouter` (Hyperlane warp route) | | **Source chains** | Ethereum, Base, Arbitrum | | **Destination chain** | Specter (Chain ID `5446`) | | **Security models** | TrustedRelayerISM, MultisigISM | ## Supported Tokens The following synthetic g-tokens are deployed on Specter, each backed 1:1 by tokens locked on their source chain: | Token | Symbol | Specter Address | Underlying | |-------|--------|-----------------|------------| | Ghost USDC | `gUSDC` | `0x65c9091a6A45Db302a343AF460657C298FAA222D` | USDC on Ethereum/Base/Arbitrum | | Ghost WETH | `gWETH` | `0x923295a3e3bE5eDe29Fc408A507dA057ee044E81` | WETH on Ethereum/Base/Arbitrum | | Ghost LABS | `gLABS` | `0x062f8a68f6386c1b448b3379abd369825bec9aa2` | LABS token | | Ghost VIRTUAL | `gVIRTUAL` | `0xaF12d2f962179274f243986604F97b961a4f4Cfc` | VIRTUAL token | ### Token ID Derivation When g-tokens enter Ghost Protocol, they need a circuit-compatible identifier. The `tokenId` used in commitments is derived as: ``` tokenId = Poseidon2(tokenAddress, 0) ``` For example, for gUSDC: ```typescript const gUSDC_ADDRESS = 0x65c9091a6A45Db302a343AF460657C298FAA222Dn; const gUSDC_TOKEN_ID = poseidon2([gUSDC_ADDRESS, 0n]); ``` This Poseidon-based derivation ensures the token identifier is a valid BN254 field element, which is required for use inside ZK circuits. ## Architecture ```mermaid graph LR subgraph Ethereum/Base/Arbitrum User[User Wallet] Collateral[HypERC20Collateral
Lock tokens here] end subgraph Hyperlane Mailbox[Hyperlane Mailbox] ISM[Interchain Security Module] Relayer[Hyperlane Relayer] end subgraph Specter Chain Synthetic[HypGhostERC20Synthetic
Mint g-tokens] GEF[GhostERC20Factory] CRV[CommitRevealVault
Privacy layer] end User -->|1. Approve + Transfer| Collateral Collateral -->|2. Lock tokens| Collateral Collateral -->|3. Dispatch message| Mailbox Mailbox -->|4. Relay cross-chain| Relayer Relayer -->|5. Deliver message| Mailbox Mailbox -->|6. Verify via ISM| ISM ISM -->|7. Call handle()| Synthetic Synthetic -->|8. Mint g-tokens| User User -->|9. Commit to vault| CRV ``` ## Lock-and-Mint Flow ### Step 1: Lock on Source Chain On the source chain (Ethereum, Base, or Arbitrum), a `HypERC20Collateral` contract holds the locked tokens. When a user initiates a bridge transfer: ```solidity // On Ethereum -- user locks USDC into the collateral contract IERC20(usdc).approve(address(collateral), amount); collateral.transferRemote{value: bridgeFee}( specterDomainId, // Hyperlane domain ID for Specter recipientBytes32, // Recipient address on Specter (bytes32-encoded) amount // Amount to bridge ); ``` The collateral contract: 1. Transfers `amount` of the token from the user to itself (lock) 2. Dispatches a Hyperlane message to Specter's mailbox containing the transfer details ### Step 2: Cross-Chain Message Relay The Hyperlane relayer infrastructure picks up the dispatched message and delivers it to Specter's Hyperlane mailbox. The message contains: | Field | Description | |-------|-------------| | `sender` | Collateral contract address on source chain | | `recipient` | HypGhostERC20Synthetic address on Specter | | `body` | ABI-encoded `(address to, uint256 amount)` | | `origin` | Source chain Hyperlane domain ID | ### Step 3: Mint on Specter When the message arrives at Specter's mailbox, it is verified by the Interchain Security Module (ISM) and delivered to the `HypGhostERC20Synthetic` contract: ```solidity // Inside HypGhostERC20Synthetic (simplified) function _handle( uint32 origin, bytes32 sender, bytes calldata body ) internal override { // Verify the sender is the registered collateral contract for this origin require( sender == routers[origin], "Unauthorized sender" ); // Decode the transfer (address to, uint256 amount) = abi.decode(body, (address, uint256)); // Mint synthetic g-tokens to the recipient _mint(to, amount); } ``` The newly minted g-tokens are standard ERC-20 tokens on Specter. They can be: - Held in a wallet - Transferred to other addresses - **Committed** to the CommitRevealVault for private transactions - Used in DeFi protocols on Specter ## Burn-and-Unlock Flow (Bridging Back) To bridge tokens back to the source chain, the process reverses: ```mermaid sequenceDiagram participant User participant Synthetic as HypGhostERC20Synthetic
(Specter) participant Mailbox as Hyperlane Mailbox participant Collateral as HypERC20Collateral
(Source Chain) User->>Synthetic: transferRemote(origin, recipient, amount) Synthetic->>Synthetic: Burn g-tokens from user Synthetic->>Mailbox: Dispatch unlock message Mailbox-->>Mailbox: Relayer delivers to source chain Mailbox->>Collateral: handle() on source chain Collateral->>User: Transfer locked tokens to recipient ``` ```solidity // On Specter -- user burns g-tokens and triggers unlock on source chain gUSDC.transferRemote{value: bridgeFee}( ethereumDomainId, // Hyperlane domain ID for Ethereum recipientBytes32, // Recipient address on Ethereum amount // Amount to bridge back ); ``` ## TokenRouter Base Class `HypGhostERC20Synthetic` extends Hyperlane's `TokenRouter` base class, which provides the standard warp route interface: ```solidity abstract contract TokenRouter is GasRouter { /// @notice Transfer tokens to a remote chain function transferRemote( uint32 destination, bytes32 recipient, uint256 amount ) public payable virtual returns (bytes32 messageId); /// @notice Handle an incoming transfer from a remote chain function _handle( uint32 origin, bytes32 sender, bytes calldata body ) internal virtual override; } ``` The `TokenRouter` handles: - Message formatting and dispatching via the Hyperlane mailbox - Gas payment for cross-chain message delivery - Router registration (mapping domain IDs to remote router addresses) - Reentrancy protection `HypGhostERC20Synthetic` overrides `_handle` to implement the mint logic and `transferRemote` to implement the burn logic. ## Interchain Security Modules (ISMs) Hyperlane's security is modular -- each warp route can configure its own security model via an Interchain Security Module. Specter uses two ISM types: ### TrustedRelayerISM The `TrustedRelayerISM` designates a single trusted relayer whose signature is sufficient to validate cross-chain messages. This is the simpler model, used during the testnet phase: ```solidity contract TrustedRelayerISM is IInterchainSecurityModule { address public trustedRelayer; function verify( bytes calldata metadata, bytes calldata message ) external view returns (bool) { // Verify the message was signed by the trusted relayer address signer = recoverSigner(metadata, message); return signer == trustedRelayer; } } ``` **Properties:** - Single point of trust - Low latency (no multi-party coordination) - Suitable for testnet and controlled environments - The trusted relayer is operated by the Specter team ### MultisigISM The `MultisigISM` requires signatures from a quorum of validators before a cross-chain message is accepted: ```solidity contract MultisigISM is IInterchainSecurityModule { address[] public validators; uint256 public threshold; function verify( bytes calldata metadata, bytes calldata message ) external view returns (bool) { // Extract signatures from metadata // Verify at least `threshold` valid validator signatures uint256 validSignatures = 0; for (uint256 i = 0; i < signatures.length; i++) { address signer = recoverSigner(signatures[i], message); if (isValidator[signer]) { validSignatures++; } } return validSignatures >= threshold; } } ``` **Properties:** - Decentralized trust (Byzantine fault tolerant with threshold `t` of `n` validators) - Higher latency (must collect `t` signatures) - Suitable for mainnet deployment - Validator set is configurable per route ## Privacy Integration The bridge itself is **not private** -- token locking on the source chain and g-token minting on Specter are visible on their respective chains. Privacy begins when the user commits g-tokens to the CommitRevealVault: ```mermaid graph TD A[USDC on Ethereum] -->|Bridge| B[gUSDC on Specter] B -->|Commit to vault| C[Commitment in Merkle Tree] C -->|Reveal with ZK proof| D[Fresh gUSDC to new address] D -->|Bridge back| E[USDC on Ethereum] style C fill:#1a1a2e,stroke:#e94560,color:#fff ``` The privacy boundary is between the commit and reveal steps. An observer can see: - Alice bridges 1000 USDC from Ethereum to Specter (public) - Someone commits gUSDC to the vault (potentially linkable to Alice) - Someone reveals gUSDC from the vault to a fresh address (unlinkable -- ZK proof) - A fresh address bridges gUSDC back to Ethereum (public, but unlinkable to Alice) For maximum privacy, users should: 1. Use a fresh address to commit (funded by the Gas Relayer) 2. Wait for other commits to increase the anonymity set 3. Reveal to another fresh address 4. Bridge back from the fresh address ## Cross-Chain Commit Flow A common pattern is committing bridged tokens directly into the privacy layer: ```typescript const BN254_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; // 1. Bridge USDC from Ethereum to Specter (already completed) // User now has gUSDC on Specter const gUSDC = new ethers.Contract( "0x65c9091a6A45Db302a343AF460657C298FAA222D", erc20ABI, signer ); // 2. Approve CommitRevealVault to spend gUSDC const amount = ethers.parseUnits("1000", 6); // 1000 USDC (6 decimals) await gUSDC.approve(commitRevealVaultAddress, amount); // 3. Compute commitment const secret = randomFieldElement(); const nullifierSecret = randomFieldElement(); const blinding = randomFieldElement(); const tokenId = poseidon2([ BigInt("0x65c9091a6A45Db302a343AF460657C298FAA222D"), 0n, ]); const policyId = 0n; const policyParamsHash = 0n; const commitment = poseidon7([ secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash, ]); // 4. Commit to the vault await commitRevealVault.commitERC20( gUSDC.address, amount, commitment ); ``` ## Deployed Warp Routes Each supported token has a warp route configuration mapping source chain collateral contracts to the Specter synthetic contract: ``` Ethereum USDC ←→ Specter gUSDC (0x65c9091a6A45Db302a343AF460657C298FAA222D) Ethereum WETH ←→ Specter gWETH (0x923295a3e3bE5eDe29Fc408A507dA057ee044E81) Base USDC ←→ Specter gUSDC (shared synthetic) Arbitrum USDC ←→ Specter gUSDC (shared synthetic) ``` Multiple source chains can map to the same synthetic token on Specter. For example, USDC bridged from Ethereum, Base, and Arbitrum all mint the same `gUSDC` token. This unifies liquidity and ensures users from different source chains share the same anonymity set when committing to the vault. ## Gas and Fees Cross-chain transfers incur two types of fees: | Fee | Paid On | Description | |-----|---------|-------------| | **Bridge gas** | Source chain | Native gas for the `transferRemote` transaction + Hyperlane relay gas payment | | **Destination gas** | Specter | Covered by the Hyperlane relayer; the user does not need GHOST to receive bridged tokens | The Hyperlane relayer covers destination gas costs, meaning a user can bridge tokens to Specter even if they have no GHOST. Once the g-tokens are minted, the user can use the Gas Relayer to obtain a small amount of GHOST for subsequent transactions. ## Security Considerations ### Collateral Risk The security of bridged tokens depends on the collateral contracts on source chains. If a collateral contract is compromised, an attacker could mint unbacked synthetic tokens on Specter. Mitigations: - Collateral contracts use audited Hyperlane `HypERC20Collateral` implementations - ISM validation ensures only authorized routers can trigger mints - Rate limits can be configured per route to cap maximum bridged amounts ### ISM Upgrade Path The ISM can be upgraded without redeploying the synthetic token contract. The planned upgrade path is: 1. **Testnet**: TrustedRelayerISM (current) 2. **Early mainnet**: MultisigISM with 3-of-5 validator threshold 3. **Mature mainnet**: MultisigISM with higher validator count + optional ZK light client ISM ### Bridge Monitoring The Bridge Relayer and Health Monitor services continuously watch for: - Mismatched collateral/synthetic balances (supply invariant) - Delayed message delivery (potential relayer failure) - Unauthorized mint attempts (ISM bypass attempts) - Unusual volume patterns (potential exploit indicators) ================================================================ SECTION: Circuits SOURCE: https://docs.specterchain.com/circuits/overview ================================================================ # Circuits Overview Specter's privacy guarantees are enforced by **zero-knowledge circuits** -- mathematical programs that define exactly what a prover must demonstrate without revealing any private data. Every commit/reveal operation in Ghost Protocol requires a valid ZK proof generated from one of these circuits. This page covers the proof system architecture, the two proving backends (Groth16 and SP1), and how circuits fit into the broader protocol. ## Two Proof Systems Specter employs a dual proof system strategy: | System | Status | Backend | Setup | Proof Size | Verification Gas | |--------|--------|---------|-------|------------|------------------| | **Groth16** | Live (production) | Circom + snarkjs | Trusted setup (completed) | 256 bytes (constant) | ~220k gas | | **SP1** | Planned | Rust + SP1 zkVM | Transparent (no trusted setup) | ~1-10 KB | ~300-500k gas (estimated) | ### Why Two Systems? **Groth16** is the current production system. It offers the smallest proof size and lowest verification gas cost of any practical ZK proof system. The tradeoff is that it requires a **trusted setup ceremony** -- a one-time procedure that generates the proving and verification keys. If the secret randomness from the ceremony is not properly destroyed, an attacker could forge proofs. Specter mitigated this risk through a multi-party ceremony where only one honest participant is needed for security. **SP1** is Succinct's zkVM, which executes arbitrary Rust programs inside a zero-knowledge virtual machine (RISC-V based). SP1 is planned as a second proving backend because: 1. **No trusted setup.** SP1 uses a transparent proof system (STARK-based), eliminating the trusted setup assumption entirely. 2. **Post-quantum path.** SP1's STARK-based proofs are not tied to elliptic curve assumptions. By integrating post-quantum signature schemes within SP1 programs, Specter can achieve quantum resistance for the proof layer itself (complementing the existing keccak256 quantum defense layer). 3. **Rust expressiveness.** Writing circuit logic in Rust rather than Circom enables more complex proving statements, better tooling, and easier auditing. 4. **Upgradability.** New proving logic can be deployed without a new trusted setup ceremony. The tradeoff is cost: SP1 proofs are larger and more expensive to verify on-chain. The plan is for both systems to coexist -- Groth16 for gas-sensitive operations and SP1 for users who prefer stronger security assumptions. ## Groth16 on BN254 The production proof system uses **Groth16** over the **BN254** (alt_bn128) elliptic curve. This combination was chosen for its EVM compatibility. ### EVM Precompile Support The Ethereum Virtual Machine includes three precompiled contracts for BN254 operations, defined in EIP-196 and EIP-197: | Precompile | Address | Operation | Gas Cost | |------------|---------|-----------|----------| | `ecAdd` | `0x06` | G1 point addition | 150 | | `ecMul` | `0x07` | G1 scalar multiplication | 6,000 | | `ecPairing` | `0x08` | Pairing check (bilinear map) | 45,000 + 34,000/pair | These precompiles make Groth16 verification over BN254 roughly 10-100x cheaper than verifying proofs over other curves that would require pure Solidity elliptic curve arithmetic. Specter's EVM inherits these precompiles. ### Trusted Setup The Groth16 trusted setup was completed for all production circuits. The ceremony followed the standard two-phase process: **Phase 1 (Powers of Tau):** A universal setup that produces structured reference string (SRS) elements. This phase is circuit-agnostic and can be reused across different circuits. **Phase 2 (Circuit-Specific):** Each circuit contributes additional randomness to specialize the SRS for its specific constraint system. The output is a proving key and verification key for each circuit. ``` Powers of Tau → Phase 2 (GhostRedemption) → proving_key.zkey + verification_key.json → Phase 2 (AccessProof) → proving_key.zkey + verification_key.json → Phase 2 (OpenGhost) → proving_key.zkey + verification_key.json ``` The verification keys are embedded as constants in the on-chain verifier contracts. The proving keys are distributed to clients (webapp, mobile, proof relayer) for proof generation. ### Security Assumption Groth16 is secure under the **knowledge-of-exponent assumption** on BN254. Additionally, the trusted setup must have been performed honestly by at least one participant. If the toxic waste (secret randomness) from the setup is compromised, an attacker can forge proofs for arbitrary statements, which would allow draining all committed assets. ## Circuit Architecture All Specter circuits share common design principles: ### Tree Depth Every circuit that performs Merkle membership proofs uses a **fixed tree depth of 20**: ``` Tree capacity = 2^20 = 1,048,576 leaves ``` This depth is hardcoded in the circuit templates as a compile-time parameter. A depth of 20 provides over one million leaf slots -- sufficient for the foreseeable future -- while keeping the Merkle proof path short enough for efficient proving (20 Poseidon2 hashes per proof). ### Hash Functions All in-circuit hashing uses Poseidon, an algebraic hash function designed for arithmetic circuits: | Variant | In-Circuit Name | Inputs | Uses | |---------|----------------|--------|------| | Poseidon2 | `PoseidonT3` | 2 | Merkle tree internal nodes, nullifier derivation, access tags, token IDs | | Poseidon4 | `PoseidonT5` | 4 | OpenGhost data commitments | | Poseidon7 | `PoseidonT8` | 7 | CommitRevealVault token commitments (with policy binding) | Poseidon is orders of magnitude cheaper than SHA-256 or keccak256 inside arithmetic circuits. A single Poseidon2 hash adds roughly 250 R1CS constraints, compared to ~25,000 for SHA-256. ### Signal Types Circom circuits distinguish between **public** and **private** signals (inputs): - **Public signals** are visible to the verifier (the on-chain contract). They are included in the proof's public inputs and checked by the verifier. - **Private signals** are known only to the prover. The ZK proof guarantees the prover knows valid private inputs satisfying the circuit's constraints, without revealing what those inputs are. ```circom template ExampleCircuit() { // Public inputs -- visible on-chain signal input root; signal input nullifier; // Private inputs -- known only to prover signal input secret; signal input pathElements[20]; signal input pathIndices[20]; // Circuit constraints verify relationships between public and private inputs // without revealing private values } ``` ## Circuit Catalog Specter currently defines three circuits: ### 1. GhostRedemption(20) The primary circuit for token privacy. Proves that the prover owns a commitment in the Merkle tree and is authorized to withdraw a specified amount. | Property | Value | |----------|-------| | **Public inputs** | 8 (root, nullifier, withdrawAmount, recipient, changeCommitment, tokenId, policyId, policyParamsHash) | | **Private inputs** | 7 (secret, nullifierSecret, amount, blinding, pathElements[20], pathIndices[20], newBlinding) | | **Key constraints** | Commitment preimage, Merkle membership, nullifier derivation, amount conservation, change commitment, recipient/policy binding | | **Used by** | CommitRevealVault | See [Redemption Circuit](./redemption-circuit.md) for the full breakdown. ### 2. Access Proof Circuit A lightweight circuit for proving access to a committed key without spending it. Used by PersistentKeyVault for reusable, unlinkable authentication. | Property | Value | |----------|-------| | **Public inputs** | 4 (root, dataHash, sessionNonce, accessTag) | | **Private inputs** | 5 (secret, nullifierSecret, blinding, pathElements[20], pathIndices[20]) | | **Key constraints** | Commitment preimage (Poseidon4), Merkle membership, access tag derivation | | **Used by** | PersistentKeyVault, OpenGhostReveal | See [Access Proof Circuit](./access-proof-circuit.md) for the full breakdown. ### 3. OpenGhost Circuit The reveal circuit for OpenGhostVault data commitments. Structurally similar to GhostRedemption but without amount handling, change commitments, or policy binding. | Property | Value | |----------|-------| | **Public inputs** | Root, nullifier, dataHash | | **Private inputs** | secret, nullifierSecret, blinding, pathElements[20], pathIndices[20] | | **Key constraints** | Poseidon4 commitment preimage, Merkle membership, nullifier derivation | | **Used by** | OpenGhostReveal | ## Client-Side Proof Generation All ZK proofs are generated on the client side -- the prover's secret inputs never need to leave their device (except when offloaded to the Proof Relayer for constrained devices). ### Browser (snarkjs + WASM) The webapp generates proofs entirely in the browser using **snarkjs** compiled to WebAssembly: ```typescript // Load circuit artifacts (fetched once, cached in browser) const wasmPath = "/circuits/GhostRedemption.wasm"; const zkeyPath = "/circuits/GhostRedemption.zkey"; // Generate proof with private inputs const { proof, publicSignals } = await snarkjs.groth16.fullProve( privateInputs, // Object with all circuit input values wasmPath, // Compiled circuit (WASM) zkeyPath // Proving key from trusted setup ); // Format for on-chain verification const calldata = await snarkjs.groth16.exportSolidityCallData( proof, publicSignals ); ``` **Performance (typical desktop browser):** | Circuit | Constraints | Proof Time | Memory | |---------|------------|------------|--------| | GhostRedemption(20) | ~45,000 | 3-8 seconds | ~200 MB | | Access Proof | ~12,000 | 1-3 seconds | ~80 MB | | OpenGhost | ~15,000 | 1-4 seconds | ~100 MB | ### Mobile (Proof Relayer) Mobile devices (React Native / Expo) typically cannot generate proofs in-browser due to memory and compute constraints. Instead, the mobile app sends the private inputs to the **Proof Relayer** over TLS: ``` Mobile App → HTTPS/TLS → Proof Relayer → snarkjs → proof + publicSignals → Mobile App ``` The Proof Relayer is a trusted service operated by the Specter team. The private inputs are processed in memory and not persisted. For users requiring maximum privacy, the webapp's in-browser proving is recommended. ### Proof Artifacts Each circuit requires two artifacts for proof generation: | Artifact | Size | Description | |----------|------|-------------| | `circuit.wasm` | ~2-5 MB | Compiled circuit (constraint system as WASM) | | `circuit.zkey` | ~10-50 MB | Proving key from trusted setup (contains SRS elements) | These artifacts are loaded once and cached. The `.zkey` file is the largest artifact and dominates the initial load time. ## Verification Flow ```mermaid sequenceDiagram participant Client participant Chain as Specter Chain participant Vault as CommitRevealVault participant Verifier as SP1ProofVerifier Client->>Client: Load WASM circuit + proving key Client->>Client: Compute private inputs (secret, path, etc.) Client->>Client: snarkjs.groth16.fullProve() → proof + publicSignals Client->>Chain: Submit reveal transaction Chain->>Vault: reveal(proof, publicInputs) Vault->>Vault: Check root is known Vault->>Vault: Check nullifier not spent Vault->>Verifier: verifyProof(proof, publicInputs) Verifier->>Verifier: ecMul x8, ecAdd x8 (public input accumulation) Verifier->>Verifier: ecPairing (4 pairs, pairing check) Verifier-->>Vault: true / false Vault->>Vault: Register nullifier, process withdrawal ``` ## Circuit Compilation Pipeline Circuits are written in **Circom 2** and compiled through the following pipeline: ``` circuit.circom ↓ circom compiler circuit.r1cs + circuit.wasm + circuit.sym ↓ snarkjs (Phase 2 trusted setup) circuit.zkey (proving key) ↓ snarkjs (export verification key) verification_key.json ↓ snarkjs (export Solidity verifier) Verifier.sol (on-chain contract) ``` | Stage | Tool | Output | |-------|------|--------| | Compile | `circom` | `.r1cs` (constraint system), `.wasm` (witness generator), `.sym` (debug symbols) | | Trusted setup | `snarkjs` | `.zkey` (proving key) | | Export VK | `snarkjs` | `verification_key.json` | | Export verifier | `snarkjs` | `Verifier.sol` (deployable Solidity contract) | The `Verifier.sol` output is the basis for the on-chain `SP1ProofVerifier` contract, with the verification key constants embedded directly in the contract bytecode. ================================================================ SECTION: Circuits SOURCE: https://docs.specterchain.com/circuits/redemption-circuit ================================================================ # GhostRedemption Circuit The `GhostRedemption(20)` circuit is the core ZK circuit powering Ghost Protocol's token privacy. It encodes the complete set of constraints that a prover must satisfy to redeem (reveal) a committed token value from the CommitRevealVault. The circuit proves six things simultaneously, all in zero knowledge: 1. The prover knows the preimage of a commitment in the Merkle tree 2. The commitment exists in the tree (Merkle membership) 3. The nullifier is correctly derived (prevents double-spending) 4. The withdrawal amount does not exceed the committed amount (conservation) 5. The change commitment is valid (leftover balance is re-committed) 6. The proof is bound to a specific recipient and policy (prevents front-running) ## Circuit Parameters ```circom template GhostRedemption(depth) { ... } // Instantiated with depth = 20 component main {public [ root, nullifier, withdrawAmount, recipient, changeCommitment, tokenId, policyId, policyParamsHash ]} = GhostRedemption(20); ``` The `depth` parameter (20) determines the Merkle tree depth, which sets the tree capacity at 2^20 = 1,048,576 leaves and requires 20 levels of path verification in the Merkle membership proof. ## Signal Layout ### Public Inputs (8) These values are visible to the on-chain verifier and are included in the proof's public signals: | Index | Signal | Type | Description | |-------|--------|------|-------------| | 0 | `root` | Field element | Merkle tree root. Must match a root in the CommitmentTree's 100-root ring buffer. | | 1 | `nullifier` | Field element | Spend tag: `Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex)`. Registered on-chain to prevent double-spending. | | 2 | `withdrawAmount` | Field element | Amount being withdrawn. Must be `<= amount` (the committed amount). | | 3 | `recipient` | Field element | Address of the withdrawal recipient (as a field element). Bound in the proof to prevent front-running. | | 4 | `changeCommitment` | Field element | New commitment for the remaining balance (`amount - withdrawAmount`). Inserted into the tree after reveal. | | 5 | `tokenId` | Field element | `Poseidon2(tokenAddress, 0)`. Identifies which token is being redeemed. | | 6 | `policyId` | Field element | Policy contract identifier. `0` for no policy. | | 7 | `policyParamsHash` | Field element | `keccak256(policyParams) % BN254_FIELD`. `0` for no policy. | ### Private Inputs (7) These values are known only to the prover and are never revealed: | Signal | Type | Description | |--------|------|-------------| | `secret` | Field element | 31-byte random secret. Proves ownership of the commitment. | | `nullifierSecret` | Field element | 31-byte random secret used in nullifier derivation. Separate from `secret` to enable delegation. | | `amount` | Field element | The full committed token amount (in base units). | | `blinding` | Field element | Random blinding factor from the original commitment. | | `pathElements[20]` | Array of 20 field elements | Sibling hashes along the Merkle path from leaf to root. | | `pathIndices[20]` | Array of 20 bits (0 or 1) | Direction bits: `0` = leaf is left child, `1` = leaf is right child at each level. | | `newBlinding` | Field element | Fresh random blinding factor for the change commitment. | ## Constraint Breakdown The circuit enforces six constraint groups. Each group is described below with its mathematical formulation and Circom implementation. ### Constraint 1: Commitment Preimage (Poseidon7) The prover must demonstrate knowledge of the 7-input preimage that produces a valid commitment: ``` commitment = Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash) ``` This is the fundamental ownership proof. Without knowing all 7 preimage values, the prover cannot construct a valid commitment that matches a leaf in the tree. ```circom // Compute the commitment from private inputs component commitmentHasher = PoseidonT8(7); commitmentHasher.inputs[0] <== secret; commitmentHasher.inputs[1] <== nullifierSecret; commitmentHasher.inputs[2] <== tokenId; commitmentHasher.inputs[3] <== amount; commitmentHasher.inputs[4] <== blinding; commitmentHasher.inputs[5] <== policyId; commitmentHasher.inputs[6] <== policyParamsHash; signal commitment <== commitmentHasher.out; ``` **Constraint count:** ~1,750 R1CS constraints (Poseidon7/PoseidonT8). ### Constraint 2: Merkle Membership The prover must show that the computed commitment exists as a leaf in the Merkle tree with the given root. This is a standard 20-level Merkle path verification: ``` For each level i from 0 to 19: if pathIndices[i] == 0: currentHash = Poseidon2(currentHash, pathElements[i]) else: currentHash = Poseidon2(pathElements[i], currentHash) assert currentHash == root ``` The verification starts at the leaf (the commitment) and hashes upward through 20 levels using the sibling hashes provided in `pathElements`. The `pathIndices` bits determine whether the current node is the left or right child at each level. ```circom component merkleProof = MerkleTreeChecker(20); merkleProof.leaf <== commitment; merkleProof.root <== root; for (var i = 0; i < 20; i++) { merkleProof.pathElements[i] <== pathElements[i]; merkleProof.pathIndices[i] <== pathIndices[i]; } ``` The `MerkleTreeChecker` template internally uses a selector at each level: ```circom template MerkleTreeChecker(depth) { signal input leaf; signal input root; signal input pathElements[depth]; signal input pathIndices[depth]; signal hashes[depth + 1]; hashes[0] <== leaf; component hashers[depth]; component selectors[depth]; for (var i = 0; i < depth; i++) { // Ensure pathIndices[i] is binary pathIndices[i] * (1 - pathIndices[i]) === 0; // Select left and right inputs based on pathIndices[i] selectors[i] = DualMux(); selectors[i].in[0] <== hashes[i]; selectors[i].in[1] <== pathElements[i]; selectors[i].sel <== pathIndices[i]; // Hash the pair hashers[i] = PoseidonT3(2); hashers[i].inputs[0] <== selectors[i].out[0]; hashers[i].inputs[1] <== selectors[i].out[1]; hashes[i + 1] <== hashers[i].out; } // Final hash must equal the public root root === hashes[depth]; } ``` **Constraint count:** ~5,000 R1CS constraints (20 x Poseidon2 + 20 x binary check + 20 x mux). ### Constraint 3: Nullifier Derivation The nullifier must be correctly derived using the two-level Poseidon2 scheme. This binds the nullifier to both the commitment content and its position in the tree: ``` Step 1: innerNullifier = Poseidon2(nullifierSecret, commitment) Step 2: leafIndex = pathIndices[0] + 2*pathIndices[1] + 4*pathIndices[2] + ... + 2^19*pathIndices[19] Step 3: nullifier = Poseidon2(innerNullifier, leafIndex) ``` The `leafIndex` is reconstructed from the `pathIndices` bits. Since each bit indicates whether the node is a left (0) or right (1) child, the bits form the binary representation of the leaf's position in the tree. ```circom // Step 1: Inner nullifier component innerNullifierHasher = PoseidonT3(2); innerNullifierHasher.inputs[0] <== nullifierSecret; innerNullifierHasher.inputs[1] <== commitment; signal innerNullifier <== innerNullifierHasher.out; // Step 2: Reconstruct leaf index from path indices signal leafIndex; signal indexBits[20]; var indexSum = 0; for (var i = 0; i < 20; i++) { indexBits[i] <== pathIndices[i] * (2 ** i); indexSum += indexBits[i]; } leafIndex <== indexSum; // Step 3: Final nullifier component nullifierHasher = PoseidonT3(2); nullifierHasher.inputs[0] <== innerNullifier; nullifierHasher.inputs[1] <== leafIndex; // Constrain against public input nullifier === nullifierHasher.out; ``` **Why two levels?** - **Level 1** (`Poseidon2(nullifierSecret, commitment)`) ensures the nullifier is bound to the commitment's content. Without knowing `nullifierSecret`, an observer cannot predict which commitment a nullifier corresponds to. - **Level 2** (`Poseidon2(innerNullifier, leafIndex)`) binds the nullifier to the leaf position. This ensures that if the same commitment value somehow appears in two different leaves, each has a unique nullifier -- preventing a scenario where spending one leaf inadvertently blocks the other. **Constraint count:** ~500 R1CS constraints (2 x Poseidon2 + index reconstruction). ### Constraint 4: Amount Conservation The withdrawal amount must not exceed the committed amount. This is enforced via a **252-bit range check** on the difference: ``` assert withdrawAmount <= amount equivalently: assert (amount - withdrawAmount) is in range [0, 2^252) ``` The range check works by decomposing `(amount - withdrawAmount)` into 252 binary bits and constraining each bit to be 0 or 1. If the difference were negative (i.e., `withdrawAmount > amount`), the subtraction would underflow in the finite field, producing a value greater than 2^252, and the bit decomposition would fail. ```circom // Compute the difference signal amountDiff; amountDiff <== amount - withdrawAmount; // 252-bit range check: decompose into bits and verify each is binary component rangeCheck = Num2Bits(252); rangeCheck.in <== amountDiff; // Num2Bits internally constrains: // - each bit is 0 or 1: bit[i] * (1 - bit[i]) === 0 // - reconstruction: sum(bit[i] * 2^i) === amountDiff ``` **Why 252 bits (not 254)?** The BN254 scalar field is ~254 bits, but not all 254-bit values are valid field elements (the field prime `p` is slightly less than 2^254). Using 252 bits provides a comfortable margin: any 252-bit value is guaranteed to be less than `p`, so the range check is sound. If we used 254 bits, a carefully chosen value could wrap around the field modulus and pass the range check despite representing a negative difference. **Constraint count:** ~252 R1CS constraints (252 binary checks + reconstruction). ### Constraint 5: Change Commitment Validity When the prover withdraws less than the full amount, the leftover balance must be re-committed. The circuit verifies that the change commitment is correctly formed: ``` changeAmount = amount - withdrawAmount changeCommitment = Poseidon7(secret, nullifierSecret, tokenId, changeAmount, newBlinding, policyId, policyParamsHash) ``` The change commitment uses the **same** `secret`, `nullifierSecret`, `tokenId`, `policyId`, and `policyParamsHash` as the original commitment but with: - The reduced amount (`changeAmount`) - A fresh `newBlinding` factor (for commitment uniqueness) ```circom // Compute the expected change commitment signal changeAmount; changeAmount <== amount - withdrawAmount; component changeHasher = PoseidonT8(7); changeHasher.inputs[0] <== secret; changeHasher.inputs[1] <== nullifierSecret; changeHasher.inputs[2] <== tokenId; changeHasher.inputs[3] <== changeAmount; changeHasher.inputs[4] <== newBlinding; changeHasher.inputs[5] <== policyId; changeHasher.inputs[6] <== policyParamsHash; // Constrain against public input changeCommitment === changeHasher.out; ``` **Policy binding in change commitments:** The change commitment inherits the same `policyId` and `policyParamsHash` as the original commitment. This ensures that policy restrictions cannot be stripped by performing a partial withdrawal -- the change output remains subject to the same policy. **Full withdrawal case:** When `withdrawAmount == amount`, the `changeAmount` is 0. The circuit still computes and constrains the change commitment. The on-chain contract inserts this zero-value commitment into the tree. If someone later tries to reveal this change commitment, the amount conservation check (Constraint 4) will allow withdrawing 0 or less, rendering it harmless. **Constraint count:** ~1,750 R1CS constraints (Poseidon7). ### Constraint 6: Recipient and Policy Binding The proof must be bound to a specific recipient address, token ID, policy ID, and policy parameters hash. This is enforced through **quadratic constraints** that include these public signals in the circuit's constraint system: ```circom // Bind recipient -- create a quadratic constraint involving recipient signal recipientSquare; recipientSquare <== recipient * recipient; // Bind tokenId signal tokenIdSquare; tokenIdSquare <== tokenId * tokenId; // Bind policyId signal policyIdSquare; policyIdSquare <== policyId * policyId; // Bind policyParamsHash signal policyParamsHashSquare; policyParamsHashSquare <== policyParamsHash * policyParamsHash; ``` **Why quadratic constraints?** In Groth16, public inputs that are not referenced by any constraint can be freely modified by an attacker without invalidating the proof. By including each public input in at least one multiplicative constraint (even a trivial `x * x`), the proof becomes irrevocably bound to those specific values. If an attacker changes the `recipient` in the transaction calldata, the proof will fail verification because the pairing equation depends on the exact public input values. This prevents **front-running attacks** where a miner or MEV bot observes a reveal transaction in the mempool and substitutes their own address as the recipient. **Constraint count:** 4 R1CS constraints. ## Total Constraint Summary | Constraint Group | Constraints (approx.) | |-----------------|----------------------| | Commitment preimage (Poseidon7) | 1,750 | | Merkle membership (20 levels) | 5,000 | | Nullifier derivation (2 x Poseidon2) | 500 | | Amount conservation (252-bit range check) | 252 | | Change commitment (Poseidon7) | 1,750 | | Recipient/policy binding (quadratic) | 4 | | **Total** | **~9,256** | The actual constraint count may vary slightly depending on the Circom compiler version and optimization settings. With overhead from signal routing and auxiliary constraints, the full circuit compiles to approximately 45,000 constraints. ## Data Flow Diagram ```mermaid graph TD subgraph Private Inputs S[secret] NS[nullifierSecret] A[amount] B[blinding] PE[pathElements 20] PI[pathIndices 20] NB[newBlinding] end subgraph Constraint 1: Preimage P7[Poseidon7] end subgraph Constraint 2: Membership MC[MerkleTreeChecker 20] end subgraph Constraint 3: Nullifier P2a[Poseidon2
inner nullifier] IDX[Leaf Index
from pathIndices] P2b[Poseidon2
final nullifier] end subgraph Constraint 4: Conservation RC[252-bit
Range Check] end subgraph Constraint 5: Change P7c[Poseidon7
change commitment] end subgraph Public Inputs ROOT[root] NULL[nullifier] WA[withdrawAmount] RECIP[recipient] CC[changeCommitment] TID[tokenId] PID[policyId] PPH[policyParamsHash] end S --> P7 NS --> P7 A --> P7 B --> P7 TID --> P7 PID --> P7 PPH --> P7 P7 -->|commitment| MC PE --> MC PI --> MC MC -->|verified root| ROOT NS --> P2a P7 -->|commitment| P2a PI --> IDX P2a --> P2b IDX --> P2b P2b -->|verified nullifier| NULL A --> RC WA --> RC S --> P7c NS --> P7c TID --> P7c NB --> P7c PID --> P7c PPH --> P7c RC -->|changeAmount| P7c P7c -->|verified| CC ``` ## Proof Generation Example ```typescript // All inputs for the GhostRedemption circuit const circuitInputs = { // Private inputs secret: "0x1a2b3c...", // 31-byte random secret nullifierSecret: "0x4d5e6f...", // 31-byte nullifier secret amount: "1000000000000000000", // 1 GHOST (18 decimals) blinding: "0x7a8b9c...", // 31-byte blinding factor pathElements: [ // 20 sibling hashes "12345...", "67890...", /* ... 18 more */ ], pathIndices: [ // 20 direction bits 0, 1, 0, 0, 1, /* ... 15 more */ ], newBlinding: "0xaabbcc...", // Fresh blinding for change // Public inputs (also provided to circuit for constraint checking) withdrawAmount: "500000000000000000", // 0.5 GHOST recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", tokenId: "18394027...", // Poseidon2(GHOST_ADDRESS, 0) policyId: "0", // No policy policyParamsHash: "0", // No policy }; const { proof, publicSignals } = await snarkjs.groth16.fullProve( circuitInputs, "/circuits/GhostRedemption.wasm", "/circuits/GhostRedemption.zkey" ); // publicSignals = [root, nullifier, withdrawAmount, recipient, // changeCommitment, tokenId, policyId, policyParamsHash] console.log("Root:", publicSignals[0]); console.log("Nullifier:", publicSignals[1]); console.log("Withdraw amount:", publicSignals[2]); console.log("Change commitment:", publicSignals[4]); ``` ## Security Properties ### Soundness A malicious prover **cannot** generate a valid proof if: - They do not know the commitment preimage (secret, nullifierSecret, amount, blinding) - The commitment is not in the Merkle tree - The nullifier does not match the commitment - The withdrawal exceeds the committed amount - The change commitment is malformed The soundness guarantee is computational (based on the hardness of the discrete logarithm problem on BN254) and relies on the trusted setup being honest. ### Zero-Knowledge The proof reveals **nothing** about: - Which commitment in the tree is being spent - The committed amount (only the withdrawal amount is public) - The secret or nullifier secret - The blinding factor - The Merkle path (which leaf position) An observer sees only the 8 public inputs and the proof bytes. The nullifier is unlinkable to the commitment without knowing the nullifier secret. ### Non-Malleability The recipient and policy binding (Constraint 6) ensures proofs cannot be modified in transit. A MEV bot cannot change the recipient address in a pending transaction -- the modified public inputs would cause the pairing check to fail. ================================================================ SECTION: Circuits SOURCE: https://docs.specterchain.com/circuits/access-proof-circuit ================================================================ # Access Proof Circuit The Access Proof circuit enables **persistent, unlinkable authentication** against a committed key stored in the OpenCommitmentTree. Unlike the GhostRedemption circuit, which spends a commitment (via nullifier registration), the Access Proof circuit proves knowledge of a commitment without consuming it. Each access session uses a different `sessionNonce`, producing a unique `accessTag` that cannot be linked to the committed key or to other sessions. This circuit powers the PersistentKeyVault -- Specter's mechanism for reusable encryption keys, recurring authenticated access, and long-lived private identities. ## Design Rationale The GhostRedemption circuit is designed for one-time use: each reveal registers a nullifier, permanently marking the commitment as spent. This is correct for token transfers (preventing double-spending), but many use cases require repeated access to the same secret: - **Reusable encryption keys**: A user commits a public key and proves knowledge of it across multiple sessions to decrypt messages. - **Authenticated API access**: A user proves they hold a valid credential without revealing the credential or creating a permanent on-chain link. - **Private channel membership**: A user proves membership in a group commitment set repeatedly, for each new session. The Access Proof circuit solves this by **omitting nullifier registration** and replacing it with a session-scoped `accessTag`. The commitment is never "spent" and can be used indefinitely. ## Signal Layout ### Public Inputs (4) | Index | Signal | Type | Description | |-------|--------|------|-------------| | 0 | `root` | Field element | Merkle tree root from the OpenCommitmentTree. Must exist in the 100-root ring buffer. | | 1 | `dataHash` | Field element | Hash of the data associated with this commitment. Typically `keccak256(data) % BN254_FIELD`. | | 2 | `sessionNonce` | Field element | A fresh random nonce provided by the verifier (or agreed upon) for this session. Different for each access. | | 3 | `accessTag` | Field element | `Poseidon2(nullifierSecret, sessionNonce)`. Proves knowledge of `nullifierSecret` without revealing it. | ### Private Inputs (5) | Signal | Type | Description | |--------|------|-------------| | `secret` | Field element | 31-byte random secret from the original commitment. | | `nullifierSecret` | Field element | 31-byte random secret. Used to derive `accessTag` (not a nullifier). | | `blinding` | Field element | Random blinding factor from the original commitment. | | `pathElements[20]` | Array of 20 field elements | Sibling hashes along the Merkle path from leaf to root. | | `pathIndices[20]` | Array of 20 bits | Direction bits for the Merkle path. | ## What This Circuit Does NOT Do Understanding the Access Proof circuit requires understanding what is deliberately **absent** compared to GhostRedemption: | Feature | GhostRedemption | Access Proof | |---------|----------------|--------------| | **Nullifier computation** | Yes -- `Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex)` | **No** -- no nullifier is computed or output | | **Nullifier registration** | Yes -- nullifier is marked as spent on-chain | **No** -- nothing is spent | | **Recipient binding** | Yes -- proof bound to specific recipient address | **No** -- no recipient concept | | **Amount handling** | Yes -- withdrawAmount, range check, change commitment | **No** -- no amounts involved | | **Change commitment** | Yes -- re-commits leftover balance | **No** -- commitment is reused as-is | | **Policy binding** | Yes -- policyId and policyParamsHash in constraints | **No** -- no policy enforcement | | **Token ID** | Yes -- tokenId in commitment preimage | **No** -- uses dataHash instead | The Access Proof circuit is structurally simpler: it proves **"I know a valid commitment in this tree with this dataHash"** and produces a session-scoped tag, nothing more. ## Circuit Template ```circom template AccessProof(depth) { // Public inputs signal input root; signal input dataHash; signal input sessionNonce; signal input accessTag; // Private inputs signal input secret; signal input nullifierSecret; signal input blinding; signal input pathElements[depth]; signal input pathIndices[depth]; // Step 1: Compute commitment from preimage component commitmentHasher = PoseidonT5(4); commitmentHasher.inputs[0] <== secret; commitmentHasher.inputs[1] <== nullifierSecret; commitmentHasher.inputs[2] <== dataHash; commitmentHasher.inputs[3] <== blinding; signal commitment <== commitmentHasher.out; // Step 2: Verify Merkle membership component merkleProof = MerkleTreeChecker(depth); merkleProof.leaf <== commitment; merkleProof.root <== root; for (var i = 0; i < depth; i++) { merkleProof.pathElements[i] <== pathElements[i]; merkleProof.pathIndices[i] <== pathIndices[i]; } // Step 3: Verify access tag component tagHasher = PoseidonT3(2); tagHasher.inputs[0] <== nullifierSecret; tagHasher.inputs[1] <== sessionNonce; accessTag === tagHasher.out; } component main {public [root, dataHash, sessionNonce, accessTag]} = AccessProof(20); ``` ## Constraint Breakdown ### Step 1: Commitment Preimage (Poseidon4) The prover demonstrates knowledge of the four values that hash to a valid commitment: ``` commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding) ``` This uses the same Poseidon4 (PoseidonT5) hash as OpenGhostVault commitments. The `dataHash` is a public input, which means the verifier knows what data the prover is claiming access to -- but not which specific commitment in the tree corresponds to that data. ```circom component commitmentHasher = PoseidonT5(4); commitmentHasher.inputs[0] <== secret; commitmentHasher.inputs[1] <== nullifierSecret; commitmentHasher.inputs[2] <== dataHash; commitmentHasher.inputs[3] <== blinding; signal commitment <== commitmentHasher.out; ``` **Constraint count:** ~700 R1CS constraints. ### Step 2: Merkle Membership Identical to GhostRedemption's Merkle membership check. The circuit verifies a 20-level path from the commitment (leaf) to the public `root`: ``` For each level i from 0 to 19: if pathIndices[i] == 0: currentHash = Poseidon2(currentHash, pathElements[i]) else: currentHash = Poseidon2(pathElements[i], currentHash) assert currentHash == root ``` ```circom component merkleProof = MerkleTreeChecker(20); merkleProof.leaf <== commitment; merkleProof.root <== root; for (var i = 0; i < 20; i++) { merkleProof.pathElements[i] <== pathElements[i]; merkleProof.pathIndices[i] <== pathIndices[i]; } ``` The prover must supply a valid Merkle path. Since the tree is append-only and the root is checked against the on-chain ring buffer, this guarantees the commitment was inserted into the tree at some point. **Constraint count:** ~5,000 R1CS constraints. ### Step 3: Access Tag Verification The access tag is the key innovation of this circuit. Instead of computing a nullifier (which would be registered on-chain and "spend" the commitment), the circuit computes a session-scoped tag: ``` accessTag = Poseidon2(nullifierSecret, sessionNonce) ``` ```circom component tagHasher = PoseidonT3(2); tagHasher.inputs[0] <== nullifierSecret; tagHasher.inputs[1] <== sessionNonce; accessTag === tagHasher.out; ``` The `sessionNonce` is provided by the verifier (or agreed upon via a protocol). For each new session, a different nonce is used, producing a different access tag. **Constraint count:** ~250 R1CS constraints. ## Total Constraint Summary | Constraint Group | Constraints (approx.) | |-----------------|----------------------| | Commitment preimage (Poseidon4) | 700 | | Merkle membership (20 levels) | 5,000 | | Access tag (Poseidon2) | 250 | | **Total** | **~5,950** | The Access Proof circuit is roughly 35% smaller than GhostRedemption (~9,250 constraints), resulting in faster proof generation and a smaller proving key. ## Unlinkability Analysis The core privacy property of the Access Proof circuit is **session unlinkability**: given two access tags from different sessions, an observer cannot determine whether they came from the same user. ### Why Access Tags Are Unlinkable Consider a user with `nullifierSecret = ns` accessing the system in two sessions: ``` Session 1: sessionNonce = n1 → accessTag1 = Poseidon2(ns, n1) Session 2: sessionNonce = n2 → accessTag2 = Poseidon2(ns, n2) ``` An observer sees `(n1, accessTag1)` and `(n2, accessTag2)`. To link these sessions, the observer would need to determine whether `accessTag1` and `accessTag2` were derived from the same `nullifierSecret`. This requires inverting Poseidon2 -- finding `ns` such that `Poseidon2(ns, n1) = accessTag1` -- which is computationally infeasible (Poseidon is preimage-resistant). ### Comparison with Nullifiers In GhostRedemption, the nullifier is deterministic: the same commitment always produces the same nullifier. This is by design -- it prevents double-spending. But it also means that if a commitment's nullifier is spent, the commitment is permanently identifiable. Access tags avoid this tradeoff: | Property | Nullifier | Access Tag | |----------|-----------|------------| | **Derivation** | `Poseidon2(Poseidon2(ns, commitment), leafIndex)` | `Poseidon2(ns, sessionNonce)` | | **Deterministic?** | Yes (same commitment always gives same nullifier) | No (different nonce each time) | | **Registered on-chain?** | Yes (NullifierRegistry) | No | | **Links sessions?** | N/A (single use) | No (different tag per session) | | **Prevents replay?** | Yes (cannot reuse nullifier) | Yes (nonce uniqueness enforced by verifier) | ### Replay Prevention Without Nullifiers Since access tags are not registered on-chain, how does the system prevent replay? The verifier controls the `sessionNonce`: 1. The verifier generates a fresh random `sessionNonce` for each session 2. The prover computes `accessTag = Poseidon2(nullifierSecret, sessionNonce)` and generates a proof 3. The verifier checks the proof and records the `(sessionNonce, accessTag)` pair 4. If the same `sessionNonce` is reused, the verifier rejects it (nonce uniqueness) This shifts replay prevention from the blockchain (nullifier registry) to the verifier application. For PersistentKeyVault, the verifier is the on-chain contract that tracks used session nonces. ## Use Case: PersistentKeyVault The PersistentKeyVault uses the Access Proof circuit to enable reusable encrypted keys: ```mermaid sequenceDiagram participant User participant PKV as PersistentKeyVault participant OCT as OpenCommitmentTree Note over User: One-time setup User->>User: Generate (secret, nullifierSecret, blinding) User->>User: keyHash = keccak256(encryptionKey) % BN254_FIELD User->>User: commitment = Poseidon4(secret, nullifierSecret, keyHash, blinding) User->>PKV: commit(commitment) {value: fee} PKV->>OCT: insert(commitment) Note over User: Session N (repeatable) User->>PKV: Request session nonce PKV-->>User: sessionNonce (fresh random) User->>User: accessTag = Poseidon2(nullifierSecret, sessionNonce) User->>User: Generate Access Proof User->>PKV: proveAccess(proof, root, keyHash, sessionNonce, accessTag) PKV->>PKV: Verify proof PKV->>PKV: Record (sessionNonce, accessTag) as used PKV-->>User: Access granted ``` Each session produces a unique `(sessionNonce, accessTag)` pair. An observer monitoring PersistentKeyVault sees a stream of access proofs but cannot: - Link any two proofs to the same user - Determine which commitment in the tree the user is proving knowledge of - Recover the encryption key from the access tag ## Proof Generation Example ```typescript const BN254_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; // User's persistent key data (stored locally) const secret = 0x1a2b3c4d5e6f...n; const nullifierSecret = 0x7a8b9c0d1e2f...n; const blinding = 0x3a4b5c6d7e8f...n; const encryptionKey = new Uint8Array([/* ... */]); const dataHash = BigInt(ethers.keccak256(encryptionKey)) % BN254_FIELD; // Merkle path (fetched from indexer) const pathElements = [/* 20 sibling hashes */]; const pathIndices = [/* 20 direction bits */]; // Session nonce (from verifier) const sessionNonce = 0xdeadbeef...n; // Compute expected access tag (for verification) const expectedTag = poseidon2([nullifierSecret, sessionNonce]); // Generate the proof const circuitInputs = { // Public inputs root: merkleRoot, dataHash: dataHash, sessionNonce: sessionNonce, accessTag: expectedTag, // Private inputs secret: secret, nullifierSecret: nullifierSecret, blinding: blinding, pathElements: pathElements, pathIndices: pathIndices, }; const { proof, publicSignals } = await snarkjs.groth16.fullProve( circuitInputs, "/circuits/AccessProof.wasm", "/circuits/AccessProof.zkey" ); // publicSignals = [root, dataHash, sessionNonce, accessTag] console.log("Access tag:", publicSignals[3]); // Submit proof to PersistentKeyVault for verification ``` ## Security Properties ### Soundness A prover **cannot** generate a valid Access Proof if: - They do not know `(secret, nullifierSecret, blinding)` for a valid commitment - The commitment is not in the OpenCommitmentTree at the given root - The `accessTag` does not equal `Poseidon2(nullifierSecret, sessionNonce)` ### Zero-Knowledge The proof reveals **nothing** about: - Which commitment in the tree the prover knows - The `secret`, `nullifierSecret`, or `blinding` values - The Merkle path (leaf position) - Any relationship between access tags from different sessions ### Non-Transferability The access proof demonstrates knowledge of a specific `nullifierSecret`. This means the proof cannot be generated by someone who only knows the `accessTag` -- they must know the underlying secret. However, if a user shares their `nullifierSecret` with another party, that party can generate access proofs. This is a feature for delegation scenarios and a consideration for access control design. ### Forward Secrecy Considerations Access tags from past sessions do not compromise future sessions. Even if an attacker learns the `sessionNonce` and `accessTag` from a past session, they cannot: - Derive the `nullifierSecret` (Poseidon preimage resistance) - Predict the `accessTag` for a future session with a different nonce - Link the past session to a specific commitment However, access tags do not provide **backward secrecy**: if the `nullifierSecret` is later compromised, all past access tags can be recomputed and linked. This is inherent to any authentication scheme based on a long-lived secret. ================================================================ SECTION: Circuits SOURCE: https://docs.specterchain.com/circuits/sp1-ghost-circuit ================================================================ # SP1 Ghost Circuit The SP1 Ghost Circuit is an alternative implementation of the Ghost Protocol's zero-knowledge proof system, targeting Succinct's SP1 zkVM instead of the Groth16/circom stack. Written in Rust, this circuit compiles to RISC-V and executes inside SP1's general-purpose zero-knowledge virtual machine, enabling fully on-chain proof verification without a trusted relayer. ## Motivation The existing Groth16-based Ghost circuit (written in circom) requires a trusted setup ceremony and produces proofs that are verified by a custom Solidity verifier contract. While efficient, this architecture has limitations: - **Trusted setup**: The Groth16 proving key and verification key are generated from a circuit-specific trusted setup. Any change to the circuit requires a new ceremony. - **Relayer dependency**: Proof generation currently requires server-side infrastructure (the proof relayer) because mobile and constrained browser environments cannot run the Groth16 prover efficiently. - **Circuit rigidity**: Circom circuits are fixed at compile time. Adding new features (e.g., new policy types, different commitment schemes) requires recompiling the entire circuit and redeploying the verifier. SP1 addresses these constraints by providing a general-purpose zkVM where the program is written in standard Rust. The SP1 prover generates STARK-based proofs that can be wrapped into SNARK proofs for on-chain verification via a universal verifier contract — no per-circuit trusted setup required. ## Architecture ``` ┌──────────────────────────────────────────────┐ │ SP1 zkVM │ │ │ │ ┌─────────────┐ ┌──────────────────────┐ │ │ │ RISC-V ELF │───▶│ SP1 Ghost Program │ │ │ └─────────────┘ │ │ │ │ │ 1. Read inputs │ │ │ │ 2. Verify commitment │ │ │ │ 3. Verify Merkle path│ │ │ │ 4. Commit public vals│ │ │ └──────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────┐ │ │ │ Public Values │ │ │ │ (on-chain data) │ │ │ └──────────────────┘ │ └──────────────────────────────────────────────┘ │ ▼ ┌──────────────────┐ │ SP1 Verifier │ │ (Solidity) │ └──────────────────┘ ``` ## Circuit Inputs The SP1 Ghost Circuit reads the following inputs from the SP1 stdin interface: | Input | Type | Description | |-------|------|-------------| | `nullifier_hash` | `[u8; 32]` | Hash of the nullifier, used to prevent double-reveals | | `commitment` | `[u8; 32]` | The Poseidon commitment stored in the Merkle tree | | `merkle_root` | `[u8; 32]` | The current root of the commitment Merkle tree | | `token_id` | `[u8; 32]` | Derived identifier for the token being transacted | | `amount` | `[u8; 32]` | The amount bound to this commitment | | `path_elements` | `[[u8; 32]; DEPTH]` | Sibling hashes along the Merkle path | | `path_indices` | `[u8; DEPTH]` | Left/right indicators for each level of the Merkle path | | `secret` | `[u8; 32]` | The user's secret, preimage component of the commitment | | `nullifier_secret` | `[u8; 32]` | The nullifier preimage, bound to the commitment | | `blinding` | `[u8; 32]` | Blinding factor for hiding the commitment | | `policy_id` | `[u8; 32]` | Identifier of the policy bound to this commitment | | `policy_params_hash` | `[u8; 32]` | Hash of the policy parameters at commit time | All values are serialized as 32-byte big-endian field elements in the BN254 scalar field. ## Verification Logic The circuit performs three core verification steps, mirroring the logic of the circom-based Ghost circuit: ### 1. Commitment Preimage Verification The circuit recomputes the Poseidon7 commitment from its constituent preimage values and asserts equality with the provided commitment: ```rust let computed_commitment = poseidon7( secret, nullifier_secret, blinding, token_id, amount, policy_id, policy_params_hash, ); assert_eq!(computed_commitment, commitment); ``` This proves that the prover knows the secret values that were used to create the commitment, without revealing them. ### 2. Merkle Path Verification The circuit walks the Merkle tree from the leaf (the commitment) to the root, hashing at each level with the provided sibling: ```rust let mut current = commitment; for i in 0..DEPTH { let sibling = path_elements[i]; current = if path_indices[i] == 0 { poseidon2(current, sibling) } else { poseidon2(sibling, current) }; } assert_eq!(current, merkle_root); ``` This proves the commitment exists in the on-chain Merkle tree without revealing which leaf position it occupies. ### 3. Nullifier Verification The circuit verifies that the nullifier hash is correctly derived from the nullifier secret: ```rust let computed_nullifier = poseidon2(nullifier_secret, nullifier_secret); assert_eq!(computed_nullifier, nullifier_hash); ``` This binds the nullifier to the commitment's preimage, ensuring each commitment can only be revealed once. ## Public Values After verification, the circuit commits the following values as public outputs. These are the values that the on-chain verifier contract can read and enforce: ```rust sp1_zkvm::io::commit(&nullifier_hash); sp1_zkvm::io::commit(&merkle_root); sp1_zkvm::io::commit(&token_id); sp1_zkvm::io::commit(&amount); sp1_zkvm::io::commit(&policy_id); sp1_zkvm::io::commit(&policy_params_hash); ``` The private inputs (`secret`, `nullifier_secret`, `blinding`, `path_elements`, `path_indices`) are never exposed. ## Poseidon Implementation The SP1 circuit uses a pure-Rust Poseidon implementation compatible with the BN254 scalar field. The hash parameters (round constants, MDS matrix) must match exactly with the circomlibjs implementation used elsewhere in the Specter stack: - **Poseidon2 (PoseidonT3)**: 2 inputs, used for Merkle node hashing and nullifier derivation - **Poseidon7 (PoseidonT8)**: 7 inputs, used for commitment construction Parameter alignment is critical. If the Rust Poseidon produces different outputs than circomlibjs for the same inputs, commitments created by the webapp would fail verification inside the SP1 circuit. ## On-Chain Verification Path SP1 proofs follow this verification flow: 1. The SP1 prover generates a STARK proof of correct execution. 2. The STARK proof is recursively compressed into a SNARK proof (Groth16 or PLONK) suitable for on-chain verification. 3. The SNARK proof is submitted to Succinct's universal verifier contract deployed on Specter. 4. The verifier contract checks the proof and exposes the public values to the calling contract (e.g., `CommitRevealVault`). This eliminates the need for a circuit-specific verifier contract. Any program compiled to the SP1 ELF format can be verified through the same universal verifier, identified by its `vkey` (verification key hash derived from the program binary). ## Comparison with Groth16 Circuit | Property | Groth16 (circom) | SP1 (Rust) | |----------|-----------------|------------| | Language | Circom DSL | Rust | | Proof system | Groth16 (BN254) | STARK → SNARK wrap | | Trusted setup | Per-circuit ceremony required | None (universal) | | Proof size | ~256 bytes | ~256 bytes (after SNARK wrap) | | Verification gas | ~200k gas | ~300k gas (universal verifier overhead) | | Prover time | ~13.5s (server) | Variable (depends on SP1 prover infrastructure) | | Circuit changes | Recompile + new setup | Recompile only | | On-chain verifier | `GhostRedemptionVerifier` (custom) | SP1 universal verifier | ## Deployment Status The SP1 Ghost Circuit is **not yet deployed**. It exists as a proof-of-concept demonstrating the feasibility of migrating the Ghost Protocol's ZK proof system from circom/Groth16 to a general-purpose zkVM. ### Remaining Work - **Poseidon parameter alignment**: Ensure the Rust Poseidon implementation produces identical outputs to circomlibjs across all input widths. - **SP1 verifier deployment**: Deploy Succinct's universal verifier contract on the Specter chain. - **Integration with CommitRevealVault**: Modify the vault contract to accept SP1 proofs alongside (or instead of) Groth16 proofs. - **Performance benchmarking**: Measure SP1 proof generation time and compare with the existing ~13.5s Groth16 baseline. - **Client-side proving**: Evaluate whether SP1's WASM prover can run in-browser, removing the relayer dependency entirely. ## Source Location The SP1 Ghost Circuit source is located in the Specter monorepo under the circuits directory alongside the circom-based circuits. The Rust program is structured as a standard SP1 project with a `program` crate (the guest code that runs inside the zkVM) and a `script` crate (the host code that prepares inputs and invokes the prover). ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/overview ================================================================ # Infrastructure Overview Specter's off-chain infrastructure consists of 11 services running on a single relayer droplet, managed by PM2 and exposed through a Caddy reverse proxy at `relayer.specterchain.com`. These services handle everything from Merkle tree synchronization and ZK proof generation to cross-chain bridging and gasless transaction submission. ## Architecture ``` ┌─────────────────────────┐ │ relayer.specterchain │ │ .com │ │ (Caddy HTTPS) │ └────────────┬────────────┘ │ Path-based routing to internal ports │ ┌────────────────────────────┼────────────────────────────┐ │ ┌───────────────┼───────────────┐ │ ▼ ▼ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ :3001 │ │ :3002 │ │ :3003 │ │ :3005 │ │ :3010 │ │ Root │ │Commit │ │ Proof │ │Faucet │ │ Ember │ │Updater │ │Relayer │ │Relayer │ │ │ │ Proxy │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │ │ ┌────────┐ │ ┌────────┐ │ │ :3009 │ │ │ No │ │ │ Token │ │ │ Port │ │ │Registry│ │ │ │ │ └────────┘ │ ┌────┴────────┴────┐ │ │ │ Offline Relayer │ │ │ │ Base Conversion │ ┌────┴─────────┐ │ │ Hyperlane Bridge │ │ Open Ghost │ │ │ Multi-Chain │ │ Root Updater │ │ └───────────────────┘ └──────────────┘ │ │ Specter Chain (Chain ID 5446) ``` ## Service Directory | Service | Port | Status | Description | |---------|------|--------|-------------| | **ghost-root-updater** | 3001 | Active — 131 commitments synced to block ~883495 | Listens for `Committed` events on `CommitmentTree`, maintains an incremental Poseidon Merkle tree, and submits updated roots on-chain via `updateRoot()` | | **ghost-commitment-relayer** | 3002 | Healthy | HTTP API for server-side Poseidon hash computation. Provides Poseidon2, Poseidon4, and Poseidon7 endpoints for mobile clients that cannot run circomlibjs efficiently | | **ghost-proof-relayer** | 3003 | Active — ~13.5s avg proof time, 4 proofs generated on testnet | Loads Groth16 circuit files and generates ZK proofs from private inputs. Returns Groth16 proof + public signals for on-chain verification | | **ghost-faucet** | 3005 | Active — 56.9M GHOST balance | Testnet faucet dispensing 100 GHOST per drip with a 24-hour cooldown per address | | **ghost-ember-proxy** | 3010 | Active | Persistent phantom key access proxy with server-side Access Proof generation. Also proxies AI chat requests to Anthropic's API | | **ghost-token-registry** | 3009 | Active | Tracks deployed GhostERC20 tokens and serves metadata (name, symbol, decimals, icon) to the webapp | | **ghost-offline-relayer** | — | Active | Gasless/sponsored transaction relayer. Submits reveal transactions on behalf of users who lack GHOST for gas | | **base-conversion-relayer** | — | Active | Watches for `TokensConverted` events on Base chain and mints corresponding g-tokens on Specter via `GhostMinter` | | **hyperlane-bridge-relayer** | — | Active | Monitors `Dispatch` events on source chains and delivers Hyperlane messages to Specter. Bidirectional Base-Specter bridging | | **hyperlane-multi-chain-relayer** | — | Active | Extends Hyperlane bridging to Ethereum, Base, and Arbitrum simultaneously | | **open-ghost-root-updater** | — | Active | Separate Merkle tree instance for OpenGhost/Revels data privacy commitments. Targets `OpenCommitmentTree` contract | ## Process Management All services are managed by [PM2](https://pm2.keymetrics.io/), a production process manager for Node.js applications. PM2 provides: - **Automatic restarts**: If a service crashes, PM2 restarts it immediately with configurable retry backoff. - **Log management**: Stdout and stderr are captured per-service under `~/.pm2/logs/`. Logs are rotated to prevent disk exhaustion. - **Startup persistence**: The PM2 process list is saved and restored on system reboot via `pm2 startup` and `pm2 save`. - **Monitoring**: `pm2 monit` provides real-time CPU/memory usage per service. `pm2 status` shows uptime and restart counts. Common PM2 commands for operating the relayer: ```bash # View all services pm2 status # View logs for a specific service pm2 logs ghost-proof-relayer # Restart a service pm2 restart ghost-root-updater # Reload all services (zero-downtime) pm2 reload all ``` ## Reverse Proxy Caddy serves as the HTTPS reverse proxy at `relayer.specterchain.com`. It handles: - **TLS termination** with automatic certificate provisioning and renewal via Let's Encrypt. - **Path-based routing** to internal service ports (e.g., `/root-updater/*` routes to `localhost:3001`). - **CORS headers** for cross-origin requests from the Specter webapp. - **Request logging** for debugging and audit purposes. See [Caddy Routing](./caddy-routing.md) for the full routing table. ## Network Topology All services run on a single DigitalOcean droplet. This simplifies deployment and inter-service communication (all traffic is `localhost`) but creates a single point of failure. The services communicate with the Specter chain via an RPC endpoint and with external chains (Base, Ethereum, Arbitrum) via their respective RPC providers. ``` ┌─────────────────────────────────────────────────┐ │ Relayer Droplet │ │ │ │ PM2 ──▶ [11 services on localhost ports] │ │ Caddy ──▶ HTTPS termination + routing │ │ │ │ Outbound connections: │ │ → Specter RPC (chain ID 5446) │ │ → Base RPC │ │ → Ethereum RPC │ │ → Arbitrum RPC │ │ → Anthropic API (for Ember chat proxy) │ └─────────────────────────────────────────────────┘ ``` ## Health Monitoring Each HTTP-accessible service exposes a health endpoint (typically `GET /health` or `GET /`) that returns service status, uptime, and relevant metrics. These endpoints are used for: - **Liveness checks**: Caddy and PM2 can detect unresponsive services. - **Dashboard metrics**: The webapp queries health endpoints to display system status. - **Alerting**: External monitoring can poll these endpoints to detect outages. Example health response from the root updater: ```json { "status": "healthy", "commitmentCount": 131, "lastSyncedBlock": 883495, "merkleRoot": "0x1a2b3c...", "uptime": 864000 } ``` ## Operator Keys Several services require operator wallet keys to submit on-chain transactions: | Service | On-Chain Action | |---------|----------------| | ghost-root-updater | `updateRoot()` on `CommitmentTree` | | open-ghost-root-updater | `updateRoot()` on `OpenCommitmentTree` | | ghost-offline-relayer | `reveal()` on `CommitRevealVault` | | ghost-faucet | Native GHOST transfers | | base-conversion-relayer | `mint()` on `GhostMinter` | | hyperlane-bridge-relayer | `process()` on Hyperlane `Mailbox` | | hyperlane-multi-chain-relayer | `process()` on Hyperlane `Mailbox` (multiple chains) | Operator keys are stored in environment variables or encrypted keystores on the droplet. Each service uses a dedicated key to isolate risk — a compromised faucet key cannot submit root updates. ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/root-updater ================================================================ # Root Updater The root updater service (`ghost-root-updater`, port 3001) is the critical link between on-chain commitment events and the Merkle tree state used by the Ghost Protocol. It listens for `Committed` events emitted by the `CommitmentTree` contract, maintains a local incremental Poseidon Merkle tree, computes the new root after each insertion, and submits the updated root on-chain via the `updateRoot()` function. ## Role in the Protocol When a user commits tokens through the Ghost Protocol, the `CommitRevealVault` contract forwards the commitment hash to the `CommitmentTree` contract, which emits a `Committed` event containing the commitment value and its leaf index. However, the on-chain contract does not compute the Merkle root itself — Poseidon hashing is too expensive to perform on-chain for an entire tree path. Instead, the root updater performs this computation off-chain and submits the result. ``` User commits tokens │ ▼ CommitRevealVault.commit() │ ▼ CommitmentTree.insertCommitment() │ ▼ emit Committed(commitment, leafIndex) │ ▼ Root Updater detects event │ ▼ Insert into local Merkle tree │ ▼ Compute new Poseidon root │ ▼ CommitmentTree.updateRoot(newRoot) ``` The on-chain `updateRoot()` function is permissioned — only the designated operator address can call it. This ensures that arbitrary parties cannot submit incorrect roots. ## Incremental Poseidon Merkle Tree The root updater maintains a full incremental Merkle tree in memory. The tree uses Poseidon2 (PoseidonT3) for internal node hashing, matching the circuit's expectations: - **Depth**: Matches the on-chain `CommitmentTree` depth (typically 20 levels, supporting up to 2^20 = 1,048,576 commitments). - **Hash function**: Poseidon2 over the BN254 scalar field. - **Zero values**: Each level has a precomputed "zero" hash. An empty leaf is `0`, and each subsequent level's zero is `Poseidon2(zero[level-1], zero[level-1])`. - **Insertion**: New commitments are inserted at the next available leaf index. The path from the leaf to the root is recomputed. ### Current State As of the last recorded snapshot: - **Commitment count**: 131 - **Last synced block**: ~883,495 - **Tree utilization**: 131 / 1,048,576 (0.01%) ## Event Listening The service connects to the Specter chain RPC and subscribes to `Committed` events on the `CommitmentTree` contract address. It processes events in order of block number and log index to maintain consistency. On startup, the service performs a historical sync: 1. Query all past `Committed` events from the contract's deployment block to the current block. 2. Insert each commitment into the local tree in order. 3. Verify the locally computed root matches the on-chain root. 4. Begin listening for new events in real time. This historical sync ensures the service can recover from restarts without data loss. ## Debounce Batching When multiple commitments arrive in rapid succession (e.g., several commits in the same block), the root updater debounces root submissions to avoid redundant on-chain transactions: 1. A new `Committed` event arrives and is inserted into the local tree. 2. A debounce timer starts (configurable, typically 2–5 seconds). 3. If additional events arrive before the timer expires, they are inserted and the timer resets. 4. When the timer expires, a single `updateRoot()` transaction is submitted with the final root that incorporates all batched commitments. This reduces gas costs and avoids transaction nonce conflicts when multiple commitments land in consecutive blocks. ## Commitment Cache The service maintains a persistent cache of all processed commitments, keyed by leaf index. This cache serves multiple purposes: - **Deduplication**: If the same event is delivered twice (e.g., due to RPC reorg or reconnection), the cache prevents double-insertion. - **Fast restart**: On restart, the cache is loaded before the historical sync begins, reducing the number of events that need to be reprocessed. - **Diagnostic queries**: The health endpoint can report the total commitment count and the most recently inserted commitment. The cache is written to disk atomically to prevent corruption on unexpected shutdown. ## Keystore Support The operator private key used for `updateRoot()` transactions can be provided in multiple ways: - **Environment variable**: `OPERATOR_PRIVATE_KEY` set directly in the PM2 environment. - **Encrypted keystore**: A JSON keystore file (Ethereum V3 format) decrypted at startup with a password from an environment variable or stdin prompt. The keystore approach is preferred for production, as it avoids storing the raw private key in plaintext environment files. ## Health Endpoint The service exposes a health check at `GET /health` on port 3001: ```json { "status": "healthy", "commitmentCount": 131, "lastSyncedBlock": 883495, "merkleRoot": "0x...", "uptime": 864000, "pendingBatch": 0 } ``` | Field | Description | |-------|-------------| | `status` | `"healthy"` if the service is running and synced, `"syncing"` during historical replay | | `commitmentCount` | Total number of commitments inserted into the local tree | | `lastSyncedBlock` | The most recent block number processed | | `merkleRoot` | The current locally computed Merkle root | | `uptime` | Service uptime in seconds | | `pendingBatch` | Number of commitments in the current debounce batch awaiting submission | ## Error Handling - **RPC disconnection**: The service implements exponential backoff reconnection. During disconnection, no events are missed — the historical sync on reconnection catches up. - **Transaction failure**: If `updateRoot()` reverts (e.g., due to gas estimation failure or nonce collision), the service retries with increased gas and an updated nonce. - **Reorgs**: If a chain reorganization invalidates previously processed events, the service detects the inconsistency during the next sync cycle and rebuilds the tree from the fork point. ## Configuration Key configuration parameters: | Parameter | Default | Description | |-----------|---------|-------------| | `RPC_URL` | — | Specter chain RPC endpoint | | `COMMITMENT_TREE_ADDRESS` | — | Address of the `CommitmentTree` contract | | `OPERATOR_PRIVATE_KEY` | — | Private key for `updateRoot()` transactions | | `DEBOUNCE_MS` | 3000 | Debounce interval for batching root updates | | `START_BLOCK` | 0 | Block number to begin historical sync from | | `PORT` | 3001 | HTTP server port for health endpoint | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/open-ghost-root-updater ================================================================ # Open Ghost Root Updater The Open Ghost Root Updater is a separate instance of the root updater service dedicated to maintaining the Merkle tree for the OpenGhost/Revels data privacy system. While it shares the same core logic as the primary [root updater](./root-updater.md), it targets a different contract (`OpenCommitmentTree`) and maintains a completely independent Merkle tree. ## Purpose The Specter protocol operates two distinct commitment trees: | Tree | Contract | Purpose | Updater Service | |------|----------|---------|-----------------| | **Ghost tree** | `CommitmentTree` | Token privacy (commit/reveal for fungible tokens) | `ghost-root-updater` | | **OpenGhost tree** | `OpenCommitmentTree` | Data privacy for OpenGhost/Revels (commit/reveal for structured data) | `open-ghost-root-updater` | Separating the trees ensures that token privacy commitments and data privacy commitments do not share Merkle state. This isolation provides several benefits: - **Independent root cadence**: Token commits and data commits can have different volumes without affecting each other's root update frequency. - **Circuit separation**: The Ghost circuit and the OpenGhost circuit reference different roots, preventing cross-domain proof confusion. - **Failure isolation**: If the OpenGhost root updater falls behind or encounters errors, token privacy operations continue unaffected, and vice versa. ## How It Works The Open Ghost Root Updater follows the same operational pattern as the primary root updater: 1. **Historical sync**: On startup, it replays all `Committed` events from the `OpenCommitmentTree` contract, rebuilding the local Merkle tree from genesis. 2. **Real-time listening**: After sync completes, it subscribes to new `Committed` events. 3. **Tree insertion**: Each commitment is inserted into the local incremental Poseidon Merkle tree at the next available leaf index. 4. **Root submission**: After debounce batching, the new root is submitted on-chain via `OpenCommitmentTree.updateRoot()`. The Poseidon hash function, tree depth, zero-value precomputation, and debounce logic are identical to the primary root updater. ## OpenGhost / Revels OpenGhost is Specter's data privacy layer, distinct from the token privacy provided by the core Ghost Protocol. While Ghost Protocol hides token transfers (who sent what amount to whom), OpenGhost enables privacy-preserving data commitments used by the Revels system. Revels allows users to commit structured data (e.g., event attendance proofs, credential attestations, NFC card bindings) into a Merkle tree and later reveal them with zero-knowledge proofs. The commitment scheme uses Poseidon4 (PoseidonT5) instead of the Poseidon7 used by the token privacy tree, reflecting the different input structure: ``` OpenGhost commitment = Poseidon4(secret, nullifier_secret, data_hash, blinding) ``` The Open Ghost Root Updater is agnostic to the commitment format — it simply inserts the emitted commitment hashes into the tree and computes roots. The commitment structure is enforced by the circuits and contracts, not by the updater. ## Configuration The Open Ghost Root Updater is configured with its own set of environment variables, separate from the primary root updater: | Parameter | Description | |-----------|-------------| | `RPC_URL` | Specter chain RPC endpoint (same as primary) | | `OPEN_COMMITMENT_TREE_ADDRESS` | Address of the `OpenCommitmentTree` contract | | `OPERATOR_PRIVATE_KEY` | Dedicated operator key for `OpenCommitmentTree.updateRoot()` | | `DEBOUNCE_MS` | Debounce interval for batching | | `START_BLOCK` | Block to begin historical sync from | The operator key should be distinct from the primary root updater's key to maintain privilege isolation. ## Deployment The service runs as a separate PM2 process alongside the primary root updater: ```bash pm2 status # ┌─────────────────────────────┬────┬───────┐ # │ name │ id │ status│ # ├─────────────────────────────┼────┼───────┤ # │ ghost-root-updater │ 0 │ online│ # │ open-ghost-root-updater │ 1 │ online│ # └─────────────────────────────┴────┴───────┘ ``` Both services connect to the same Specter RPC endpoint but watch different contract addresses and maintain independent tree state, caches, and operator keys. ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/commitment-relayer ================================================================ # Commitment Relayer The commitment relayer (`ghost-commitment-relayer`, port 3002) is an HTTP API that provides server-side Poseidon hash computation for clients that cannot perform these operations locally. Mobile devices and constrained browser environments struggle to run `circomlibjs` efficiently, so this service offloads the computation to the server. ## Problem Poseidon hashing is fundamental to the Ghost Protocol — every commitment, nullifier derivation, token ID computation, and Merkle node hash uses a Poseidon variant. The reference JavaScript implementation (`circomlibjs`) relies on big-integer arithmetic over the BN254 scalar field, which is computationally expensive: - **Mobile browsers**: JavaScript BigInt performance on iOS Safari and Android Chrome is significantly slower than desktop, making real-time Poseidon computation impractical for interactive flows. - **React Native**: The `circomlibjs` library depends on Node.js-specific APIs and WASM modules that do not load reliably in React Native environments. - **Low-power devices**: NFC card scanning flows require immediate hash computation; a multi-second Poseidon call on a low-end phone degrades the user experience unacceptably. The commitment relayer solves this by running Poseidon on the server and exposing the results via a simple HTTP API. ## Endpoints ### POST /poseidon2 Computes a Poseidon hash over 2 inputs (PoseidonT3). **Request:** ```json { "inputs": ["0x1a2b...", "0x3c4d..."] } ``` **Response:** ```json { "hash": "0x7e8f..." } ``` **Use cases**: Merkle node hashing, nullifier derivation (`Poseidon2(nullifier_secret, nullifier_secret)`), token ID derivation, access tags. ### POST /poseidon4 Computes a Poseidon hash over 4 inputs (PoseidonT5). **Request:** ```json { "inputs": ["0x...", "0x...", "0x...", "0x..."] } ``` **Response:** ```json { "hash": "0x..." } ``` **Use cases**: OpenGhost commitments (`Poseidon4(secret, nullifier_secret, data_hash, blinding)`). ### POST /poseidon7 Computes a Poseidon hash over 7 inputs (PoseidonT8). **Request:** ```json { "inputs": ["0x...", "0x...", "0x...", "0x...", "0x...", "0x...", "0x..."] } ``` **Response:** ```json { "hash": "0x..." } ``` **Use cases**: Ghost Protocol commitments (`Poseidon7(secret, nullifier_secret, blinding, token_id, amount, policy_id, policy_params_hash)`). ### GET /health Returns service health status. ```json { "status": "healthy", "uptime": 432000 } ``` ## Input Format All inputs must be valid BN254 scalar field elements provided as hex-encoded strings (with or without `0x` prefix) or decimal strings. The service validates that each input is in the range `[0, p)` where: ``` p = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` If any input exceeds the field modulus, the service returns a `400 Bad Request` error with a descriptive message. ## Security Considerations ### Trust Model Clients that use the commitment relayer are trusting the server to compute hashes correctly. A malicious or compromised server could return incorrect hashes, which would result in: - **Incorrect commitments**: The user would commit a value that does not match their local secret, making the commitment unrevealable. - **Incorrect nullifiers**: Nullifier derivation errors would prevent reveal transactions from being accepted by the circuit. This trust assumption is acceptable for the current deployment because: 1. The commitment relayer is operated by the same entity that operates the chain and contracts. 2. Clients can optionally verify the server's output by computing the hash locally when resources permit (e.g., on desktop). 3. The correctness of the hash is ultimately verified by the ZK circuit at reveal time — an incorrect commitment simply becomes unredeemable, not exploitable. ### CORS Policy The service enforces a CORS (Cross-Origin Resource Sharing) policy that restricts which origins can call the API: - **Allowed origins**: The Specter webapp domain and localhost for development. - **Allowed methods**: `POST`, `GET`, `OPTIONS`. - **Allowed headers**: `Content-Type`, `Authorization`. Requests from unauthorized origins receive a `403 Forbidden` response. ### Rate Limiting To prevent abuse and resource exhaustion, the commitment relayer enforces rate limits: | Limit | Value | |-------|-------| | Per-IP request rate | 60 requests/minute | | Burst allowance | 10 requests | | Max payload size | 4 KB | Requests exceeding the rate limit receive a `429 Too Many Requests` response with a `Retry-After` header. ## Implementation The service is a Node.js Express application that loads `circomlibjs` at startup and initializes the Poseidon hash instances for each input width. The initialization is performed once and the hash functions are reused for all subsequent requests: ```javascript // Initialization (once at startup) const poseidon2 = await buildPoseidon(); // PoseidonT3 const poseidon4 = await buildPoseidon(); // PoseidonT5 const poseidon7 = await buildPoseidon(); // PoseidonT8 ``` Each request deserializes the inputs, calls the appropriate Poseidon function, and serializes the output as a hex string. ## Deployment The commitment relayer runs as a PM2-managed process on port 3002 and is accessible through Caddy at `relayer.specterchain.com` via path-based routing. ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/proof-relayer ================================================================ # Proof Relayer The proof relayer (`ghost-proof-relayer`, port 3003) is the ZK proof generation service for the Specter network. It loads the Groth16 circuit files (proving key, WASM witness generator), accepts proof requests containing private inputs, generates Groth16 proofs, and returns them to the client for on-chain submission. The service averages approximately 13.5 seconds per proof and has generated 4 proofs on the current testnet deployment. ## Why Server-Side Proving Groth16 proof generation is computationally intensive. The Ghost Protocol circuit has thousands of R1CS constraints, and the proving process involves multi-scalar multiplications over the BN254 curve. On constrained devices, this is prohibitively slow or impossible: - **Mobile browsers**: The `snarkjs` WASM prover can take 30–60 seconds on mid-range phones, with frequent out-of-memory crashes on devices with limited RAM. - **React Native**: WASM execution inside React Native's JavaScript engine (Hermes or JSC) is significantly slower than V8/SpiderMonkey and often fails to complete. - **Older desktops**: Even on desktop browsers, proof generation can take 15–30 seconds and freezes the UI thread. The proof relayer centralizes this computation on a server with adequate CPU and memory, reducing the client's role to assembling inputs and submitting the returned proof on-chain. ## Circuit Files The proof relayer loads two files at startup: | File | Description | |------|-------------| | `ghost_redemption.zkey` | The Groth16 proving key, generated from the trusted setup ceremony. Contains the toxic waste-derived parameters needed for proof construction. | | `ghost_redemption.wasm` | The compiled witness generator. Takes circuit inputs and computes the full witness (all intermediate signals). | These files must correspond to the same circuit compilation and trusted setup. A mismatch between the `.zkey` and `.wasm` will produce invalid proofs that fail on-chain verification. The circuit files are loaded into memory once at startup. Subsequent proof requests reuse the loaded artifacts, avoiding repeated disk I/O. ## API ### POST /prove Generates a Groth16 proof from the provided private and public inputs. **Request:** ```json { "nullifier_hash": "0x...", "commitment": "0x...", "merkle_root": "0x...", "token_id": "0x...", "amount": "0x...", "path_elements": ["0x...", "0x...", "..."], "path_indices": [0, 1, 0, "..."], "secret": "0x...", "nullifier_secret": "0x...", "blinding": "0x...", "policy_id": "0x...", "policy_params_hash": "0x..." } ``` **Response (success):** ```json { "proof": { "pi_a": ["0x...", "0x...", "1"], "pi_b": [["0x...", "0x..."], ["0x...", "0x..."], ["1", "0"]], "pi_c": ["0x...", "0x...", "1"], "protocol": "groth16", "curve": "bn128" }, "publicSignals": [ "nullifier_hash", "merkle_root", "token_id", "amount", "policy_id", "policy_params_hash" ] } ``` **Response (error):** ```json { "error": "Invalid merkle root: commitment not found in tree", "code": "INVALID_INPUT" } ``` The returned `proof` object and `publicSignals` array are formatted for direct use with the on-chain `GhostRedemptionVerifier.verifyProof()` function. ### GET /health Returns service status and metrics. ```json { "status": "healthy", "circuitLoaded": true, "proofsGenerated": 4, "avgProofTimeMs": 13500, "commitmentCount": 131, "merkleRoot": "0x...", "uptime": 864000 } ``` ## Local Merkle Tree State The proof relayer maintains its own local copy of the commitment Merkle tree, identical to the one maintained by the root updater. This is necessary because proof generation requires the Merkle path (sibling hashes from leaf to root) for the commitment being revealed, and these path elements are not stored on-chain. When a client requests a proof, the relayer: 1. Locates the commitment in its local tree by scanning leaf values. 2. Extracts the Merkle path (path elements and path indices) from the local tree. 3. Combines the client-provided private inputs with the extracted Merkle path. 4. Generates the witness and computes the Groth16 proof. The local tree is kept in sync by listening for the same `Committed` events as the root updater. If the relayer's tree falls out of sync, proof generation will fail because the computed root will not match the on-chain root, and the proof will be rejected by the verifier contract. ## Proof Generation Pipeline The proof generation process follows these steps: ``` Client request │ ▼ Input validation │ ▼ Merkle path extraction (from local tree) │ ▼ Witness computation (ghost_redemption.wasm) │ ▼ Groth16 proof generation (ghost_redemption.zkey) │ ▼ Proof + public signals returned to client ``` ### Performance | Metric | Value | |--------|-------| | Average proof time | ~13.5 seconds | | Peak memory usage | ~1.5 GB during proof generation | | Proofs generated (testnet) | 4 | | Concurrent proof capacity | 1 (sequential processing) | Proof generation is CPU-bound and memory-intensive. The service processes proof requests sequentially to avoid memory exhaustion. Concurrent requests are queued and processed in order. ## Input Validation Before generating a proof, the service validates: 1. **Field element range**: All inputs must be valid BN254 scalar field elements (less than the field modulus). 2. **Commitment existence**: The provided commitment must exist in the local Merkle tree. 3. **Root consistency**: The Merkle root derived from the local tree must match the current on-chain root. 4. **Commitment preimage**: The Poseidon7 hash of `(secret, nullifier_secret, blinding, token_id, amount, policy_id, policy_params_hash)` must equal the provided commitment. If any validation fails, the service returns an error without attempting proof generation, saving the ~13.5 seconds that would be wasted on an invalid proof. ## Security Considerations The proof relayer receives the user's private inputs (secret, nullifier secret, blinding factor). This means: - **The relayer operator can see private inputs.** Users who use the proof relayer are trusting the operator not to log or exfiltrate their secrets. - **Compromise of the relayer could expose user secrets.** The relayer should be hardened against unauthorized access. - **Users with sufficient compute should generate proofs locally.** The relayer exists as a convenience for constrained clients, not as the primary proving path. In the future, the SP1 Ghost Circuit may enable client-side proving in more environments, reducing reliance on the proof relayer. ## Configuration | Parameter | Default | Description | |-----------|---------|-------------| | `PORT` | 3003 | HTTP server port | | `ZKEY_PATH` | — | Path to `ghost_redemption.zkey` | | `WASM_PATH` | — | Path to `ghost_redemption.wasm` | | `RPC_URL` | — | Specter chain RPC for Merkle tree sync | | `COMMITMENT_TREE_ADDRESS` | — | Address of `CommitmentTree` contract | | `START_BLOCK` | 0 | Block to begin historical event sync | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/faucet ================================================================ # Testnet Faucet The testnet faucet (`ghost-faucet`, port 3005) distributes GHOST tokens to developers and users on the Specter testnet. It dispenses 100 GHOST per request with a 24-hour cooldown per recipient address. ## Overview GHOST is the native gas token of the Specter chain. Without GHOST, users cannot submit transactions — including the reveal transactions that complete a Ghost Protocol flow. The faucet provides a frictionless way for new users and developers to obtain testnet GHOST for experimentation. ### Current State | Metric | Value | |--------|-------| | Faucet balance | 56.9M GHOST | | Drip amount | 100 GHOST | | Cooldown period | 24 hours per address | ## API ### POST /drip Sends 100 GHOST to the specified recipient address. **Request:** ```json { "address": "0x1234567890abcdef1234567890abcdef12345678" } ``` **Response (success):** ```json { "success": true, "txHash": "0xabc123...", "amount": "100", "recipient": "0x1234567890abcdef1234567890abcdef12345678" } ``` **Response (cooldown active):** ```json { "error": "Address is in cooldown period", "nextAvailable": "2026-03-17T14:30:00Z", "remainingSeconds": 43200 } ``` **Response (invalid address):** ```json { "error": "Invalid Ethereum address" } ``` ### GET /health Returns faucet status and balance. ```json { "status": "healthy", "balance": "56900000", "dripAmount": "100", "cooldownHours": 24, "totalDrips": 1250, "uptime": 864000 } ``` ## Cooldown Mechanism The faucet tracks the last drip timestamp for each recipient address in an in-memory map backed by periodic disk persistence. When a drip request arrives: 1. Look up the recipient address in the cooldown map. 2. If no entry exists, or the entry's timestamp is more than 24 hours old, proceed with the drip. 3. If the cooldown is still active, reject the request with the time remaining. 4. After a successful drip, update the cooldown map with the current timestamp. The cooldown is enforced per recipient address, not per requesting IP. This means a single user cannot circumvent the cooldown by switching IP addresses (they would need a different wallet address). ## Rate Limiting In addition to the per-address cooldown, the faucet enforces global rate limits to prevent abuse: | Limit | Value | |-------|-------| | Global request rate | 30 requests/minute | | Per-IP request rate | 5 requests/minute | | Max payload size | 1 KB | These limits protect the faucet from automated draining attacks where an adversary generates many fresh addresses to bypass the per-address cooldown. ## Transaction Submission The faucet submits native GHOST transfers using an operator wallet: 1. Construct a simple value transfer transaction to the recipient address with `value = 100 * 10^18` (100 GHOST in wei). 2. Estimate gas and set a gas price based on current network conditions. 3. Sign the transaction with the operator private key. 4. Submit the transaction and return the transaction hash to the caller. The faucet does not wait for transaction confirmation before responding — it returns the transaction hash immediately. The client can poll the chain for confirmation if needed. ### Nonce Management The faucet maintains a local nonce counter to handle rapid sequential drip requests without waiting for each transaction to be mined. This prevents nonce collision errors when multiple drip requests arrive within the same block time. If a transaction fails due to a nonce mismatch (e.g., after a service restart), the faucet resynchronizes its nonce from the chain and retries. ## Balance Monitoring The faucet periodically checks its on-chain GHOST balance and logs warnings when the balance drops below configurable thresholds: | Threshold | Action | |-----------|--------| | < 1,000,000 GHOST | Warning logged | | < 100,000 GHOST | Alert logged, reduced drip amount considered | | < 10,000 GHOST | Critical alert, faucet may pause drips | At the current drip rate of 100 GHOST per request with a 24-hour cooldown per address, the 56.9M GHOST balance is sufficient for hundreds of thousands of unique addresses. ## Configuration | Parameter | Default | Description | |-----------|---------|-------------| | `PORT` | 3005 | HTTP server port | | `RPC_URL` | — | Specter chain RPC endpoint | | `OPERATOR_PRIVATE_KEY` | — | Faucet operator wallet private key | | `DRIP_AMOUNT` | 100 | GHOST tokens per drip (in whole units) | | `COOLDOWN_HOURS` | 24 | Cooldown period between drips for the same address | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/ember-proxy ================================================================ # Ember Proxy The Ember proxy (`ghost-ember-proxy`, port 3010) is a persistent phantom key access service that performs server-side Access Proof generation and proxies AI chat requests to Anthropic's API. It enables the Specter webapp's Ember feature — a privacy-preserving AI assistant that operates within the context of a user's phantom key session. ## Purpose Ember combines two capabilities that require server-side execution: 1. **Phantom key access**: Generating ZK Access Proofs requires Poseidon hashing and Groth16 proving, which are too expensive for mobile clients. The Ember proxy performs this computation on behalf of the user, maintaining persistent access sessions tied to phantom keys. 2. **AI chat proxy**: The Ember chat feature communicates with Anthropic's Claude API. API keys cannot be embedded in client-side code, so the proxy holds the decrypted API key server-side and forwards chat requests. ## Endpoints ### POST /api/ember/connect Establishes a persistent phantom key access session. The client provides their phantom key credentials, and the proxy generates an Access Proof that authenticates the user to the Ghost Protocol without revealing their identity. **Request:** ```json { "phantomKey": "0x...", "accessTag": "0x...", "merkleProof": { "pathElements": ["0x...", "..."], "pathIndices": [0, 1, "..."] } } ``` **Response:** ```json { "sessionId": "uuid-v4", "connected": true, "expiresAt": "2026-03-17T14:30:00Z" } ``` The proxy stores the session state server-side, keyed by the returned `sessionId`. Subsequent requests include this session ID to authenticate. ### POST /api/ember/disconnect Terminates an active Ember session and clears all server-side state associated with it. **Request:** ```json { "sessionId": "uuid-v4" } ``` **Response:** ```json { "disconnected": true } ``` After disconnection, the phantom key material and any cached access proofs are securely wiped from memory. ### POST /api/ember/start Initializes a new AI chat conversation within an active Ember session. This creates a conversation context that persists across multiple chat messages. **Request:** ```json { "sessionId": "uuid-v4", "systemPrompt": "You are Ember, an AI assistant for Specter..." } ``` **Response:** ```json { "conversationId": "uuid-v4", "started": true } ``` ### POST /api/ember/chat Sends a message to the AI assistant and receives a response. The proxy forwards the message to Anthropic's API along with the conversation history. **Request:** ```json { "sessionId": "uuid-v4", "conversationId": "uuid-v4", "message": "How do I reveal a committed token?" } ``` **Response:** ```json { "response": "To reveal a committed token, you need to...", "conversationId": "uuid-v4" } ``` The proxy maintains the conversation history server-side, appending each user message and assistant response. This allows the AI to maintain context across a multi-turn conversation without the client needing to retransmit the full history. ### POST /api/ember/end Terminates a specific chat conversation, clearing its history from memory. The Ember session remains active for future conversations. **Request:** ```json { "sessionId": "uuid-v4", "conversationId": "uuid-v4" } ``` **Response:** ```json { "ended": true } ``` ## Session Lifecycle ``` Client Ember Proxy Anthropic API │ │ │ │── POST /connect ───────────────▶│ │ │ (phantom key credentials) │ │ │◀── sessionId ──────────────────│ │ │ │ │ │── POST /start ─────────────────▶│ │ │ (sessionId, systemPrompt) │ │ │◀── conversationId ─────────────│ │ │ │ │ │── POST /chat ──────────────────▶│── API request ──────────────▶│ │ (sessionId, conversationId, │ (messages + system prompt) │ │ message) │◀── API response ─────────────│ │◀── response ───────────────────│ │ │ │ │ │── POST /chat ──────────────────▶│── API request ──────────────▶│ │◀── response ───────────────────│◀── API response ─────────────│ │ │ │ │── POST /end ───────────────────▶│ │ │ (conversationId) │ │ │◀── ended ──────────────────────│ │ │ │ │ │── POST /disconnect ────────────▶│ │ │◀── disconnected ───────────────│ │ ``` ## Security Model ### Phantom Key Isolation Each Ember session is bound to a single phantom key. The proxy never stores phantom keys persistently — they exist only in-memory for the duration of the session. When a session is disconnected: - The phantom key material is zeroed out in memory. - The access proof cache is cleared. - The session ID is invalidated. ### API Key Protection The Anthropic API key is stored as an environment variable on the server and is never transmitted to clients. The proxy acts as an authenticated gateway: - Clients authenticate to the proxy via their Ember session. - The proxy authenticates to Anthropic via the API key. - The client never sees the API key. ### Session Expiry Sessions expire automatically after a configurable timeout (default: 1 hour of inactivity). Expired sessions are cleaned up by a background sweep that runs periodically. ### Rate Limiting Chat requests are rate-limited per session to prevent abuse of the Anthropic API: | Limit | Value | |-------|-------| | Chat messages per session per minute | 10 | | Maximum conversation length | 100 messages | | Maximum message size | 4 KB | ## Configuration | Parameter | Default | Description | |-----------|---------|-------------| | `PORT` | 3010 | HTTP server port | | `ANTHROPIC_API_KEY` | — | Anthropic API key for Claude access | | `SESSION_TIMEOUT_MS` | 3600000 | Session inactivity timeout (1 hour) | | `MAX_CONVERSATIONS_PER_SESSION` | 5 | Maximum concurrent conversations per session | | `RPC_URL` | — | Specter chain RPC for access proof validation | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/token-registry ================================================================ # Token Registry The token registry service (`ghost-token-registry`, port 3009) tracks deployed GhostERC20 tokens on the Specter chain and provides token metadata to the webapp. It serves as the canonical directory of all privacy-wrapped tokens available in the Ghost Protocol ecosystem. ## Purpose When a new GhostERC20 token is deployed via the `GhostERC20Factory`, the on-chain contract records the token's address, name, symbol, and decimals. However, the on-chain data does not include rich metadata such as token icons, display categories, or ordering preferences. The token registry supplements on-chain data with this additional metadata, providing a complete picture for the webapp's token selection UI. ## Data Model Each token entry in the registry contains: | Field | Source | Description | |-------|--------|-------------| | `address` | On-chain | The GhostERC20 contract address on Specter | | `name` | On-chain | Token name (e.g., "Ghost Wrapped USDC") | | `symbol` | On-chain | Token symbol (e.g., "gUSDC") | | `decimals` | On-chain | Token decimal places (typically 18) | | `tokenId` | Derived | The Poseidon2-derived token identifier used in commitments | | `icon` | Admin-uploaded | URL or base64-encoded token icon image | | `category` | Admin-managed | Classification (e.g., "stablecoin", "governance", "native") | | `enabled` | Admin-managed | Whether the token is visible in the webapp | | `sortOrder` | Admin-managed | Display ordering in the token list | | `underlyingChain` | Admin-managed | The source chain of the underlying asset (e.g., "Base", "Ethereum") | ## API ### GET /tokens Returns the full list of registered tokens. **Response:** ```json { "tokens": [ { "address": "0x...", "name": "Ghost Wrapped USDC", "symbol": "gUSDC", "decimals": 18, "tokenId": "0x...", "icon": "https://relayer.specterchain.com/icons/gusdc.png", "category": "stablecoin", "enabled": true, "sortOrder": 1, "underlyingChain": "Base" } ] } ``` ### GET /tokens/:address Returns metadata for a single token by its contract address. **Response:** ```json { "address": "0x...", "name": "Ghost Wrapped USDC", "symbol": "gUSDC", "decimals": 18, "tokenId": "0x...", "icon": "https://relayer.specterchain.com/icons/gusdc.png", "category": "stablecoin", "enabled": true, "sortOrder": 1, "underlyingChain": "Base" } ``` ### POST /tokens (admin) Adds or updates a token entry. Requires admin authentication. **Request:** ```json { "address": "0x...", "icon": "", "category": "stablecoin", "enabled": true, "sortOrder": 1, "underlyingChain": "Base" } ``` The service fetches `name`, `symbol`, and `decimals` directly from the on-chain contract, so the admin only needs to provide supplementary metadata. ### POST /tokens/:address/icon (admin) Uploads a token icon image. Accepts `multipart/form-data` with a single image file. **Response:** ```json { "iconUrl": "https://relayer.specterchain.com/icons/gusdc.png" } ``` Icons are stored on the local filesystem and served statically by Caddy. ### GET /health ```json { "status": "healthy", "tokenCount": 12, "uptime": 864000 } ``` ## Admin Authentication Admin endpoints are protected by an API key passed in the `Authorization` header: ``` Authorization: Bearer ``` The admin API key is stored as an environment variable. Only the Specter team uses admin endpoints — the webapp only calls the public `GET` endpoints. ## Token ID Derivation The `tokenId` field is the Poseidon2-derived identifier used within the Ghost Protocol's ZK circuits to identify which token a commitment is associated with. The derivation follows this pattern: ``` tokenId = Poseidon2(tokenAddress, chainId) mod BN254_FIELD ``` The token registry computes this value when a token is added and includes it in API responses so that clients do not need to perform the Poseidon computation themselves. ## Data Persistence Token metadata is persisted to a JSON file on disk. The file is read at startup and written after any admin modification. The registry does not use a database — the token count is small enough (typically under 50 tokens) that a flat file is sufficient. ## Configuration | Parameter | Default | Description | |-----------|---------|-------------| | `PORT` | 3009 | HTTP server port | | `RPC_URL` | — | Specter chain RPC for on-chain token data | | `ADMIN_API_KEY` | — | API key for admin endpoints | | `ICONS_DIR` | `./icons` | Directory for uploaded token icons | | `DATA_FILE` | `./tokens.json` | Path to the persistent token data file | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/bridge-relayers ================================================================ # Bridge Relayers The Specter infrastructure operates two Hyperlane-based bridge relayer services that enable cross-chain messaging between Specter and external EVM chains. The **hyperlane-bridge-relayer** handles bidirectional messaging between Base and Specter, while the **hyperlane-multi-chain-relayer** extends this to Ethereum, Base, and Arbitrum simultaneously. ## Hyperlane Overview [Hyperlane](https://www.hyperlane.xyz/) is a permissionless interchain messaging protocol. It provides a standardized interface for sending and receiving messages across chains through a pair of core contracts: - **Mailbox**: The entry and exit point for cross-chain messages. Sending chains call `dispatch()` to send messages; receiving chains call `process()` to deliver them. - **ISM (Interchain Security Module)**: Defines the security model for validating inbound messages. Specter uses a Multisig ISM where a configurable set of validators must sign off on a message before it can be processed. The bridge relayers watch for outbound messages on source chains and deliver them to destination chains, paying gas on both sides. ## Hyperlane Bridge Relayer The `hyperlane-bridge-relayer` handles bidirectional messaging between Base and Specter. ### Message Flow: Base to Specter ``` Base Chain Relayer Specter Chain │ │ │ │ User calls Mailbox.dispatch() │ │ │ on Base │ │ │──── Dispatch event ─────────────▶│ │ │ │ Validates message + ISM │ │ │── Mailbox.process() ──────────▶│ │ │ │ │ │ Recipient contract receives │ │ │ handle() callback │ ``` ### Message Flow: Specter to Base ``` Specter Chain Relayer Base Chain │ │ │ │ Contract calls Mailbox │ │ │ .dispatch() on Specter │ │ │──── Dispatch event ─────────────▶│ │ │ │ Validates message + ISM │ │ │── Mailbox.process() ──────────▶│ │ │ │ │ │ Recipient contract receives │ │ │ handle() callback │ ``` ### Watched Contracts | Chain | Contract | Event | |-------|----------|-------| | Base | Hyperlane Mailbox | `Dispatch(sender, destination, recipient, message)` | | Specter | Hyperlane Mailbox | `Dispatch(sender, destination, recipient, message)` | ### Use Cases - **Token bridging**: Users bridge ERC20 tokens from Base to Specter, where they are minted as GhostERC20 tokens. - **Governance messages**: Cross-chain governance proposals or votes relayed between chains. - **State synchronization**: Contract state updates propagated from one chain to another. ## Hyperlane Multi-Chain Relayer The `hyperlane-multi-chain-relayer` extends cross-chain messaging to multiple chains simultaneously: **Ethereum**, **Base**, and **Arbitrum**. ### Architecture ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Ethereum │ │ Base │ │ Arbitrum │ │ Mailbox │ │ Mailbox │ │ Mailbox │ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ │ │ │ Dispatch │ Dispatch │ Dispatch │ events │ events │ events │ │ │ └────────────────┼────────────────┘ │ ▼ ┌────────────────────────┐ │ Multi-Chain Relayer │ │ │ │ Watches all chains │ │ Routes messages to │ │ correct destinations │ └───────────┬────────────┘ │ ▼ ┌──────────────┐ │ Specter │ │ Mailbox │ └──────────────┘ ``` The multi-chain relayer maintains RPC connections to all four chains and multiplexes event watching across them. When a `Dispatch` event is detected on any source chain with Specter as the destination, the relayer: 1. Fetches the full message payload from the source chain. 2. Retrieves the ISM metadata required for validation (validator signatures). 3. Calls `Mailbox.process()` on Specter with the message and metadata. For outbound messages from Specter, the relayer determines the destination chain from the message's destination domain ID and delivers accordingly. ### Chain Configuration | Chain | Domain ID | RPC | |-------|-----------|-----| | Ethereum | 1 | Ethereum mainnet/testnet RPC | | Base | 8453 | Base RPC | | Arbitrum | 42161 | Arbitrum One RPC | | Specter | 5446 | Specter RPC | ## Interchain Security Module (ISM) The ISM deployed on Specter defines how inbound messages are validated. Specter uses a **Multisig ISM** that requires a threshold of validator signatures before a message can be processed: - **Validators** are off-chain signers that attest to the validity of messages on source chains. They watch the source chain's Mailbox for `Dispatch` events and produce signatures over the message content and metadata. - **Threshold** is the minimum number of valid signatures required. For example, a 2-of-3 multisig requires at least 2 out of 3 validators to sign. The relayer collects validator signatures as part of the message delivery process and includes them in the `process()` call. ## Gas Management Bridge relayers pay gas on both the source chain (for event monitoring) and the destination chain (for `process()` calls). Each relayer maintains funded operator wallets on all chains it interacts with: | Relayer | Funded Wallets | |---------|---------------| | hyperlane-bridge-relayer | Base, Specter | | hyperlane-multi-chain-relayer | Ethereum, Base, Arbitrum, Specter | Gas costs are monitored and logged. The relayers implement gas price estimation and retry logic with escalating gas prices for stuck transactions. ## Error Handling - **RPC failures**: The relayers implement automatic RPC failover. If the primary RPC for a chain becomes unresponsive, the relayer switches to a backup RPC endpoint. - **Message delivery failure**: If `process()` reverts on the destination chain, the relayer retries with increased gas. After a configurable number of retries, the message is logged as failed for manual investigation. - **Reorgs**: The relayers wait for a configurable number of block confirmations before processing `Dispatch` events, reducing the risk of delivering messages from reorged blocks. ## Configuration | Parameter | Description | |-----------|-------------| | `SPECTER_RPC_URL` | Specter chain RPC | | `BASE_RPC_URL` | Base chain RPC | | `ETHEREUM_RPC_URL` | Ethereum RPC (multi-chain only) | | `ARBITRUM_RPC_URL` | Arbitrum RPC (multi-chain only) | | `OPERATOR_PRIVATE_KEY` | Relayer operator private key (funded on all relevant chains) | | `SPECTER_MAILBOX_ADDRESS` | Hyperlane Mailbox contract on Specter | | `BASE_MAILBOX_ADDRESS` | Hyperlane Mailbox contract on Base | | `CONFIRMATIONS` | Block confirmations to wait before processing events | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/base-conversion-relayer ================================================================ # Base Conversion Relayer The Base conversion relayer (`base-conversion-relayer`) watches for `TokensConverted` events on the Base chain and mints corresponding GhostERC20 tokens (g-tokens) on the Specter chain via the `GhostMinter` contract. This enables a one-way conversion flow from Base-native tokens to privacy-wrapped tokens on Specter. ## Conversion Flow ``` Base Chain Relayer Specter Chain │ │ │ │ User calls │ │ │ ConversionContract │ │ │ .convert(token, amount) │ │ │ │ │ │ Tokens transferred to │ │ │ conversion contract │ │ │ │ │ │ emit TokensConverted( │ │ │ user, token, amount, │ │ │ specterRecipient) │ │ │────── Event detected ──────────▶│ │ │ │ │ │ │ Validate event │ │ │ Determine g-token mapping │ │ │ │ │ │── GhostMinter.mint( │ │ │ gToken, recipient, ──────▶│ │ │ amount) │ │ │ │ │ │ g-tokens minted to │ │ │ specterRecipient │ ``` ## How It Works ### 1. Event Detection The relayer maintains a WebSocket or polling connection to a Base chain RPC endpoint. It listens for `TokensConverted` events emitted by the Specter conversion contract deployed on Base: ```solidity event TokensConverted( address indexed user, address indexed token, uint256 amount, address specterRecipient ); ``` The event contains: - **user**: The Base address that initiated the conversion. - **token**: The ERC20 token address on Base that was converted. - **amount**: The quantity of tokens converted. - **specterRecipient**: The Specter chain address that should receive the minted g-tokens. ### 2. Token Mapping The relayer maintains a mapping from Base token addresses to their corresponding GhostERC20 addresses on Specter. For example: | Base Token | Base Address | Specter g-Token | Specter Address | |------------|-------------|-----------------|-----------------| | USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | gUSDC | `0x...` | | WETH | `0x4200000000000000000000000000000000000006` | gWETH | `0x...` | If a `TokensConverted` event references an unmapped token, the relayer logs a warning and skips the event. ### 3. Minting on Specter After validating the event, the relayer submits a `mint()` transaction on the Specter chain: ```solidity GhostMinter.mint(gTokenAddress, specterRecipient, amount); ``` The `GhostMinter` contract is a permissioned minting authority for GhostERC20 tokens. Only authorized minters (such as the conversion relayer's operator address) can call `mint()`. This ensures that g-tokens are only created in response to legitimate conversion events on Base. ### 4. Confirmation and Logging After the mint transaction is confirmed on Specter, the relayer logs the completed conversion: ```json { "event": "conversion_complete", "baseToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "gToken": "0x...", "amount": "1000000000000000000", "baseUser": "0x...", "specterRecipient": "0x...", "baseTxHash": "0x...", "specterTxHash": "0x...", "blockNumber": 12345678 } ``` ## One-Way Design The Base conversion relayer is intentionally **one-way**: tokens flow from Base to Specter, but not back. This design choice reflects the privacy model — once tokens are on Specter, they enter the Ghost Protocol's commit/reveal system. Exiting back to Base is handled by the Hyperlane bridge relayers (see [Bridge Relayers](./bridge-relayers.md)), which provide a more general cross-chain messaging path. The one-way design also simplifies the security model. The relayer only needs minting authority on Specter; it does not need the ability to unlock or transfer tokens on Base. The tokens deposited into the Base conversion contract are held in custody by that contract. ## Duplicate Prevention The relayer tracks processed events by their Base transaction hash and log index to prevent double-minting: 1. Before processing an event, check if the `(txHash, logIndex)` pair has already been processed. 2. If already processed, skip the event. 3. After successful minting, record the `(txHash, logIndex)` pair in the processed events store. The processed events store is persisted to disk and loaded at startup. This ensures that a relayer restart does not re-process events that were already minted. ## Block Confirmations To mitigate the risk of processing events from reorged blocks, the relayer waits for a configurable number of block confirmations on Base before processing a `TokensConverted` event: | Setting | Default | |---------|---------| | Confirmations required | 12 blocks (~24 seconds on Base) | This provides a reasonable balance between latency and safety. A reorg deeper than 12 blocks on Base is extremely unlikely under normal conditions. ## Error Handling - **Base RPC failure**: Exponential backoff reconnection. Events are not lost because the relayer resumes from the last processed block on reconnection. - **Mint transaction failure**: Retry with increased gas. If the `GhostMinter` contract reverts (e.g., due to an invalid token address), the event is logged as failed for manual investigation. - **Unmapped token**: Logged as a warning and skipped. No mint is attempted. - **Insufficient operator balance**: If the Specter operator wallet lacks gas, the relayer pauses and logs a critical alert. ## Configuration | Parameter | Description | |-----------|-------------| | `BASE_RPC_URL` | Base chain RPC endpoint | | `SPECTER_RPC_URL` | Specter chain RPC endpoint | | `CONVERSION_CONTRACT_ADDRESS` | Address of the conversion contract on Base | | `GHOST_MINTER_ADDRESS` | Address of `GhostMinter` on Specter | | `OPERATOR_PRIVATE_KEY` | Relayer operator key (authorized minter on Specter) | | `TOKEN_MAPPING` | JSON mapping of Base token addresses to Specter g-token addresses | | `CONFIRMATIONS` | Block confirmations to wait on Base | | `START_BLOCK` | Base block to begin historical sync from | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/offline-relayer ================================================================ # Offline Relayer The offline relayer (`ghost-offline-relayer`) provides gasless, sponsored transaction submission for the Specter network. It enables users who do not hold GHOST (the native gas token) to submit reveal transactions through the Ghost Protocol by having the relayer pay gas on their behalf. ## Problem The Ghost Protocol's reveal flow requires the recipient to submit an on-chain transaction that includes a ZK proof, nullifier, and other public inputs. This transaction consumes gas, payable in GHOST. However, several legitimate use cases involve recipients who have no GHOST balance: - **NFC card redemptions**: A user taps an NFC card to claim tokens. They may be new to Specter and have never acquired GHOST. - **First-time users**: Someone receiving their first private transfer on Specter cannot pay gas to reveal it. - **Non-custodial flows**: The sender committed tokens for a recipient who may not even have a Specter wallet yet. Without the offline relayer, these users would need to first obtain GHOST from the faucet, wait for confirmation, and then submit the reveal — adding friction to what should be a seamless experience. ## How It Works ``` User Offline Relayer Specter Chain │ │ │ │ POST /reveal │ │ │ {proof, publicSignals, │ │ │ recipient, ...} │ │ │─────────────────────────────────▶│ │ │ │ │ │ │ Validate proof inputs │ │ │ Verify recipient address │ │ │ │ │ │ CommitRevealVault.reveal( │ │ │ proof, publicSignals, ────▶│ │ │ recipient, ...) │ │ │ │ │ │ Gas paid by operator wallet │ │ │ │ │ │◀── tx receipt ────────────────│ │◀── {txHash, status} ────────────│ │ │ │ │ │ Tokens revealed to recipient │ │ ``` The user provides all the inputs needed for a reveal transaction, but instead of submitting it themselves (which would require gas), they send it to the offline relayer. The relayer constructs and submits the transaction using its own operator wallet, paying gas from its own GHOST balance. ## API ### POST /reveal Submits a sponsored reveal transaction. **Request:** ```json { "proof": { "pi_a": ["0x...", "0x...", "1"], "pi_b": [["0x...", "0x..."], ["0x...", "0x..."], ["1", "0"]], "pi_c": ["0x...", "0x...", "1"] }, "publicSignals": ["0x...", "0x...", "0x...", "0x...", "0x...", "0x..."], "recipient": "0x...", "tokenAddress": "0x...", "amount": "1000000000000000000" } ``` **Response (success):** ```json { "success": true, "txHash": "0x...", "blockNumber": 883500, "gasUsed": "350000" } ``` **Response (error):** ```json { "error": "Nullifier already spent", "code": "NULLIFIER_USED" } ``` ## Validation Before submitting the transaction, the offline relayer performs pre-flight validation to avoid wasting gas on transactions that would revert: 1. **Nullifier check**: Query the `NullifierRegistry` contract to verify the nullifier has not already been spent. A double-reveal would revert on-chain. 2. **Root validity**: Verify the Merkle root in the public signals matches a valid on-chain root (current or recent). 3. **Proof format**: Validate that the proof components (`pi_a`, `pi_b`, `pi_c`) have the correct structure and field element ranges. 4. **Recipient address**: Verify the recipient is a valid Ethereum address. If any validation fails, the relayer returns an error without submitting a transaction. ## Operator Wallet The offline relayer uses a dedicated operator wallet funded with GHOST for gas payments. This wallet: - Is distinct from the operator wallets used by other services (root updater, faucet, etc.) to maintain privilege isolation. - Only needs the `reveal()` function permission on the `CommitRevealVault` (if the contract enforces caller restrictions; otherwise, any address can call `reveal()`). - Should be monitored for low balance conditions. If the operator runs out of gas, sponsored reveals stop working. ### Gas Economics Each reveal transaction costs approximately 300,000–400,000 gas. On the Specter testnet, gas prices are minimal, so the cost per sponsored reveal is negligible. In a production environment, the economics of who funds the operator wallet would need to be addressed — possibilities include: - The protocol treasury funding the relayer. - The sender pre-paying gas as part of the commit flow. - A fee deducted from the revealed tokens. ## Rate Limiting To prevent abuse of the gas sponsorship, the offline relayer enforces rate limits: | Limit | Value | |-------|-------| | Per-IP request rate | 5 requests/minute | | Per-recipient rate | 10 reveals/hour | | Global rate | 60 reveals/minute | These limits ensure that a single actor cannot drain the operator wallet by submitting a high volume of sponsored reveals. ## NFC Card Redemption Flow The primary use case for the offline relayer is NFC card redemptions. The flow works as follows: 1. A sender commits tokens to the Ghost Protocol, encoding the commitment secret into an NFC card. 2. A recipient taps the NFC card with their phone, which reads the commitment secret. 3. The recipient's device generates a ZK proof (via the proof relayer) using the commitment secret. 4. The device submits the proof to the offline relayer, which reveals the tokens to the recipient's address. 5. The recipient receives the tokens without ever holding GHOST for gas. This creates a seamless experience where physical NFC cards function as bearer instruments for private token transfers. ## Error Handling - **Transaction revert**: If the on-chain reveal reverts despite pre-flight validation (e.g., due to a race condition with another reveal), the error is returned to the client. No retry is attempted because the likely cause is a spent nullifier. - **Nonce collision**: The relayer maintains a local nonce counter with mutex locking to prevent concurrent reveals from using the same nonce. - **Operator balance exhaustion**: When the operator balance falls below a configurable threshold, the relayer returns `503 Service Unavailable` for new requests and logs an alert. ## Configuration | Parameter | Description | |-----------|-------------| | `RPC_URL` | Specter chain RPC endpoint | | `OPERATOR_PRIVATE_KEY` | Operator wallet private key (funded with GHOST) | | `COMMIT_REVEAL_VAULT_ADDRESS` | Address of `CommitRevealVault` contract | | `NULLIFIER_REGISTRY_ADDRESS` | Address of `NullifierRegistry` contract | | `COMMITMENT_TREE_ADDRESS` | Address of `CommitmentTree` contract | | `MIN_OPERATOR_BALANCE` | Minimum GHOST balance before pausing (default: 100 GHOST) | ================================================================ SECTION: Infrastructure SOURCE: https://docs.specterchain.com/infrastructure/caddy-routing ================================================================ # Caddy Routing All Specter relayer services are exposed through a single Caddy reverse proxy at `relayer.specterchain.com`. Caddy handles HTTPS termination, automatic certificate renewal, and path-based routing to internal service ports. ## Why Caddy Caddy was chosen over alternatives (Nginx, Traefik, HAProxy) for several reasons: - **Automatic HTTPS**: Caddy obtains and renews TLS certificates from Let's Encrypt without any manual configuration or cron jobs. Certificate management is fully automated. - **Simple configuration**: The Caddyfile format is concise and readable compared to Nginx's directive-based config. - **HTTP/2 and HTTP/3**: Enabled by default with no additional configuration. - **Graceful reloads**: Configuration changes are applied without dropping active connections. ## Routing Table All services are accessible through `relayer.specterchain.com` with path-based routing. Each path prefix is reverse-proxied to the corresponding internal service port: | Path | Internal Target | Service | |------|----------------|---------| | `/root-updater/*` | `localhost:3001` | ghost-root-updater | | `/commitment/*` | `localhost:3002` | ghost-commitment-relayer | | `/prove/*` | `localhost:3003` | ghost-proof-relayer | | `/faucet/*` | `localhost:3005` | ghost-faucet | | `/registry/*` | `localhost:3009` | ghost-token-registry | | `/ember/*` | `localhost:3010` | ghost-ember-proxy | Services without HTTP ports (offline relayer, base conversion relayer, bridge relayers) are not exposed through Caddy. They operate autonomously, watching chain events and submitting transactions without accepting inbound HTTP requests. ## Caddyfile Structure The Caddyfile follows this general structure: ``` relayer.specterchain.com { # Root updater handle /root-updater/* { reverse_proxy localhost:3001 } # Commitment relayer (Poseidon computation) handle /commitment/* { reverse_proxy localhost:3002 } # Proof relayer (ZK proof generation) handle /prove/* { reverse_proxy localhost:3003 } # Testnet faucet handle /faucet/* { reverse_proxy localhost:3005 } # Token registry handle /registry/* { reverse_proxy localhost:3009 } # Ember proxy (phantom key access + AI chat) handle /ember/* { reverse_proxy localhost:3010 } # Static icon files for token registry handle /icons/* { root * /var/www/specter/icons file_server } } ``` The `handle` directive matches path prefixes and routes to the appropriate backend. Caddy strips or preserves the path prefix depending on the configuration — individual services may expect the prefix to be present or absent. ## TLS Configuration Caddy automatically provisions TLS certificates for `relayer.specterchain.com` via the ACME protocol with Let's Encrypt: - **Certificate type**: RSA 2048 or ECDSA P-256 (Caddy's default selection). - **Renewal**: Certificates are renewed automatically when they approach expiration (typically 30 days before expiry). No manual intervention required. - **OCSP stapling**: Enabled by default. Caddy fetches and staples OCSP responses to improve client TLS handshake performance. - **TLS versions**: TLS 1.2 and 1.3 are supported. TLS 1.0 and 1.1 are disabled by default. ### DNS Requirements For automatic certificate provisioning, the following DNS records must be configured: | Record | Type | Value | |--------|------|-------| | `relayer.specterchain.com` | A | IP address of the relayer droplet | Caddy uses HTTP-01 ACME challenges by default, which require the domain to resolve to the server and port 80 to be accessible for the challenge response. ## CORS Headers Caddy injects CORS headers for cross-origin requests from the Specter webapp. The CORS configuration is applied globally to all reverse-proxied routes: ``` header { Access-Control-Allow-Origin "https://app.specterchain.com" Access-Control-Allow-Methods "GET, POST, OPTIONS" Access-Control-Allow-Headers "Content-Type, Authorization" Access-Control-Max-Age "86400" } ``` Preflight `OPTIONS` requests are handled by Caddy directly, returning the CORS headers without forwarding to the backend service. This reduces latency for preflight checks and ensures consistent CORS behavior across all services. For development, `localhost` origins may be added to the allowed list. ## Request Logging Caddy logs all requests in structured JSON format for debugging and audit purposes: ```json { "level": "info", "ts": 1710600000.123, "msg": "handled request", "request": { "remote_ip": "203.0.113.42", "method": "POST", "uri": "/prove/", "protocol": "HTTP/2.0", "host": "relayer.specterchain.com" }, "duration": 13.542, "status": 200, "size": 1234 } ``` Logs are written to `/var/log/caddy/access.log` and rotated by Caddy or an external log rotation tool to prevent disk exhaustion. ## Health Check Routing Each service's health endpoint is accessible through the Caddy routing: | Health Endpoint | URL | |----------------|-----| | Root updater | `https://relayer.specterchain.com/root-updater/health` | | Commitment relayer | `https://relayer.specterchain.com/commitment/health` | | Proof relayer | `https://relayer.specterchain.com/prove/health` | | Faucet | `https://relayer.specterchain.com/faucet/health` | | Token registry | `https://relayer.specterchain.com/registry/health` | | Ember proxy | `https://relayer.specterchain.com/ember/health` | External monitoring systems can poll these endpoints to detect service outages. ## Operational Notes ### Reloading Configuration After editing the Caddyfile, reload Caddy without downtime: ```bash caddy reload --config /etc/caddy/Caddyfile ``` Active connections are preserved during the reload. New connections use the updated configuration. ### Viewing Active Configuration To inspect the currently running configuration: ```bash caddy adapt --config /etc/caddy/Caddyfile ``` This outputs the JSON representation of the Caddyfile, useful for debugging routing issues. ### Common Troubleshooting | Issue | Cause | Solution | |-------|-------|----------| | 502 Bad Gateway | Backend service is down | Check `pm2 status` and restart the service | | 503 Service Unavailable | Backend not responding within timeout | Check service logs; may be overloaded | | Certificate error | DNS not pointing to server | Verify A record for `relayer.specterchain.com` | | CORS error in browser | Origin not in allowed list | Add the requesting origin to the CORS header config | | 404 Not Found | Path does not match any `handle` block | Verify the path prefix matches the Caddyfile routing | ================================================================ SECTION: Building on Specter SOURCE: https://docs.specterchain.com/building-on-specter/getting-started ================================================================ # Getting Started This guide walks you through connecting to the Specter Testnet, obtaining testnet GHOST tokens, and performing your first commit/reveal cycle using the Specter webapp. ## Prerequisites - **MetaMask** (or any EVM-compatible wallet) installed in your browser - A modern browser (Chrome, Firefox, Brave, or Edge) - Basic familiarity with EVM transactions ## Step 1: Connect MetaMask to Specter Testnet Add the Specter Testnet as a custom network in MetaMask with the following parameters: | Parameter | Value | |-------------------|--------------------------------------| | **Network Name** | Specter Testnet | | **RPC URL** | `https://testnet.specterchain.com` | | **Chain ID** | `5446` | | **Currency Symbol**| `GHOST` | | **Decimals** | `18` | | **Block Explorer**| *(leave blank or use testnet explorer if available)* | ### Manual Configuration 1. Open MetaMask and click the network dropdown at the top. 2. Select **Add Network** (or **Add a network manually**). 3. Enter the values from the table above. 4. Click **Save**. MetaMask should now show your GHOST balance on the Specter Testnet (initially 0). :::caution Chain ID Must Be 5446 An older Avalanche L1 deployment used Chain ID `47474`. That chain is deprecated. If your balance shows `0` after receiving tokens, verify that your wallet is configured with Chain ID **5446**, not `47474`. ::: ## Step 2: Get Testnet GHOST from the Faucet The Specter faucet distributes **100 GHOST per request** with a **24-hour cooldown** per address. ### Using the Faucet API Send a `POST` request to the faucet endpoint with your wallet address: ```bash curl -X POST https://faucet.specterchain.com/api/drip \ -H "Content-Type: application/json" \ -d '{"address": "0xYourWalletAddressHere"}' ``` A successful response returns the transaction hash: ```json { "status": "ok", "txHash": "0xabc123...", "amount": "100000000000000000000", "cooldownEnds": "2026-03-17T12:00:00Z" } ``` If you request again before the 24-hour cooldown expires, you will receive an error: ```json { "status": "error", "message": "Cooldown active. Next drip available at 2026-03-17T12:00:00Z" } ``` ### Using the Webapp Faucet Alternatively, the Specter webapp includes a built-in faucet UI. Connect your wallet and navigate to the faucet page to request tokens with a single click. ### Verifying Your Balance After the faucet transaction confirms, verify your balance in MetaMask or via RPC: ```bash curl -X POST https://testnet.specterchain.com \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "method": "eth_getBalance", "params": ["0xYourWalletAddressHere", "latest"], "id": 1 }' ``` The result will be your balance in `aghost` (the smallest unit, 18 decimals), encoded as a hex string. `100 GHOST` = `0x56BC75E2D63100000`. ## Step 3: Make Your First Commit A **commit** is the first phase of Ghost Protocol. You lock tokens (or data) into a cryptographic commitment that is stored in the on-chain Merkle tree. Once committed, the tokens leave your address and cannot be traced back to you. ### Using the Webapp (Vanish Screen) 1. Navigate to the Specter webapp and connect your wallet. 2. Go to the **Vanish** screen. 3. Select the token you want to commit (e.g., GHOST). 4. Enter the amount. 5. The webapp generates a **phantom key** — a JSON blob containing your secret, nullifier secret, blinding factor, and commitment. **Save this key securely.** Without it, you cannot reveal your tokens. 6. Confirm the transaction in MetaMask. The webapp calls `commit()` on the `CommitRevealVault` contract (`0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a`). 7. Once the transaction confirms, your commitment is inserted into the `CommitmentTree` (`0xE29DD14998f6FE8e7862571c883090d14FE29475`). Your tokens are now committed. The phantom key is your only proof of ownership. ## Step 4: Reveal Your Tokens A **reveal** is the second phase. You prove knowledge of a valid commitment in the Merkle tree using a zero-knowledge proof, without revealing which commitment is yours. Fresh tokens are minted to the recipient address. ### Using the Webapp (Summon Screen) 1. Go to the **Summon** screen. 2. Import your phantom key (paste JSON, scan QR, or tap NFC card). 3. The webapp generates a Groth16 proof client-side using `snarkjs`. This takes a few seconds. 4. Choose a recipient address (can be your own wallet or a different address). 5. Confirm the reveal transaction. The webapp calls `reveal()` on the `CommitRevealVault`, which: - Verifies the Groth16 proof on-chain via `GhostRedemptionVerifier` (`0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee`). - Checks the nullifier has not been used before via `NullifierRegistry` (`0xaadb9c3394835B450023daA91Ad5a46beA6e43a1`). - Mints fresh tokens to the recipient via `NativeAssetHandler` (`0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3`). 6. The recipient receives unlinkable tokens at their address. ## Step 5: Verify the Privacy Guarantee After completing a commit and reveal: - The **commit transaction** shows tokens leaving your address into the vault. An observer knows *someone* committed. - The **reveal transaction** shows tokens arriving at the recipient from the vault. An observer knows *someone* revealed. - There is **no on-chain link** between the two transactions. The zero-knowledge proof proves membership in the Merkle tree without revealing which leaf. ## What's Next - [Deploy a Privacy Token](./deploy-privacy-token.md) — create your own GhostERC20 token that works with the commit/reveal system. - [Create a Custom Policy](./create-custom-policy.md) — enforce compliance rules at reveal time. - [Integrate Phantom Keys](./integrate-phantom-keys.md) — programmatically generate and manage phantom keys. - [Client-Side Proofs](./client-side-proofs.md) — generate ZK proofs in your own application. ================================================================ SECTION: Building on Specter SOURCE: https://docs.specterchain.com/building-on-specter/deploy-privacy-token ================================================================ # Deploy a Privacy-Enabled Token Specter provides the `GhostERC20Factory` contract to deploy ERC-20 tokens that are automatically integrated with the Ghost Protocol privacy system. Tokens deployed through this factory can be committed and revealed through the `CommitRevealVault`, enabling private transfers with zero-knowledge proofs. ## Overview The deployment flow is: 1. **Deploy** — Call `deployToken()` on the `GhostERC20Factory` to create a new GhostERC20 token contract via `CREATE2`. 2. **Register** — The factory automatically registers the new token with `AssetGuard`, authorizing it for use in the commit/reveal system. 3. **Enable** — Call `enableGhost()` on the newly deployed token to finalize its connection to the privacy infrastructure. Once enabled, the token can be committed into the `CommitRevealVault` and revealed with zero-knowledge proofs, just like native GHOST. ## Contract Addresses | Contract | Address | |---------------------|----------------------------------------------| | GhostERC20Factory | `0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95` | | AssetGuard | `0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1` | | CommitRevealVault | `0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a` | ## Step 1: Deploy the Token Call `deployToken` on the `GhostERC20Factory`: ```solidity function deployToken( string memory name, string memory symbol, uint8 decimals, bytes32 salt ) external returns (address tokenAddress); ``` ### Parameters | Parameter | Type | Description | |------------|-----------|-------------| | `name` | `string` | The full name of the token (e.g., `"Ghost Wrapped DAI"`). | | `symbol` | `string` | The ticker symbol (e.g., `"gDAI"`). Convention is to prefix with `g`. | | `decimals` | `uint8` | Number of decimal places. Use `18` for standard EVM tokens, `6` for stablecoin equivalents. | | `salt` | `bytes32` | A unique salt for `CREATE2` deterministic deployment. Use a random value or a domain-specific identifier. | ### Example: Deploy with ethers.js ```javascript const FACTORY_ADDRESS = "0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95"; const factoryAbi = [ "function deployToken(string name, string symbol, uint8 decimals, bytes32 salt) external returns (address)" ]; const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com"); const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider); const factory = new ethers.Contract(FACTORY_ADDRESS, factoryAbi, signer); const salt = ethers.randomBytes(32); const tx = await factory.deployToken("Ghost Wrapped DAI", "gDAI", 18, salt); const receipt = await tx.wait(); // The new token address is emitted in the TokenDeployed event const event = receipt.logs.find( (log) => log.address.toLowerCase() === FACTORY_ADDRESS.toLowerCase() ); console.log("Token deployed at:", event.args.tokenAddress); ``` ### Deterministic Addresses with CREATE2 Because the factory uses `CREATE2`, the deployed token address is deterministic given the same `name`, `symbol`, `decimals`, and `salt`. You can pre-compute the address off-chain: ```javascript const initCodeHash = ethers.keccak256( ethers.concat([ ghostERC20Bytecode, ethers.AbiCoder.defaultAbiCoder().encode( ["string", "string", "uint8"], ["Ghost Wrapped DAI", "gDAI", 18] ), ]) ); const tokenAddress = ethers.getCreate2Address( FACTORY_ADDRESS, salt, initCodeHash ); ``` ## Step 2: Automatic AssetGuard Registration When the factory deploys a token, it automatically calls `AssetGuard.registerToken()` to whitelist the new token for use in the `CommitRevealVault`. You do not need to perform this step manually. The `AssetGuard` contract (`0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1`) maintains a registry of all tokens authorized to interact with the privacy system. Only registered tokens can be committed and revealed. You can verify registration: ```javascript const assetGuardAbi = ["function isRegistered(address token) view returns (bool)"]; const assetGuard = new ethers.Contract( "0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1", assetGuardAbi, provider ); const registered = await assetGuard.isRegistered(tokenAddress); console.log("Registered:", registered); // true ``` ## Step 3: Enable Ghost Mode After deployment and registration, call `enableGhost()` on the token contract itself to finalize the privacy integration: ```javascript const ghostTokenAbi = [ "function enableGhost() external", "function ghostEnabled() view returns (bool)" ]; const ghostToken = new ethers.Contract(tokenAddress, ghostTokenAbi, signer); const tx = await ghostToken.enableGhost(); await tx.wait(); const enabled = await ghostToken.ghostEnabled(); console.log("Ghost enabled:", enabled); // true ``` `enableGhost()` configures the token's internal hooks so that when tokens are committed to the `CommitRevealVault`, they are burned from the sender, and when revealed, they are minted to the recipient. This mirrors the native GHOST behavior but for ERC-20 tokens. :::caution Only the token deployer (or a designated admin) can call `enableGhost()`. This is a one-time, irreversible operation. ::: ## Step 4: Use the Token with CommitRevealVault Once the token is ghost-enabled, it works seamlessly with the `CommitRevealVault`: ### Commit (Deposit into Privacy Pool) ```javascript const vaultAbi = [ "function commit(bytes32 commitment, address token, uint256 amount) external" ]; const vault = new ethers.Contract( "0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a", vaultAbi, signer ); // First, approve the vault to spend your tokens const approveTx = await ghostToken.approve(vault.target, amount); await approveTx.wait(); // Then commit const commitment = /* your computed Poseidon commitment */; const commitTx = await vault.commit(commitment, tokenAddress, amount); await commitTx.wait(); ``` ### Reveal (Withdraw from Privacy Pool) The reveal flow is identical to native GHOST reveals — generate a Groth16 proof demonstrating knowledge of a valid commitment in the Merkle tree, then submit the proof along with the nullifier. See [Client-Side Proofs](./client-side-proofs.md) for details on proof generation. ## Token Naming Conventions Existing privacy tokens on Specter follow a `g`-prefix convention: | Token | Address | Underlying | |---------|----------------------------------------------|------------| | gLABS | `0x062f8a68f6386c1b448b3379abd369825bec9aa2` | LABS | | gUSDC | `0x65c9091a6A45Db302a343AF460657C298FAA222D` | USDC | | gWETH | `0x923295a3e3bE5eDe29Fc408A507dA057ee044E81` | WETH | | gVIRTUAL| `0xaF12d2f962179274f243986604F97b961a4f4Cfc` | VIRTUAL | ## Troubleshooting - **`deployToken` reverts**: Ensure the `salt` has not been used before. Each unique combination of constructor parameters and salt produces one address. - **`enableGhost` reverts with unauthorized**: Only the deployer (msg.sender of `deployToken`) can call `enableGhost()`. - **Token not appearing in webapp**: The webapp reads tokens from its `config.js`. You may need to add your token's address and `tokenIdHash` to the configuration. See [Webapp Configuration](../webapp/configuration.md). - **Commit reverts with "token not registered"**: The `AssetGuard` registration may have failed. Check `isRegistered()` and contact the team if needed. ================================================================ SECTION: Building on Specter SOURCE: https://docs.specterchain.com/building-on-specter/create-custom-policy ================================================================ # Create a Custom Policy Policies are smart contracts that enforce rules at **reveal time** in the Ghost Protocol commit/reveal flow. When a user commits tokens with a policy attached, the policy contract is invoked during the reveal to validate that the reveal meets the policy's constraints — without breaking the zero-knowledge privacy guarantee for compliant users. ## How Policies Work 1. During **commit**, the user specifies a `policyId` (the policy contract address) and `policyParams` (ABI-encoded parameters). These are hashed into the commitment. 2. During **reveal**, the zero-knowledge proof demonstrates that the commitment included a specific `policyId` and `policyParamsHash`. The `CommitRevealVault` then calls the policy contract's `validateReveal()` function. 3. The policy contract performs **pure validation** — it checks conditions and either allows or reverts the reveal. Policies cannot write state. ## The IRevealPolicy Interface Every policy contract must implement the `IRevealPolicy` interface: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IRevealPolicy { /// @notice Validates whether a reveal operation is permitted. /// @param revealer The address submitting the reveal transaction. /// @param recipient The address that will receive the revealed tokens. /// @param token The token contract address being revealed. /// @param amount The amount of tokens being revealed. /// @param policyParams ABI-encoded parameters specific to this policy instance. /// @return valid True if the reveal is permitted, false otherwise. function validateReveal( address revealer, address recipient, address token, uint256 amount, bytes calldata policyParams ) external view returns (bool valid); } ``` ### Constraints - **View-only**: `validateReveal` must be a `view` function. It cannot modify state. The `CommitRevealVault` calls it with `staticcall`. - **Gas limit**: Policy validation is subject to a **100,000 gas limit**. If your policy exceeds this, the reveal reverts. Keep logic simple and avoid loops over unbounded data. - **Deterministic**: The function must return the same result for the same inputs. Do not rely on block-dependent values like `block.timestamp` for anything other than time-based policies where temporal variance is expected. ## Step 1: Implement the Policy Here is a complete example of a policy that restricts reveals to a maximum amount per transaction: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title MaxAmountPolicy /// @notice Restricts reveals to a maximum token amount per transaction. contract MaxAmountPolicy is IRevealPolicy { /// @notice Validates that the reveal amount does not exceed the maximum. /// @param policyParams ABI-encoded (uint256 maxAmount). function validateReveal( address /* revealer */, address /* recipient */, address /* token */, uint256 amount, bytes calldata policyParams ) external pure returns (bool valid) { uint256 maxAmount = abi.decode(policyParams, (uint256)); return amount <= maxAmount; } } ``` ### Encoding policyParams Policy parameters are encoded using Solidity's `abi.encode`. The encoding must match what the policy contract expects to decode: ```javascript // For MaxAmountPolicy: encode a single uint256 const maxAmount = ethers.parseEther("1000"); // 1000 tokens max const policyParams = ethers.AbiCoder.defaultAbiCoder().encode( ["uint256"], [maxAmount] ); ``` For more complex policies with multiple parameters: ```javascript // For a policy that takes (address allowedRecipient, uint256 minAmount, uint256 maxAmount) const policyParams = ethers.AbiCoder.defaultAbiCoder().encode( ["address", "uint256", "uint256"], ["0xRecipientAddress", ethers.parseEther("10"), ethers.parseEther("1000")] ); ``` The `policyParamsHash` stored in the commitment is computed as: ```javascript const policyParamsHash = ethers.keccak256(policyParams); ``` This hash is included in the Poseidon commitment, binding the policy parameters to the commitment without revealing them until reveal time. ## Step 2: Deploy to Specter Deploy your policy contract to the Specter chain (Chain ID `5446`): ```javascript const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com"); const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider); const MaxAmountPolicy = new ethers.ContractFactory(abi, bytecode, signer); const policy = await MaxAmountPolicy.deploy(); await policy.waitForDeployment(); const policyAddress = await policy.getAddress(); console.log("Policy deployed at:", policyAddress); ``` ## Step 3: Register in PolicyRegistry (Optional) The `PolicyRegistry` (`0x2DC1641d5A32D6788264690D42710edC843Cb1db`) is an on-chain directory of verified policy contracts. Registration is optional but recommended for discoverability and trust: ```javascript const registryAbi = [ "function registerPolicy(address policy, string name, string description) external" ]; const registry = new ethers.Contract( "0x2DC1641d5A32D6788264690D42710edC843Cb1db", registryAbi, signer ); const tx = await registry.registerPolicy( policyAddress, "MaxAmountPolicy", "Restricts reveals to a maximum token amount per transaction." ); await tx.wait(); ``` Registered policies can be queried by anyone: ```javascript const queryAbi = [ "function getPolicy(address policy) view returns (string name, string description, address deployer, bool active)" ]; const registryReader = new ethers.Contract( "0x2DC1641d5A32D6788264690D42710edC843Cb1db", queryAbi, provider ); const info = await registryReader.getPolicy(policyAddress); console.log(info.name, info.description); ``` ## Step 4: Use the Policy with commitWithPolicy When committing tokens, specify the policy contract and its parameters: ```javascript const vaultAbi = [ "function commitWithPolicy(bytes32 commitment, address token, uint256 amount, address policyId, bytes policyParams) external" ]; const vault = new ethers.Contract( "0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a", vaultAbi, signer ); // Encode the policy params const policyParams = ethers.AbiCoder.defaultAbiCoder().encode( ["uint256"], [ethers.parseEther("1000")] ); // The commitment must include the policyId and policyParamsHash // (computed using Poseidon7 with all fields including policyId and keccak256(policyParams)) const commitment = computeCommitment({ secret, nullifierSecret, blinding, tokenIdHash, amount, policyId: policyAddress, policyParamsHash: ethers.keccak256(policyParams), }); const tx = await vault.commitWithPolicy( commitment, tokenAddress, amount, policyAddress, policyParams ); await tx.wait(); ``` At reveal time, the `CommitRevealVault` automatically calls `validateReveal()` on the specified policy contract. If the policy returns `false` or reverts, the entire reveal transaction reverts. ## Built-In Policies Specter ships with three built-in policies: | Policy | Address | Description | |-----------------------|----------------------------------------------|-------------| | TimelockExpiry | `0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c` | Enforces a minimum time delay and optional expiry on reveals. `policyParams`: `(uint256 unlockTime, uint256 expiryTime)`. | | DestinationRestriction| `0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1` | Restricts reveals to a specific recipient address. `policyParams`: `(address allowedRecipient)`. | | ThresholdWitness | `0x5814e4755C0D98218ddb752D26dD03feba428c80` | Requires M-of-N signatures from designated witnesses. `policyParams`: `(uint256 threshold, address[] witnesses)`. | ## Complete Example: Time-Locked Commit ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// @title WeekdayOnlyPolicy /// @notice Only allows reveals on weekdays (Monday through Friday, UTC). contract WeekdayOnlyPolicy is IRevealPolicy { function validateReveal( address, address, address, uint256, bytes calldata ) external view returns (bool valid) { // day: 0 = Sunday, 1 = Monday, ..., 6 = Saturday uint256 day = (block.timestamp / 1 days + 4) % 7; return day >= 1 && day <= 5; } } ``` ## Security Considerations - **Immutability**: Once a commitment includes a `policyId`, it cannot be changed. Choose your policy carefully before committing. - **Gas awareness**: The 100,000 gas limit is strictly enforced. Test your policy's gas consumption with `estimateGas` before deployment. - **No external calls**: Avoid making external calls from within `validateReveal`. Reentrancy is not possible (it is a `staticcall`), but external calls consume gas and may fail unpredictably. - **Parameter validation**: Always validate that `policyParams` decodes correctly. A malformed encoding will cause `abi.decode` to revert, which reverts the reveal. ================================================================ SECTION: Building on Specter SOURCE: https://docs.specterchain.com/building-on-specter/integrate-phantom-keys ================================================================ # Integrate Phantom Keys Phantom keys are the secret material that links a commit to its reveal. They encode all the cryptographic inputs needed to generate a zero-knowledge proof and claim committed tokens. This guide covers programmatic generation, export formats, and import methods. ## Anatomy of a Phantom Key A phantom key contains the following fields: | Field | Type | Description | |--------------------|-----------|-------------| | `secret` | `bigint` | Random scalar in the BN254 field. Primary secret for the commitment. | | `nullifierSecret` | `bigint` | Random scalar. Used to derive the nullifier that prevents double-reveals. | | `blinding` | `bigint` | Random scalar. Provides additional entropy to the commitment hash. | | `tokenIdHash` | `bigint` | Poseidon2 hash of the token address. Identifies which token is committed. | | `amount` | `bigint` | The committed token amount in smallest units (e.g., aghost for GHOST). | | `commitment` | `bigint` | The Poseidon7 hash of all fields. Stored on-chain in the Merkle tree. | | `leafIndex` | `uint32` | The index of the commitment in the on-chain Merkle tree. | | `quantumSecret` | `string` | A 256-bit random hex string reserved for future quantum-resistant upgrades. | | `policyId` | `address` | The address of the policy contract (or `0x0` for no policy). | | `policyParamsHash` | `bigint` | keccak256 hash of the ABI-encoded policy parameters. | ## Step 1: Generate Random Secrets All secrets must be random scalars within the BN254 field. The field prime is: ``` p = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` Generate each secret as a cryptographically random 254-bit value reduced modulo `p`: ```javascript const BN254_FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; function randomFieldElement() { // Generate 32 random bytes (256 bits), then reduce mod p const buf = crypto.randomBytes(32); const value = BigInt("0x" + buf.toString("hex")); return value % BN254_FIELD_PRIME; } const secret = randomFieldElement(); const nullifierSecret = randomFieldElement(); const blinding = randomFieldElement(); ``` For the `quantumSecret`, generate a full 256-bit random hex string: ```javascript const quantumSecret = "0x" + crypto.randomBytes(32).toString("hex"); ``` ## Step 2: Compute tokenIdHash The `tokenIdHash` identifies the token within the commitment. It is computed as a Poseidon hash with 2 inputs (Poseidon2, using the T3 circuit): ```javascript const poseidon = await buildPoseidon(); // tokenIdHash = Poseidon2(tokenAddress, 0) // Convert the token address to a BigInt const tokenAddress = BigInt("0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a"); const tokenIdHash = poseidon.F.toObject(poseidon([tokenAddress, 0n])); console.log("tokenIdHash:", tokenIdHash.toString()); ``` For native GHOST, use the `NativeAssetHandler` address (`0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3`) as the token address. ## Step 3: Compute the Commitment The commitment is a Poseidon hash with 7 inputs (Poseidon7, using the T8 circuit): ```javascript // commitment = Poseidon7(secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash) const policyId = 0n; // No policy const policyParamsHash = 0n; // No policy params const commitment = poseidon.F.toObject( poseidon([ secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash, ]) ); console.log("commitment:", commitment.toString()); ``` The resulting `commitment` is a single field element that is submitted on-chain during the commit transaction and inserted into the Merkle tree. ## Step 4: Assemble the Phantom Key Combine all values into the `ghostchain-v2` key format: ```javascript const phantomKey = { version: "ghostchain-v2", token: tokenAddress.toString(), seed: null, // Not used in v2; reserved for HD derivation secret: secret.toString(), nullifierSecret: nullifierSecret.toString(), blinding: blinding.toString(), amount: amount.toString(), commitment: commitment.toString(), leafIndex: null, // Set after the commit transaction confirms tokenIdHash: tokenIdHash.toString(), quantumSecret: quantumSecret, policyId: "0x0000000000000000000000000000000000000000", policyParamsHash: policyParamsHash.toString(), policyParams: null, }; ``` After the commit transaction confirms and the commitment is inserted into the Merkle tree, update the `leafIndex`: ```javascript // Parse the leafIndex from the Commit event const receipt = await commitTx.wait(); const commitEvent = receipt.logs.find(/* parse CommitmentInserted event */); phantomKey.leafIndex = commitEvent.args.leafIndex; ``` ## Step 5: Export the Phantom Key Phantom keys can be exported in three formats for different use cases. ### JSON Export The canonical format. Used for clipboard copy, file storage, and programmatic import: ```javascript const jsonString = JSON.stringify(phantomKey, null, 2); // Save to file, copy to clipboard, or transmit securely ``` ### QR Code Export (PNG) Encode the JSON as a QR code for scanning with the Specter webapp or a mobile app: ```javascript // Generate a QR code as a PNG buffer const pngBuffer = await QRCode.toBuffer(JSON.stringify(phantomKey), { errorCorrectionLevel: "M", type: "png", width: 512, margin: 2, }); // Write to file fs.writeFileSync("phantom-key.png", pngBuffer); ``` :::tip For large phantom keys (e.g., those with policy parameters), the QR code may become dense. Use error correction level `"L"` and increase the width to maintain scannability. ::: ### Numeric Export (Fallback) For environments where QR scanning and JSON pasting are not available (e.g., paper backup, phone input), export the key as a compact numeric string. This encodes the essential fields as a hyphen-separated decimal string: ```javascript function exportNumeric(key) { // Encode essential fields as decimal strings separated by hyphens const parts = [ key.secret, key.nullifierSecret, key.blinding, key.amount, key.leafIndex ?? "0", key.tokenIdHash, key.commitment, ]; return parts.join("-"); } const numericKey = exportNumeric(phantomKey); console.log(numericKey); // "12345678901234567890-98765432109876543210-..." ``` The numeric format omits the `quantumSecret`, `policyId`, and `policyParams` fields. It is suitable only for simple, policy-free commitments. ## Step 6: Import Phantom Keys ### JSON Import Parse the JSON string and validate all required fields: ```javascript function importPhantomKey(jsonString) { const key = JSON.parse(jsonString); if (key.version !== "ghostchain-v2") { throw new Error(`Unsupported key version: ${key.version}`); } // Convert string fields back to BigInt return { ...key, secret: BigInt(key.secret), nullifierSecret: BigInt(key.nullifierSecret), blinding: BigInt(key.blinding), amount: BigInt(key.amount), commitment: BigInt(key.commitment), tokenIdHash: BigInt(key.tokenIdHash), policyParamsHash: BigInt(key.policyParamsHash), }; } ``` ### QR Code Import Use a QR scanner library to decode the image, then parse as JSON: ```javascript function importFromQR(imageData, width, height) { const code = jsQR(imageData, width, height); if (!code) throw new Error("No QR code found in image"); return importPhantomKey(code.data); } ``` ### NFC Import For NFC-enabled hardware cards (NTAG 424 DNA), the phantom key is stored in the card's NDEF payload. Use the Web NFC API or a native NFC library to read the card: ```javascript if ("NDEFReader" in window) { const reader = new NDEFReader(); await reader.scan(); reader.onreading = (event) => { for (const record of event.message.records) { if (record.recordType === "text") { const decoder = new TextDecoder(); const jsonString = decoder.decode(record.data); const key = importPhantomKey(jsonString); console.log("Imported from NFC:", key.commitment.toString()); } } }; } ``` ## Security Best Practices - **Never transmit phantom keys over insecure channels.** A phantom key is equivalent to a private key for the committed tokens. Anyone with the key can reveal the tokens. - **Delete keys after reveal.** Once tokens are revealed, the nullifier prevents double-spending, but the key still contains sensitive cryptographic material. - **Use hardware-backed storage** (NFC cards, secure enclaves) for high-value commitments. - **Validate commitments.** After importing a key, recompute the commitment from the secret fields and verify it matches the stored `commitment` value before attempting a reveal. ## Reference: Token ID Hashes Pre-computed `tokenIdHash` values for deployed tokens: | Token | Address | tokenIdHash | |--------|----------------------------------------------|-------------| | GHOST | `0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3` | Poseidon2(address, 0) | | gLABS | `0x062f8a68f6386c1b448b3379abd369825bec9aa2` | Poseidon2(address, 0) | | gUSDC | `0x65c9091a6A45Db302a343AF460657C298FAA222D` | Poseidon2(address, 0) | | gWETH | `0x923295a3e3bE5eDe29Fc408A507dA057ee044E81` | Poseidon2(address, 0) | | gVIRTUAL | `0xaF12d2f962179274f243986604F97b961a4f4Cfc` | Poseidon2(address, 0) | Compute the exact numeric value using the code in Step 2, substituting each token's address. ================================================================ SECTION: Building on Specter SOURCE: https://docs.specterchain.com/building-on-specter/client-side-proofs ================================================================ # Client-Side Proofs Specter's privacy model relies on zero-knowledge proofs generated entirely on the client. No server ever sees the user's secret data. This guide covers how to generate Groth16 proofs using `circomlibjs` and `snarkjs` in JavaScript, format them for on-chain submission, and handle BN254 field arithmetic. ## Overview The reveal flow requires a **Groth16 proof** over the **BN254** (alt-bn128) elliptic curve. The proof demonstrates that: 1. The prover knows a valid commitment (secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash) whose Poseidon7 hash exists as a leaf in the on-chain Merkle tree. 2. The prover correctly derived the nullifier from the nullifierSecret. 3. The Merkle proof is valid (the commitment is in the tree at the claimed leaf index). The proof reveals **nothing** about which commitment in the tree belongs to the prover. ## Prerequisites Install the required packages: ```bash npm install snarkjs circomlibjs ``` You also need the circuit artifacts: - **Circuit WASM** (`ghostRedemption.wasm`) — the compiled circuit for witness generation. - **Proving key** (`ghostRedemption_final.zkey`) — the Groth16 proving key from the trusted setup. - **Verification key** (`verification_key.json`) — for local proof verification (optional; on-chain verification uses the deployed verifier contract). These artifacts are distributed with the Specter webapp and are also available from the project's release assets. ## Step 1: Poseidon Hashing with circomlibjs The `circomlibjs` library provides a JavaScript implementation of the Poseidon hash function that is compatible with the on-chain Poseidon contracts and the circuit. ### Build the Poseidon Instance ```javascript // buildPoseidon() is async — it initializes the WASM module const poseidon = await buildPoseidon(); ``` The `poseidon` object is a function that accepts an array of BigInt inputs and returns a field element. Use `poseidon.F.toObject()` to convert the result to a JavaScript BigInt: ```javascript // Poseidon hash of two inputs (Poseidon2 / T3) const hash2 = poseidon.F.toObject(poseidon([input1, input2])); // Poseidon hash of four inputs (Poseidon4 / T5) const hash4 = poseidon.F.toObject(poseidon([a, b, c, d])); // Poseidon hash of seven inputs (Poseidon7 / T8) const hash7 = poseidon.F.toObject(poseidon([a, b, c, d, e, f, g])); ``` ### Compute the Commitment ```javascript const commitment = poseidon.F.toObject( poseidon([ secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash, ]) ); ``` ### Compute the Nullifier The nullifier prevents double-reveals. It is derived from the `nullifierSecret` and the `leafIndex`: ```javascript const nullifier = poseidon.F.toObject( poseidon([nullifierSecret, BigInt(leafIndex)]) ); ``` ### Compute the Access Tag The access tag is used for stealth/persistent key lookups: ```javascript const accessTag = poseidon.F.toObject( poseidon([secret, tokenIdHash]) ); ``` ## Step 2: Build the Merkle Proof The circuit requires a Merkle proof demonstrating that the commitment exists in the on-chain `CommitmentTree`. You need: - The **leaf** (your commitment). - The **leaf index** in the tree. - The **sibling hashes** (path elements) from the leaf to the root. - The **path indices** (0 for left, 1 for right at each level). Fetch the Merkle proof from the Specter relayer or reconstruct it from on-chain data: ```javascript // Option 1: Fetch from the relayer API const response = await fetch( `https://relayer.specterchain.com/api/merkle-proof?leafIndex=${leafIndex}` ); const { root, pathElements, pathIndices } = await response.json(); // Option 2: Reconstruct from on-chain events // Query CommitmentInserted events from CommitmentTree (0xE29DD14998f6FE8e7862571c883090d14FE29475) // and rebuild the tree locally using Poseidon2 for internal nodes. ``` ## Step 3: Generate the Groth16 Proof Use `snarkjs` to generate the proof. The `groth16.fullProve` function takes the circuit inputs, WASM file, and zkey file: ```javascript const circuitInputs = { // Private inputs (not revealed on-chain) secret: secret.toString(), nullifierSecret: nullifierSecret.toString(), blinding: blinding.toString(), amount: amount.toString(), policyId: policyId.toString(), policyParamsHash: policyParamsHash.toString(), leafIndex: leafIndex.toString(), pathElements: pathElements.map((e) => e.toString()), pathIndices: pathIndices.map((i) => i.toString()), // Public inputs (revealed on-chain, verified by the contract) root: root.toString(), nullifier: nullifier.toString(), tokenIdHash: tokenIdHash.toString(), recipient: BigInt(recipientAddress).toString(), }; const { proof, publicSignals } = await snarkjs.groth16.fullProve( circuitInputs, "ghostRedemption.wasm", // Path to circuit WASM "ghostRedemption_final.zkey" // Path to proving key ); console.log("Proof generated successfully"); console.log("Public signals:", publicSignals); ``` ### Proof Generation Time Proof generation is CPU-intensive. Expect: - **Desktop browser**: 3-8 seconds - **Mobile browser**: 10-30 seconds - **Node.js**: 2-5 seconds The webapp runs proof generation in a Web Worker to avoid blocking the UI. ## Step 4: Format the Proof for On-Chain Submission The on-chain `GhostRedemptionVerifier` (`0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee`) expects the proof in a specific format. Convert the `snarkjs` output to Solidity-compatible calldata: ```javascript // Method 1: Use snarkjs exportSolidityCallData const calldataRaw = await snarkjs.groth16.exportSolidityCallData( proof, publicSignals ); // Parse the calldata string into structured values const calldata = JSON.parse("[" + calldataRaw + "]"); const proofA = calldata[0]; // uint256[2] - point on G1 const proofB = calldata[1]; // uint256[2][2] - point on G2 const proofC = calldata[2]; // uint256[2] - point on G1 const publicInputs = calldata[3]; // uint256[] - public signals ``` ### Manual Formatting If you need more control, format the proof manually: ```javascript function formatProofForContract(proof) { return { a: [BigInt(proof.pi_a[0]), BigInt(proof.pi_a[1])], b: [ [BigInt(proof.pi_b[0][1]), BigInt(proof.pi_b[0][0])], // Note: reversed order for BN254 [BigInt(proof.pi_b[1][1]), BigInt(proof.pi_b[1][0])], ], c: [BigInt(proof.pi_c[0]), BigInt(proof.pi_c[1])], }; } const formattedProof = formatProofForContract(proof); ``` :::caution BN254 G2 Point Ordering The `pi_b` coordinates are in **reversed order** compared to `snarkjs` output. The Solidity verifier expects `[y, x]` order for each G2 coordinate pair, while `snarkjs` outputs `[x, y]`. The `exportSolidityCallData` function handles this automatically. ::: ## Step 5: Submit the Reveal Transaction ```javascript const vaultAbi = [ "function reveal(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] publicInputs, address token, uint256 amount, address recipient, address policyId, bytes policyParams) external" ]; const vault = new ethers.Contract( "0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a", vaultAbi, signer ); const tx = await vault.reveal( proofA, proofB, proofC, publicInputs, tokenAddress, amount, recipientAddress, policyId, policyParams ); const receipt = await tx.wait(); console.log("Reveal confirmed:", receipt.hash); ``` ## BN254 Field Operations All values in the circuit operate within the BN254 scalar field: ``` p = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` ### Field Reduction Any value used as a circuit input must be reduced modulo `p`. This is particularly important for values derived from `keccak256`, which produces 256-bit outputs that can exceed the ~254-bit field: ```javascript const BN254_FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; function toField(value) { const v = BigInt(value); return ((v % BN254_FIELD_PRIME) + BN254_FIELD_PRIME) % BN254_FIELD_PRIME; } // Example: reduce a keccak256 hash to a field element const hash = ethers.keccak256(someData); // 256-bit const fieldElement = toField(hash); ``` ### Address Conversion Ethereum addresses (160-bit) always fit within the BN254 field without reduction: ```javascript const addressAsBigInt = BigInt("0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3"); // No reduction needed — 160-bit < 254-bit ``` ## Local Proof Verification (Optional) Before submitting on-chain, you can verify the proof locally: ```javascript const vkey = JSON.parse(fs.readFileSync("verification_key.json", "utf8")); const valid = await snarkjs.groth16.verify(vkey, publicSignals, proof); if (!valid) { throw new Error("Proof verification failed locally — do not submit on-chain"); } ``` ## Troubleshooting - **"Scalar size does not match" error**: A circuit input exceeds the BN254 field. Apply `toField()` to all values before passing them to `fullProve`. - **Proof verification fails on-chain but passes locally**: The public signals order may differ from what the contract expects. Check that `publicSignals` matches the contract's expected ordering (root, nullifier, tokenIdHash, recipient, etc.). - **Out-of-memory in browser**: The zkey file can be large (50-100 MB). Use streaming reads or serve the zkey from a CDN with range request support. `snarkjs` supports `groth16.fullProve` with a URL for the zkey. - **Slow proof generation**: Ensure the WASM file is being loaded correctly. Mismatched WASM and zkey versions will cause either errors or extreme slowness. ================================================================ SECTION: Webapp SOURCE: https://docs.specterchain.com/webapp/overview ================================================================ # Webapp Overview The Specter webapp is the primary user interface for interacting with Ghost Protocol. It is a single-page application that handles wallet connection, commitment generation, zero-knowledge proof creation, and transaction submission — all client-side. ## Technology Stack | Layer | Technology | |-------------------|-------------------------------------| | **Framework** | React 18 with functional components and hooks | | **Build tool** | Vite (fast HMR, optimized production builds) | | **Blockchain** | ethers.js v6 and viem for contract interaction | | **Wallet** | RainbowKit + wagmi for wallet connection and chain management | | **ZK Proofs** | snarkjs (Groth16 prover, runs in Web Worker) | | **Hashing** | circomlibjs (Poseidon hash, WASM-based) | | **Deployment** | Vercel (automatic deployments from main branch) | ## Architecture ``` ┌─────────────────────────────────────────────────────┐ │ React UI Layer │ │ ┌──────────┐ ┌──────────┐ ┌───────┐ ┌───────────┐ │ │ │ Vanish │ │ Summon │ │ Bridge│ │ Stealth │ │ │ │ Screen │ │ Screen │ │ Screen│ │ Screen │ │ │ └────┬─────┘ └────┬─────┘ └───┬───┘ └─────┬─────┘ │ │ │ │ │ │ │ │ ┌────▼─────────────▼───────────▼────────────▼────┐ │ │ │ Shared Hooks & Services │ │ │ │ useCommit() useReveal() useBridge() useKeys() │ │ │ └────┬─────────────┬───────────┬────────────┬────┘ │ │ │ │ │ │ │ │ ┌────▼─────┐ ┌─────▼────┐ ┌───▼────┐ ┌─────▼────┐ │ │ │ Crypto │ │ Wallet │ │Contract│ │ Relayer │ │ │ │ Module │ │ Module │ │ Module │ │ Client │ │ │ │(snarkjs) │ │(wagmi/RK)│ │(ethers)│ │ (fetch) │ │ │ └──────────┘ └──────────┘ └────────┘ └──────────┘ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌────▼────┐ ┌─────▼────┐ ┌───▼─────┐ ┌───▼──────┐ │ Circuit │ │ MetaMask │ │ Specter │ │ Relayer │ │ Artifacts│ │ / Wallet │ │ Chain │ │ API │ └─────────┘ └──────────┘ └─────────┘ └──────────┘ ``` ### Client-Side ZK Proofs All zero-knowledge proof generation happens in the browser. The webapp loads the circuit WASM and proving key (zkey), then runs `snarkjs.groth16.fullProve()` inside a **Web Worker** to avoid blocking the main thread. No secret data ever leaves the user's browser. ### Wallet Connection RainbowKit provides the wallet connection modal, supporting MetaMask, WalletConnect, Coinbase Wallet, and other EVM wallets. The `wagmi` library manages chain state, transaction signing, and event subscriptions. The webapp auto-prompts users to switch to Chain ID `5446` if they are connected to a different network. ## Main Screens ### Vanish (Commit) The Vanish screen is where users commit tokens into the privacy pool: 1. Select a token (GHOST, gLABS, gUSDC, gWETH, gVIRTUAL, or any registered GhostERC20). 2. Enter the amount to commit. 3. The app generates a phantom key containing all cryptographic secrets. 4. The user saves the phantom key (download JSON, copy to clipboard, export as QR, or write to NFC card). 5. The app submits the `commit()` transaction to the `CommitRevealVault`. 6. On confirmation, the commitment is inserted into the Merkle tree and the leaf index is recorded in the phantom key. ### Summon (Reveal) The Summon screen is where users reveal committed tokens: 1. Import a phantom key (paste JSON, scan QR, tap NFC card, or select from local storage). 2. The app fetches the current Merkle root and proof from the relayer. 3. The app generates a Groth16 proof in a Web Worker (3-30 seconds depending on device). 4. Enter the recipient address. 5. The app submits the `reveal()` transaction to the `CommitRevealVault`. 6. On confirmation, fresh tokens are minted to the recipient. ### Bridge The Bridge screen enables cross-chain transfers using Hyperlane: - Bridge tokens between Specter and supported chains using Hyperlane warp routes. - The bridge uses `HypGhostERC20Synthetic` contracts for wrapped representations on remote chains. - Users select the source and destination chains, token, and amount. - The app handles approval, wrapping, and Hyperlane message dispatch. ### Revels The Revels screen provides a history view: - Displays the user's commit and reveal history from on-chain events and local storage. - Shows phantom key status (committed, revealed, expired). - Allows re-export of stored phantom keys. ### Stealth The Stealth screen manages persistent keys for the OpenGhost data privacy system: - Create persistent keys for encrypted data storage. - Manage keys stored in the on-chain `PersistentKeyVault` (`0x338B0e3c722702E705357C291E151D76B8Fd9F61`). - View and revoke access to persistent commitments. ## State Management The webapp uses React hooks and context for state management: - **Wallet state**: Managed by `wagmi` and RainbowKit. Includes connected address, chain ID, and provider/signer instances. - **Phantom keys**: Stored in the browser's `localStorage` (encrypted with the user's wallet signature). Keys are never sent to any server. - **Contract state**: Fetched on-demand via ethers.js calls to the Specter RPC. Cached in React state with TTLs. - **Proof artifacts**: Circuit WASM and zkey files are loaded from the deployment CDN and cached in the browser's Cache API. ## Configuration The webapp's behavior is controlled by `config.js`, which defines chain parameters, contract addresses, token configurations, and relayer endpoints. See [Webapp Configuration](./configuration.md) for details. ## Development ```bash # Clone and install git clone cd webapp npm install # Start development server npm run dev # → http://localhost:5173 # Build for production npm run build # Preview production build npm run preview ``` ### Environment Variables The webapp reads configuration from `config.js` rather than environment variables, so no `.env` file is required for standard operation. For custom deployments, modify `config.js` directly. ================================================================ SECTION: Webapp SOURCE: https://docs.specterchain.com/webapp/configuration ================================================================ # Webapp Configuration The Specter webapp is configured through a central `config.js` file that defines chain parameters, contract addresses, token registrations, relayer endpoints, bridge settings, and ABI definitions. This page documents every section of the configuration. ## File Structure ``` config.js ├── chainConfig — Network and RPC settings ├── contractAddresses — Deployed contract addresses ├── tokenConfigs — Token metadata and tokenIdHash values ├── relayerConfig — Relayer API endpoints ├── bridgeConfig — Hyperlane bridge settings └── abis — Contract ABI definitions ``` ## Chain Configuration ```javascript export const chainConfig = { chainId: 5446, chainIdHex: "0x1546", chainName: "Specter Testnet", nativeCurrency: { name: "GHOST", symbol: "GHOST", decimals: 18, }, rpcUrls: [ "https://testnet.specterchain.com", "https://testnet.umbraline.com", // Legacy alias — both resolve to the same node ], blockExplorerUrls: [], }; ``` | Field | Value | Notes | |----------------|--------------------------------------|-------| | `chainId` | `5446` | Decimal. Must match MetaMask configuration. | | `chainIdHex` | `"0x1546"` | Hex encoding of 5446. Used by `wallet_addEthereumChain`. | | `rpcUrls` | `testnet.specterchain.com` | Primary RPC. The `umbraline.com` alias also works. | | `nativeCurrency.decimals` | `18` | 1 GHOST = 10^18 aghost. | :::caution Do **not** use Chain ID `47474`. That was the old Avalanche L1 identifier and is no longer valid. Using it will cause balance queries to return 0 and transactions to fail. ::: ## Contract Addresses ```javascript export const contractAddresses = { // Core COMMIT_REVEAL_VAULT_ADDRESS: "0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a", COMMITMENT_TREE_ADDRESS: "0xE29DD14998f6FE8e7862571c883090d14FE29475", GHOST_REDEMPTION_VERIFIER_ADDRESS: "0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee", NULLIFIER_REGISTRY_ADDRESS: "0xaadb9c3394835B450023daA91Ad5a46beA6e43a1", NATIVE_ASSET_HANDLER_ADDRESS: "0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3", ASSET_GUARD_ADDRESS: "0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1", GHOST_ERC20_FACTORY_ADDRESS: "0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95", // Policies POLICY_REGISTRY_ADDRESS: "0x2DC1641d5A32D6788264690D42710edC843Cb1db", TIMELOCK_EXPIRY_ADDRESS: "0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c", DESTINATION_RESTRICTION_ADDRESS: "0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1", THRESHOLD_WITNESS_ADDRESS: "0x5814e4755C0D98218ddb752D26dD03feba428c80", // Cryptographic primitives POSEIDON_T3_ADDRESS: "0xacaef99b13d5846e3309017586de9f777da41548", // Other DMS_REGISTRY_ADDRESS: "0x14d5629136edAc7ef2b2E5956838b9Bb0211eB9d", PERSISTENT_KEY_VAULT_ADDRESS: "0x338B0e3c722702E705357C291E151D76B8Fd9F61", ACCESS_PROOF_VERIFIER_ADDRESS: "0x508d326D68e5da728f8A74CB4ADB7552f7768B66", }; ``` ### Key Addresses - **`COMMIT_REVEAL_VAULT_ADDRESS`** — The central vault contract. All commit and reveal transactions go through this address. - **`COMMITMENT_TREE_ADDRESS`** — The append-only Merkle tree that stores commitments. The webapp queries this for the current root and tree size. - **`GHOST_REDEMPTION_VERIFIER_ADDRESS`** — The Groth16 verifier contract. Called by the vault during reveals to verify the ZK proof. - **`NULLIFIER_REGISTRY_ADDRESS`** — Tracks spent nullifiers to prevent double-reveals. - **`NATIVE_ASSET_HANDLER_ADDRESS`** — The sole contract authorized to mint/burn native GHOST via the `ghostmint` precompile. ## Token Configurations Each token that the webapp supports is defined with its address, metadata, and pre-computed `tokenIdHash`: ```javascript export const tokenConfigs = { GHOST: { address: "0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3", // NativeAssetHandler symbol: "GHOST", name: "GHOST", decimals: 18, isNative: true, tokenIdHash: "", }, gLABS: { address: "0x062f8a68f6386c1b448b3379abd369825bec9aa2", symbol: "gLABS", name: "Ghost LABS", decimals: 18, isNative: false, tokenIdHash: "", }, gUSDC: { address: "0x65c9091a6A45Db302a343AF460657C298FAA222D", symbol: "gUSDC", name: "Ghost USDC", decimals: 6, isNative: false, tokenIdHash: "", }, gWETH: { address: "0x923295a3e3bE5eDe29Fc408A507dA057ee044E81", symbol: "gWETH", name: "Ghost WETH", decimals: 18, isNative: false, tokenIdHash: "", }, gVIRTUAL: { address: "0xaF12d2f962179274f243986604F97b961a4f4Cfc", symbol: "gVIRTUAL", name: "Ghost VIRTUAL", decimals: 18, isNative: false, tokenIdHash: "", }, }; ``` ### tokenIdHash The `tokenIdHash` is a Poseidon2 hash of the token's contract address and `0`. It is used inside the commitment to identify the token without revealing the address in the ZK circuit's public inputs. The value must be pre-computed using `circomlibjs`: ```javascript const poseidon = await buildPoseidon(); const tokenIdHash = poseidon.F.toObject( poseidon([BigInt(tokenAddress), 0n]) ); ``` Each token's `tokenIdHash` in the config must match the on-chain computation exactly, or reveals will fail with a proof verification error. ## Relayer Configuration The relayer provides off-chain services including Merkle proof generation and transaction relay: ```javascript export const relayerConfig = { baseUrl: "https://relayer.specterchain.com", endpoints: { merkleProof: "/api/merkle-proof", submitReveal: "/api/submit-reveal", treeStatus: "/api/tree-status", indexer: "/api/indexer", health: "/api/health", }, timeout: 30000, // 30 second timeout }; ``` ### Endpoints | Endpoint | Method | Description | |-------------------|--------|-------------| | `/api/merkle-proof` | `GET` | Returns the Merkle proof for a given `leafIndex`. Query param: `?leafIndex=N`. | | `/api/submit-reveal` | `POST` | Submits a reveal transaction via the relayer (for gasless reveals). Body: proof + public inputs. | | `/api/tree-status` | `GET` | Returns the current tree root, size, and latest block. | | `/api/indexer` | `GET` | Queries indexed commitment and reveal events. Supports filtering by address, token, and block range. | | `/api/health` | `GET` | Health check. Returns 200 if the relayer is operational. | ## Bridge Configuration The bridge uses Hyperlane for cross-chain token transfers: ```javascript export const bridgeConfig = { hyperlane: { specterDomainId: 5446, warpRoutes: { gLABS: { specterToken: "0x062f8a68f6386c1b448b3379abd369825bec9aa2", syntheticToken: "0xa3239B0FDEE28De133e545424F644503527E508A", // HypGhostERC20Synthetic }, }, remoteDomains: { // Add supported remote chains here with their domain IDs }, }, }; ``` ### Hyperlane Domain IDs Hyperlane uses domain IDs to identify chains. The Specter domain ID is `5446` (matching the EVM chain ID). Remote chain domain IDs are configured per deployment. ### Warp Routes Warp routes define the token mapping between Specter and remote chains. Each route specifies: - `specterToken` — the GhostERC20 address on Specter. - `syntheticToken` — the `HypGhostERC20Synthetic` contract on Specter that handles cross-chain minting/burning. ## ABI Definitions The config file includes ABI fragments for all contracts the webapp interacts with. These are minimal ABIs containing only the functions and events the webapp uses: ```javascript export const abis = { commitRevealVault: [ "function commit(bytes32 commitment, address token, uint256 amount) external payable", "function commitWithPolicy(bytes32 commitment, address token, uint256 amount, address policyId, bytes policyParams) external payable", "function reveal(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] publicInputs, address token, uint256 amount, address recipient, address policyId, bytes policyParams) external", "event CommitmentInserted(bytes32 indexed commitment, uint256 leafIndex, address indexed token, uint256 amount)", "event Revealed(bytes32 indexed nullifier, address indexed recipient, address token, uint256 amount)", ], commitmentTree: [ "function getRoot() view returns (bytes32)", "function getTreeSize() view returns (uint256)", "function getLeaf(uint256 index) view returns (bytes32)", ], ghostERC20: [ "function approve(address spender, uint256 amount) returns (bool)", "function balanceOf(address account) view returns (uint256)", "function enableGhost() external", "function ghostEnabled() view returns (bool)", ], nullifierRegistry: [ "function isSpent(bytes32 nullifier) view returns (bool)", ], assetGuard: [ "function isRegistered(address token) view returns (bool)", ], }; ``` ## Adding a New Token To add a custom GhostERC20 token to the webapp: 1. Deploy the token via `GhostERC20Factory` and call `enableGhost()` (see [Deploy a Privacy Token](../building-on-specter/deploy-privacy-token.md)). 2. Compute the token's `tokenIdHash` using Poseidon2. 3. Add an entry to `tokenConfigs` in `config.js`: ```javascript myToken: { address: "0xYourTokenAddress", symbol: "gMYTOKEN", name: "Ghost MyToken", decimals: 18, isNative: false, tokenIdHash: "", }, ``` 4. Rebuild and redeploy the webapp. ## Common Misconfigurations | Problem | Cause | Fix | |---------|-------|-----| | Balance shows 0 | Chain ID set to `47474` instead of `5446` | Update `chainConfig.chainId` to `5446` | | Reveal proof fails | `tokenIdHash` mismatch | Recompute tokenIdHash with Poseidon2(address, 0) | | RPC timeouts | Using stale RPC URL | Use `testnet.specterchain.com` as primary | | Wallet won't connect | Missing chain in RainbowKit config | Ensure Specter chain is defined in wagmi config | ================================================================ SECTION: Reference SOURCE: https://docs.specterchain.com/reference/contract-addresses ================================================================ # Contract Addresses All contracts listed here are deployed on the **Specter Testnet** (Chain ID `5446`) as part of the **v4.5** release, deployed on **March 4, 2026**. ## Core Contracts These contracts form the foundation of Ghost Protocol: | Contract | Address | Description | |---------------------------|------------------------------------------------|-------------| | **CommitRevealVault** | `0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a` | Central vault for commit and reveal operations. All token commits and ZK-verified reveals route through this contract. | | **GhostRedemptionVerifier** | `0xc0A9BcF60A6E4Aabf5Dd3e195b99DE2b9fac3Dee` | On-chain Groth16 verifier for the ghost redemption circuit. Called by the vault during reveals. | | **CommitmentTree** | `0xE29DD14998f6FE8e7862571c883090d14FE29475` | Append-only Merkle tree (depth 20) storing all commitments. Uses PoseidonT3 for internal node hashing. | | **NullifierRegistry** | `0xaadb9c3394835B450023daA91Ad5a46beA6e43a1` | Tracks spent nullifiers to prevent double-reveals. A nullifier can only be marked as spent once. | | **NativeAssetHandler** | `0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3` | The sole contract authorized to invoke the `ghostmint` precompile (0x0808) for minting and burning native GHOST. | | **AssetGuard** | `0x12d5a4d9Db0607312Fc8F8eE51FDf18D40794aD1` | Token registry that whitelists ERC-20 tokens for use in the commit/reveal system. | | **GhostERC20Factory** | `0xE842ffe639a770a162e0b7EB9f274E49aCA8Fb95` | Factory for deploying privacy-enabled ERC-20 tokens via CREATE2. Auto-registers with AssetGuard. | | **PoseidonT3** | `0xacaef99b13d5846e3309017586de9f777da41548` | On-chain Poseidon hash function with 2 inputs (T3 configuration). Used by CommitmentTree for Merkle nodes. | ## Policy Contracts Policy contracts enforce rules at reveal time without breaking zero-knowledge privacy: | Contract | Address | Description | |---------------------------|------------------------------------------------|-------------| | **PolicyRegistry** | `0x2DC1641d5A32D6788264690D42710edC843Cb1db` | On-chain directory of registered policy contracts. Optional for discoverability. | | **TimelockExpiry** | `0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c` | Enforces a minimum time delay before reveals are permitted, with an optional expiry after which the commitment can no longer be revealed. | | **DestinationRestriction**| `0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1` | Restricts reveals to a specific pre-committed recipient address. | | **ThresholdWitness** | `0x5814e4755C0D98218ddb752D26dD03feba428c80` | Requires M-of-N witness signatures to authorize a reveal. | ## Token Contracts Privacy-enabled ERC-20 tokens deployed through the GhostERC20Factory: | Token | Address | Decimals | Underlying | |------------|------------------------------------------------|----------|------------| | **gLABS** | `0x062f8a68f6386c1b448b3379abd369825bec9aa2` | 18 | LABS | | **gUSDC** | `0x65c9091a6A45Db302a343AF460657C298FAA222D` | 6 | USDC | | **gWETH** | `0x923295a3e3bE5eDe29Fc408A507dA057ee044E81` | 18 | WETH | | **gVIRTUAL** | `0xaF12d2f962179274f243986604F97b961a4f4Cfc` | 18 | VIRTUAL | ## Other Contracts | Contract | Address | Description | |-----------------------------------|------------------------------------------------|-------------| | **DMSRegistry** | `0x14d5629136edAc7ef2b2E5956838b9Bb0211eB9d` | Dead Man's Switch registry. Manages time-based automatic reveals triggered by inactivity. | | **HypGhostERC20Synthetic (gLABS)**| `0xa3239B0FDEE28De133e545424F644503527E508A` | Hyperlane warp route synthetic token for cross-chain gLABS transfers. | | **PersistentKeyVault** | `0x338B0e3c722702E705357C291E151D76B8Fd9F61` | On-chain vault for storing persistent key metadata used by the OpenGhost data privacy system. | | **AccessProofVerifier** | `0x508d326D68e5da728f8A74CB4ADB7552f7768B66` | Groth16 verifier for access proofs used in the persistent key / data privacy system. | ## Verification To verify that a contract is deployed at the expected address, query the code at that address: ```bash curl -s -X POST https://testnet.specterchain.com \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "method": "eth_getCode", "params": ["0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a", "latest"], "id": 1 }' | jq -r '.result' | head -c 66 ``` A non-`0x` result confirms deployed bytecode exists at the address. ## Network Details | Parameter | Value | |-------------|-------| | Chain ID | `5446` (`0x1546`) | | Network | Specter Testnet | | RPC URL | `https://testnet.specterchain.com` | | Deployment | v4.5, March 4, 2026 | ================================================================ SECTION: Reference SOURCE: https://docs.specterchain.com/reference/chain-configuration ================================================================ # Chain Configuration Specter is a Cosmos SDK-based blockchain with a full EVM execution environment. This page documents all chain identifiers, RPC endpoints, native token parameters, and related configuration details. ## Chain Identity | Parameter | Value | |------------------------|--------------------------------| | **EVM Chain ID** | `5446` (decimal) / `0x1546` (hex) | | **Cosmos chain-id** | `umbraline_9001-1` | | **Bech32 prefix** | `umbra` | | **Consensus** | CometBFT (Byzantine Fault Tolerant) | | **Execution** | EVM-compatible (go-ethereum based) | :::danger Chain ID 47474 is Deprecated An earlier Avalanche L1 deployment used Chain ID `47474`. That chain is **no longer active**. If you have `47474` configured in your wallet or application, you must update to `5446`. Symptoms of using the wrong chain ID include: - Balance showing `0` despite receiving tokens - Transactions failing silently - RPC calls returning unexpected results ::: ## RPC Endpoints | Endpoint | Type | Status | |---------------------------------------|-----------|---------| | `https://testnet.specterchain.com` | EVM JSON-RPC | **Primary** | | `https://testnet.umbraline.com` | EVM JSON-RPC | Active (legacy alias) | | `https://testnet-rpc.umbraline.com` | EVM JSON-RPC | **May be stale** — avoid for production | Both `testnet.specterchain.com` and `testnet.umbraline.com` resolve to the same infrastructure and are interchangeable. Use `testnet.specterchain.com` as the canonical endpoint. :::caution The `testnet-rpc.umbraline.com` endpoint may point to a stale or unmaintained node. Always prefer `testnet.specterchain.com` for reliable connectivity. ::: ### RPC Methods Specter supports standard Ethereum JSON-RPC methods with the following caveats: | Method | Support | Notes | |-----------------------------|---------|-------| | `eth_getBalance` | Full | Returns native GHOST balance in aghost (wei equivalent). | | `eth_call` | Full | Standard contract call support. | | `eth_sendRawTransaction` | Full | Standard transaction submission. | | `eth_getTransactionReceipt` | Partial | May return parse errors for certain transaction types. Use computed addresses instead of relying on receipt logs for CREATE/CREATE2 deployments. | | `eth_getLogs` | Limited | **Known limitation**: May return empty results due to Cosmos EVM indexing constraints. Use the relayer indexer API (`relayer.specterchain.com/api/indexer`) for reliable event queries. | | `eth_getBlockByNumber` | Full | Standard block queries. | | `eth_estimateGas` | Full | Standard gas estimation. | ## Native Token | Parameter | Value | |--------------------|-------------| | **Name** | GHOST | | **Symbol** | GHOST | | **Decimals** | 18 | | **Smallest unit** | aghost | | **Conversion** | 1 GHOST = 10^18 aghost | | **Genesis supply** | 1,000,000,000 GHOST (1 billion) | The `aghost` denomination follows the Cosmos SDK convention of using an `a` prefix for the smallest unit (analogous to `aevmos`, `aatom` in other Cosmos chains). In the EVM context, `aghost` is equivalent to `wei` — the 18-decimal base unit. ### Minting and Burning Unlike standard EVM chains where the native token cannot be minted or burned by contracts, Specter provides the `ghostmint` precompile at address `0x0808`. This precompile bridges between the EVM and the Cosmos `x/bank` module, enabling: - **Minting**: The `NativeAssetHandler` contract mints fresh GHOST during reveal operations. - **Burning**: The `NativeAssetHandler` burns GHOST during commit operations. Only the `NativeAssetHandler` (`0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3`) is authorized to call the precompile. ## Relayer API The Specter relayer provides off-chain services that complement the on-chain protocol: | Endpoint | Description | |-----------------------------------------------|-------------| | `https://relayer.specterchain.com/api/merkle-proof` | Fetch Merkle inclusion proofs for a given leaf index. | | `https://relayer.specterchain.com/api/submit-reveal` | Submit reveal transactions via the relayer (gasless reveals). | | `https://relayer.specterchain.com/api/tree-status` | Query current Merkle tree root, size, and sync status. | | `https://relayer.specterchain.com/api/indexer` | Query indexed on-chain events (commitments, reveals, transfers). | | `https://relayer.specterchain.com/api/health` | Relayer health check. | ## Cosmos Configuration For applications interacting with the Cosmos layer (staking, governance, IBC): | Parameter | Value | |---------------------|--------------------------| | **chain-id** | `umbraline_9001-1` | | **Bech32 prefix** | `umbra` | | **Coin type** | `60` (Ethereum) | | **Address format** | `umbra1...` (Bech32) or `0x...` (EVM hex) | | **Key algorithm** | `eth_secp256k1` | ### Address Interoperability Specter uses the same key derivation as Ethereum (coin type 60, secp256k1). A single private key maps to both: - An EVM address: `0x...` (20 bytes, hex-encoded) - A Cosmos address: `umbra1...` (Bech32-encoded with the `umbra` prefix) Both addresses refer to the same account. Use the EVM address for smart contract interactions and the Cosmos address for native Cosmos operations (staking, governance, IBC transfers). ## MetaMask Configuration Add Specter to MetaMask with these parameters: | Field | Value | |-------------------|------------------------------------| | Network Name | Specter Testnet | | RPC URL | `https://testnet.specterchain.com` | | Chain ID | `5446` | | Currency Symbol | `GHOST` | | Decimals | `18` | ## Faucet The testnet faucet provides GHOST tokens for development and testing: - **Amount**: 100 GHOST per request - **Cooldown**: 24 hours per address - **Endpoint**: `POST https://faucet.specterchain.com/api/drip` - **Body**: `{"address": "0xYourAddress"}` ================================================================ SECTION: Reference SOURCE: https://docs.specterchain.com/reference/key-formats ================================================================ # Key Formats Specter uses two JSON key formats for different privacy use cases. **ghostchain-v2** keys are one-time keys used for token privacy (commit/reveal), while **open-ghost-persistent-v1** keys are persistent keys used for data privacy (encrypted storage with revocable access). ## ghostchain-v2 (Token Privacy Keys) The `ghostchain-v2` format encodes all cryptographic material needed to reveal a committed token deposit. Each key corresponds to exactly one commitment in the Merkle tree and is consumed (invalidated by nullifier) after a single reveal. ### Schema ```json { "version": "ghostchain-v2", "token": "0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3", "seed": null, "secret": "18493027561038472950183746502918374650291837465029183746502918", "nullifierSecret": "7482910384756102938475610293847561029384756102938475610293847", "blinding": "3948271506382940175603829401756038294017560382940175603829401", "amount": "100000000000000000000", "commitment": "12849305718294057182940571829405718294057182940571829405718294", "leafIndex": 42, "tokenIdHash": "9182736450918273645091827364509182736450918273645091827364509", "quantumSecret": "0xa1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "policyId": "0x0000000000000000000000000000000000000000", "policyParamsHash": "0", "policyParams": null } ``` ### Field Descriptions | Field | Type | Required | Description | |--------------------|----------------|----------|-------------| | `version` | `string` | Yes | Must be `"ghostchain-v2"`. Used for format detection and migration. | | `token` | `string` | Yes | The EVM address of the committed token contract. For native GHOST, this is the `NativeAssetHandler` address (`0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3`). | | `seed` | `string\|null` | No | Reserved for future HD key derivation. Currently always `null`. | | `secret` | `string` | Yes | A random scalar in the BN254 field (decimal string). The primary secret input to the Poseidon7 commitment hash. Knowledge of this value is required to generate a valid reveal proof. | | `nullifierSecret` | `string` | Yes | A random scalar in the BN254 field (decimal string). Combined with `leafIndex` via Poseidon2 to derive the nullifier. The nullifier is published during reveal to prevent double-spending. | | `blinding` | `string` | Yes | A random scalar in the BN254 field (decimal string). Provides additional entropy to the commitment, ensuring that two commitments with the same secret, token, and amount produce different hashes. | | `amount` | `string` | Yes | The committed token amount in the smallest unit (e.g., aghost for GHOST, 6-decimal units for gUSDC). Decimal string representation of a uint256. | | `commitment` | `string` | Yes | The Poseidon7 hash of `(secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash)`. Stored on-chain in the Merkle tree. Decimal string representation of a BN254 field element. | | `leafIndex` | `number\|null` | Yes* | The index of the commitment in the on-chain Merkle tree (uint32). `null` before the commit transaction confirms; must be populated before reveal. Required for nullifier derivation and Merkle proof generation. | | `tokenIdHash` | `string` | Yes | Poseidon2 hash of `(tokenAddress, 0)`. Identifies the token inside the ZK circuit without revealing the raw address. Decimal string. | | `quantumSecret` | `string` | Yes | A 256-bit random hex string (with `0x` prefix). Reserved for future post-quantum key encapsulation. Currently included in key material but not used in the ZK circuit. | | `policyId` | `string` | Yes | The EVM address of the policy contract applied to this commitment. `"0x0000000000000000000000000000000000000000"` for no policy. | | `policyParamsHash` | `string` | Yes | The keccak256 hash of the ABI-encoded policy parameters, represented as a decimal string. `"0"` when no policy is applied. | | `policyParams` | `string\|null` | No | The raw ABI-encoded policy parameters (hex string). `null` when no policy is applied. Stored for convenience so the user does not need to reconstruct the encoding at reveal time. | ### Commitment Derivation ``` commitment = Poseidon7( secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, // cast to uint256 policyParamsHash // reduced to BN254 field ) ``` ### Nullifier Derivation ``` nullifier = Poseidon2(nullifierSecret, leafIndex) ``` --- ## open-ghost-persistent-v1 (Data Privacy Keys) The `open-ghost-persistent-v1` format encodes persistent keys used for the OpenGhost data privacy system. Unlike token keys which are one-time-use, persistent keys can be used repeatedly for encrypted data storage, access control, and revocable sharing. ### Schema ```json { "version": "open-ghost-persistent-v1", "persistent": true, "contentType": "text/plain", "encryptedSecret": "0xabcdef1234567890...", "encKeyPartA": "0x1234567890abcdef...", "keyVaultId": 7, "revokePolicy": "0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c", "secret": "18493027561038472950183746502918374650291837465029183746502918", "nullifierSecret": "7482910384756102938475610293847561029384756102938475610293847", "dataHash": "0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "blinding": "3948271506382940175603829401756038294017560382940175603829401", "commitment": "12849305718294057182940571829405718294057182940571829405718294", "leafIndex": 15, "createdAt": "2026-03-10T14:30:00Z" } ``` ### Field Descriptions | Field | Type | Required | Description | |--------------------|----------------|----------|-------------| | `version` | `string` | Yes | Must be `"open-ghost-persistent-v1"`. | | `persistent` | `boolean` | Yes | Always `true`. Distinguishes persistent keys from one-time token keys. | | `contentType` | `string` | Yes | MIME type of the encrypted content (e.g., `"text/plain"`, `"application/json"`, `"application/octet-stream"`). Used by the client to determine how to display or process the decrypted data. | | `encryptedSecret` | `string` | Yes | The encrypted payload (hex string with `0x` prefix). This is the actual encrypted data, encrypted with a key derived from `secret`. Only the key holder can decrypt it. | | `encKeyPartA` | `string` | Yes | The first part of the encryption key (hex string with `0x` prefix). Used in a split-key scheme where part A is stored in the key JSON and part B is derived from the on-chain commitment. Both parts are needed to reconstruct the decryption key. | | `keyVaultId` | `number` | Yes | The ID of the key entry in the on-chain `PersistentKeyVault` contract (`0x338B0e3c722702E705357C291E151D76B8Fd9F61`). Used to fetch on-chain metadata, check revocation status, and retrieve part B of the encryption key. | | `revokePolicy` | `string` | Yes | The EVM address of the policy contract that governs revocation of this persistent key. For example, the `TimelockExpiry` policy can auto-revoke access after a specified time. `"0x0000000000000000000000000000000000000000"` for no revocation policy. | | `secret` | `string` | Yes | A random scalar in the BN254 field (decimal string). Used as the primary input for deriving the encryption key and the commitment hash. | | `nullifierSecret` | `string` | Yes | A random scalar in the BN254 field (decimal string). Used to derive nullifiers for access proofs. Unlike token keys, persistent key nullifiers may be used multiple times (access is non-destructive). | | `dataHash` | `string` | Yes | The keccak256 hash of the unencrypted data (hex string with `0x` prefix). Allows the client to verify data integrity after decryption without re-encrypting. | | `blinding` | `string` | Yes | A random scalar in the BN254 field (decimal string). Additional entropy for the commitment hash. | | `commitment` | `string` | Yes | The Poseidon4 hash of `(secret, nullifierSecret, dataHash, blinding)`. Stored on-chain in the persistent key tree. Decimal string. | | `leafIndex` | `number\|null` | Yes* | The index of the commitment in the persistent key Merkle tree. `null` before the on-chain registration confirms. | | `createdAt` | `string` | Yes | ISO 8601 timestamp of key creation. Used for display and ordering in the client UI. | ### Commitment Derivation Persistent keys use Poseidon4 (T5) instead of Poseidon7: ``` commitment = Poseidon4( secret, nullifierSecret, dataHash, // reduced to BN254 field from keccak256 blinding ) ``` ### Access Proof To prove access to a persistent key without revealing which key, the holder generates a Groth16 proof demonstrating knowledge of the commitment's preimage and its presence in the persistent key Merkle tree. The proof is verified by the `AccessProofVerifier` contract (`0x508d326D68e5da728f8A74CB4ADB7552f7768B66`). --- ## Format Detection To determine which key format a JSON blob uses, check the `version` field: ```javascript function detectKeyFormat(json) { const key = typeof json === "string" ? JSON.parse(json) : json; switch (key.version) { case "ghostchain-v2": return "token-privacy"; case "open-ghost-persistent-v1": return "data-privacy"; default: throw new Error(`Unknown key format: ${key.version}`); } } ``` ## Security Notes - Both key formats contain **secret cryptographic material**. Treat them with the same care as private keys. - Keys should be stored encrypted (the webapp encrypts localStorage entries with a wallet-signed key). - Never transmit keys over unencrypted channels. - For `ghostchain-v2` keys, the `secret`, `nullifierSecret`, and `blinding` fields together are sufficient to steal committed tokens. - For `open-ghost-persistent-v1` keys, the `secret` and `encKeyPartA` fields together (combined with on-chain data) are sufficient to decrypt the stored data. ================================================================ SECTION: Reference SOURCE: https://docs.specterchain.com/reference/poseidon-reference ================================================================ # Poseidon Hash Reference Specter uses the Poseidon hash function throughout its cryptographic protocol. Poseidon is an algebraic hash function designed for efficiency inside arithmetic circuits (ZK-SNARKs), operating natively over prime fields without the bit-decomposition overhead of hash functions like SHA-256 or Keccak. ## BN254 Field All Poseidon operations in Specter use the scalar field of the **BN254** (alt-bn128) elliptic curve: ``` p = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` This is a 254-bit prime. All inputs and outputs of Poseidon are elements of this field (integers in the range `[0, p-1]`). ### Field Reduction Any value used as a Poseidon input must be reduced modulo `p`. This is critical for values that may exceed the field size: - **BN254 field elements** (from other Poseidon outputs): Already in the field, no reduction needed. - **uint256 values** (e.g., token amounts): Always less than `2^256`, but can exceed `p`. Must reduce. - **keccak256 outputs** (256-bit): These are uniformly distributed over `[0, 2^256)` and will exceed the ~254-bit field roughly 13% of the time. **Must reduce.** - **Ethereum addresses** (160-bit): Always less than `p`. No reduction needed. - **Leaf indices** (uint32): Always less than `p`. No reduction needed. ```javascript const BN254_FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; function toField(value) { const v = BigInt(value); return ((v % BN254_FIELD_PRIME) + BN254_FIELD_PRIME) % BN254_FIELD_PRIME; } ``` The double-modulo pattern `((v % p) + p) % p` handles negative values correctly, though in practice Specter inputs are always non-negative. ## Poseidon Variants Specter uses three Poseidon configurations, distinguished by the number of inputs. The "T" number refers to the internal state width (number of inputs + 1). ### Poseidon2 (T3) — 2 Inputs | Property | Value | |----------------|-------| | **Inputs** | 2 | | **State width**| T = 3 | | **On-chain** | Yes — `PoseidonT3` at `0xacaef99b13d5846e3309017586de9f777da41548` | | **Gas cost** | ~30,000 gas per hash | **Use cases:** - **Merkle tree internal nodes**: `hash(left, right)` for each level of the CommitmentTree. - **Nullifier derivation**: `nullifier = Poseidon2(nullifierSecret, leafIndex)`. - **Access tags**: `accessTag = Poseidon2(secret, tokenIdHash)`. - **Token ID hashing**: `tokenIdHash = Poseidon2(tokenAddress, 0)`. **On-chain interface:** ```solidity // PoseidonT3 contract function poseidon(uint256[2] memory inputs) public pure returns (uint256); ``` **JavaScript usage:** ```javascript const poseidon = await buildPoseidon(); // Merkle node: hash two children const node = poseidon.F.toObject(poseidon([leftChild, rightChild])); // Nullifier const nullifier = poseidon.F.toObject(poseidon([nullifierSecret, BigInt(leafIndex)])); // Token ID hash const tokenIdHash = poseidon.F.toObject(poseidon([BigInt(tokenAddress), 0n])); ``` ### Poseidon4 (T5) — 4 Inputs | Property | Value | |----------------|-------| | **Inputs** | 4 | | **State width**| T = 5 | | **On-chain** | No — off-chain only | | **Gas cost** | ~80,000 gas (estimated, not deployed) | **Use cases:** - **OpenGhost persistent key commitments**: `commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding)`. This variant is used exclusively in the data privacy (OpenGhost) subsystem. It is not deployed on-chain because persistent key commitments are computed client-side and only the resulting hash is submitted. **JavaScript usage:** ```javascript const poseidon = await buildPoseidon(); // OpenGhost commitment (4 inputs) const commitment = poseidon.F.toObject( poseidon([ secret, nullifierSecret, toField(dataHash), // keccak256 output, must reduce to field blinding, ]) ); ``` ### Poseidon7 (T8) — 7 Inputs | Property | Value | |----------------|-------| | **Inputs** | 7 | | **State width**| T = 8 | | **On-chain** | No — off-chain only | | **Gas cost** | ~179,000+ gas (prohibitively expensive for on-chain use) | **Use cases:** - **CommitRevealVault commitments**: `commitment = Poseidon7(secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash)`. This is the primary commitment hash for the token privacy system. It takes 7 inputs to bind the commitment to a specific token, amount, and optional policy. The hash is computed client-side and the result is submitted in the `commit()` transaction. **Why not on-chain?** At ~179,000+ gas per invocation, an on-chain PoseidonT8 would make commit transactions prohibitively expensive. Instead, the commitment is computed off-chain, and the ZK circuit proves correct computation during the reveal. **JavaScript usage:** ```javascript const poseidon = await buildPoseidon(); const commitment = poseidon.F.toObject( poseidon([ secret, nullifierSecret, blinding, tokenIdHash, toField(amount), toField(policyId), // address cast to uint256 toField(policyParamsHash), // keccak256 output, reduce to field ]) ); ``` ## Comparison Table | Variant | Inputs | State (T) | On-chain | Gas | Primary Use | |------------|--------|-----------|----------|-----------|-------------| | Poseidon2 | 2 | T3 | Yes | ~30,000 | Merkle nodes, nullifiers, token IDs, access tags | | Poseidon4 | 4 | T5 | No | ~80,000* | OpenGhost persistent key commitments | | Poseidon7 | 7 | T8 | No | ~179,000+*| CommitRevealVault commitments | *Estimated gas if deployed on-chain; these variants are off-chain only. ## circomlibjs Implementation Notes The `circomlibjs` library provides a single `poseidon` function that automatically selects the correct internal configuration based on the number of inputs: ```javascript const poseidon = await buildPoseidon(); // 2 inputs → uses T3 configuration internally poseidon([a, b]); // 4 inputs → uses T5 configuration internally poseidon([a, b, c, d]); // 7 inputs → uses T8 configuration internally poseidon([a, b, c, d, e, f, g]); ``` ### Output Conversion The `poseidon()` function returns an internal field representation. Use `poseidon.F.toObject()` to convert to a JavaScript `BigInt`: ```javascript const rawResult = poseidon([a, b]); const bigintResult = poseidon.F.toObject(rawResult); // bigintResult is a BigInt in [0, p-1] ``` ### Input Types The `poseidon()` function accepts `BigInt`, `number`, or `string` inputs. For consistency and to avoid precision issues, always use `BigInt`: ```javascript // Preferred poseidon([123n, 456n]); // Also works but less explicit poseidon([123, 456]); poseidon(["123", "456"]); ``` ## On-Chain Verification The on-chain `PoseidonT3` contract at `0xacaef99b13d5846e3309017586de9f777da41548` is used by the `CommitmentTree` to compute Merkle tree internal nodes. You can call it directly for verification: ```javascript const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com"); const poseidonT3 = new ethers.Contract( "0xacaef99b13d5846e3309017586de9f777da41548", ["function poseidon(uint256[2] memory) pure returns (uint256)"], provider ); const result = await poseidonT3.poseidon([leftChild, rightChild]); console.log("On-chain Poseidon2:", result.toString()); ``` The on-chain result must match the `circomlibjs` off-chain result for the same inputs. If they do not match, check that inputs are properly reduced to the BN254 field. ================================================================ SECTION: Reference SOURCE: https://docs.specterchain.com/reference/troubleshooting ================================================================ # Troubleshooting This page documents common issues encountered when building on or operating Specter, along with their causes and solutions. ## Chain ID 47474 Instead of 5446 **Symptoms:** - Wallet balance shows `0` despite receiving tokens from the faucet or transfers. - Transactions fail or are rejected by the RPC. - MetaMask shows the wrong network name. **Cause:** An earlier Avalanche L1 deployment of Specter used Chain ID `47474`. That chain is deprecated. The current Specter Testnet uses Chain ID **5446** (`0x1546`). **Solution:** 1. Open MetaMask and remove the old network entry with Chain ID `47474`. 2. Add a new network with the correct parameters: - **RPC URL**: `https://testnet.specterchain.com` - **Chain ID**: `5446` - **Symbol**: `GHOST` - **Decimals**: `18` 3. If building a dApp, update your chain configuration (hardcoded chain IDs, wagmi config, etc.) to use `5446`. --- ## Using testnet-rpc.umbraline.com **Symptoms:** - RPC requests intermittently fail or time out. - Block numbers are behind the current chain head. - Transaction submissions succeed but never confirm. **Cause:** The `testnet-rpc.umbraline.com` endpoint may point to a stale or unmaintained node that is no longer syncing with the current chain. **Solution:** - Switch to the primary RPC endpoint: `https://testnet.specterchain.com`. - The `https://testnet.umbraline.com` endpoint (without the `-rpc` prefix) is also valid and maintained. - Update all configuration files, scripts, and environment variables to use the correct endpoint. --- ## Go 1.24.0 Build Error (Sonic / bytedance linkname) **Symptoms:** When building the Specter node binary with Go 1.24.0, the build fails with errors related to `go:linkname` directives in the `bytedance/sonic` package: ``` github.com/bytedance/sonic/internal/rt: //go:linkname must refer to declared function or variable ``` **Cause:** Go 1.24.0 introduced stricter enforcement of the `//go:linkname` compiler directive. The `bytedance/sonic` JSON library (a transitive dependency via the Cosmos SDK dependency tree) uses `//go:linkname` to access internal Go runtime symbols, which the stricter compiler rejects. **Solution:** - Upgrade to **Go 1.24.1 or later**, which relaxes the `linkname` restrictions for existing code. - Alternatively, you can set `GOFLAGS=-ldflags=-checklinkname=0` as a temporary workaround, but upgrading Go is the recommended fix. ```bash # Check your Go version go version # If go1.24.0, upgrade # macOS with Homebrew: brew upgrade go # Or download directly: # https://go.dev/dl/ # Verify go version # go version go1.24.1 darwin/arm64 # Clean and rebuild cd specter-chain go clean -cache make build ``` --- ## eth_getLogs Returns Empty Results **Symptoms:** - Calls to `eth_getLogs` return an empty array even though events were emitted. - Event-based indexing (e.g., watching for `CommitmentInserted` events) misses events. - The webapp's Revels screen shows no history. **Cause:** This is a known limitation of the Cosmos EVM implementation. The EVM module's log indexing is not fully compatible with Ethereum's `eth_getLogs` behavior, particularly for historical queries and large block ranges. **Solution:** - Use the **Specter relayer indexer** instead of `eth_getLogs` for reliable event queries: ```bash curl "https://relayer.specterchain.com/api/indexer?event=CommitmentInserted&fromBlock=0&toBlock=latest" ``` - For real-time event monitoring, subscribe via WebSocket if supported, or poll the indexer API at regular intervals. - If you must use `eth_getLogs`, limit the block range to small windows (100-500 blocks) and retry on empty results. --- ## eth_getTransactionReceipt Parse Errors **Symptoms:** - Calls to `eth_getTransactionReceipt` return malformed or unparseable responses for certain transactions. - `ethers.js` or `viem` throw errors when processing receipts. - Contract deployment receipts fail to parse the `contractAddress` field. **Cause:** The Cosmos EVM module's transaction receipt encoding can differ from standard Ethereum in edge cases, particularly for contract creation transactions and transactions involving precompile calls. **Solution:** - For contract deployments, **use computed addresses** instead of relying on the receipt's `contractAddress` field: ```javascript // For CREATE deployments: compute from sender + nonce const deployerAddress = "0xYourDeployerAddress"; const nonce = await provider.getTransactionCount(deployerAddress); const computedAddress = ethers.getCreateAddress({ from: deployerAddress, nonce: nonce, }); // For CREATE2 deployments: compute from factory + salt + initCodeHash const computedAddress = ethers.getCreate2Address( factoryAddress, salt, initCodeHash ); ``` - For general receipt parsing, wrap receipt fetching in a try-catch and fall back to the RPC's raw response if the library parser fails: ```javascript try { const receipt = await provider.getTransactionReceipt(txHash); } catch (e) { // Fall back to raw RPC call const rawReceipt = await provider.send("eth_getTransactionReceipt", [txHash]); // Parse manually } ``` --- ## Balance Shows 0 in the Webapp **Symptoms:** - The webapp shows a balance of `0` for all tokens. - The faucet confirms a successful drip, but the balance does not update. - MetaMask shows the correct balance, but the webapp does not. **Cause:** The webapp's `config.js` has the wrong Chain ID configured. If `chainConfig.chainId` is set to the deprecated `47474` instead of `5446`, the webapp connects to the wrong chain or fails to match the wallet's chain, resulting in zero balances. **Solution:** 1. Open `config.js` in the webapp source. 2. Verify that `chainConfig.chainId` is `5446`: ```javascript export const chainConfig = { chainId: 5446, // NOT 47474 // ... }; ``` 3. Also verify `chainIdHex` is `"0x1546"`. 4. Rebuild and redeploy the webapp. 5. If using a custom deployment, check that the RPC URL in `config.js` points to `testnet.specterchain.com`. --- ## AutoCliOpts Panic in app.go **Symptoms:** When starting the Specter node, it panics during initialization with an error referencing `AutoCliOpts` and address codec: ``` panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation] goroutine 1 [running]: github.com/cosmos/cosmos-sdk/server.AddCommands (...) autocli.go:XX ``` **Cause:** The `AutoCliOpts` function in `app.go` is missing the address codec configuration. The Cosmos SDK's `autocli` module requires a properly initialized `AddressCodec` to encode and decode Bech32 addresses. If the codec is nil, it causes a nil pointer dereference at startup. **Solution:** Ensure that the `AutoCliOpts()` method on the app struct returns a properly configured `AddressCodec`: ```go func (app *SpecterApp) AutoCliOpts() autocli.AppOptions { modules := make(map[string]appmodule.AppModule, 0) for _, m := range app.ModuleManager.Modules { if moduleWithName, ok := m.(module.HasName); ok { moduleName := moduleWithName.Name() if appModule, ok := m.(appmodule.AppModule); ok { modules[moduleName] = appModule } } } return autocli.AppOptions{ Modules: modules, AddressCodec: authcodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()), ValidatorAddressCodec: authcodec.NewBech32Codec(sdk.GetConfig().GetBech32ValidatorAddrPrefix()), ConsensusAddressCodec: authcodec.NewBech32Codec(sdk.GetConfig().GetBech32ConsensusAddrPrefix()), } } ``` The key fix is including `AddressCodec`, `ValidatorAddressCodec`, and `ConsensusAddressCodec` in the returned `AppOptions`. Without these, any CLI command that formats addresses will panic. --- ## General Debugging Tips ### Verify Chain Connectivity ```bash # Check if the RPC is responding curl -s -X POST https://testnet.specterchain.com \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' # Expected: {"jsonrpc":"2.0","id":1,"result":"0x1546"} ``` ### Check Contract Deployment ```bash # Verify a contract exists at an address curl -s -X POST https://testnet.specterchain.com \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_getCode","params":["0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a","latest"],"id":1}' \ | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print('Deployed' if len(r) > 2 else 'No code')" ``` ### Inspect a Transaction ```bash # Get transaction details curl -s -X POST https://testnet.specterchain.com \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_getTransactionByHash","params":["0xYourTxHash"],"id":1}' ``` ### Check Nullifier Status ```javascript const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com"); const nullifierRegistry = new ethers.Contract( "0xaadb9c3394835B450023daA91Ad5a46beA6e43a1", ["function isSpent(bytes32 nullifier) view returns (bool)"], provider ); const spent = await nullifierRegistry.isSpent(nullifierHash); console.log("Nullifier spent:", spent); ``` ### Verify Merkle Root ```javascript const commitmentTree = new ethers.Contract( "0xE29DD14998f6FE8e7862571c883090d14FE29475", ["function getRoot() view returns (bytes32)"], provider ); const root = await commitmentTree.getRoot(); console.log("Current Merkle root:", root); ```