DEV Community

Wen cat
Wen cat

Posted on

Testing Token-2022 in LiteSVM: 37 checks, no network

Every Token-2022 tutorial says "test on devnet first". Devnet is great, but it is slow for a test loop: airdrops get rate-limited, transactions take seconds, and a failed test leaves junk accounts behind.

LiteSVM runs a Solana virtual machine inside your test process. The real SPL Token-2022 and Memo programs are loaded, transactions execute in milliseconds, and nothing touches the network. This walkthrough shows the small harness behind the Wen Open Lab tests: 37 checks across six Token-2022 models, run in one command.

⚠️ Educational example, not audited. LiteSVM proves your instructions do what you think; it does not replace an audit or a devnet run.

1. The whole harness is five helpers

import { LiteSVM, FailedTransactionMetadata } from 'litesvm';
import { address, lamports, getTransactionDecoder } from '@solana/kit';
import { Keypair, PublicKey, Transaction, type TransactionInstruction } from '@solana/web3.js';

const svm = new LiteSVM();
let failures = 0;
const check = (name: string, ok: boolean, extra = '') => {
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${name}${extra ? '  — ' + extra : ''}`);
  if (!ok) failures++;
};
Enter fullscreen mode Exit fullscreen mode
Helper Job
send(ixs, signers) build, sign and execute a transaction; return ok, err and logs
fund(keypair) airdrop test SOL instantly
rent(space) rent-exempt minimum for an account size
account(pubkey) read an account back in the shape @solana/spl-token expects
check(name, ok) print PASS / FAIL and count failures

2. send: one bridge between web3.js and LiteSVM

The models are written with @solana/web3.js v1 and @solana/spl-token, the libraries most tutorials use. Current LiteSVM speaks @solana/kit types. One decoder joins them:

function send(ixs: TransactionInstruction[], signers: Keypair[]) {
  const tx = new Transaction().add(...ixs);
  tx.feePayer = signers[0].publicKey;
  tx.recentBlockhash = svm.latestBlockhash();
  tx.sign(...signers);
  const kitTx = getTransactionDecoder().decode(tx.serialize());
  const res = svm.sendTransaction(kitTx);
  svm.expireBlockhash();
  return res instanceof FailedTransactionMetadata
    ? { ok: false, err: res.toString(), logs: res.meta().logs() }
    : { ok: true, logs: res.logs() };
}
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • expireBlockhash() after every send. Two identical transactions with the same blockhash have the same signature, and the second one is rejected as a duplicate. Expiring the blockhash keeps repeated steps independent.
  • Failures are values, not exceptions. A rejected transfer is often the expected result, so send returns it with its logs instead of throwing.

3. Reading accounts back

const fund = (k: Keypair) => svm.airdrop(address(k.publicKey.toBase58()), lamports(10_000_000_000n));
const rent = (space: number) => Number(svm.minimumBalanceForRentExemption(BigInt(space)));
function account(pk: PublicKey) {
  const a = svm.getAccount(address(pk.toBase58()));
  if (!a.exists) throw new Error('missing account ' + pk.toBase58());
  return { data: Buffer.from(a.data), owner: new PublicKey(a.programAddress),
           lamports: Number(a.lamports), executable: a.executable };
}
Enter fullscreen mode Exit fullscreen mode

The object returned by account() is what unpackMint and unpackAccount from @solana/spl-token expect, so the same parsing code works locally and against a real RPC.

4. Test the "no", not only the "yes"

Token-2022 extensions are mostly about refusing things. The useful tests are the ones that expect a rejection and check why:

const noMemo = send(buildPayInvoice({ mint: mint.publicKey, from: buyerAcc.publicKey, to: sellerAcc.publicKey,
  payer: buyerCo.publicKey, invoiceId: null, amount: 1_000n * unit }), [buyerCo]);
check('M3 payment WITHOUT invoice number is rejected',
  !noMemo.ok && (noMemo.logs ?? []).some(l => /memo/i.test(l)),
  (noMemo.logs ?? []).find(l => /memo/i.test(l)) ?? '');
Enter fullscreen mode Exit fullscreen mode

Matching the program log matters: a transaction can fail for an unrelated reason (wrong account, missing signer), and a test that only checks !ok would still pass.

5. What 37 checks look like

PASS  M1 soulbound receipt cannot be transferred — rejected by Token-2022
PASS  M1 nobody can mint a second copy
PASS  M3 payment WITHOUT invoice number is rejected
        — Program log: Error: No memo in previous instruction required for recipient to receive a transfer
PASS  M3 payment to a NOT approved company is rejected (account frozen)
        — Program log: Error: Account is frozen
PASS  M3 fee cap works (fee on 100,000 is 50, not 250)
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

ALL CHECKS PASSED
Enter fullscreen mode Exit fullscreen mode

The full suite covers a soulbound receipt, a royalty transfer hook, an invoice-only settlement token, a Solana Pay payment link, a receipt verifier and a mint inspector.

6. Run it

npm install
npm run typecheck
npm test        # LiteSVM: local, no network, no funds
Enter fullscreen mode Exit fullscreen mode

When everything passes locally, the same instructions go to devnet with npm run devnet, which prints Solana Explorer links for every step.

Limits worth stating plainly

  • LiteSVM runs the programs you load. A hook program that is not loaded (for example a third-party royalty program) is not executed; test what you can see, and say what you cannot.
  • Clock, slots and rent behave as you set them. Time-dependent logic needs explicit tests.
  • Local green is not mainnet-ready. Devnet, review and audit still come after.


The harness, the six models and the full test log (Apache-2.0) are open in one place:
https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code

Top comments (0)