Skip to main content

How to Require Memos on Incoming Transfers Using Solana Token Extensions

Created on
Updated on
Dec 17, 2024

20 min read

Overview

Token Extensions (aka Token-2022 program) are a new primitive enabled by Solana Labs that offers developers enhanced flexibility and control, allowing for intricate tokenomic structures and implementation of web3 tokens. In this guide, we will dig into one of the new Account features associated with the Token-2022 program, required memos for incoming transactions (akin to bank transfers).

What You Will Do

In this guide, you will test sending tokens using Token-2022's Required Memo extension:

  1. Mint a token using the Token-2022 Program
  2. Test various scenarios of sending tokens with and without memo instructions
  3. Toggle an account's memo requirement on and off

What You Will Need

DependencyVersion
node.js18.12.1
tsc5.0.2
ts-node10.9.1
solana-cli1.14.16

Step 1 - Set Up Your Environment

Let's create a new Node.js project and install the Solana-Web3.js library. In your terminal, enter the following commands in order:

mkdir token-2022-memo && cd token-2022-memo
npm init -y # or yarn init -y
npm install @solana/web3.js@1 @solana/spl-token @solana/spl-memo # or yarn add @solana/web3.js@1 @solana/spl-token @solana/spl-memo
echo > app.ts

Open the app.ts file in your favorite editor and add the following imports:

// Import necessary functions and constants from the Solana web3.js and SPL Token packages
import {
sendAndConfirmTransaction,
Connection,
Keypair,
SystemProgram,
Transaction,
LAMPORTS_PER_SOL,
PublicKey,
SendTransactionError,
TransactionSignature,
SignatureStatus,
TransactionConfirmationStatus
} from '@solana/web3.js';
import {
createMint,
createEnableRequiredMemoTransfersInstruction,
createInitializeAccountInstruction,
disableRequiredMemoTransfers,
enableRequiredMemoTransfers,
getAccountLen,
ExtensionType,
TOKEN_2022_PROGRAM_ID,
mintTo,
createAssociatedTokenAccountIdempotent,
createTransferCheckedInstruction,
unpackAccount,
getMemoTransfer
} from '@solana/spl-token';
import { createMemoInstruction } from '@solana/spl-memo';

We import necessary dependencies from @solana/web3.js, @solana/spl-token, and @solana/spl-memo. We will use these to create, mint, and transfer Token-2022 tokens. There are a couple of things here that are specific to memo transfers: createEnableRequiredMemoTransfersInstruction, disableRequiredMemoTransfers, enableRequiredMemoTransfers, getAccountLen, and getMemoTransfer. We will cover these in more detail later in the guide.

Let's define a helper function to confirm transactions. Add the following to your app.ts file:

    async function confirmTransaction(
connection: Connection,
signature: TransactionSignature,
desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
timeout: number = 30000,
pollInterval: number = 1000,
searchTransactionHistory: boolean = false
): Promise<SignatureStatus> {
const start = Date.now();

while (Date.now() - start < timeout) {
const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });

if (!statuses || statuses.length === 0) {
throw new Error('Failed to get signature status');
}

const status = statuses[0];

if (status === null) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}

if (status.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
}

if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
return status;
}

if (status.confirmationStatus === 'finalized') {
return status;
}

await new Promise(resolve => setTimeout(resolve, pollInterval));
}

throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
}

Finally, create an async function called main and add the following code:

async function main() {

// Initialize connection to local Solana node
const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

// Amount of Tokens to transfer
const decimals = 9;
const transferAmount = BigInt(1_000 * Math.pow(10, decimals)); // Transfer 1,000 tokens

// Define Keypair - payer and owner of the source account
const payer = Keypair.generate();

// Define Keypair - mint authority
const mintAuthority = Keypair.generate();

// Define Keypair - destination account (owner of the destination account)
const owner = Keypair.generate();

// Define destination account (Token Account subject to memo requirement)
const destinationKeypair = Keypair.generate();
const destination = destinationKeypair.publicKey;

// 1 - Request an airdrop for payer
const airdropSignature = await connection.requestAirdrop(payer.publicKey, 2 * LAMPORTS_PER_SOL);
await confirmTransaction(connection, airdropSignature);

// 2 - Create a mint


// 3 - Create a destination account with memo requirement enabled


// 4 - Mint tokens to source account (owned by the payer)


// 5 - Create a transfer instruction


// 6 - Try to send a transaction without a memo (should fail)


// 7 - Try to send a transaction with a memo (should succeed)


// 8 - Disable required memo transfers


// 9 - Try to send a transaction without a memo (should succeed)


// 10 - Verify the memo requirement toggle

}

// Call the main function
main().then(() => {
console.log("🎉 - Demo complete.");
}).catch((err) => {
console.error("⚠️ - Demo failed: ", err);
});

We are outlining the steps we will take to create, mint, and run some tests with our Token-2022 tokens. We have added the first steps: define several important constants and airdrop some SOL to our payer account. This is necessary to pay for the transaction fees. Here's a summary of our declarations:

  • connection - the connection to a local Solana cluster (if you prefer to use devnet or mainnet, simply change the Connection URL to your QuickNode RPC endpoint)
  • decimals - the number of decimals for our token
  • transferAmount - the number of tokens we will transfer as a BigInt
  • payer - the account that will pay for the transaction fees and own the token source account
  • mintAuthority - the account that will have minting authority
  • owner - the account that will own the destination account
  • destinationKeypair - the keypair for the destination account
  • destination - the public key for the destination token account

Let's build out the remaining steps.

Step 2 - Create a New Token

Let's start by creating our new token.

    // 2 - Create a mint
const mint = await createMint(
connection,
payer,
mintAuthority.publicKey,
mintAuthority.publicKey,
decimals,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID
);

We use the common createMint method and make sure to pass in the TOKEN_2022_PROGRAM_ID. If you do not use the TOKEN_2022_PROGRAM_ID, you will not be able to utilize the memo requirement functionality (the default value is the older version of the token program). If you have completed another one of our Token-2022 guides, you might be asking, why are we not using the Token-2022 extensions when initializing our mint? The reason is that the memo requirement is an Account-level requirement, not a Mint-level requirement. This means the memo requirement is set when initializing a new token holder's account. Let's do that next.

Step 3 - Mint Tokens to Owner

Now that we have a token mint let's mint some tokens! Add the following to your main() function:

    // 3 - Create a destination account with memo requirement enabled
const accountLen = getAccountLen([ExtensionType.MemoTransfer]);
const lamports = await connection.getMinimumBalanceForRentExemption(accountLen);
const transaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: destination,
space: accountLen,
lamports,
programId: TOKEN_2022_PROGRAM_ID,
}),
createInitializeAccountInstruction(destination, mint, owner.publicKey, TOKEN_2022_PROGRAM_ID),
createEnableRequiredMemoTransfersInstruction(destination, owner.publicKey)
);
await sendAndConfirmTransaction(connection, transaction, [payer, owner, destinationKeypair], undefined);

In this section, we use the getAccountLen function (similar to getMintLen but targeted at Accounts using Token-2022 extensions) to determine the account size we will create. We then use the getMinimumBalanceForRentExemption function to determine the minimum Lamport balance required for the account to maintain rent-exempt status.

We use the SystemProgram.createAccount and createInitializeAccountInstruction to initialize the new token account. Make sure to pass the TOKEN_2022_PROGRAM_ID as the program ID for both instructions, or you will not be able to use the memo requirement functionality. Note that we cannot use the createAssociatedTokenAccount function because we need to use a custom account size and lamports due to the use of the Token 2022 extension.

Finally, we use a new function, createEnableRequiredMemoTransfersInstruction, to enable the memo requirement for the new token account. This function takes the following parameters: the account and the authority. Send the transaction to the cluster and confirm it using sendAndConfirmTransaction before moving on to the next step.

Step 4 - Mint Tokens to Source Account

Now that we have a destination account, we need to mint some tokens to a source account. Add the following to your main() function:

    // 4 - Mint tokens to source account (owned by the payer)
const sourceAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, payer.publicKey, {}, TOKEN_2022_PROGRAM_ID);

await mintTo(
connection,
payer,
mint,
sourceAccount,
mintAuthority,
Number(transferAmount) * 10,
[],
undefined,
TOKEN_2022_PROGRAM_ID
);

We use two familiar functions for minting tokens to our source account:

  • createAssociatedTokenAccountIdempotent - creates an associated token account for the payer account. Unlike our destination, we can use the createAssociatedTokenAccountIdempotent function because we are not using any Token-2022 extensions for this account.
  • mintTo - mints tokens to the sourceAccount using the mintAuthority account. Note that we need to convert our transferAmount from a BigInt to a Number for this function.

For both instructions, we are careful to pass the TOKEN_2022_PROGRAM_ID as the program ID to ensure compatibility with our mint.

Step 5 - Create a Token Transfer Instruction

Now that we have our accounts and token set up let's create a few scenarios to test the memo requirement. We will start by creating a token transfer instruction that can be used across our tests. Add the following to your main() function:

    // 5 - Create a transfer instruction
const ix = createTransferCheckedInstruction(
sourceAccount,
mint,
destination,
payer.publicKey,
transferAmount,
decimals,
undefined,
TOKEN_2022_PROGRAM_ID
)

This is a simple transfer instruction that uses the createTransferCheckedInstruction function. This function takes the following parameters:

  • sourceAccount - the source token account for the transfer
  • mint - the mint for the token
  • destination - the destination token account for the transfer
  • owner (payer.publicKey) - the owner of the source token account
  • amount (transferAmount) - the number of tokens to transfer
  • decimals - the number of decimals in the transfer amount ("Checked" instructions require this parameter to ensure the amount passed is correctly accounting for the number of decimals the token has)
  • multiSigners - n/a for this demonstration
  • programId (TOKEN_2022_PROGRAM_ID) - the program ID for the token program

Step 6 - Send a Transaction Without a Memo

Now that we have our token transfer instruction, let's try to send a transaction without a memo. This transaction should fail since our destination account has the memo requirement enabled. Add the following to your main() function:

    // 6 - Try to send a transaction without a memo (should fail)
try {
const failedTx = new Transaction().add(ix);
const failedTxSig = await sendAndConfirmTransaction(connection, failedTx, [payer], undefined);
console.log("❌ - This should have failed, but didn't. Tx: ", failedTxSig);
} catch (e) {
if (e instanceof SendTransactionError && e.logs) {
const errorMessage = e.logs.join('\n');
if (errorMessage.includes("No memo in previous instruction")) {
// https://github.com/solana-labs/solana-program-library/blob/d755eae17e0a2220f31bfc69548a78be832643af/token/program-2022/src/error.rs#L143
console.log("✅ - Transaction failed without memo (memo is required).");
} else {
console.error(`❌ - Unexpected error: ${errorMessage}`);
}
} else {
console.error(`❌ - Unknown error: ${e}`);
}
}

Here we are creating a new Transaction, failedTx (given this name because we expect it to fail), and adding our token transfer instruction to it. We then send the transaction to the cluster and confirm it using sendAndConfirmTransaction. If the transaction fails, we catch the error and check the logs for the error message. We print a success message if the error message contains the expected error ("No memo in previous instruction"). Otherwise, we print an error message.

Step 7 - Send a Transaction With a Memo

Now that we have created a test to check that our memo requirement blocks transactions without a memo, let's try testing a transaction with a memo. Add the following to your main() function:

    // 7 - Try to send a transaction with a memo (should succeed)
try {
const memo = createMemoInstruction("QuickNode demo.");
const memoTx = new Transaction().add(memo, ix);
await sendAndConfirmTransaction(connection, memoTx, [payer], undefined);
console.log("✅ - Successful transaction with memo (memo is required).");
} catch (e) {
console.error("❌ - Something went wrong. Tx failed unexpectedly: ", e);
}

Similar to our previous step, we create a new Transaction, memoTx. Unlike our previous step, we create and add a memo instruction to the transaction using the createMemoInstruction function. One important note here is that the memo instruction must be added before the token transfer instruction (.add(memo,ix) works, but .add(ix,memo) does not). We then send the transaction to the cluster and confirm it using sendAndConfirmTransaction. If the transaction fails, we catch the error and print an error message. Otherwise, we print a success message.

So now we have tested the memo requirement for our token account (both ensuring that transactions without a memo fail and that transactions with a memo succeed). What if we want to disable the requirement? Let's try that next.

Step 8 - Disable the Memo Requirement

Solana has added a very easy-to-use function, disableRequiredMemoTransfers to the SPL token library. Go ahead and add it to your main() function:

    // 8 - Disable required memo transfers
await disableRequiredMemoTransfers(connection, payer, destination, owner);

This function will require you to pass a Connection, a fee payer, the account you would like to update, and the owner/authority of the account. It will then send a transaction to the cluster to disable the memo requirement.

Let's check that it worked as intended.

Step 9 - Verify the Memo Requirement is Disabled

Now that the memo requirement has been disabled, we should be able to send a transaction without a memo. Add the following to your main() function:

    // 9 - Try to send a transaction without a memo (should succeed)
try {
const noMemoTx = new Transaction().add(ix);
await sendAndConfirmTransaction(connection, noMemoTx, [payer], undefined);
console.log("✅ - Successful transaction without memo (memo is NOT required).");
} catch (e) {
console.error("❌ - Something went wrong. Tx failed unexpectedly: ", e);
}

This is very similar to our first test, but we expect the transaction to succeed this time. We create a new Transaction, noMemoTx, add our token transfer instruction to it, and send it to the cluster. If the transaction fails, we catch the error and print an error message. Otherwise, we print a success message.

Though this works for a simple test here, what if we need to check whether a token account has the memo requirement enabled or disabled? Let's try that next.

Step 10 - Check if the Memo Requirement is Enabled

For our tests above, we have known (or had a good idea) the state of the account's memo requirement (we initialed it as required and later disabled it). What if we did not know? We need a way to look it up. Let's create a function to check it out. Below your main() function, add the following function:

async function verifyMemoRequirement(tokenAccount: PublicKey, connection: Connection): Promise<boolean> {
const accountInfo = await connection.getAccountInfo(tokenAccount);
const account = unpackAccount(tokenAccount, accountInfo, TOKEN_2022_PROGRAM_ID);
const memoDetails = getMemoTransfer(account);
if (!memoDetails) {
throw new Error("Memo details not found.");
}
return memoDetails.requireIncomingTransferMemos;
}

Our async function accepts a token account address and a Connection and returns a promise for a boolean (whether or not a memo is required to send tokens to the account). Here's how it works:

  1. We get the account info for the token account using getAccountInfo().
  2. We unpack the account using unpackAccount(). This will return an Account object with the account's data.
  3. We get the memo details using getMemoTransfer(). This will return an object with a boolean value, requireIncomingTransferMemos, which we return.

Now let's call it in our main() function to verify that the memo requirement is disabled. Add the following to your main() function:

    // 10 - Verify the memo requirement toggle
let isMemoRequired = await verifyMemoRequirement(destination, connection);
if (isMemoRequired) {
console.log("❌ - Something's wrong. Expected memo requirement to be disabled.");
} else {
console.log("✅ - Memo requirement disabled.");
}

await enableRequiredMemoTransfers(connection, payer, destination, owner);

isMemoRequired = await verifyMemoRequirement(destination, connection);
if (isMemoRequired) {
console.log("✅ - Memo requirement enabled.");
} else {
console.log("❌ - Something's wrong. Expected memo to be required.");
}

We call our verifyMemoRequirement() function by passing the destination account and the Solana connection. We then check the returned value and print a success or error message.

We then run enableRequiredMemoTransfers (which is just the opposite of disableRequiredMemoTransfers) to re-enable the memo requirement. We then call our verifyMemoRequirement() function again and check that it worked as expected.

Run the Code

Finally, in a separate terminal, run the following command to start a local Solana cluster:

solana-test-validator

And in your main terminal, run your script:

ts-node app.ts

You should see output similar to the following:

% ts-node memo
✅ - Transaction failed without memo (memo is required).
✅ - Successful transaction with memo (memo is required).
✅ - Successful transaction without memo (memo is NOT required).
✅ - Memo requirement disabled.
✅ - Memo requirement enabled.
🎉 - Demo complete.

Great job!


Bonus - Implement a Memo Requirement on an Existing Account

In our guide, we created a new token account that we initialized with the memo requirement. What if we wanted to add the memo requirement to an existing account?

We can do this by using the createReallocateInstruction() function from the SPL token library. This function allows us to add additional lamports to an account based on the Extensions that are passed in. We can pass in the MemoTransfer ExtensionType to ensure the correct reallocation. We can then use the createEnableRequiredMemoTransfersInstruction() function to enable the memo requirement.

Feel free to add # 11 below into your main() function to try it out (make sure to update your imports).

    // Additional imports required
import { createAccount, createReallocateInstruction } from '@solana/spl-token';

// ...

// 11 - Bonus - add memo requirement to an existing account
try {
// Create a new token account without a memo requirement
const newOwner = Keypair.generate();
const bonusAccount = await createAccount(
connection,
payer,
mint,
newOwner.publicKey,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID
);

const extensions = [ExtensionType.MemoTransfer];
const addExtensionTx = new Transaction().add(
// Create a reallocate instruction to add lamports for the the memo requirement
createReallocateInstruction(
bonusAccount,
payer.publicKey,
extensions,
newOwner.publicKey
),
// Create an instruction to enable the memo requirement
createEnableRequiredMemoTransfersInstruction(bonusAccount, newOwner.publicKey)
);
await sendAndConfirmTransaction(connection, addExtensionTx, [payer, newOwner]);
console.log("✅ - Memo requirement added to existing account.");
} catch (e) {
console.error("❌ - Something went wrong. Tx failed unexpectedly: ", e);
}

Wrap Up

Let's recap what we did here:

  • We created and minted a new Token-2022 token.
  • We created a new token account that has a memo requirement.
  • We tried to send tokens to the account without a memo (it failed).
  • We tried to send tokens to the account with a memo (it succeeded).
  • We disabled the memo requirement.
  • We tried to send tokens to the account without a memo (it succeeded).
  • We checked that the memo requirement was disabled, enabled it again, and checked that it is enabled.

Hopefully, this guide has helped you understand how the memo requirement feature of the Token-2022 Program works.

We would love to hear more about what you are building and how you plan to use Token-2022 for your projects. Drop us a line in Discord, or give us a follow on Twitter to stay up to date on all the latest information!

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide