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++;
};
| 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() };
}
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
sendreturns 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 };
}
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)) ?? '');
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
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
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)