Paper warranty cards get lost. PDF receipts sit in an inbox nobody searches. And a warranty is tied to one buyer, so it should not be resold together with a "second-hand" receipt.
Solana's Token-2022 program can express all of that with built-in extensions, without a custom on-chain program:
Need Token-2022 tool
One unique card per purchase 0 decimals, mint 1, then remove the mint authority
Card details on-chain, no extra account MetadataPointer + TokenMetadata stored inside the mint
Warranty stays with the buyer NonTransferable extension (a "soulbound" token)
No personal data on a public ledger store a SHA-256 hash of the order, not the order
This is the same light-NFT layout used by the Wen New Standard (WNS): zero decimals, supply locked at 1, metadata in the mint. Everything below runs on devnet only.
⚠️ Educational example, not audited. Use a new, empty devnet wallet. Never paste a real seed phrase into scripts.
- The order, and why we hash it ts export interface Order { orderId: string; sku: string; priceMinorUnits: number; // e.g. cents currency: string; purchasedAt: string; // ISO date warrantyMonths: number; }
export function orderHash(order: Order): string {
return createHash('sha256').update(JSON.stringify(order)).digest('hex');
}
The chain is public forever. So the card carries only a hash: the shop (or the buyer, who keeps the order JSON) can later prove "this card belongs to this order" by re-hashing it, while nobody else learns what was bought, by whom or for how much.
*2. Size the mint before creating it
*
Token-2022 extensions change the account size, and the metadata is variable-length. Two numbers matter:
ts
const extensions = [ExtensionType.MetadataPointer, ExtensionType.NonTransferable];
const mintLen = getMintLen(extensions); // space at creation
const metadataLen = TYPE_SIZE + LENGTH_SIZE + pack(metadata).length; // grows later
const rent = await connection.getMinimumBalanceForRentExemption(mintLen + metadataLen);
A common bug: creating the account with mintLen + metadataLen as space. The account must be created with mintLen only; it is funded for the full size, because the metadata initialization reallocates it.
- The metadata: name, hash and warranty date ts const metadata: TokenMetadata = { mint, updateAuthority: merchant, name:
Receipt ${order.orderId}, symbol: 'RCPT', uri: opts.uri, // public JSON with non-personal details additionalMetadata: [ ['order_hash', orderHash(order)], ['warranty_until', '2027-10-10'], // purchasedAt + warrantyMonths ['sku', order.sku], ], };
additionalMetadata is a list of free key/value pairs. A service desk only needs warranty_until and order_hash; wallets and explorers display the name and symbol.
**4. The instruction order is the whole trick
**ts
const instructions = [
SystemProgram.createAccount({ fromPubkey: merchant, newAccountPubkey: mint,
space: mintLen, lamports: rent, programId: TOKEN_2022_PROGRAM_ID }),
// extensions are initialized BEFORE the mint itself
createInitializeMetadataPointerInstruction(mint, merchant, mint, TOKEN_2022_PROGRAM_ID),
createInitializeNonTransferableMintInstruction(mint, TOKEN_2022_PROGRAM_ID),
createInitializeMintInstruction(mint, 0, merchant, null, TOKEN_2022_PROGRAM_ID),
// metadata lives inside the mint (the pointer points to itself)
createInitializeInstruction({ programId: TOKEN_2022_PROGRAM_ID, metadata: mint,
updateAuthority: merchant, mint, mintAuthority: merchant,
name: metadata.name, symbol: metadata.symbol, uri: metadata.uri }),
...metadata.additionalMetadata.map(([field, value]) =>
createUpdateFieldInstruction({ programId: TOKEN_2022_PROGRAM_ID, metadata: mint,
updateAuthority: merchant, field, value })),
// give exactly one to the buyer...
createAssociatedTokenAccountIdempotentInstruction(merchant, buyerAta, buyer, mint, TOKEN_2022_PROGRAM_ID),
createMintToInstruction(mint, buyerAta, merchant, 1, [], TOKEN_2022_PROGRAM_ID),
// ...and make sure there will never be a second one
createSetAuthorityInstruction(mint, merchant, AuthorityType.MintTokens, null, [], TOKEN_2022_PROGRAM_ID),
];
*Three rules that save hours:
*
Extensions first, InitializeMint after. Mint extensions such as MetadataPointer and NonTransferable must be initialized on the uninitialized account.
Metadata after InitializeMint. The metadata instruction needs a live mint and its mint authority.
Remove the mint authority last. Zero decimals + supply 1 + no mint authority = a real one-of-one.
The whole thing fits in one transaction, signed by the merchant and the new mint keypair.
*5. Prove it, locally, before touching devnet
*
The example repository runs every claim inside LiteSVM, a local Solana VM: no network, no airdrop, results in a second.
text
_PASS M1 receipt minted (soulbound=true)
PASS M1 supply is exactly 1, decimals 0, mint authority removed
PASS M1 metadata inside the mint: name, order hash, warranty date — warranty_until=2027-10-10
PASS M1 buyer holds the receipt
PASS M1 soulbound receipt cannot be transferred — rejected by Token-2022
PASS M1 nobody can mint a second copy
_
The same test also builds the card with soulbound: false and checks that it can be transferred. That is useful when the shop wants the warranty to follow a gifted or resold product.
Reading the card back is plain unpacking:
ts
const m = unpackMint(mintAddress, accountInfo, TOKEN_2022_PROGRAM_ID);
const md = unpackMetadata(getExtensionData(ExtensionType.TokenMetadata, m.tlvData)!);
// m.supply === 1n, m.decimals === 0, m.mintAuthority === null
// md.additionalMetadata -> order_hash, warranty_until, sku
- Run it on devnet bash solana-keygen new -o devnet-wallet.json # new throwaway wallet solana airdrop 2 $(solana-keygen pubkey devnet-wallet.json) --url devnet npm install npm test # LiteSVM first KEYPAIR=./devnet-wallet.json npm run devnet # then devnet
The devnet script refuses to run against any non-devnet RPC and prints Solana Explorer links (?cluster=devnet) for the mint and the buyer's account, so anyone can open the card and check the metadata themselves.
**What this model does not do
**It does not replace consumer law. The token is a proof; the warranty terms still live in the shop's policy.
Soulbound is not "unlosable". If the buyer loses the wallet, the card is lost with it. A shop can reissue a card to a new address after its own checks; the old one stays as history.
Update authority is still the merchant. That allows corrections (for example, extending warranty_unti
l). If you want the card frozen forever, remove the update authority as well.
Not audited. Treat this as a model to learn from and test, not production code.
The code (Apache-2.0), the LiteSVM tests and three more Token-2022 models (creator royalties, B2B invoices, a payment link bridge) are open in one place: https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code



