Architecture
Components
┌──────────────── user's browser ────────────────┐ ┌──────── GHOST server ─────────┐
│ Wallet (Phantom / Solflare) │ │ Registry (encrypted at rest) │
│ │ signMessage only — never private keys │ sig │ wallet → {walletKeyCommit, │
│ ▼ │ ─────▶ │ identityCommits} │
│ GHOST client │ │ │
│ • derives walletKey + vault key (HKDF) │ │ Snapshot attester │
│ • identity secrets in AES-GCM vault │ ◀───── │ reads finalized SPL │
│ • downloads FULL public snapshot │ public │ balances via RPC, builds │
│ • generates Groth16 proof locally │ data │ Poseidon tree, signs root │
└──────────────────────┬─────────────────────────┘ └──────────────┬────────────────┘
│ proof bundle (no address, no balance) │ RPC reads
▼ ▼
┌──── relying party (any app) ────┐ Solana RPC (finalized)
│ GhostVerifier (SDK) │
│ • issues challenge │
│ • Groth16 verify │
│ • attester signature + age │
│ • consume challenge, nullifier │
└─────────────────────────────────┘
| Component | Code | Knows |
|---|---|---|
| Browser client | app/src/components/ghost-session.tsx, app/src/app/prove | Everything about its own user, nothing about others |
| Registry | app/src/lib/server/registry.ts | wallet ↔ commitments (encrypted file). Cannot compute nullifiers or link proofs |
| Snapshot attester | packages/sdk/src/operator.ts, app/src/lib/server/services.ts | Registered wallets' balances at snapshot time (discarded after signing) |
| Relying party | packages/sdk/src/verifier.ts | The statement, the nullifier, the snapshot used |
| Circuit | circuits/ghost.circom | — |
Cryptographic objects
All values are BN254 scalar-field elements unless stated otherwise. H is Poseidon with circomlib parameters. Domain tags are small integers prepended as the first input.
| Name | Definition | Where it lives |
|---|---|---|
identitySecret | 248 random bits from crypto.getRandomValues | Browser vault only |
walletKey | HKDF-SHA256(sig, "ghost/wallet-key") (64 bytes) mod p, where sig = wallet signature over the fixed key-derivation message | Memory only; re-derived on unlock |
storageKey | HKDF-SHA256(sig, "ghost/storage-key"), 32 bytes | Memory only; AES-256-GCM key for the local vault |
| identity commitment | H(1, identitySecret) | Registry |
| wallet-key commitment | H(2, walletKey) | Registry (one per wallet) |
| leaf | H(3, idCommit, wkCommit, mintHi, mintLo, balance) | Public snapshot |
| nullifier | H(4, mode ? walletKey : identitySecret, appScope, mode) | Public output of each proof |
| locator | H(5, idCommit, mintHi, mintLo, salt) | Public snapshot (lets the owner find their leaf) |
| balance hint | balance XOR (H(6, idCommit, wkCommit, mintHi, mintLo, salt) mod 2^64) | Public snapshot |
appScope | SHA-256("ghost/app-scope/v1:" ‖ appId) mod p | Request / public input |
| mint limbs | 32-byte mint split into two 128-bit big-endian integers | Public inputs |
Why the key-derivation message is SIWS-formatted
The wallet key must be deterministic (so a user can recover it on a new device and so the registry can enforce one wallet key per wallet). Ed25519 signatures are deterministic (RFC 8032), so signing a fixed message yields a stable secret. The message uses the Sign In With Solana format so wallets that implement SIWS domain binding (e.g. Phantom) warn when a different origin asks for it. A site that obtains this signature can use the user's GHOST identities — this is the main phishing risk and is documented in the threat model.
Protocol flows
1. Unlock
- Client builds
keyDerivationMessage({domain: location.host, uri: origin, address, chainId: cluster}). - Wallet signs it. Client verifies the signature against the address before using it.
- Client derives
walletKey,storageKey; decrypts the local vault (slot name is derived fromstorageKey, so localStorage does not reveal which wallet is used).
2. Create identity
- Client samples
identitySecret, computesidCommit,wkCommit. - Wallet signs a plain-text registration message containing registry host, wallet, both commitments, ISO timestamp and a random nonce.
- Registry rebuilds the message server-side, verifies Ed25519, checks: host matches, timestamp within 10 min, nonce unused,
wkCommitequals the wallet's existing one (if any), commitment not already registered, per-wallet limit. - Mapping is stored in an AES-256-GCM-encrypted file. Nothing about it is ever served by any endpoint.
3. Snapshot
- Read
baseSlot = getSlot(finalized). - For every (registered wallet, configured mint):
getTokenAccountsByOwner(owner, {mint}, {commitment: finalized, minContextSlot: baseSlot}). Sum all initialized token accounts (Token and Token-2022 base layout; frozen balances count). Record each response's context slot. - For every active identity of a wallet with non-zero balance: emit
{leaf, locator, hint}. - Shuffle entries (unbiased Fisher–Yates with CSPRNG) so tree position carries no registration-order information.
- Build the depth-20 Poseidon tree; sign the canonical attestation:
{protocol, snapshotId, cluster, root, depth, leafCount, salt, baseSlot, slotMin, slotMax, createdAt, mints[{mint, decimals, leafCount}], attester}. - Publish attestation + entries. Per-wallet readings are discarded.
4. Prove
- Relying party's backend creates a request (
GhostVerifier.createRequest) with a fresh 248-bit challenge, expiry andcallbackOrigin. - User opens GHOST (
/prove?request=…, typically a popup viarequestGhostProof). - Client downloads the entire snapshot (so the server cannot tell who is about to prove), verifies the attestation signature against trusted attesters, rebuilds the tree and checks the root, finds its entry via the locator, decrypts the balance hint and checks the recomputed leaf.
- If
callbackOrigindoes not match the origin ofappId, GHOST refuses to deliver (relay protection). - snarkjs generates the Groth16 proof in the browser (~1–3 s). The bundle is
{request, proof, publicSignals, snapshot attestation}. - The bundle is posted to
window.openerwithtargetOrigin = callbackOrigin(never*).
5. Verify (GhostVerifier.verify)
In this order — all cryptographic checks happen before any state changes, so an invalid proof cannot burn a challenge:
- Bundle structure; every public signal is a canonical decimal < p (prevents input aliasing).
- Challenge was issued by this verifier; bundle request equals the stored request.
now ≤ expiresAt(verifier clock, no skew allowance).- Every public signal equals the value derived from the stored request (root from attestation, mint limbs, threshold, scope, mode, challenge, expiry).
- Attestation signature valid and attester in
trustedAttesters. - Snapshot age ≤
maxSnapshotAgeSeconds; snapshot covers the mint. - Groth16 pairing check.
- Atomically consume the challenge (replay protection).
- Atomically insert
(appScope, nullifier)(double-claim / sybil protection), unless the app opted out.
Nullifier modes
| Mode | Nullifier key | Same wallet, two ghosts, same app | Use for |
|---|---|---|---|
wallet (default) | walletKey | Same nullifier → second is rejected | Airdrops, votes, one-per-wallet claims |
identity | identitySecret | Different nullifiers | Pseudonymous access where each ghost is its own persona |
In both modes, nullifiers for different appIds are unrelated. A nullifier is stable for a given (key, appId): an app sees the same value each time the same wallet (or ghost) proves to it. Apps wanting unlinkable sessions should include an epoch in the appId (e.g. https://app.xyz/2026-10).
Sybil resistance is per wallet, not per person: someone with ten funded wallets passes ten times.
Trust model
| Party | Trusted for | If malicious / compromised |
|---|---|---|
| Snapshot attester | Correct balances in leaves | Can include false balances (e.g. let an empty wallet pass). Cannot forge proofs for leaves it didn't create, learn identity secrets, or link nullifiers to wallets. All snapshots it signs are retained and auditable by anyone holding the registry. |
| Registry operator | Liveness; not publishing the mapping | Learns which wallets use GHOST and their commitments. Cannot compute nullifiers. A leak of the registry reveals who uses GHOST and, with snapshots, which leaves belong to which wallet — but still not which proofs they made. |
| Solana RPC used by attester | Returning correct finalized state | Could feed false balances (same effect as malicious attester). Use a trusted / self-hosted RPC. Learns the list of registered wallets. |
| Groth16 phase-2 participants | At least one honest contributor | With the toxic waste, anyone can forge proofs. The shipped zkey has a single local contribution. |
| Relying party | Its own decisions | Learns statement + nullifier. Can't learn wallet. |
Why not something trustless?
- Verify Ed25519 + account data in-circuit against a Solana state root. Solana does not expose a per-block account-state commitment that light clients can check (the accounts hash is not part of a consensus-verifiable header in a form dApps can use), and Ed25519 + SHA-256 in Groth16 costs millions of constraints — not viable for browser proving.
- Compressed-account state trees (Light Protocol). These do have on-chain Poseidon roots and would remove the attester for compressed tokens. Ordinary SPL token accounts are not in such trees. This is the most promising path to a trust-minimised v2 (see roadmap).
- On-chain registry. Would make the wallet→commitment mapping public. Rejected per requirement “do not publicly publish sensitive identity mappings”.
What GHOST does instead: it makes the trust explicit, narrow and auditable — one Ed25519 key, signed over a canonical message, with slots and time embedded, verifiable by every verifier, and replaceable by a set of attesters (trustedAttesters accepts several keys).
Data at rest
| Data | Location | Protection |
|---|---|---|
| Identity secrets, labels, proof history | Browser localStorage | AES-256-GCM, key derived from wallet signature, slot name unlinkable to address |
| Registry | GHOST_DATA_DIR/registry.enc.json | AES-256-GCM (GHOST_REGISTRY_ENCRYPTION_KEY), file mode 0600 |
| Snapshots | GHOST_DATA_DIR/snapshots/*.json | Public by design |
| Demo verifier state | GHOST_DATA_DIR/demo-verifier.json | Challenges & nullifiers only |
| Attester key | Environment variable | Use a secret manager / HSM in production |
Roadmap
- MPC phase-2 ceremony; publish transcript.
- Multiple independent attesters with k-of-n acceptance in
GhostVerifier. - Root anchoring: post each attested root to Solana (memo or small program) for public timestamping.
- Single-slot reads: batch
getMultipleAccountsper slot to eliminate the slot-range window. - Light Protocol compressed-token support for attester-free proofs.
- NFT collection membership proofs.
- Web-Worker proving and a WASM-free verifier for edge runtimes.