I spent 7 days learning Solana token extensions. Here's what clicked, what surprised me, and the code you need to build tokens that can't be traded but can be revoked.
The Problem (In Web2 Terms)
Imagine you work in HR. You issue an employee a digital badge proving they're a certified security officer. Here's what you'd want:
- The badge stays in their wallet — they can't trade or sell it
- Only they can use it
- If they leave the company or fail a compliance check, you can revoke it silently without their permission
- The badge metadata (name, symbol, type) is on-chain and permanent
You could build this with centralized databases and APIs. On Solana, it's just three extensions on a token mint.
What Are Token Extensions?
Solana's Token-2022 program lets you attach additional behaviors to any mint at creation time. Think of them like middleware for tokens.
Before extensions, every token was the same — a mint with supply and decimals, token accounts holding balances, and transfer instructions. Extensions let you add rules on top:
| Extension | What It Does | Use Case |
|---|---|---|
| Transfer Fee | Charge a percentage on every transfer | Protocol revenue, marketplace commissions |
| Non-Transferable | Make tokens unmovable after minting | Soulbound badges, credentials, memberships |
| Permanent Delegate | Let the issuer burn tokens from anyone | Revocable credentials, subscriptions with expiry |
| Metadata | Store name, symbol, URI on-chain | Self-describing tokens, no external API needed |
| Default Account State | Freeze all new accounts by default | Compliance gates, KYC verification |
The critical rule: extensions must be declared at mint creation. You cannot add them later. This forces you to think about your token's full lifecycle before deploying — which is good design discipline.
The Journey: Three Combinations That Matter
Over the past week I built three different token types. Here's what I learned from each.
Day 34: Transfer Fees (The Marketplace Token)
A token that charges 1% on every transfer, withheld automatically and sweepable by the issuer.
What clicked: Fees are calculated at the protocol level — there's no fee handler to bypass. If someone transfers your token, the fee is withheld. Full stop.
Day 37: Multi-Extension Token (The Compliance Token)
I combined three extensions at once: TransferFeeConfig + InterestBearingConfig + MetadataPointer.
What clicked: Extensions are truly independent. The interest-bearing display formula applies regardless of whether a fee is configured. The metadata doesn't affect functionality. They compose without interfering.
Days 38–40: Soulbound Credentials (The Revocable Badge)
The combination that unlocked a genuinely new primitive:
- NonTransferable — token can't move
- PermanentDelegate — issuer can revoke without the holder's consent
- MetadataPointer — credential is self-describing on-chain
The Code: Building a Revocable Credential
I'm on Windows without access to the spl-token CLI, so I built this entirely in Node.js using @solana/web3.js and @solana/spl-token.
import {
Connection,
Keypair,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
import {
ExtensionType,
TOKEN_2022_PROGRAM_ID,
createInitializeMintInstruction,
createInitializeNonTransferableMintInstruction,
createInitializePermanentDelegateInstruction,
createInitializeMetadataPointerInstruction,
getMintLen,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountInstruction,
mintTo,
createBurnInstruction,
} from "@solana/spl-token";
import { createInitializeInstruction, pack, TYPE_SIZE, LENGTH_SIZE } from "@solana/spl-token-metadata";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
// Load your saved keypairs (never generate fresh keypairs on every run)
const authority = loadWallet("~/.config/solana/id.json"); // issuer
const recipient = loadWallet("~/recipient-wallet.json"); // holder
const mintKeypair = Keypair.generate(); // new mint each run
const mint = mintKeypair.publicKey;
// Define metadata
const tokenMetadata = {
mint,
name: "Solana Dev Credential",
symbol: "CRED",
uri: "https://example.com/credential.json",
additionalMetadata: [],
};
// Calculate space: base mint + extensions + metadata
const metadataExtLen = TYPE_SIZE + LENGTH_SIZE + pack(tokenMetadata).length;
const extensions = [
ExtensionType.NonTransferable,
ExtensionType.PermanentDelegate,
ExtensionType.MetadataPointer,
];
const mintLen = getMintLen(extensions);
const mintLamports = await connection.getMinimumBalanceForRentExemption(
mintLen + metadataExtLen
);
// Build the transaction — instruction order matters
const createMintTx = new Transaction().add(
// 1. Allocate the account
SystemProgram.createAccount({
fromPubkey: authority.publicKey,
newAccountPubkey: mint,
space: mintLen,
lamports: mintLamports,
programId: TOKEN_2022_PROGRAM_ID,
}),
// 2. Extension initializers MUST come before createInitializeMintInstruction
createInitializeNonTransferableMintInstruction(mint, TOKEN_2022_PROGRAM_ID),
createInitializePermanentDelegateInstruction(
mint,
authority.publicKey, // issuing authority = permanent delegate
TOKEN_2022_PROGRAM_ID
),
createInitializeMetadataPointerInstruction(
mint, authority.publicKey, mint, TOKEN_2022_PROGRAM_ID
),
// 3. Initialize the mint itself (0 decimals — credentials are whole units)
createInitializeMintInstruction(
mint, 0, authority.publicKey, authority.publicKey, TOKEN_2022_PROGRAM_ID
),
// 4. Write metadata on-chain
createInitializeInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: mint,
updateAuthority: authority.publicKey,
mint,
mintAuthority: authority.publicKey,
name: tokenMetadata.name,
symbol: tokenMetadata.symbol,
uri: tokenMetadata.uri,
})
);
await sendAndConfirmTransaction(
connection, createMintTx, [authority, mintKeypair]
);
console.log("✅ Mint created:", mint.toBase58());
// Create recipient's token account (authority pays)
const recipientATA = getAssociatedTokenAddressSync(
mint, recipient.publicKey, false, TOKEN_2022_PROGRAM_ID
);
const createATATx = new Transaction().add(
createAssociatedTokenAccountInstruction(
authority.publicKey, recipientATA, recipient.publicKey,
mint, TOKEN_2022_PROGRAM_ID
)
);
await sendAndConfirmTransaction(connection, createATATx, [authority]);
// Mint 1 credential to the recipient
await mintTo(
connection, authority, mint, recipientATA, authority,
BigInt(1), [], undefined, TOKEN_2022_PROGRAM_ID
);
console.log("✅ Credential issued to recipient");
// === TRANSFER ATTEMPT — will fail ===
// (Try this yourself — the NonTransferable extension blocks it at simulation)
// === REVOCATION — authority burns without holder's consent ===
const revokeTx = new Transaction().add(
createBurnInstruction(
recipientATA,
mint,
authority.publicKey, // permanent delegate signs — NOT the holder
BigInt(1),
[],
TOKEN_2022_PROGRAM_ID
)
);
await sendAndConfirmTransaction(connection, revokeTx, [authority]);
console.log("✅ Credential revoked — recipient balance: 0");
What Surprised Me
Silent revocation is real. I expected revoking to require the holder's signature. It doesn't. The permanent delegate burns the token without any interaction from the holder. For compliance scenarios, that's exactly what you want — but it's worth being deliberate about who you hand this power to.
Transfer is blocked at simulation, before the chain. When I tried to transfer a NonTransferable token, the RPC rejected the transaction before it was even submitted. No gas spent, no on-chain footprint. The rejection is that clean.
Instruction order is unforgiving. Extension initializers must run before createInitializeMintInstruction in the same transaction. Put them in the wrong order and you get a cryptic error. Once I understood that extensions configure the account before the mint is initialized, the order made sense.
What Confused Me
Token accounts vs. mints. For the first few days I kept conflating these. The mint holds supply, decimals, authorities, and extensions. A token account (ATA) is where a holder's balance lives. Extensions live on the mint. Token accounts just hold tokens and follow the mint's rules.
Why so many instructions? Creating a mint with three extensions means six instructions in one transaction. The reason is modularity — you only pay for what you use. A basic mint is one instruction. A complex mint is six. Cost scales with your needs.
What Clicked
Extensions are middleware, not token types. I kept thinking of NonTransferable tokens as a different kind of token. They're not. It's the same token with a rule bolted on. Transfer fee is a rule. Permanent delegate is a rule. Rules compose.
This unlocks genuinely new primitives. Before Token-2022 extensions, soulbound + revocable in a single token was not possible on Solana without a custom program. Now it's a 50-line script. That matters.
The Real Gotchas
Save your keypairs. Generate once, save to JSON, load on every run. Generating fresh keypairs every time means your authority has no SOL on the next run.
// Save
fs.writeFileSync("authority.json", JSON.stringify(Array.from(kp.secretKey)));
// Load
const kp = Keypair.fromSecretKey(
Uint8Array.from(JSON.parse(fs.readFileSync("authority.json", "utf8")))
);
Always pass TOKEN_2022_PROGRAM_ID when working with Token-2022 mints. The default program ID in most helper functions points to the original SPL Token program. Pass the wrong one and your transactions fail with confusing errors.
Base units, not whole tokens. Every numeric parameter is in base units. With 0 decimals this is fine — 1 base unit = 1 token. With 9 decimals, BigInt(1) = 0.000000001 tokens. Always scale by 10 ** decimals.
How This Compares to Web2
| Feature | Web2 | Solana Extensions |
|---|---|---|
| Issue a credential | API call, DB entry | Mint a token with extensions |
| Holder keeps it forever | Require them to log in to see it | Non-Transferable: it just lives in their wallet |
| Revoke silently | Flip a flag in your DB, holder doesn't know | Permanent Delegate: authority burns it without consent |
| Verify credential | Call your API | Query the chain for the token balance |
| Cost | Your servers, your infrastructure | Minimal (rent-exempt account on Solana) |
The shift: you don't control the infrastructure. The token is self-custody. The rules are on-chain. The program enforces them, not your API.
Going Deeper
The official Token Extensions documentation covers every extension with parameters, use cases, and CLI examples. If you're following the 100 Days of Solana challenge, the extension challenges build on each other across several days — start with a basic mint, add metadata, add transfer fees, then combine everything.
Extensions I haven't tried yet that look interesting:
- Interest-bearing — display a time-adjusted balance using continuous compounding, no new tokens minted
- Confidential transfers — encrypted token amounts on a public chain
- Oracle-driven freezes — freeze accounts based on external data
The pattern is clear: token behavior is becoming a set of composable, declared rules. That's what programmable money looks like.
Built on Solana devnet. All code written in Node.js on Windows — no CLI available, every challenge solved programmatically using @solana/web3.js and @solana/spl-token.
Top comments (0)