CA
GHOST

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 │
             └─────────────────────────────────┘
ComponentCodeKnows
Browser clientapp/src/components/ghost-session.tsx, app/src/app/proveEverything about its own user, nothing about others
Registryapp/src/lib/server/registry.tswallet ↔ commitments (encrypted file). Cannot compute nullifiers or link proofs
Snapshot attesterpackages/sdk/src/operator.ts, app/src/lib/server/services.tsRegistered wallets' balances at snapshot time (discarded after signing)
Relying partypackages/sdk/src/verifier.tsThe statement, the nullifier, the snapshot used
Circuitcircuits/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.

NameDefinitionWhere it lives
identitySecret248 random bits from crypto.getRandomValuesBrowser vault only
walletKeyHKDF-SHA256(sig, "ghost/wallet-key") (64 bytes) mod p, where sig = wallet signature over the fixed key-derivation messageMemory only; re-derived on unlock
storageKeyHKDF-SHA256(sig, "ghost/storage-key"), 32 bytesMemory only; AES-256-GCM key for the local vault
identity commitmentH(1, identitySecret)Registry
wallet-key commitmentH(2, walletKey)Registry (one per wallet)
leafH(3, idCommit, wkCommit, mintHi, mintLo, balance)Public snapshot
nullifierH(4, mode ? walletKey : identitySecret, appScope, mode)Public output of each proof
locatorH(5, idCommit, mintHi, mintLo, salt)Public snapshot (lets the owner find their leaf)
balance hintbalance XOR (H(6, idCommit, wkCommit, mintHi, mintLo, salt) mod 2^64)Public snapshot
appScopeSHA-256("ghost/app-scope/v1:" ‖ appId) mod pRequest / public input
mint limbs32-byte mint split into two 128-bit big-endian integersPublic 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

  1. Client builds keyDerivationMessage({domain: location.host, uri: origin, address, chainId: cluster}).
  2. Wallet signs it. Client verifies the signature against the address before using it.
  3. Client derives walletKey, storageKey; decrypts the local vault (slot name is derived from storageKey, so localStorage does not reveal which wallet is used).

2. Create identity

  1. Client samples identitySecret, computes idCommit, wkCommit.
  2. Wallet signs a plain-text registration message containing registry host, wallet, both commitments, ISO timestamp and a random nonce.
  3. Registry rebuilds the message server-side, verifies Ed25519, checks: host matches, timestamp within 10 min, nonce unused, wkCommit equals the wallet's existing one (if any), commitment not already registered, per-wallet limit.
  4. Mapping is stored in an AES-256-GCM-encrypted file. Nothing about it is ever served by any endpoint.

3. Snapshot

  1. Read baseSlot = getSlot(finalized).
  2. 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.
  3. For every active identity of a wallet with non-zero balance: emit {leaf, locator, hint}.
  4. Shuffle entries (unbiased Fisher–Yates with CSPRNG) so tree position carries no registration-order information.
  5. 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}.
  6. Publish attestation + entries. Per-wallet readings are discarded.

4. Prove

  1. Relying party's backend creates a request (GhostVerifier.createRequest) with a fresh 248-bit challenge, expiry and callbackOrigin.
  2. User opens GHOST (/prove?request=…, typically a popup via requestGhostProof).
  3. 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.
  4. If callbackOrigin does not match the origin of appId, GHOST refuses to deliver (relay protection).
  5. snarkjs generates the Groth16 proof in the browser (~1–3 s). The bundle is {request, proof, publicSignals, snapshot attestation}.
  6. The bundle is posted to window.opener with targetOrigin = 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:

  1. Bundle structure; every public signal is a canonical decimal < p (prevents input aliasing).
  2. Challenge was issued by this verifier; bundle request equals the stored request.
  3. now ≤ expiresAt (verifier clock, no skew allowance).
  4. Every public signal equals the value derived from the stored request (root from attestation, mint limbs, threshold, scope, mode, challenge, expiry).
  5. Attestation signature valid and attester in trustedAttesters.
  6. Snapshot age ≤ maxSnapshotAgeSeconds; snapshot covers the mint.
  7. Groth16 pairing check.
  8. Atomically consume the challenge (replay protection).
  9. Atomically insert (appScope, nullifier) (double-claim / sybil protection), unless the app opted out.

Nullifier modes

ModeNullifier keySame wallet, two ghosts, same appUse for
wallet (default)walletKeySame nullifier → second is rejectedAirdrops, votes, one-per-wallet claims
identityidentitySecretDifferent nullifiersPseudonymous 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

PartyTrusted forIf malicious / compromised
Snapshot attesterCorrect balances in leavesCan 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 operatorLiveness; not publishing the mappingLearns 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 attesterReturning correct finalized stateCould feed false balances (same effect as malicious attester). Use a trusted / self-hosted RPC. Learns the list of registered wallets.
Groth16 phase-2 participantsAt least one honest contributorWith the toxic waste, anyone can forge proofs. The shipped zkey has a single local contribution.
Relying partyIts own decisionsLearns 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

DataLocationProtection
Identity secrets, labels, proof historyBrowser localStorageAES-256-GCM, key derived from wallet signature, slot name unlinkable to address
RegistryGHOST_DATA_DIR/registry.enc.jsonAES-256-GCM (GHOST_REGISTRY_ENCRYPTION_KEY), file mode 0600
SnapshotsGHOST_DATA_DIR/snapshots/*.jsonPublic by design
Demo verifier stateGHOST_DATA_DIR/demo-verifier.jsonChallenges & nullifiers only
Attester keyEnvironment variableUse a secret manager / HSM in production

Roadmap

  1. MPC phase-2 ceremony; publish transcript.
  2. Multiple independent attesters with k-of-n acceptance in GhostVerifier.
  3. Root anchoring: post each attested root to Solana (memo or small program) for public timestamping.
  4. Single-slot reads: batch getMultipleAccounts per slot to eliminate the slot-range window.
  5. Light Protocol compressed-token support for attester-free proofs.
  6. NFT collection membership proofs.
  7. Web-Worker proving and a WASM-free verifier for edge runtimes.