Two readers looked at one of our open Token-2022 examples and found two things our tests had missed. Both are easy to get wrong in any Solana payment code, so here they are, with the reproduction and the fix.

The example is Model 3 of the Wen Open Lab: a B2B settlement token with a transfer fee, accounts frozen by default, and a receiving account that requires a memo on every incoming payment.

Edge 1: Token-2022 rounds the transfer fee up

TransferCheckedWithFee makes the client pass the fee it expects. The program computes its own value and rejects the transfer if the two differ.

Our helper looked reasonable:

// before export function expectedFee(amount: bigint): bigint { const pct = (amount * BigInt(FEE_BPS)) / 10_000n; // integer division: rounds DOWN return pct > FEE_CAP ? FEE_CAP : pct; } 

Token-2022 uses ceiling division. With a 25 bps fee:

Amount (base units) amount × 25 / 10,000 Token-2022 fee Our old fee
400 1.0000 1 1
401 1.0025 2 1
1,000,000 2,500 2,500 2,500

At 401 base units the program expects 2, we passed 1, and the transaction failed:

Program log: Calculated fee does not match expected fee 

Our original test paid a round amount where both formulas agree, so it never saw the difference. The fix uses the same formula as the program:

// after export function expectedFee(amount: bigint): bigint { if (amount === 0n || FEE_BPS === 0) return 0n; const raw = (amount * BigInt(FEE_BPS) + 9_999n) / 10_000n; // ceiling division return raw > FEE_CAP ? FEE_CAP : raw; } 


Two new tests guard it: a real payment of 401 base units now goes through, and expectedFee is compared with calculateFee from @solana/spl-token on ten edge amounts, including both sides of the fee cap.

Takeaway: never compute a Token-2022 fee with plain integer division. Use the library's calculateFee with the mint's fee config, or the exact ceiling formula, and test an amount that does not divide evenly.

Edge 2: Required Memo checks that a memo exists, not what it says

The receiving account has the Required Memo (MemoTransfer) extension. A transfer without a memo instruction right before it is rejected:

No memo in previous instruction required for recipient to receive a transfer 

It is tempting to read that as "no invoice number, no payment". It is not. The program only checks that the previous instruction is a call to the Memo program. It never reads the text. In LiteSVM, with the real Token-2022 and Memo programs:

Memo before the transfer Result on-chain
none rejected
invoice:INV-2026-0042 (correct) accepted
invoice:INV-WRONG-999 accepted
invoice:hello accepted

That is the design, so the architecture stays. What has to change is where the invoice number is checked: off-chain, at reconciliation. The Memo program logs Program log: Memo (len N) and then the text on the next line, so the memo can be read back from the transaction logs:

export function memosFromLogs(logs: string[]): string[] { const out: string[] = []; logs.forEach((l, i) => { const m = /Memo \(len (\d+)\)(?:: "(.*)")?$/.exec(l); if (!m) return; if (m[2] !== undefined) out.push(m[2]); // older format else if (logs[i + 1]?.startsWith('Program log: ')) out.push(logs[i + 1].slice(13)); }); return out; } export function matchInvoicePayment(logs: string[], invoiceId: string): boolean { const inv = memosFromLogs(logs).filter(m => m.startsWith('invoice:')); return inv.length === 1 && inv[0] === `invoice:${invoiceId}`; } 

Two more tests: the wrong-number payment passes on-chain (documenting the limit), and reconciliation accepts the right invoice and rejects the wrong one.

Takeaway: an on-chain rule proves exactly what it checks and nothing more. A required memo is a good guard against payments with no reference at all; matching the reference to an invoice is your job.

What changed

  • 41 checks in LiteSVM now, all green (37 before).
  • The model page says plainly what the memo rule does and does not do.
  • Both questions are recorded with their tests on a "reader findings" page: one question, one accepted answer, an honest status (fixed or known limit), and the test that proves it.

If you write payment code on Token-2022, two cheap habits catch both edges: test an amount that does not divide evenly, and reconcile references off-chain.

Example code, not audited. Devnet only, with a new empty wallet.

The model, the four new tests and the full diff (Apache-2.0):
https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code