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();
| Step | Kiosk | Fan's phone (web) |
|---|---|---|
| Issue challenge | Local | Your API |
| NFC tap | startAuthenticationWithChallengeResponse(message, transceive) | startAuthenticationWithChallengeResponse(message) |
| Verify + gate | Same process | Your 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