A customer walks into a repair shop with a phone and says: "It's under warranty, the card is in my wallet." The shop's software has one second to decide whether to believe it.
An earlier walkthrough built that card: a soulbound Token-2022 token with its details stored inside the mint. This one builds the other half, a verifier that checks the card the way a machine would, from public chain data only. No screenshots, no PDFs, no trust in the person holding the phone.
⚠️ Educational example, not audited. Devnet only. A warranty decision still follows the shop's policy and consumer law; the verifier only answers "is this card genuine?".
The eight questions
| # | Question | How it is checked |
|---|---|---|
| 1 | Is this a Token-2022 mint? | the mint account's owner is the Token-2022 program |
| 2 | Is it one of one? | supply 1, decimals 0, mint authority removed |
| 3 | Is it soulbound? | the NonTransferable extension is present |
| 4 | Is the metadata inside the mint? | the MetadataPointer points to the mint itself |
| 5 | Did this shop issue it? | the metadata update authority is the shop's key |
| 6 | Is the warranty still valid? |
warranty_until is today or later |
| 7 | Is it for this order? |
order_hash equals SHA-256 of the buyer's order (optional) |
| 8 | Does this wallet hold it? | the holder's associated token account holds exactly 1 |
All eight must pass. Any single "no" makes the card not valid, and the verifier says which question failed.
A pure function first
The core takes raw account data and returns a list of checks. It does no network calls, which makes it easy to test and easy to reuse in a server, a wallet or an agent:
export function verifyReceipt(mint: PublicKey, mintInfo: AccountInfo<Buffer> | null, holderAta: PublicKey,
ataInfo: AccountInfo<Buffer> | null, exp: Expected): { valid: boolean; checks: Check[] } {
const checks: Check[] = [];
const add = (name: string, ok: boolean, detail = '') => checks.push({ name, ok, detail });
if (!mintInfo || !mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID)) {
add('mint is owned by Token-2022', false, mintInfo ? mintInfo.owner.toBase58() : 'not found');
return { valid: false, checks };
}
add('mint is owned by Token-2022', true);
const m = unpackMint(mint, mintInfo, TOKEN_2022_PROGRAM_ID);
add('one of one: supply 1, decimals 0, mint authority removed',
m.supply === 1n && m.decimals === 0 && m.mintAuthority === null);
add('soulbound: NonTransferable extension present',
getExtensionTypes(m.tlvData).includes(ExtensionType.NonTransferable));
const ptr = getMetadataPointerState(m);
add('metadata pointer points to the mint itself', !!ptr?.metadataAddress?.equals(mint));
const raw = getExtensionData(ExtensionType.TokenMetadata, m.tlvData);
if (!raw) { add('metadata stored inside the mint', false); return { valid: false, checks }; }
const md = unpackMetadata(raw);
const f = Object.fromEntries(md.additionalMetadata);
add('issued by the expected shop (update authority)', !!md.updateAuthority?.equals(exp.merchant));
const today = exp.today ?? new Date().toISOString().slice(0, 10);
add('warranty still valid', typeof f.warranty_until === 'string' && f.warranty_until >= today);
if (exp.order) add('order hash matches the buyer\'s order', f.order_hash === orderHash(exp.order));
// ...then: the holder's associated token account must hold exactly 1 of this mint
Two choices worth copying:
- Check the owner program before parsing. Parsing a random account as a mint "works" more often than you would like; checking the owner first rules out look-alikes.
-
Return every check, not just
true/false. A person at a counter needs "expired" or "issued by someone else", not "invalid".
Then a thin network wrapper
export async function verifyReceiptOnChain(mintAddress: string, exp: Expected) {
const connection = getConnection();
const mint = new PublicKey(mintAddress);
const ata = getAssociatedTokenAddressSync(mint, exp.holder, false, TOKEN_2022_PROGRAM_ID);
const [mintInfo, ataInfo] = await connection.getMultipleAccountsInfo([mint, ata]);
return verifyReceipt(mint, mintInfo, ata, ataInfo, exp);
}
One RPC call for two accounts. From the command line:
npx tsx src/05-verify-receipt.ts <MINT> <SHOP_KEY> <HOLDER_WALLET>
The tests try to fool it
Inside LiteSVM (a local Solana VM with the real Token-2022 program) the test suite issues real cards and then attacks the verifier from six sides:
PASS M5 genuine soulbound card passes every check — 8 checks
PASS M5 a different order is rejected (hash mismatch) — order hash matches the buyer's order
PASS M5 a card from another issuer is rejected — issued by the expected shop (update authority)
PASS M5 an expired warranty is rejected — warranty still valid
PASS M5 a wallet that does not hold the card is rejected — held by the expected wallet
PASS M5 a transferable (not soulbound) card is flagged — soulbound: NonTransferable extension present
The text after each dash is the name of the check that caught it. Each attack fails for exactly the reason it should.
Why this matters
- For people: a warranty that cannot be lost in an inbox and cannot be faked with an edited PDF.
- For shops: a check that runs in one RPC call and needs no customer database.
- For agents and robots: a proof they can verify themselves, without trusting a screenshot or a human in the loop.
Limits worth stating plainly
- The verifier trusts the shop's key. If that key is stolen, fake cards can look genuine; key management is the real security boundary.
-
warranty_untilis compared as an ISO date string in UTC. Time zones and grace periods are policy decisions, not code. - The merchant can still update metadata (for example to extend a warranty). If that is not wanted, remove the update authority when issuing.
- Not audited.
The verifier, the card it checks, the tests and five more Token-2022 models (Apache-2.0) are open in one place:
https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code





Top comments (0)