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.
1. The order, and why we hash it
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:
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.
3. The metadata: name, hash and warranty date
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
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,
InitializeMintafter. Mint extensions such asMetadataPointerandNonTransferablemust 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.
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:
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
6. Run it on devnet
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_until). 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


Top comments (0)