Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ spl-pod = "0.5.1"
spl-tlv-account-resolution = "0.10.0"
spl-token = { version = "8.0.0", features = ["no-entrypoint"] }
spl-token-2022 = { version = "9.0.0", features = ["no-entrypoint"] }
spl-token-metadata-interface = "0.7.0"
spl-token-wrap = { path = "program", features = ["no-entrypoint"] }
spl-transfer-hook-interface = "0.10.0"
tempfile = "3.20.0"
Expand Down
1 change: 1 addition & 0 deletions clients/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ solana-system-interface = { workspace = true }
solana-transaction = { workspace = true }
spl-associated-token-account-client = { workspace = true }
spl-token = { workspace = true }
spl-token-metadata-interface = { workspace = true }
spl-token-wrap = { workspace = true }
spl-token-2022 = { workspace = true }
tokio = { workspace = true }
Expand Down
12 changes: 10 additions & 2 deletions clients/cli/src/create_mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ use {
solana_signature::Signature,
solana_system_interface::instruction::transfer,
solana_transaction::Transaction,
spl_token::solana_program::program_pack::Pack,
spl_token_wrap::{
get_wrapped_mint_address, get_wrapped_mint_backpointer_address, id,
get_wrapped_mint_address, get_wrapped_mint_authority, get_wrapped_mint_backpointer_address,
id,
instruction::create_mint,
mint_customizer::{
default_token_2022::DefaultToken2022Customizer, interface::MintCustomizer,
Expand Down Expand Up @@ -120,7 +122,11 @@ pub async fn command_create_mint(config: &Config, args: CreateMintArgs) -> Comma
Err(_) => 0,
};

let mint_size = DefaultToken2022Customizer::get_token_2022_mint_space()?;
let mint_size = if args.wrapped_token_program == spl_token_2022::id() {
DefaultToken2022Customizer::get_token_2022_total_space()?
} else {
spl_token::state::Mint::LEN
};
let mint_rent = rpc_client
.get_minimum_balance_for_rent_exemption(mint_size)
.await?;
Expand Down Expand Up @@ -171,11 +177,13 @@ pub async fn command_create_mint(config: &Config, args: CreateMintArgs) -> Comma
}

// Add the create_mint instruction
let wrapped_mint_authority_address = get_wrapped_mint_authority(&wrapped_mint_address);
instructions.push(create_mint(
&id(),
&wrapped_mint_address,
&wrapped_backpointer_address,
&args.unwrapped_mint,
&wrapped_mint_authority_address,
&args.wrapped_token_program,
args.idempotent,
));
Expand Down
44 changes: 36 additions & 8 deletions clients/cli/tests/test_create_mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ use {
crate::helpers::{create_unwrapped_mint, execute_create_mint, setup_test_env},
serial_test::serial,
solana_program_pack::Pack,
solana_pubkey::Pubkey,
spl_token::{self, state::Mint as SplTokenMint},
spl_token_2022::{
extension::{
confidential_transfer::ConfidentialTransferMint, BaseStateWithExtensions,
PodStateWithExtensions,
confidential_transfer::ConfidentialTransferMint, metadata_pointer::MetadataPointer,
BaseStateWithExtensions, PodStateWithExtensions,
},
pod::PodMint,
},
spl_token_metadata_interface::state::TokenMetadata,
spl_token_wrap::{
self, get_wrapped_mint_address, get_wrapped_mint_backpointer_address, state::Backpointer,
self, get_wrapped_mint_address, get_wrapped_mint_authority,
get_wrapped_mint_backpointer_address, state::Backpointer,
},
};

Expand Down Expand Up @@ -57,13 +60,38 @@ async fn test_create_mint() {
unwrapped_mint_data.decimals
);

// Verify confidential transfer extension is present
// Verify backpointer data
let backpointer = *bytemuck::from_bytes::<Backpointer>(&backpointer_account.data);
assert_eq!(backpointer.unwrapped_mint, unwrapped_mint);

// Verify extension state
assert_eq!(wrapped_mint_state.get_extension_types().unwrap().len(), 3);

assert!(wrapped_mint_state
.get_extension::<ConfidentialTransferMint>()
.is_ok());
assert_eq!(wrapped_mint_state.get_extension_types().unwrap().len(), 1);

// Verify backpointer data
let backpointer = *bytemuck::from_bytes::<Backpointer>(&backpointer_account.data);
assert_eq!(backpointer.unwrapped_mint, unwrapped_mint);
// Verify MetadataPointer content
let pointer_ext = wrapped_mint_state
.get_extension::<MetadataPointer>()
.unwrap();
let expected_mint_authority = get_wrapped_mint_authority(&wrapped_mint_address);
assert_eq!(
Option::<Pubkey>::from(pointer_ext.authority).unwrap(),
expected_mint_authority
);
assert_eq!(
Option::<Pubkey>::from(pointer_ext.metadata_address).unwrap(),
wrapped_mint_address
);

// Verify TokenMetadata content
let metadata_ext = wrapped_mint_state
.get_variable_len_extension::<TokenMetadata>()
.unwrap();
assert_eq!(
Option::<Pubkey>::from(metadata_ext.update_authority).unwrap(),
expected_mint_authority
);
assert_eq!(metadata_ext.mint, wrapped_mint_address);
}
1 change: 1 addition & 0 deletions program/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ solana-sysvar = { workspace = true }
spl-associated-token-account-client = { workspace = true }
spl-pod = { workspace = true }
spl-token = { workspace = true }
spl-token-metadata-interface = { workspace = true }
spl-transfer-hook-interface = { workspace = true }
thiserror = { workspace = true }

Expand Down
2 changes: 2 additions & 0 deletions program/src/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,15 @@ pub fn create_mint(
wrapped_mint_address: &Pubkey,
wrapped_backpointer_address: &Pubkey,
unwrapped_mint_address: &Pubkey,
wrapped_mint_authority_address: &Pubkey,
wrapped_token_program_id: &Pubkey,
idempotent: bool,
) -> Instruction {
let accounts = vec![
AccountMeta::new(*wrapped_mint_address, false),
AccountMeta::new(*wrapped_backpointer_address, false),
AccountMeta::new_readonly(*unwrapped_mint_address, false),
AccountMeta::new_readonly(*wrapped_mint_authority_address, false),
AccountMeta::new_readonly(solana_system_interface::program::id(), false),
AccountMeta::new_readonly(*wrapped_token_program_id, false),
];
Expand Down
92 changes: 79 additions & 13 deletions program/src/mint_customizer/default_token_2022.rs
Original file line number Diff line number Diff line change
@@ -1,35 +1,52 @@
use {
crate::mint_customizer::interface::MintCustomizer,
crate::{get_wrapped_mint_authority, mint_customizer::interface::MintCustomizer},
solana_account_info::AccountInfo,
solana_cpi::invoke,
solana_cpi::{invoke, invoke_signed},
solana_program_error::{ProgramError, ProgramResult},
solana_pubkey::Pubkey,
spl_token_2022::{
extension::{
confidential_transfer::instruction::initialize_mint as initialize_confidential_transfer_mint,
ExtensionType, PodStateWithExtensions,
metadata_pointer::instruction::initialize as initialize_metadata_pointer,
ExtensionType::{self},
PodStateWithExtensions,
},
pod::PodMint,
state::Mint,
},
spl_token_metadata_interface::{
instruction::initialize as initialize_token_metadata, state::TokenMetadata,
},
};

/// This implementation adds the `ConfidentialTransferMint` extension by
/// default.
/// This implementation adds the `ConfidentialTransferMint` & `TokenMetadata`
/// extensions by default.
pub struct DefaultToken2022Customizer;

impl MintCustomizer for DefaultToken2022Customizer {
fn get_token_2022_mint_space() -> Result<usize, ProgramError> {
let extensions = vec![ExtensionType::ConfidentialTransferMint];
ExtensionType::try_calculate_account_len::<Mint>(&extensions)
fn get_token_2022_mint_initialization_space() -> Result<usize, ProgramError> {
// Calculate space for all extensions that are initialized *before* the base
// mint. The TokenMetadata extension is initialized *after* and its
// `initialize` instruction handles its own reallocation.
ExtensionType::try_calculate_account_len::<Mint>(&[
ExtensionType::ConfidentialTransferMint,
ExtensionType::MetadataPointer,
])
}

fn get_token_2022_total_space() -> Result<usize, ProgramError> {
let base_size = Self::get_token_2022_mint_initialization_space()?;
let metadata_size = TokenMetadata::default().tlv_size_of()?;
base_size
.checked_add(metadata_size)
.ok_or(ProgramError::ArithmeticOverflow)
}

fn initialize_extensions(
fn pre_initialize_extensions(
wrapped_mint_account: &AccountInfo,
_unwrapped_mint_account: &AccountInfo,
wrapped_token_program_account: &AccountInfo,
_all_accounts: &[AccountInfo],
) -> ProgramResult {
// Initialize confidential transfer ext
invoke(
&initialize_confidential_transfer_mint(
wrapped_token_program_account.key,
Expand All @@ -39,12 +56,61 @@ impl MintCustomizer for DefaultToken2022Customizer {
None, // No auditor can decrypt transaction amounts.
)?,
&[wrapped_mint_account.clone()],
)
)?;

// Initialize metadata pointer
let wrapped_mint_authority = get_wrapped_mint_authority(wrapped_mint_account.key);
invoke(
&initialize_metadata_pointer(
wrapped_token_program_account.key,
wrapped_mint_account.key,
Some(wrapped_mint_authority),
Copy link

@buffalojoec buffalojoec Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you also going to allow this to be updated? For metaplex metadata accounts, this is actually a PDA, and for Token-2022 metadata-enabled mints, this authority can be separate from the mint authority.

Is it possible to first check if the metadata extensions exists already and if so, inherit those first?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have four cases that need to be handled after this:

  • SPL token -> Token-2022: an instruction to copy metaplex metadata into the token2022 metadata.
  • Token-2022 -> Token-2022: an instruction to copy token-2022 metadata from the unwrapped mint to the wrapped token-2022 metadata
  • Token-2022 -> SPL token: an instruction to create & update metaplex metadata (copied from token-2022 ext metadata)
  • SPL token -> SPL token: an instruction to create & update metaplex metadata (copied from existing metaplex data)

These should exist to essentially sync metadata state from the unwrapped to wrapped.

Is it possible to first check if the metadata extensions exists already and if so, inherit those first?

I worry about moving some of the above's case branching into the initialization logic. It seems simpler to separate this complexity into the individual instructions handling those. I don't think it should be too difficult for the consumer to make an additional call to update. What do you think?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will I be able to call "SyncMetadata" again if my unwrapped token's metadata changes? Also, if my unwrapped token has no metadata, am I paying extra rent on the wrapped mint for no reason?

This is kind of a tradeoff, since if we only added metadata for tokens who have it during the time of wrapping, then we lose the ability to sync newly created metadata on the unwrapped token with the wrapped token.

Nonetheless I think it's ok to leave this as the program's mint authority PDA as long as it never goes beyond sync.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will I be able to call "SyncMetadata" again if my unwrapped token's metadata changes?

Yes, I was intending for that subsequent instruction to be something that can be repeatedly called

if my unwrapped token has no metadata, am I paying extra rent on the wrapped mint for no reason?

Ah, this is an important point I think. As of this PR, for token-2022 wrapped mints, the answer is yes. If we were to move initialize_token_metadata() to a dedicated instruction, this would move this from opt-out to opt-in. Think that is probably best. Will follow up with a PR for that.

Copy link

@buffalojoec buffalojoec Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the only gotcha there is that you can create Metaplex token metadata for your mint anytime after it's been initialized, as long as you hold the mint authority. However, for Token-2022, the MetadataPointer extension can only be added before the mint is initialized.

So, if you have no metadata on an SPL Token, and you wrap it under Token-2022, then later you create metadata for your unwrapped mint via Metaplex, you wouldn't be able to add metadata to the already-wrapped mint. You'd have to burn it all and re-wrap.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, don't think we could ever get around removing the MetadataPointer extension. We'd have to continue adding that to each mint.

Some(*wrapped_mint_account.key),
)?,
&[wrapped_mint_account.clone()],
)?;

Ok(())
}

fn post_initialize_extensions<'a>(
wrapped_mint_account: &AccountInfo<'a>,
wrapped_token_program_account: &AccountInfo,
wrapped_mint_authority_account: &AccountInfo<'a>,
mint_authority_signer_seeds: &[&[u8]],
) -> ProgramResult {
// Initialize metadata ext (must be done after mint initialization)
let wrapped_mint_authority = get_wrapped_mint_authority(wrapped_mint_account.key);

let cpi_accounts = [
wrapped_mint_account.clone(),
wrapped_mint_authority_account.clone(),
wrapped_mint_account.clone(),
wrapped_mint_authority_account.clone(),
];

invoke_signed(
&initialize_token_metadata(
wrapped_token_program_account.key,
wrapped_mint_account.key,
&wrapped_mint_authority,
wrapped_mint_account.key,
&wrapped_mint_authority,
// Initialized as empty, but separate instructions are available
// to update these fields
"".to_string(),
"".to_string(),
"".to_string(),
),
&cpi_accounts,
&[mint_authority_signer_seeds],
)?;

Ok(())
}

fn get_freeze_auth_and_decimals(
unwrapped_mint_account: &AccountInfo,
_all_accounts: &[AccountInfo],
) -> Result<(Option<Pubkey>, u8), ProgramError> {
// Copy fields over from original mint
let unwrapped_mint_data = unwrapped_mint_account.try_borrow_data()?;
Expand Down
33 changes: 25 additions & 8 deletions program/src/mint_customizer/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,39 @@ use {

/// The interface for customizing attributes of the new wrapped mint.
pub trait MintCustomizer {
/// Calculates the total space required for a new spl-token-2022 mint
/// Calculates the space required for a new spl-token-2022 mint
/// account, including any custom extensions
fn get_token_2022_mint_space() -> Result<usize, ProgramError>;
fn get_token_2022_mint_initialization_space() -> Result<usize, ProgramError>;

/// Customizes initialization for the extensions for the wrapped mint
/// (only relevant if creating spl-token-2022 mint)
fn initialize_extensions(
/// Calculates the total space required for a new spl-token-2022
/// mint, after `post_initialize_extensions()` is called. This is useful in
/// calculating rent requirements if something like `TokenMetadata` does a
/// realloc after the mint is created. If not implemented, defaults to
/// `get_token_2022_mint_initialization_space()` result.
fn get_token_2022_total_space() -> Result<usize, ProgramError> {
Self::get_token_2022_mint_initialization_space()
}

/// Customizes extensions for the wrapped mint *before* the base mint is
/// initialized. This is for extensions that must be initialized on an
/// uninitialized mint account, like `ConfidentialTransferMint`.
fn pre_initialize_extensions(
wrapped_mint_account: &AccountInfo,
unwrapped_mint_account: &AccountInfo,
wrapped_token_program_account: &AccountInfo,
all_accounts: &[AccountInfo],
) -> ProgramResult;

/// Customizes extensions for the wrapped mint *after* the base mint is
/// initialized. This is for extensions that require the mint to be
/// initialized, like `TokenMetadata`.
fn post_initialize_extensions<'a>(
wrapped_mint_account: &AccountInfo<'a>,
wrapped_token_program_account: &AccountInfo,
wrapped_mint_authority_account: &AccountInfo<'a>,
mint_authority_signer_seeds: &[&[u8]],
) -> ProgramResult;

/// Customize the freeze authority and decimals for the wrapped mint
fn get_freeze_auth_and_decimals(
unwrapped_mint_account: &AccountInfo,
all_accounts: &[AccountInfo],
) -> Result<(Option<Pubkey>, u8), ProgramError>;
}
16 changes: 11 additions & 5 deletions program/src/mint_customizer/no_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,29 @@ use {
pub struct NoExtensionCustomizer;

impl MintCustomizer for NoExtensionCustomizer {
fn get_token_2022_mint_space() -> Result<usize, ProgramError> {
fn get_token_2022_mint_initialization_space() -> Result<usize, ProgramError> {
let extensions = vec![];
ExtensionType::try_calculate_account_len::<Mint>(&extensions)
}

fn initialize_extensions(
fn pre_initialize_extensions(
_wrapped_mint_account: &AccountInfo,
_unwrapped_mint_account: &AccountInfo,
_wrapped_token_program_account: &AccountInfo,
_all_accounts: &[AccountInfo],
) -> ProgramResult {
Ok(())
}

fn post_initialize_extensions<'a>(
_wrapped_mint_account: &AccountInfo<'a>,
_wrapped_token_program_account: &AccountInfo,
_wrapped_mint_authority_account: &AccountInfo<'a>,
_mint_authority_signer_seeds: &[&[u8]],
) -> ProgramResult {
Ok(())
}

fn get_freeze_auth_and_decimals(
unwrapped_mint_account: &AccountInfo,
_all_accounts: &[AccountInfo],
) -> Result<(Option<Pubkey>, u8), ProgramError> {
// Copy fields over from original mint
let unwrapped_mint_data = unwrapped_mint_account.try_borrow_data()?;
Expand Down
Loading