Quickstart

Build a proof, verify it against trusted roots, and authenticate the proof-bound session with a single-use server challenge. The example below uses a software Ed25519 session.

1 · Install

# From the repository root: use the local workspace packages.
pnpm install --frozen-lockfile
pnpm build

Declare the workspace packages your application imports. The backend example also uses the repository's @noble/curves Ed25519 API. Public npm availability depends on the release; these commands do not assume packages have already been published.

2 · Prove on the frontend

import { GhostLink, softwareSession, loginChallengeDigest } from "@ghostlink/sdk";
import { bytesToHex, hexToBytes } from "@ghostlink/common";
import { createProver } from "@ghostlink/prover-wasm";

const gl = await GhostLink.init({
  cluster: "devnet",
  indexer: "https://your-indexer.example",   // or self-host: services/indexer
  relayer: "https://your-relayer.example",
  prover: createProver({ login: LOGIN_ARTIFACTS, owner: OWNER_ARTIFACTS }),
});

// This low-level example trusts this page with identity AND wallet witness.
// Never pass those values to an untrusted third-party app.
const currentSlot = BigInt(await connection.getSlot());
const session = await gl.prove({
  identity,
  session: softwareSession(),      // this example uses Ed25519
  claim: {
    kind: "holdsAtLeast",
    mint: USDC_MINT,
    amount: 100_000_000n,         // 100 USDC, 6 decimals
    maxAgeSlots: 9_000n,          // snapshot at most ~1h old
  },
  currentSlot,                    // turns maxAgeSlots into min_slot
  witness,                        // paths checked against supplied roots
  expirySlot: currentSlot + 216_000n,   // existing pockets also require a new maximum
  home: userHomeAddress,
});

// Application-owned endpoint: creates a fresh 32-byte nonce, binds it to
// this browser's pending login, and stores it server-side with a short TTL.
const { challengeId, nonceHex } = await getChallengeFromYourBackend();
const signed = await session.signer.sign(loginChallengeDigest(hexToBytes(nonceHex)));

await sendToYourBackend({
  challengeId,
  proof: session.proof.snarkjsProof, // JSON proof, NOT session.proof.proof
  publicSignals: session.proof.publicSignals,
  sessionKind: 0,
  sessionKeyHex: bytesToHex(session.signer.key32),
  expirySlot: session.expirySlot.toString(),
  challengeSignatureHex: bytesToHex(signed.signature),
});

Supply a trusted identity, wallet witness, RPC connection, home address and mint. The browser facade uses its page origin, which must match the backend's expected domain. This does not isolate the secret from page scripts: an app receiving the raw identity can derive IDs for other origins. An isolated identity broker is not included.

LOGIN_ARTIFACTS and OWNER_ARTIFACTS each specify wasmUrl, zkeyUrl, and optionally wasmSha256/zkeySha256. Set both hashes from a trusted release to enable integrity checks; omitting them disables those checks. createProver runs in its caller's context. The development dashboard currently uses unpinned local artifacts. Put proving in a worker yourself if the UI must remain responsive; never send its secret-bearing input to a server.

3 · Verify on the backend

import { verifyLogin, loginChallengeDigest } from "@ghostlink/verify";
import { splitBytes32 } from "@ghostlink/common";
import { ed25519 } from "@noble/curves/ed25519";

function exactHex(value, length) {
  if (typeof value !== "string" ||
      !new RegExp("^[0-9a-fA-F]{" + (2 * length) + "}$").test(value)) {
    throw new Error("invalid hex bytes");
  }
  return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));
}

async function authenticate(payload, requestBinding) {
  // Read YOUR stored nonce. Do not accept a nonce supplied by the client.
  const challenge = await challengeStore.read(payload.challengeId, requestBinding);
  if (!challenge || challenge.expiresAt <= Date.now()) throw new Error("challenge expired");
  if (payload.sessionKind !== 0) throw new Error("this endpoint supports Ed25519 only");
  if (typeof payload.expirySlot !== "string" ||
      !/^(0|[1-9][0-9]{0,19})$/.test(payload.expirySlot)) throw new Error("invalid expiry");

  const key32 = exactHex(payload.sessionKeyHex, 32);
  const signature = exactHex(payload.challengeSignatureHex, 64);
  const expirySlot = BigInt(payload.expirySlot);
  const currentSlot = BigInt(await connection.getSlot()); // trusted RPC, not client input
  const mint = splitBytes32(USDC_MINT);                    // your required mint
  const result = await verifyLogin({
    proof: payload.proof,
    publicSignals: payload.publicSignals,
    expectedDomain: "https://app.example.com",             // fixed server configuration
    roots: trustedRootSource, // on-chain ring reader, or an indexer you trust
    expect: {
      holdsAtLeast: { mintHi: mint.hi.toString(), mintLo: mint.lo.toString(), threshold: 100_000_000n },
      minSlot: currentSlot > 9_000n ? currentSlot - 9_000n : 0n,
      session: { key32, expirySlot, currentSlot },
    },
  });
  if (!result.ok || !result.appId) throw new Error("proof refused");
  if (!ed25519.verify(signature, loginChallengeDigest(challenge.nonce), key32, { zip215: false })) {
    throw new Error("challenge signature refused");
  }
  // Atomic consume must also recheck expiry and binding. Two simultaneous
  // requests may verify, but only ONE may consume and receive a session.
  if (!await challengeStore.consumeIfUnexpired(payload.challengeId, requestBinding)) {
    throw new Error("challenge already used or expired");
  }
  return issueApplicationSession(result.appId);
}

Challenge endpoints, challengeStore, requestBinding, RPC and session issuance are application-owned integrations, not GhostLink services. Generate nonces with a cryptographically secure random source, bind them to the pending browser login, expire them promptly, and atomically consume them. Reject malformed requests and thrown verification errors before issuing a cookie. For P-256 sessions, use WebAuthn assertion verification with the expected challenge, RP ID, origin and user verification. Derive expect.session.key32 as SHA-256 of that same compressed 33-byte P-256 public key, so the assertion key is bound to the proof. The Ed25519 example above does not verify those assertions.

verifyLogin checks the proof and requested bindings. It does not check a challenge signature or consume a nonce. Anyone can copy the public proof, key and expiry; the separate signature and atomic nonce consumption are required for authentication. An indexer URL used as roots is a trusted source. Read roots from the chain if that indexer must not control which roots your backend accepts.

4 · Add spending later

When you need payments or swaps, open a pocket session with the same proof and submit intents through a solver. For an existing pocket, read its maximum prior session expiry and choose a later expiry within the current policy's allowed horizon. See Pockets & intents.

Migrating an existing app

  1. Keep your "Connect wallet" button; add "Continue with GhostLink" beside it.
  2. Store appId in your users table next to wallet_address, nullable.
  3. Move swap/purchase flows to intents one feature at a time; anything needing a pay-out stays on the old path (pay-outs are deliberately not in v1).

The Wallet Standard facade exposes standard:connect and the custom ghostlink:signIn feature. Call its signIn method with a challenge property containing the server's 32-byte nonce; it returns the JSON proof, public signals, session public keys and challengeSignature over loginChallengeDigest(challenge). It does not implement solana:signIn or transaction signing; consumers must implement GhostLink verification rather than an ordinary SIWS verifier.

For React, GhostLinkProvider configuration is fixed for the provider's lifetime. Remount it with a different React key when changing cluster, endpoints, origin or prover, so the previous client and session are discarded together.