Summary #
- The
immutable owner
extension ensures that once a token account is created, its owner is unchangeable, securing the ownership against any modifications. - Token accounts with this extension can have only one permanent state regarding ownership: Immutable.
- Associated Token Accounts (ATAs) have the
immutable owner
extension enabled by default. - The
immutable owner
extension is a token account extension; enabled on each token account, not the mint.
Overview #
Associated Token Accounts (ATAs) are uniquely determined by the owner and the mint, streamlining the process of identifying the correct Token Account for a specific owner. Initially, any token account could change its owner, even ATAs. This led to security concerns, as users could mistakenly send funds to an account no longer owned by the expected recipient. This can unknowingly lead to the loss of funds should the owner change.
The immutable owner
extension, which is automatically applied to ATAs,
prevents any changes in ownership. This extension can also be enabled for new
Token Accounts created through the Token Extensions Program, guaranteeing that
once ownership is set it is permanent. This secures accounts against
unauthorized access and transfer attempts.
It is important to note that this extension is a Token Account extension, meaning it's on the token account, not the mint.
Creating token account with immutable owner #
All Token Extensions Program ATAs have immutable owners enabled by default. If
you want to create an ATA you may use createAssociatedTokenAccount
.
Outside of ATAs, which enable the immutable owner extension by default, you can enable it manually on any Token Extensions Program token account.
Initializing a token account with immutable owner involves three instructions:
SystemProgram.createAccount
createInitializeImmutableOwnerInstruction
createInitializeAccountInstruction
Note: We are assuming a mint has already been created.
The first instruction SystemProgram.createAccount
allocates space on the
blockchain for the token account. This instruction accomplishes three things:
- Allocates
space
- Transfers
lamports
for rent - Assigns to its owning program
const tokenAccountKeypair = Keypair.generate();
const tokenAccount = tokenAccountKeypair.publicKey;
const extensions = [ExtensionType.ImmutableOwner];
const tokenAccountLen = getAccountLen(extensions);
const lamports =
await connection.getMinimumBalanceForRentExemption(tokenAccountLen);
const createTokenAccountInstruction = SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: tokenAccount,
space: tokenAccountLen,
lamports,
programId: TOKEN_2022_PROGRAM_ID,
});
The second instruction createInitializeImmutableOwnerInstruction
initializes
the immutable owner extension.
const initializeImmutableOwnerInstruction =
createInitializeImmutableOwnerInstruction(
tokenAccount,
TOKEN_2022_PROGRAM_ID,
);
The third instruction createInitializeAccountInstruction
initializes the token
account.
const initializeAccountInstruction = createInitializeAccountInstruction(
tokenAccount,
mint,
owner.publicKey,
TOKEN_2022_PROGRAM_ID,
);
Lastly, add all of these instructions to a transaction and send it to the blockchain.
const transaction = new Transaction().add(
createTokenAccountInstruction,
initializeImmutableOwnerInstruction,
initializeAccountInstruction,
);
transaction.feePayer = payer.publicKey;
return await sendAndConfirmTransaction(connection, transaction, [
payer,
owner,
tokenAccountKeypair,
]);
When the transaction with these three instructions is sent, a new token account is created with the immutable owner extension.
Lab #
In this lab, we'll be creating a token account with an immutable owner. We'll then write tests to check if the extension is working as intended by attempting to transfer ownership of the token account.
1. Setup Environment #
To get started, create an empty directory named immutable-owner
and navigate
to it. We'll be initializing a brand new project. Run npm init -y
to make a
project with defaults.
Next, we'll need to add our dependencies. Run the following to install the required packages:
npm i @solana-developers/helpers @solana/spl-token @solana/web3.js esrun dotenv typescript
Create a directory named src
. In this directory, create a file named
index.ts
. This is where we will run checks against the rules of this
extension. Paste the following code in index.ts
:
import {
AuthorityType,
TOKEN_2022_PROGRAM_ID,
createMint,
setAuthority,
} from "@solana/spl-token";
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
} from "@solana/web3.js";
import { initializeKeypair, makeKeypairs } from "@solana-developers/helpers";
const connection = new Connection("http://127.0.0.1:8899", "confirmed");
const payer = await initializeKeypair(connection);
const [otherOwner, mintKeypair, ourTokenAccountKeypair] = makeKeypairs(3);
const ourTokenAccount = ourTokenAccountKeypair.publicKey;
2. Run validator node #
For the sake of this guide, we'll be running our own validator node.
In a separate terminal, run the following command: solana-test-validator
. This
will run the node and also log out some keys and values. The value we need to
retrieve and use in our connection is the JSON RPC URL, which in this case is
http://127.0.0.1:8899
. We then use that in the connection to specify to use of
the local RPC URL.
const connection = new Connection("http://127.0.0.1:8899", "confirmed");
Alternatively, if you'd like to use testnet or devnet, import the
clusterApiUrl
from @solana/web3.js
and pass it to the connection as such:
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
3. Helpers #
When we pasted the index.ts
code from earlier, we added the following helpers:
initializeKeypair
: This function creates the keypair for thepayer
and also airdrops 2 testnet SOL to itmakeKeypairs
: This function creates keypairs without airdropping any SOL
Additionally, we have some initial accounts:
payer
: Used to pay for and be the authority for everythingmintKeypair
: Our mintourTokenAccountKeypair
: The token account owned by the payer that we'll use for testingotherOwner
: The token account we'll try to transfer ownership of the two immutable accounts to
4. Create mint #
Let's create the mint we'll be using for our token accounts.
Inside of src/index.ts
, the required dependencies will already be imported,
along with the aforementioned accounts. Add the following createMint
function
beneath the existing code:
// CREATE MINT
const mint = await createMint(
connection,
payer,
mintKeypair.publicKey,
null,
2,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID,
);
5. Create Token Account with immutable owner #
Remember, all ATAs come with the immutable owner
extension. However, we're
going to create a token account using a keypair. This requires us to create the
account, initialize the immutable owner extension, and initialize the account.
Inside the src
directory, create a new file named token-helper.ts
and create
a new function within it called createTokenAccountWithImmutableOwner
. This
function is where we'll be creating the associated token account with the
immutable owner. The function will take the following arguments:
connection
: The connection objectmint
: Public key for the new mintpayer
: Payer for the transactionowner
: Owner of the associated token accounttokenAccountKeypair
: The token account keypair associated with the token account
import {
ExtensionType,
TOKEN_2022_PROGRAM_ID,
createInitializeAccountInstruction,
createInitializeImmutableOwnerInstruction,
getAccountLen,
} from "@solana/spl-token";
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
export async function createTokenAccountWithImmutableOwner(
connection: Connection,
mint: PublicKey,
payer: Keypair,
owner: Keypair,
tokenAccountKeypair: Keypair,
): Promise<string> {
// Create account instruction
// Enable immutable owner instruction
// Initialize account instruction
// Send to blockchain
return "TODO Replace with signature";
}
The first step in creating the token account is reserving space on Solana with
the SystemProgram.createAccount
method. This requires specifying the
payer's keypair, (the account that will fund the creation and provide SOL for
rent exemption), the new token account's public key
(tokenAccountKeypair.publicKey
), the space required to store the token
information on the blockchain, the amount of SOL (lamports) necessary to exempt
the account from rent and the ID of the token program that will manage this
token account (TOKEN_2022_PROGRAM_ID
).
// CREATE ACCOUNT INSTRUCTION
const tokenAccount = tokenAccountKeypair.publicKey;
const extensions = [ExtensionType.ImmutableOwner];
const tokenAccountLen = getAccountLen(extensions);
const lamports =
await connection.getMinimumBalanceForRentExemption(tokenAccountLen);
const createTokenAccountInstruction = SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: tokenAccount,
space: tokenAccountLen,
lamports,
programId: TOKEN_2022_PROGRAM_ID,
});
After the token account creation, the next instruction initializes the
immutable owner
extension. The createInitializeImmutableOwnerInstruction
function is used to generate this instruction.
// ENABLE IMMUTABLE OWNER INSTRUCTION
const initializeImmutableOwnerInstruction =
createInitializeImmutableOwnerInstruction(
tokenAccount,
TOKEN_2022_PROGRAM_ID,
);
We then add the initialize account instruction by calling
createInitializeAccountInstruction
and passing in the required arguments. This
function is provided by the SPL Token package and it constructs a transaction
instruction that initializes a new token account.
// INITIALIZE ACCOUNT INSTRUCTION
const initializeAccountInstruction = createInitializeAccountInstruction(
tokenAccount,
mint,
owner.publicKey,
TOKEN_2022_PROGRAM_ID,
);
Now that the instructions have been created, the token account can be created with an immutable owner.
// SEND TO BLOCKCHAIN
const transaction = new Transaction().add(
createTokenAccountInstruction,
initializeImmutableOwnerInstruction,
initializeAccountInstruction,
);
transaction.feePayer = payer.publicKey;
const signature = await sendAndConfirmTransaction(connection, transaction, [
payer,
owner,
tokenAccountKeypair,
]);
return signature;
Now that we've added the functionality for token-helper
, we can create our
test token accounts. One of the two test token accounts will be created by
calling createTokenAccountWithImmutableOwner
. The other will be created with
the baked-in SPL helper function createAssociatedTokenAccount
. This helper
will create an associated token account which by default includes an immutable
owner. For the sake of this guide, we'll be testing against both of these
approaches.
Back in index.ts
underneath the mint variable, create the following two token
accounts:
// CREATE TEST TOKEN ACCOUNTS: Create explicitly with immutable owner instructions
const createOurTokenAccountSignature = await createTokenAccountWithImmutableOwner(
connection,
mint,
payer,
payer,
ourTokenAccountKeypair
);
// CREATE TEST TOKEN ACCOUNTS: Create an associated token account with default immutable owner
const associatedTokenAccount = await createAssociatedTokenAccount(
connection,
payer,
mint,
payer.publicKey,
undefined,
TOKEN_2022_PROGRAM_ID,
);
That's it for the token accounts! Now we can move on and start testing that the extension rules are applied correctly by running a few tests against it.
If you'd like to test that everything is working, feel free to run the script.
npx esrun src/index.ts
6. Tests #
Test trying to transfer owner
The first token account that is being created is the account is tied to
ourTokenAccountKeypair
. We'll be attempting to transfer ownership of the
account to otherOwner
which was generated earlier. This test is expected to
fail as the new authority is not the owner of the account upon creation.
Add the following code to your src/index.ts
file:
// TEST TRANSFER ATTEMPT ON IMMUTABLE ACCOUNT
try {
await setAuthority(
connection,
payer,
ourTokenAccount,
payer.publicKey,
AuthorityType.AccountOwner,
otherOwner.publicKey,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID,
);
console.error("You should not be able to change the owner of the account.");
} catch (error) {
console.log(
`✅ - We expected this to fail because the account is immutable, and cannot change owner.`,
);
}
We can now invoke the setAuthority
function by running
npx esrun src/index.ts
. We should see the following error logged out in the
terminal, meaning the extension is working as we need it to:
✅ - We expected this to fail because the account is immutable, and cannot change owner.
Test trying to transfer owner with associated token account
This test will attempt to transfer ownership to the Associated Token Account. This test is also expected to fail as the new authority is not the owner of the account upon creation.
Below the previous test, add the following try/catch:
// TEST TRANSFER ATTEMPT ON ASSOCIATED IMMUTABLE ACCOUNT
try {
await setAuthority(
connection,
payer,
associatedTokenAccount,
payer.publicKey,
AuthorityType.AccountOwner,
otherOwner.publicKey,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID,
);
console.error("You should not be able to change the owner of the account.");
} catch (error) {
console.log(
`✅ - We expected this to fail because the associated token account is immutable, and cannot change owner.`,
);
}
Now we can run npx esrun src/index.ts
. This test should log a failure message
similar to the one from the previous test. This means that both of our token
accounts are in fact immutable and working as intended.
Congratulations! We've just created token accounts and tested the immutable
owner extension! If you are stuck at any point, you can find the working code on
the solution
branch of
this repository.
Challenge #
Go create your own token account with an immutable owner.