DEV Community

Cover image for How to model a warranty card as a soulbound Token-2022 token (devnet walkthrough)
Wen cat
Wen cat

Posted on

How to model a warranty card as a soulbound Token-2022 token (devnet walkthrough)

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

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

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

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

Three rules that save hours:

  1. Extensions first, InitializeMint after. Mint extensions such as MetadataPointer and NonTransferable must be initialized on the uninitialized account.
  2. Metadata after InitializeMint. The metadata instruction needs a live mint and its mint authority.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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

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)