DEV Community

Wen cat
Wen cat

Posted on

Card fees vs on-chain fees: five formulas instead of opinions

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

One RPC call for two accounts. From the command line:

npx tsx src/05-verify-receipt.ts <MINT> <SHOP_KEY> <HOLDER_WALLET>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_until is 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 (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the eight-question checklist is a clean way to structure it, and making the verifier a pure function over raw account data is the part that will age well. anything that needs a network call mid-verification becomes untestable fast, so keeping the checks serializable like this makes every failure case replayable. the optional order_hash match is a nice touch too, it keeps the card from being reusable across orders without hard-coding an order id.