TL;DR

  • What you’ll learn: How to implement sRFC 37 step by step.

  • Why it matters: This is a new standard specially used by RWAs providers and there’s not that many implementation documents out there.

  • Exo Edge: Early adopters of this standard.

Why use sRFC 37?

  • One of the most common requirements from institutions and RWA teams is the need to control who may hold/transfer their token. This can be achieved by using transfer hooks.

  • However, transfer hooks are problematic because they introduce additional overhead and complexity. That is why most tokens with transfer hooks are blacklisted in many DeFi protocols.

  • Opportunity: Using sRFC 37 allows you to implement a permissioned token without using transfer hooks.

  • The Token ACL architecture uses a permission de-escalation pattern through a trusted intermediary (the program itself), which provides an overall better product and user experience.

Prerequisites

Disclaimer: I will first discuss a few disjointed topics, then provide the code in a single section (finally, hands-on code). Skip ahead if you don’t want to follow the step-by-step explanation.

In order to implement sRFC 37, it’s necessary to understand the following concepts

Default Account State is a token 2022 extension that allows the token creator to specify whether a new token account should be set in a Frozen state to start.

Token ACL

A program developed by the Solana Foundation which implements the sRFC 37.

This program aims to introduce a novel mechanism for permissioned tokens that avoids the drawbacks of existing solutions. With it, issuers can create permissioned tokens using Token22, the Default Account State extension and an allow/block listing smart contract with delegated freezing authority (The Token ACL program itself).

Token ACL Interface

To use the token-acl program in your smart contract, you must implement its interface.

Token ACL Interface Instructions:

  • CanThawPermissionlessInstruction: This instruction will be used to thaw our whitelisted account.

  • CanFreezePermissionlessInstruction: This instruction will be used to freeze our whitelisted account (We won’t be using this for this example)

Crazy Little Thing called Extra Meta Account

What is an Extra Meta Account?

It’s a standardized, automated, and composable way for an account to store meta-account information. They are used to dynamically inject account metas into a CPI. These will be used by the Token-ACL to CPI into our program’s implementation of the Token-ACL-interface instruction (In this case, thaw-permissionless).

In other words, it’s a way by which a program can store a recipe for finding additional required accounts in an on-chain account using Type-Length-Value (TLV) encoding.

In our example, the list config meta account and the whitelisted user token meta account will be derived from extra meta accounts.

This is where meta accounts are added and then passed into the interface function (Remember the TOKEN-ACL interface?):

// interface/src/onchain/invoke_can_thaw_permissionless.rs
if let Some(validation_info) = additional_accounts
        .iter()
        .find(|&x| *x.key == validation_pubkey)
    {

        instruction
            .accounts
            .push(AccountMeta::new_readonly(validation_pubkey, false));
        cpi_account_infos.push(validation_info.clone());
        
        add_to_cpi_instruction_two::<instruction::CanThawPermissionlessInstruction>(
            &mut instruction,
            &mut cpi_account_infos,
            &validation_info.try_borrow_data()?,
            additional_accounts,
        )?;
    }

And this is where everything is marshalled into an instruction that will CPI into our Program:

// interface/src/instruction/can_thaw_permissionless.rs
pub fn can_thaw_permissionless(
    program_id: &Pubkey,
    signer: &Pubkey,
    token_account: &Pubkey,
    mint: &Pubkey,
    token_account_owner: &Pubkey,
    flag_account: &Pubkey,
) -> Instruction {
    let data = EfficientBlockAllowInstruction::CanThawPermissionless.pack();
    let accounts = vec![
        AccountMeta::new_readonly(*signer, false),
        AccountMeta::new_readonly(*token_account, false),
        AccountMeta::new_readonly(*mint, false),
        AccountMeta::new_readonly(*token_account_owner, false),
        AccountMeta::new_readonly(*flag_account, false),
    ];

    Instruction {
        program_id: *program_id,
        accounts,
        data,
    }
}

But wait… Where do I set this up?

Well, when creating the Mint account and the Mint config account, we should also create an extra_meta_thaw account:

#[account(
        init,
        payer = payer,
        space = get_extra_metas_size(),
        seeds = [token_acl_interface::THAW_EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()],
        bump
    )]
    pub extra_metas_thaw: AccountInfo<'info>,

fn get_extra_metas(config: &Pubkey) -> Result<Vec<ExtraAccountMeta>> {
    let list_config_meta = ExtraAccountMeta::new_with_pubkey(config, false, false)?;
    let whitelisted_user_meta = ExtraAccountMeta::new_with_seeds(
        &[
            Seed::AccountKey { index: 6 },
            Seed::AccountData {
                account_index: 1,
                data_index: 32,
                length: 32,
            },
        ],
        false,
        false,
    )?;
    Ok([list_config_meta, whitelisted_user_meta].to_vec())
}

fn get_extra_metas_size() -> usize {
    ExtraAccountMetaList::size_of(2).unwrap()
}

Let’s break down this code…

So first, we are injecting the Pubkey of the config list because that one will be known when we create the extra metas account, but what about the whitelisted user? Its PDA is not created yet…

Well, if we use:

ExtraAccountMeta::new_with_seeds

We can verify the metadata we pass and derive the PDA from the pubkeys. In this case, we are using the token_account, and within that account, we access the token's owner, which we use as the PDA here.

So, a quick recap of all the extraAccountMetas:

// Your meta account for any account you know the pubkey
// will be static. It will be writable.
AccountMeta::new()
// Your meta account for any account you know the pubkey
// will be static. It will not be writable.
AccountMeta::new_readonly().into()
// This will be used when we need to set the account dynamically
// The PDA here will be owned by our program
ExtraAccountMeta::new_with_seeds(
        &[
            Seed::Literal {
                bytes: b"some_string".to_vec(),
            },
            Seed::InstructionData {
                index: 1,
                length: 1, // u8
            },
            Seed::AccountKey { index: 1 },
        ],
    )
// This will be used when we need to set the account dynamically
// The PDA here will be owned by an external program.
ExtraAccountMeta::new_external_pda_with_seeds(),

Finally, Hands-on Code

First, we need to create our mint, which will be our Permissioned Token. For this article, we will use an account that bundles a few components in a clean way. This is not the only way to do it, but this is how I went about it:

// src/state/list_config.rs

// This account stores the whitelist authority, seed and mode (type of list e.g: Allow, AllowWithPermissionlessEOAs, Block)
// PDA [LIST_CONFIG_SEED, mint.key().as_ref()]
#[account]
#[derive(InitSpace)]
pub struct ListConfig {
    pub authority: Pubkey,
    pub seed: Pubkey,
    pub mode: Mode,
    pub bump: u8,
}

And here’s how our instruction handler should look:

// src/instructions/initialize_mint.rs

#[derive(Accounts)]
pub struct InitializeMint<'info> {
    pub admin: Signer<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,

    /// CHECK: This account will be created and initialized manually
    #[account(mut)]
    pub mint: UncheckedAccount<'info>,
 
    #[account(
        init,
        payer = payer,
        space = 8 + ListConfig::INIT_SPACE,
        seeds = [LIST_CONFIG_SEED, mint.key().as_ref()],
        bump
    )]
    pub list_config: Account<'info, ListConfig>,

    /// CHECK: This account will be created and initialized manually
    #[account(mut)]
    pub mint_config: UncheckedAccount<'info>,

    /// CHECK: extra metas, its checked by seeds
    #[account(
        init,
        payer = payer,
        space = get_extra_metas_size(),
        seeds = [token_acl_interface::THAW_EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()],
        bump
    )]
    pub extra_metas_thaw: AccountInfo<'info>,

    /// CHECK: This is the TOKEN-ACL program ID
    pub token_acl_program: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

Here are the functions we will use. First, we are going to create the account with the right account size for the enabled token 2022 extensions (Default Account State):

// src/instructions/initialize_mint.rs

impl<'info> InitializeMint<'info> {
		// Here we are creating the account with the Default Account State extension size
    pub fn create_mint_account(&mut self) -> Result<()> {
        let extension_types = vec![
            ExtensionType::DefaultAccountState,
        ];
        let space = ExtensionType::try_calculate_account_len::<Mint>(&extension_types)?;

        let rent = Rent::get()?;
        let lamports = rent.minimum_balance(space);

        let create_account_ix = anchor_lang::system_program::CreateAccount {
            from: self.payer.to_account_info(),
            to: self.mint.to_account_info(),
        };

        anchor_lang::system_program::create_account(
            CpiContext::new(
                self.system_program.to_account_info(),
                create_account_ix,
            ),
            lamports,
            space as u64,
            &self.token_program.key(),
        )?;
        Ok(())
    }

Now we are going to initialize the DAS extension and set it to frozen:

// src/instructions/initialize_mint.rs

// Here we are initializing the DAS extension in FROZEN state
pub fn initialize_default_account_state(&mut self) -> Result<()> {
        let init_default_state_ix = spl_token_2022::extension::default_account_state::instruction::initialize_default_account_state(
        self.token_program.key,
        &self.mint.key(),
        &AccountState::Frozen,
    )?;

        anchor_lang::solana_program::program::invoke(
            &init_default_state_ix,
            &[
                self.token_program.to_account_info(),
                self.mint.to_account_info(),
            ],
        )?;
        Ok(())
}

We are initializing the mint here since Anchor does not support initializing the extension with a macro at the time of writing:

// src/instructions/initialize_mint.rs

// Here we are initializing the mint
    pub fn initialize_mint(&mut self, decimals: u8) -> Result<()> {
        let cpi_accounts = InitializeMint2 {
            mint: self.mint.to_account_info(),
        };
        let cpi_ctx = CpiContext::new(
            self.token_program.to_account_info(),
            cpi_accounts,
        );

        token_2022::initialize_mint2(
            cpi_ctx,
            decimals,
            &self.share_class.key(),
            Some(&self.admin.key()),
        )
    }

Here, we interact with the TOKEN-ACL program to create the mint config account.

The mint config account is where all the necessary data for our program is stored to interact with Token-ACL

  • Mint: Our mint Pubkey

  • Freeze authority: It stores who the freeze authority is

  • Gating program: It's our program pubkey

#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]
pub struct MintConfig {
    pub discriminator: u8,
    pub bump: u8,
    pub enable_permissionless_thaw: PodBool,
    pub enable_permissionless_freeze: PodBool,
    pub mint: Pubkey,
    pub freeze_authority: Pubkey,
    pub gating_program: Pubkey,
}

And this is how our program will call the token-acl mint config instruction to create said account:

// src/instructions/initialize_mint.rs

// We are interfacing with the token ACL program to create the mint config account
    pub fn create_mint_config_token_acl(&mut self, gating_program: Pubkey) -> Result<()> {
        let create_config_ix = create_config(
            &token_acl::ID,
            &self.admin.key(),
            &self.admin.key(),
            &self.mint.key(),
            &gating_program,
            &self.mint_config.key(),
            &self.token_program.key(),
        );

        invoke(
            &create_config_ix,
            &[
                self.admin.to_account_info(),
                self.admin.to_account_info(),
                self.mint.to_account_info(),
                self.mint_config.to_account_info(),
                self.system_program.to_account_info(),
                self.token_program.to_account_info(),
            ],
        )?;

        Ok(())
    }

Finally, you may skip this part if you want to do it later down the line, but this is how you would interact with the token-acl program to turn on the permissionless thawing/freezing:

// src/instructions/initialize_mint.rs

 pub fn toggle_permissionless_instructions(&mut self) -> Result<()> {
        let toggle_ix = toggle_permissionless_instructions(
            &token_acl::ID,
            &self.admin.key(),
            &self.mint_config.key(),
            false,
            true,
        );

        invoke(
            &toggle_ix,
            &[
                self.admin.to_account_info(),
                self.mint_config.to_account_info(),
            ],
        )?;
        Ok(())
    }

Here, we are going to be initializing the extra account meta list, which will be used later:

// src/instructions/initialize_mint.rs
 pub fn create_extra_account_meta(&mut self) -> Result<()> {
        let extra_metas_account = &self.extra_metas_thaw;
        let metas = get_extra_metas(&self.list_config.key());
        let mut data = extra_metas_account.try_borrow_mut_data().unwrap();
        ExtraAccountMetaList::init::<
            token_acl_interface::instruction::CanThawPermissionlessInstruction,
        >(&mut data, &metas?)
        .unwrap();
        Ok(())
 }

Here is how we will set up the meta accounts for later reading by the Token-acl program. Notice that we are setting the list config meta we created earlier, but we derive the whitelisted user from the parameters passed to thawPermissionlessInterface.

(index 6 → Token account).

The size will be 2 since we only have 2 extra meta accounts:

// src/instructions/initialize_mint.rs

fn get_extra_metas(config: &Pubkey) -> Result<Vec<ExtraAccountMeta>> {
    let list_config_meta = ExtraAccountMeta::new_with_pubkey(config, false, false)?;
    let whitelisted_user_meta = ExtraAccountMeta::new_with_seeds(
        &[
            Seed::AccountKey { index: 6 },
            Seed::AccountData {
                account_index: 1,
                data_index: 32,
                length: 32,
            },
        ],
        false,
        false,
    )?;
    Ok([list_config_meta, whitelisted_user_meta].to_vec())
}

fn get_extra_metas_size() -> usize {
    ExtraAccountMetaList::size_of(2).unwrap()
}

And this is how our handler looks like:

  1. Create the Mint account

  2. Initialize the default account state

  3. Initialize the mint

  4. Create a Mint config in Token ACL

  5. Toggle permissionless thawing to true

  6. Create extra account meta

  7. Create and populate the whitelist config

// src/instructions/initialize_mint.rs

pub fn handler(ctx: Context<InitializeMint>, args: MintArgs) -> Result<()> {
    ctx.accounts.create_mint_account()?;
    ctx.accounts
        .initialize_default_account_state()?;
    ctx.accounts.initialize_mint(args.decimals)?;
    ctx.accounts.create_mint_config_token_acl(*ctx.program_id)?;
    ctx.accounts.toggle_permissionless_instructions()?;
    ctx.accounts.create_extra_account_meta()?;

    ctx.accounts.list_config.set_inner(ListConfig {
        authority: ctx.accounts.mint.key(),
        seed: ctx.accounts.list_config.key(),
        mode: Mode::Allow,
        bump: ctx.bumps.list_config,
    });
    Ok(())
}

Let’s implement the CanThawPermissionlessInstruction

Since all the accounts will be filled by the Token ACL we will be using UncheckedAccount:

// src/instructions/can_thaw_permissionless.rs

#[derive(Accounts)]
pub struct ThawPermissionless<'info> {
    /// CHECK:
    pub authority: UncheckedAccount<'info>,
    /// CHECK:
    pub token_account: UncheckedAccount<'info>,
    /// CHECK:
    pub mint: UncheckedAccount<'info>,
    /// CHECK:
    pub owner: UncheckedAccount<'info>,
    /// CHECK:
    pub flag_account: UncheckedAccount<'info>,
    /// CHECK:
    pub extra_metas: UncheckedAccount<'info>,
    /// CHECK:
    pub list_config: Account<'info, ListConfig>,
    /// CHECK:
    pub whitelisted_user: UncheckedAccount<'info>,
}

pub fn handler(ctx: Context<ThawPermissionless>) -> Result<()> {
    // 3 operation modes
    // allow: only wallets that have been allowlisted can thaw, requires previously created WhitelistedUser account
    // block: only wallets that have been blocklisted can't thaw, thawing requires WhitelistedUser to not exist
    // allow with permissionless eoas: all wallets that can sign can thaw, otherwise requires previously created WhitelistedUser account (for PDAs)
    match ctx.accounts.list_config.mode {
        Mode::Allow => {
            let mut whitelisted_user_data: &[u8] =
                &mut ctx.accounts.whitelisted_user.data.borrow_mut();
            let _ = WhitelistedUser::try_deserialize(&mut whitelisted_user_data)
                .map_err(|_| CalastoneProgramError::Unauthorized)?;

            Ok(())
        }
        Mode::AllowWithPermissionlessEOAs => {
            let pt = PodEdwardsPoint(ctx.accounts.owner.key.to_bytes());
            let mut whitelisted_user_data: &[u8] =
                &mut ctx.accounts.whitelisted_user.data.borrow_mut();
            let res = WhitelistedUser::try_deserialize(&mut whitelisted_user_data);
            require!(
                solana_curve25519::edwards::validate_edwards(&pt) || res.is_ok(),
                CalastoneProgramError::Unauthorized
            );
            Ok(())
        }
        Mode::Block => {
            let mut whitelisted_user_data: &[u8] =
                &mut ctx.accounts.whitelisted_user.data.borrow_mut();
            let res = WhitelistedUser::try_deserialize(&mut whitelisted_user_data);

            if res.is_ok() {
                Err(CalastoneProgramError::Unauthorized.into())
            } else {
                Ok(())
            }
        }
    }
}

But wait… How will Token-ACL CPI fit into my implementation?

In order for the Token ACL program to CPI into our program, we have to implement the Token-ACL Interface SPL_DISCRIMINATOR for the can_thaw_permissionless instruction:

// src/lib.rs

/// Main gating function that determines if thawing is allowed
#[instruction(discriminator = CanThawPermissionlessInstruction::SPL_DISCRIMINATOR_SLICE)]
pub fn can_thaw_permissionless(ctx: Context<ThawPermissionless>) -> Result<()> {
    instructions::can_thaw_permissionless::handler(ctx)
}

Setting your testing environment

For this example, we’ll be using LiteSVM:

describe("Init permissioned token tests", async () => {
  const tokenAclProgramPubkey = new PublicKey(
    tokenACLClient.TOKEN_ACL_PROGRAM_ADDRESS.toString()
  );
  const client = fromWorkspace("./");
  const provider = new LiteSVMProvider(client);
  provider.client.addProgramFromFile(
    tokenAclProgramPubkey,
    "tests/token-acl/token_acl.so"
  );
  const program = new anchor.Program<OurProgram>(OurProgramIDL, provider);
  const payer = new Keypair();
  const administrator = new Keypair();
  const whitelistedUser = new Keypair();
  client.airdrop(payer.publicKey, BigInt(LAMPORTS_PER_SOL * 100));
  client.airdrop(administrator.publicKey, BigInt(LAMPORTS_PER_SOL * 100));
  client.airdrop(whitelistedUser.publicKey, BigInt(LAMPORTS_PER_SOL * 100));

  const [mintShare, mintBump] = PublicKey.findProgramAddressSync(
    [
      Buffer.from("mint"),
      Buffer.from("white_stone"),
    ],
    program.programId
  );

  const [listConfig, listConfigBump] = PublicKey.findProgramAddressSync(
    [Buffer.from("list_config"), shareClass.toBuffer()],
    program.programId
  );

  const whitelistedUserTokenAccountAddress = getAssociatedTokenAddressSync(
    mintShare,
    whitelistedUser.publicKey
  );

  const [whitelistedUserConfig, whitelistedUserBump] =
    PublicKey.findProgramAddressSync(
      [listConfig.toBuffer(), whitelistedUser.publicKey.toBuffer()],
      program.programId
    );

  const [mintConfig, mintConfigBump] = PublicKey.findProgramAddressSync(
    [Buffer.from("MINT_CONFIG"), mintShare.toBuffer()],
    new PublicKey(tokenACLClient.TOKEN_ACL_PROGRAM_ADDRESS.toString())
  );

  const [extraMetasThaw, extraMetaBump] = PublicKey.findProgramAddressSync(
    [Buffer.from("thaw_extra_account_metas"), mintShare.toBuffer()],
    program.programId
  );

  const [flagAccount, flagAccountBump] = PublicKey.findProgramAddressSync(
    [Buffer.from("FLAG_ACCOUNT"), whitelistedUserTokenAccountAddress.toBuffer()],
    new PublicKey(tokenACLClient.TOKEN_ACL_PROGRAM_ADDRESS.toString())
  );

  before(async () => {

    const whitelistedUserTokenAccountInfo = await createTokenAccountInfo(
      provider,
      mintShare,
      whitelistedUser.publicKey,
      BigInt(1)
    );

    provider.client.setAccount(
      whitelistedUserTokenAccountAddress,
      whitelistedUserTokenAccountInfo
    );
  });

  describe("Permissioned token", async () => {
    it("Should add to whitelist successfully", async () => {
      await program.methods
        .addUserToShareClassWhitelist(whitelistedUser.publicKey)
        .accounts({
          administrator: administrator.publicKey,
          payer: payer.publicKey,
          shareClass: shareClass,
          listConfig: listConfig,
        })
        .accountsPartial({
          whitelistedUser: whitelistedUserConfig,
        })
        .signers([administrator, payer])
        .rpc();
      const whitelistedUserConfigData =
        await program.account.whitelistedUser.fetch(whitelistedUserConfig);
      expect(whitelistedUserConfigData.wallet.toString()).equal(
        whitelistedUser.publicKey.toString()
      );
      expect(whitelistedUserConfigData.listConfig.toString()).equal(
        listConfig.toString()
      );
    });

    it("Can thaw successfully", async () => {
      let whitelistedUserTokenAccount = getTokenAccount(
        provider.client,
        whitelistedUserTokenAccountAddress
      );
      expect(whitelistedUserTokenAccount.isFrozen).to.equal(true);
      const ix = createThawPermissionlessInstruction(
        administrator,
        mintShare,
        whitelistedUserTokenAccountAddress,
        whitelistedUser.publicKey,
        mintConfig,
        TOKEN_2022_PROGRAM_ID,
        program.programId,
        extraMetasThaw,
        listConfig,
        whitelistedUserConfig,
        flagAccount
      );
      const tx = new Transaction();
      tx.recentBlockhash = provider.client.latestBlockhash();
      tx.add(ix);
      tx.sign(payer, fundAdmin);
      const resp = provider.client.sendTransaction(tx);
      if (resp instanceof FailedTransactionMetadata) {
        console.log("Logs ", resp.meta().logs());
        assert(false, resp.err().toString());
      }
      whitelistedUserTokenAccount = getTokenAccount(
        provider.client,
        whitelistedUserTokenAccountAddress
      );
      expect(whitelistedUserTokenAccount.isFrozen).to.equal(false);
    });
   })

That’s (probably) all folks!

It’s worth noting that, at the time, the Token-ACL program is still undergoing changes, so some accounts or the interface may change, but the underlying topics (extra meta accounts, Default Account State) remain.

If you’re working on RWAs, permissioned assets, or institutional-grade tokens on Solana, sRFC 37 could be right for you. Early implementation lets you avoid transfer hook pitfalls while staying composable with the broader DeFi ecosystem.

If you’re implementing this in production and want a second set of eyes, architectural guidance, or help adapting sRFC 37 to your specific compliance or custody requirements, reach out to Exo. We’re actively working with early adopters and are here to help ship faster.

~ Exo

Reply

or to participate

Keep Reading

No posts found