The NonTransferable
extension makes it possible to create tokens that cannot
be transferred. This enables the creation of "soul-bound" tokens, where digital
assets are intrinsically linked to an individual. While these tokens cannot be
transferred, the owner can still burn tokens and close the Token Account. This
prevents users from being "stuck" with an unwanted asset.
In this guide, we will walk through an example of creating "soul-bound" tokens
with the NonTransferable
extension using Solana Playground. Here is the
final script.
Getting Started
Start by opening this Solana Playground link with the following starter code.
// Clientconsole.log("My address:", pg.wallet.publicKey.toString());const balance = await pg.connection.getBalance(pg.wallet.publicKey);console.log(`My balance: ${balance / web3.LAMPORTS_PER_SOL} SOL`);
If it is your first time using Solana Playground, you'll first need to create a Playground Wallet and fund the wallet with devnet SOL.
If you do not have a Playground wallet, you may see a type error within the
editor on all declarations of pg.wallet.publicKey
. This type error will clear
after you create a Playground wallet.
To get devnet SOL, run the solana airdrop
command in the Playground's
terminal, or visit this devnet faucet.
solana airdrop 5
Once you've created and funded the Playground wallet, click the "Run" button to run the starter code.
Add Dependencies
Let's start by setting up our script. We'll be using the @solana/web3.js
and
@solana/spl-token
libraries.
Replace the starter code with the following:
import {Connection,Keypair,SystemProgram,Transaction,clusterApiUrl,sendAndConfirmTransaction,} from "@solana/web3.js";import {ExtensionType,TOKEN_2022_PROGRAM_ID,createInitializeMintInstruction,createInitializeNonTransferableMintInstruction,getMintLen,mintTo,createAccount,transfer,burn,closeAccount,} from "@solana/spl-token";// Playground walletconst payer = pg.wallet.keypair;// Connection to devnet clusterconst connection = new Connection(clusterApiUrl("devnet"), "confirmed");// Transaction signature returned from sent transactionlet transactionSignature: string;
Mint Setup
First, let's define the properties of the Mint Account we'll be creating in the following step.
// Generate new keypair for Mint Accountconst mintKeypair = Keypair.generate();// Address for Mint Accountconst mint = mintKeypair.publicKey;// Decimals for Mint Accountconst decimals = 2;// Authority that can mint new tokensconst mintAuthority = pg.wallet.publicKey;
Next, let's determine the size of the new Mint Account and calculate the minimum lamports needed for rent exemption.
// Size of Mint Account with extensionconst mintLen = getMintLen([ExtensionType.NonTransferable]);// Minimum lamports required for Mint Accountconst lamports = await connection.getMinimumBalanceForRentExemption(mintLen);
With Token Extensions, the size of the Mint Account will vary based on the extensions enabled.
Build Instructions
Next, let's build the set of instructions to:
- Create a new account
- Initialize the
NonTransferable
extension - Initialize the remaining Mint Account data
First, build the instruction to invoke the System Program to create an account and assign ownership to the Token Extensions Program.
// Instruction to invoke System Program to create new accountconst createAccountInstruction = SystemProgram.createAccount({fromPubkey: payer.publicKey, // Account that will transfer lamports to created accountnewAccountPubkey: mint, // Address of the account to createspace: mintLen, // Amount of bytes to allocate to the created accountlamports, // Amount of lamports transferred to created accountprogramId: TOKEN_2022_PROGRAM_ID, // Program assigned as owner of created account});
Next, build the instruction to initialize the NonTransferable
extension for
the Mint Account.
// Instruction to initialize the NonTransferable Extensionconst initializeNonTransferableMintInstruction =createInitializeNonTransferableMintInstruction(mint, // Mint Account addressTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Lastly, build the instruction to initialize the rest of the Mint Account data. This is the same as with the original Token Program.
// Instruction to initialize Mint Account dataconst initializeMintInstruction = createInitializeMintInstruction(mint, // Mint Account Addressdecimals, // Decimals of MintmintAuthority, // Designated Mint Authoritynull, // Optional Freeze AuthorityTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Send Transaction
Next, let's add the instructions to a new transaction and send it to the
network. This will create a Mint Account with the NonTransferable
extension
enabled.
// Add instructions to new transactionconst transaction = new Transaction().add(createAccountInstruction,initializeNonTransferableMintInstruction,initializeMintInstruction,);// Send transactiontransactionSignature = await sendAndConfirmTransaction(connection,transaction,[payer, mintKeypair], // Signers);console.log("\nCreate Mint Account:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Run the script by clicking the Run
button. You can then inspect the
transaction on the SolanaFM.
Create Token Accounts
Next, let's set up two Token Accounts to demonstrate the functionality of the
NonTransferable
extension.
First, create a sourceTokenAccount
owned by the Playground wallet.
// Create Token Account for Playground walletconst sourceTokenAccount = await createAccount(connection,payer, // Payer to create Token Accountmint, // Mint Account addresspayer.publicKey, // Token Account ownerundefined, // Optional keypair, default to Associated Token Accountundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Next, generate a random keypair and use it as the owner of a
destinationTokenAccount
.
// Random keypair to use as owner of Token Accountconst randomKeypair = new Keypair();// Create Token Account for random keypairconst destinationTokenAccount = await createAccount(connection,payer, // Payer to create Token Accountmint, // Mint Account addressrandomKeypair.publicKey, // Token Account ownerundefined, // Optional keypair, default to Associated Token Accountundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);
Lastly, mint 1 token to the sourceTokenAccount
to test the non-transferrable
enforcement.
// Mint tokens to sourceTokenAccounttransactionSignature = await mintTo(connection,payer, // Transaction fee payermint, // Mint Account addresssourceTokenAccount, // Mint tomintAuthority, // Mint Authority address100, // Amountundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nMint Tokens:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Attempt Token Transfer
Next, let's try to transfer tokens from the sourceTokenAccount
to the
destinationTokenAccount
. We expect this transaction to fail due to the
NonTransferable
extension.
try {// Attempt to Transfer tokensawait transfer(connection,payer, // Transaction fee payersourceTokenAccount, // Transfer fromdestinationTokenAccount, // Transfer topayer.publicKey, // Source Token Account owner100, // Amountundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);} catch (error) {console.log("\nExpect Error:", error);}
Run the script by clicking the Run
button. You can then inspect the error in
the Playground terminal. You should see a message similar to the following:
Expect Error: { [Error: failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x25]logs:[ 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [1]','Program log: Instruction: Transfer','Program log: Transfer is disabled for this mint','Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 3454 of 200000 compute units','Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb failed: custom program error: 0x25' ] }
Burn Tokens and Close Token Account
While tokens can't be transferred, they can still be burned.
// Burn tokenstransactionSignature = await burn(connection,payer, // Transaction fee payersourceTokenAccount, // Burn frommint, // Mint Account addresspayer.publicKey, // Token Account owner100, // Amountundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nBurn Tokens:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
The Token Account can then be closed to recover the SOL that was allocated to the account. Note that the token balance must be 0.
// Close Token AccounttransactionSignature = await closeAccount(connection,payer, // Transaction fee payersourceTokenAccount, // Token Account to closepayer.publicKey, // Account to receive lamports from closed accountpayer.publicKey, // Owner of Token Accountundefined, // Additional signersundefined, // Confirmation optionsTOKEN_2022_PROGRAM_ID, // Token Extension Program ID);console.log("\nClose Token Account:",`https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,);
Run the script by clicking the Run
button. You can then inspect the
transaction on the SolanaFM.
Conclusion
The NonTransferable
mint extension enables the creation of "soul-bound"
tokens, ensuring that digital assets are bound to an individual account. This
feature enables a unique mechanism for digital ownership such as for personal
achievements, identity, or credentials that are inherently non-transferable.