Revi

Build gated experiences

pnpm add phygital-token-sdk @solana/kit

You need: HTTPS (mobile web), a DAS RPC, tags that are claimed to a wallet.

The SDK splits trigger (startAuthenticationWithChallengeResponse) from verify (verifyWithChallengeResponse). Run verify in a trusted environment — your kiosk app, staff device, or API — not in code the visitor can tamper with.

Kiosk example (all on one device)

Fixed reader at the gate — challenge, tap, verify, and gate locally.

import { randomUUID } from "crypto";
import { createSolanaRpc } from "@solana/kit";
import {
  startAuthenticationWithChallengeResponse,
  verifyWithChallengeResponse,
  evaluateAssetGating,
  Gating,
  GatingTraitValue,
} from "phygital-token-sdk";

const rpc = createSolanaRpc(process.env.SOLANA_RPC_URL!);
const COLLECTION = "YourCollectionMint...";

const doorTiers = [
  Gating.tier("admit", Gating.count(1, { collection: Gating.eq(COLLECTION) })),
  Gating.tier("vip_lounge", Gating.count(1, {
    collection: Gating.eq(COLLECTION),
    traits: Gating.traitsAll(
      Gating.trait("Access", GatingTraitValue.eq("VIP")),
    ),
  })),
] as const;

async function checkIn(transceive: (apdu: Uint8Array) => Promise<Uint8Array>) {
  const message = randomUUID();

  const response = await startAuthenticationWithChallengeResponse(message, transceive);
  const { publicKey, isVerified } = await verifyWithChallengeResponse({
    rpc,
    expectedMessage: message,
    response,
  });
  if (!isVerified) throw new Error("Invalid tap");

  const { passedTierIds } = await evaluateAssetGating({
    assetPublicKey: publicKey,
    rpc,
    tiers: doorTiers,
  });

  if (passedTierIds.includes("vip_lounge")) return openVipLounge();
  if (passedTierIds.includes("admit")) return openMainGate();
  return showDenied();
}

transceive is your NFC reader driver. Everything else runs in the same process — no API round-trip.


Web example: fan's phone + your API

Use when the tap happens on the visitor's browser (members site, mobile checkout). Verify and gate on your API; the browser only handles NFC.

1. Issue a challenge

Return a one-time message the tag must sign. Store it with a short TTL so you can reject replays.

import { randomUUID } from "crypto";

export async function GET() {
  const message = randomUUID();
  // persist message for this session (e.g. 60s TTL)
  return Response.json({ message });
}

2. Tap (browser — NFC required)

import { startAuthenticationWithChallengeResponse } from "phygital-token-sdk";

async function tapTag() {
  const { message } = await fetch("/api/tag/challenge").then((r) => r.json());

  const response = await startAuthenticationWithChallengeResponse(message);

  const result = await fetch("/api/tag/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message, response }),
  });
  if (!result.ok) throw new Error("Tap failed");
  return result.json(); // { publicKey, passed, passedTierIds, ... }
}

No NFC on desktop browser? Send users to the same URL on their phone.

3. Verify + gate (your API)

import type { AuthenticationResponseJSON } from "@simplewebauthn/browser";
import { createSolanaRpc } from "@solana/kit";
import { verifyWithChallengeResponse, evaluateAssetGating } from "phygital-token-sdk";
import { doorTiers } from "./tiers";

const rpc = createSolanaRpc(process.env.SOLANA_RPC_URL!);

export async function POST(req: Request) {
  const { message, response } = await req.json() as {
    message: string;
    response: AuthenticationResponseJSON;
  };

  const { publicKey, isVerified } = await verifyWithChallengeResponse({
    rpc,
    expectedMessage: message,
    response,
  });
  if (!isVerified) return Response.json({ error: "Invalid tap" }, { status: 401 });

  const { passed, passedTierIds } = await evaluateAssetGating({
    assetPublicKey: publicKey,
    rpc,
    tiers: doorTiers,
  });

  return Response.json({ publicKey, passed, passedTierIds });
}
const { passedTierIds } = await tapTag();

if (!passedTierIds?.length) return showDenied();
if (passedTierIds.includes("vip_lounge")) return openVipLounge();
if (passedTierIds.includes("admit")) return openMainGate();
StepKioskFan's phone (web)
Issue challengeLocalYour API
NFC tapstartAuthenticationWithChallengeResponse(message, transceive)startAuthenticationWithChallengeResponse(message)
Verify + gateSame processYour API

More tier patterns

Copy the doorTiers shape; swap the filter inside Gating.tier(...).

Shop discounts (pick highest passedTierIds):

Gating.tier("discount_10", Gating.count(1, { collection: Gating.eq(COLLECTION) })),
Gating.tier("discount_25", Gating.count(3, { collection: Gating.eq(COLLECTION) })),
Gating.tier("discount_40", Gating.count(1, { mint: Gating.eq(FOUNDER_MINT) })),

Partner bundle (both collections):

Gating.tier("collab", Gating.and(
  Gating.count(1, { collection: Gating.eq(YOUR_COLLECTION) }),
  Gating.count(1, { collection: Gating.eq(PARTNER_COLLECTION) }),
)),

Trait on the same NFT (Gold rarity — not a separate mint):

Gating.count(1, {
  collection: Gating.eq(COLLECTION),
  traits: Gating.traitsAll(
    Gating.trait("Rarity", GatingTraitValue.eq("Gold")),
  ),
})

Wallet token balance:

Gating.totalBalance("RewardTokenMint...", 1_000_000n) // raw units

Two different mints (any two NFTs):

Gating.and(
  Gating.count(1, { mint: Gating.eq("MintA...") }),
  Gating.count(1, { mint: Gating.eq("MintB...") }),
)

When gating fails

import { summarizeGatingEvaluationFailure } from "phygital-token-sdk";

const reasons = summarizeGatingEvaluationFailure(result);
// e.g. 'Need at least 3 asset(s) matching collection = ...; found 1.'

Use this for upgrade copy (“Collect 2 more for VIP”) instead of a generic denied screen.


Launch checklist

  • Test tap on a real NFC phone or kiosk reader
  • Challenge stored with short TTL; reject unknown or replayed messages
  • HTTPS on your domain (mobile web)
  • Production collection mints in tier rules
  • Pass and fail wallets tested
  • Desktop → “Open on phone” fallback
  • Sold NFT after claim → gate correctly denies