Back to Research
Are sRFC 37 Permissioned Tokens replacing Transfer Hooks?

Are sRFC 37 Permissioned Tokens replacing Transfer Hooks?

Implementing Solana's new permissioned token standard without transfer hooks

P
Patricio
January 15, 20269 min read

TL;DR

  • What you’ll learn: How to 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

DisclaimerI 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

  • Token Extensions, specifically Default State Account
  • (ACL) Program (NEW)
  • / Extra Meta Accounts

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, automatic and composable way for an account to store meta accounts 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?)

Link: https://github.com/tiago18c/token-acl/blob/1509d0b970f5247376ecf6f48e3070fdb2b7fa4d/interface/src/onchain.rs

// 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 marshaled 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 a 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 at the time we are creating 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 check the metas we are passing and derive the PDA from the pubkeys. In this case we are using the token_account and inside that token account we are also accessing the owner of said token account which is what we are using as the PDA in 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 have an account that will bundle a few things 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 like:

// 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>,
}

Now here are the functions that we will be using. 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 are interacting 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 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 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 are going to set the meta accounts to be later read by the Token-acl program. Notice we are setting the list config meta we created earlier but we are deriving the whitelisted user from the passed parameters from the 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 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 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 some changes so perhaps some of the accounts or the interface may change but the underlying topics (extra meta accounts, Default Account State) remain.

If you are implementing it yourself and have any issues you can contact us!