Developer SDK — @ghost/sdk
TypeScript, ESM, isomorphic (Node ≥ 20 and modern browsers). Lives in packages/sdk.
| Entry point | Use in | Purpose |
|---|---|---|
@ghost/sdk | anywhere | Types, encoding, Poseidon, commitments, Merkle tree, request building, attestation checks |
@ghost/sdk/verifier | your backend | GhostVerifier (stateful, replay-protected) and verifyGhostProof (stateless) |
@ghost/sdk/client | your frontend | requestGhostProof — opens GHOST, returns the bundle |
@ghost/sdk/prover | GHOST client / power users | generateGhostProof |
@ghost/sdk/operator | GHOST operators | Snapshot building from Solana RPC |
@ghost/sdk/artifacts/* | — | verification_key.json, ghost.wasm, ghost_final.zkey, manifest.json |
Integrating verification (relying party)
You need three things from the GHOST deployment you trust:
- its verification key (
artifacts/verification_key.json, check the sha256 inmanifest.json), - its attester public key(s) (
GET /api/config→trustedAttesters, or published out-of-band — pin them, don't fetch them at runtime from the same server you're verifying against), - its origin (for the popup).
1. Backend: create a verifier
import { GhostVerifier, type ChallengeStore, type NullifierStore } from "@ghost/sdk/verifier";
import vkey from "@ghost/sdk/artifacts/verification_key.json" with { type: "json" };
export const verifier = new GhostVerifier({
appId: "https://dao.example/vote-7", // origin + optional path; scopes nullifiers
verificationKey: vkey,
trustedAttesters: ["9keoPHR6qMFqZYJMAWEj8DMgmRXrZmKnec2Gq5dx7m1Y"],
challengeStore: myRedisChallengeStore, // default: in-memory (single process only!)
nullifierStore: myPostgresNullifierStore, // default: in-memory
enforceUniqueNullifier: true, // one proof per wallet/identity per appId
});
ChallengeStore.consume and NullifierStore.add must be atomic (e.g. SET NX in Redis, a unique index in Postgres). The in-memory defaults are for tests and single-process demos.
2. Backend: issue a request
app.post("/ghost/request", async (req, res) => {
const request = await verifier.createRequest({
mint: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
minBalance: 1_000_000_000n, // raw base units (here: 1,000 JUP at 6 decimals)
nullifierMode: "wallet", // or "identity"
ttlSeconds: 600, // ≤ 3600
maxSnapshotAgeSeconds: 86_400,
callbackOrigin: "https://dao.example", // must equal the origin of appId
label: "Vote in proposal 7",
});
res.json(request);
});
3. Frontend: ask the user for a proof
import { requestGhostProof } from "@ghost/sdk/client";
const request = await fetch("/ghost/request", { method: "POST" }).then((r) => r.json());
const bundle = await requestGhostProof(request, { ghostUrl: "https://ghost.example" });
await fetch("/ghost/verify", { method: "POST", body: JSON.stringify(bundle) });
requestGhostProof opens a popup, accepts messages only from the GHOST origin and only for the matching challenge. Never trust a client-side result — always verify on your backend.
Alternative without popups: buildProveUrl(ghostUrl, request) gives a link; the user copies the resulting bundle back to you.
4. Backend: verify
app.post("/ghost/verify", async (req, res) => {
const result = await verifier.verify(req.body);
if (!result.valid) return res.status(403).json(result.checks.filter((c) => !c.passed));
// result.statement: { appId, mint, minBalance, nullifierMode, nullifier,
// snapshotId, snapshotCreatedAt, snapshotSlotMax, cluster,
// anonymitySetCeiling }
grantAccess(result.statement.nullifier);
res.json({ ok: true });
});
result.checks lists every check with id, label, passed, detail:
request-issued, structure, request-match, not-expired, statement, attestation, snapshot-fresh, mint-covered, groth16, challenge-unused, nullifier-unique.
Stateless verification
import { verifyGhostProof } from "@ghost/sdk/verifier";
const r = await verifyGhostProof(bundle, { verificationKey, trustedAttesters, expectedRequest });
Without a challenge store this cannot prevent replay. Use it for audits, explorers and debugging.
Integration checklist
- Pin the verification key hash and attester keys in config.
- Use atomic, persistent challenge and nullifier stores.
- Use fixed, coarse thresholds (avoid letting thresholds become a balance oracle — see threat model).
- Choose
nullifierModedeliberately:walletfor one-per-wallet,identityfor personas. - Put an epoch in
appIdif you don't want to recognise returning users. - Display
snapshotCreatedAtto your users/admins; proofs are historical. - Rate-limit
/ghost/request— each call stores a challenge.
Prover API (for completeness)
import { generateGhostProof, ProverError } from "@ghost/sdk/prover";
const bundle = await generateGhostProof({
request, // ProofRequest
identitySecret, // bigint
walletKey, // bigint (from deriveWalletKeys)
snapshot, // PublishedSnapshot (full, from GET /api/snapshots/latest)
artifacts: { wasm: "/circuits/ghost.wasm", zkey: "/circuits/ghost_final.zkey" },
trustedAttesters,
});
Throws ProverError with code in INVALID_REQUEST | REQUEST_EXPIRED | UNTRUSTED_SNAPSHOT | SNAPSHOT_TOO_OLD | MINT_NOT_IN_SNAPSHOT | NOT_IN_SNAPSHOT | INSUFFICIENT_BALANCE.
Operator API
import { buildSnapshot, createRpcBalanceSource } from "@ghost/sdk/operator";
const { snapshot } = await buildSnapshot({
records, // [{ wallet, walletKeyCommitment, identityCommitments }]
mints: ["…"],
source: createRpcBalanceSource(RPC_URL), // finalized commitment, minContextSlot pinned
cluster: "mainnet-beta",
attesterSecretKey, // 64-byte Ed25519 secret key
});
Types
See packages/sdk/src/core/types.ts: ProofRequest, ProofBundle, SnapshotAttestation, PublishedSnapshot, SnapshotEntry, Groth16Proof.
Building and testing
npm run build -w @ghost/sdk
npm run test -w @ghost/sdk # 41 tests, real proofs
GHOST_RPC_TESTS=1 GHOST_RPC_TEST_OWNER=<devnet holder> npx vitest run test/rpc.integration.test.ts