Skip to content
This repository has been archived by the owner on Nov 26, 2024. It is now read-only.

Latest commit

 

History

History

09_transfer_hooks_with_wSOL

🛹 Demo 9: Transfer Hooks with wSOL Transfer fee


tl; dr


  • In this demo, we build a more advanced transfer_hook program that requires the sender to pay a wSOL fee for every token transfer.

  • The wSOL transfers are executed using a delegate PDA from the transfer hook program (as the signature from the initial sender of the token transfer instruction is not accessible):


pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
    
    msg!("Transfer WSOL using delegate PDA");
    let signer_seeds: &[&[&[u8]]] = &[&[b"delegate", &[ctx.bumps.delegate]]];

    transfer_checked(
      CpiContext::new(
          ctx.accounts.token_program.to_account_info(),
            TransferChecked {
              from: ctx.accounts.sender_wsol_token_account.to_account_info(),
              mint: ctx.accounts.wsol_mint.to_account_info(),
              to: ctx.accounts.delegate_wsol_token_account.to_account_info(),
              authority: ctx.accounts.delegate.to_account_info(),
            },
       )
      .with_signer(signer_seeds),
      amount,
      ctx.accounts.wsol_mint.decimals,
    )?;

  Ok(())
}


.
├── Anchor.toml
├── Cargo.toml
├── README.md
├── package.json
├── programs
│   └── transfer-hooks-with-w-soi
│       ├── Cargo.toml
│       ├── Xargo.toml
│       └── src
│           ├── errors.rs
│           ├── instructions
│           │   ├── metalist.rs
│           │   ├── mod.rs
│           │   └── transfer_hook.rs
│           ├── lib.rs
│           └── state
│               ├── global.rs
│               └── mod.rs
├── tests
│   └── transfer-hooks-with-w-soi.ts
└── tsconfig.json



Source Code for the Program


  • We covered the basics in the previous demos, so now let's use this example to go over the flow of a transfer hook.

  • First, we import the required interfaces for this program, spl_tlv_account_resolution and spl_transfer_hook_interface:


use anchor_lang::{
  prelude::*,
  system_program::{create_account, CreateAccount},
};
use anchor_spl::{
  associated_token::AssociatedToken,
  token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked},
};
use spl_tlv_account_resolution::{
  account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList,
};
use spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction};


declare_id!("3VTHXbzY92FgZR7TK58pbEoFnrzrAWLdwj65JiXB2MV1");

  • We start the #[program] module:

#[program]
pub mod transfer_hooks_with_w_soi {

  use super::*;

  • We create an account that stores a list of extra accounts required by the transfer_hook() instruction, where:
    • indices 0-3 are the accounts required for token transfer (source, mint, destination, owner)
    • index 4 is the address of the ExtraAccountMetaList account
    • index 5 is the wrapped SOL mint account
    • index 6 is the token program account
    • index 7 is the associated token program
    • index 8 is the delegate PDA
    • index 9 is the delegate wrapped SOL token account
    • index 10 is the sender wrapped SOL token account

  pub fn initialize_extra_account_meta_list(
      ctx: Context<InitializeExtraAccountMetaList>,
  ) -> Result<()> {

      let account_metas = vec![
         
          ExtraAccountMeta::new_with_pubkey(
              &ctx.accounts.wsol_mint.key(), 
              false, 
              false)?,
          
          ExtraAccountMeta::new_with_pubkey(
              &ctx.accounts.token_program.key(), 
              false, 
              false)?,
          
          ExtraAccountMeta::new_with_pubkey(
              &ctx.accounts.associated_token_program.key(),
              false,
              false,
          )?,
          
          ExtraAccountMeta::new_with_seeds(
              &[Seed::Literal {
                  bytes: "delegate".as_bytes().to_vec(),
              }],
              false, 
              true,  
          )?,
          
          ExtraAccountMeta::new_external_pda_with_seeds(
              7, // associated token program index
              &[
                  Seed::AccountKey { index: 8 }, // owner index (delegate PDA)
                  Seed::AccountKey { index: 6 }, // token program index
                  Seed::AccountKey { index: 5 }, // wsol mint index
              ],
              false, 
              true,  
          )?,
          
          ExtraAccountMeta::new_external_pda_with_seeds(
              7, // associated token program index
              &[
                  Seed::AccountKey { index: 3 }, // owner index
                  Seed::AccountKey { index: 6 }, // token program index
                  Seed::AccountKey { index: 5 }, // wsol mint index
              ],
              false, // is_signer
              true,  // is_writable
          )?
      ];

  • Let's create the PDA:

      let account_size = ExtraAccountMetaList::size_of(account_metas.len())? as u64;
      let lamports = Rent::get()?.minimum_balance(account_size as usize);
      let mint = ctx.accounts.mint.key();

      let signer_seeds: &[&[&[u8]]] = &[&[
          b"extra-account-metas",
          &mint.as_ref(),
          &[ctx.bumps.extra_account_meta_list],
      ]];

      create_account(
          CpiContext::new(
              ctx.accounts.system_program.to_account_info(),
              CreateAccount {
                  from: ctx.accounts.payer.to_account_info(),
                  to: ctx.accounts.extra_account_meta_list.to_account_info(),
              },
          )
          .with_signer(signer_seeds),
          lamports,
          account_size,
          ctx.program_id,
      )?;

  • Let's write all the signers in the meta list:

      ExtraAccountMetaList::init::<ExecuteInstruction>(
          &mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
          &account_metas,
      )?;

      Ok(())
  }

  • The transfer_hook() instruction is invoked via CPI on every token transfer to perform a wrapped SOL token transfer using a delegate PDA:

pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
  let signer_seeds: &[&[&[u8]]] = &[&[b"delegate", &[ctx.bumps.delegate]]];
  msg!("Transfer WSOL using delegate PDA");

  transfer_checked(
    CpiContext::new(
      ctx.accounts.token_program.to_account_info(),
      TransferChecked {
        from: ctx.accounts.sender_wsol_token_account.to_account_info(),
        mint: ctx.accounts.wsol_mint.to_account_info(),
        to: ctx.accounts.delegate_wsol_token_account.to_account_info(),
        authority: ctx.accounts.delegate.to_account_info(),
      },
    )
    .with_signer(signer_seeds),
    amount,
    ctx.accounts.wsol_mint.decimals,
  )?;
Ok(())
}

  • Whenever the token is transferred, the TransferHookInstruction::Execute from fallback() is executed, which takes the bytes out of the data with to_le_bytes() to call transfer_hook() above:

  pub fn fallback<'info>(
      program_id: &Pubkey,
      accounts: &'info [AccountInfo<'info>],
      data: &[u8],
  ) -> Result<()> {
      let instruction = TransferHookInstruction::unpack(data)?;

      match instruction {
          TransferHookInstruction::Execute { amount } => {
              let amount_bytes = amount.to_le_bytes();
              __private::__global::transfer_hook(program_id, accounts, &amount_bytes)
          }
          _ => return Err(ProgramError::InvalidInstructionData.into()),
      }
  }
}

  • Let's look at the accounts:

#[derive(Accounts)]
pub struct InitializeExtraAccountMetaList<'info> {
  #[account(mut)]
  payer: Signer<'info>,
  #[account(
      mut,
      seeds = [b"extra-account-metas", mint.key().as_ref()], 
      bump
  )]
  pub extra_account_meta_list: AccountInfo<'info>,
  pub mint: InterfaceAccount<'info, Mint>,
  pub wsol_mint: InterfaceAccount<'info, Mint>,
  pub token_program: Interface<'info, TokenInterface>,
  pub associated_token_program: Program<'info, AssociatedToken>,
  pub system_program: Program<'info, System>,
}


#[derive(Accounts)]
pub struct TransferHook<'info> {
  #[account(
      token::mint = mint, 
      token::authority = owner,
  )]
  pub source_token: InterfaceAccount<'info, TokenAccount>,
  pub mint: InterfaceAccount<'info, Mint>,
  #[account(
      token::mint = mint,
  )]
  pub destination_token: InterfaceAccount<'info, TokenAccount>,
  /// CHECK
  pub owner: UncheckedAccount<'info>,
  /// CHECK
  #[account(
      seeds = [b"extra-account-metas", mint.key().as_ref()], 
      bump
  )]
  pub extra_account_meta_list: UncheckedAccount<'info>,
  pub wsol_mint: InterfaceAccount<'info, Mint>,
  pub token_program: Interface<'info, TokenInterface>,
  pub associated_token_program: Program<'info, AssociatedToken>,
  #[account(
      mut,
      seeds = [b"delegate"], 
      bump
  )]
  pub delegate: SystemAccount<'info>,
  #[account(
      mut,
      token::mint = wsol_mint, 
      token::authority = delegate,
  )]
  pub delegate_wsol_token_account: InterfaceAccount<'info, TokenAccount>,
  #[account(
      mut,
      token::mint = wsol_mint, 
      token::authority = owner,
  )]
  pub sender_wsol_token_account: InterfaceAccount<'info, TokenAccount>,
}


Source Code for the Client


  • Now let's look at test/transfer-hooks-with-w-soi.ts. We start by importing dependencies and retrieving the IDL file:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { TransferHooksWithWSoi } from "../target/types/transfer_hooks_with_w_soi";
import {
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
  Keypair,
} from "@solana/web3.js";
import {
  ExtensionType,
  TOKEN_2022_PROGRAM_ID,
  getMintLen,
  createInitializeMintInstruction,
  createInitializeTransferHookInstruction,
  ASSOCIATED_TOKEN_PROGRAM_ID,
  createAssociatedTokenAccountInstruction,
  createMintToInstruction,
  getAssociatedTokenAddressSync,
  createApproveInstruction,
  createSyncNativeInstruction,
  NATIVE_MINT,
  TOKEN_PROGRAM_ID,
  getAccount,
  getOrCreateAssociatedTokenAccount,
  createTransferCheckedWithTransferHookInstruction,
} from "@solana/spl-token";
import assert from "assert";

  • We create Anchor's Provider, get the program from the IDL, the wallet provider, and the connection:

describe("transfer_hooks_with_w_soi", () => {

  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.TransferHooksWithWSoi as Program<TransferHooksWithWSoi>;
  const wallet = provider.wallet as anchor.Wallet;
  const connection = provider.connection;

  • We generate keypair to use as an address for the transfer-hook() enabled mint:

  const mint = new Keypair();
  const decimals = 9;

  • Create the source token account (from the sender):

  const sourceTokenAccount = getAssociatedTokenAddressSync(
    mint.publicKey,
    wallet.publicKey,
    false,
    TOKEN_2022_PROGRAM_ID,
    ASSOCIATED_TOKEN_PROGRAM_ID
  );

  • Create the recipient (random keypair) and the recipient's token account:

  const recipient = Keypair.generate();
  const destinationTokenAccount = getAssociatedTokenAddressSync(
    mint.publicKey,
    recipient.publicKey,
    false,
    TOKEN_2022_PROGRAM_ID,
    ASSOCIATED_TOKEN_PROGRAM_ID
  );

  • Get the meta accounts need for the Transfer Hook:

  const [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync(
    [Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()],
    program.programId
  );

  // PDA delegate to transfer wSOL tokens from sender
  const [delegatePDA] = PublicKey.findProgramAddressSync(
    [Buffer.from("delegate")],
    program.programId
  );

  // Sender wSOL token account address
  const senderWSolTokenAccount = getAssociatedTokenAddressSync(
    NATIVE_MINT, // mint
    wallet.publicKey // owner
  );

  // Delegate PDA wSOL token account address, to receive wSOL tokens from sender
  const delegateWSolTokenAccount = getAssociatedTokenAddressSync(
    NATIVE_MINT, // mint
    delegatePDA, // owner
    true // allowOwnerOffCurve
  );

  // Create the two wSOL token accounts as part of setup
  before(async () => {
    // wSOL Token Account for sender
    await getOrCreateAssociatedTokenAccount(
      connection,
      wallet.payer,
      NATIVE_MINT,
      wallet.publicKey
    );

    // wSOL Token Account for delegate PDA
    await getOrCreateAssociatedTokenAccount(
      connection,
      wallet.payer,
      NATIVE_MINT,
      delegatePDA,
      true
    );
  });

  • Create the mint account, adding some extra space through extensions:

  it("Create Mint Account with Transfer Hook Extension", async () => {
    const extensions = [ExtensionType.TransferHook];
    const mintLen = getMintLen(extensions);
    const lamports =
      await provider.connection.getMinimumBalanceForRentExemption(mintLen);

  • Create an account, initialize the Transfer Hook instruction, initialize the Mint, and send the transaction:

    const transaction = new Transaction().add(
      SystemProgram.createAccount({
        fromPubkey: wallet.publicKey,
        newAccountPubkey: mint.publicKey,
        space: mintLen,
        lamports: lamports,
        programId: TOKEN_2022_PROGRAM_ID,
      }),
      createInitializeTransferHookInstruction(
        mint.publicKey,
        wallet.publicKey,
        program.programId, // Transfer Hook Program ID
        TOKEN_2022_PROGRAM_ID
      ),
      createInitializeMintInstruction(
        mint.publicKey,
        decimals,
        wallet.publicKey,
        null,
        TOKEN_2022_PROGRAM_ID
      )
    );

    const txSig = await sendAndConfirmTransaction(
      provider.connection,
      transaction,
      [wallet.payer, mint]
    );
    console.log(`Transaction Signature: ${txSig}`);
  });

  • Create two associated token accounts (one for the wallet and one for the destination) for the transfer-hook enabled mint, and send the tranasction:

  // Fund the sender token account with 100 tokens
  it("Create Token Accounts and Mint Tokens", async () => {
    const amount = 100 * 10 ** decimals;

    const transaction = new Transaction().add(
      createAssociatedTokenAccountInstruction(
        wallet.publicKey,
        sourceTokenAccount,
        wallet.publicKey,
        mint.publicKey,
        TOKEN_2022_PROGRAM_ID,
        ASSOCIATED_TOKEN_PROGRAM_ID
      ),
      createAssociatedTokenAccountInstruction(
        wallet.publicKey,
        destinationTokenAccount,
        recipient.publicKey,
        mint.publicKey,
        TOKEN_2022_PROGRAM_ID,
        ASSOCIATED_TOKEN_PROGRAM_ID
      ),
      createMintToInstruction(
        mint.publicKey,
        sourceTokenAccount,
        wallet.publicKey,
        amount,
        [],
        TOKEN_2022_PROGRAM_ID
      )
    );

    const txSig = await sendAndConfirmTransaction(
      connection,
      transaction,
      [wallet.payer],
      { skipPreflight: true }
    );

    console.log(`Transaction Signature: ${txSig}`);
  });

  • The third account creates an extra account meta to store extra accounts required by the transfer hook instruction. Note that this is a PDA derived from our program:

  it("Create ExtraAccountMetaList Account", async () => {
    const initializeExtraAccountMetaListInstruction = await program.methods
      .initializeExtraAccountMetaList()
      .accounts({
        payer: wallet.publicKey,
        extraAccountMetaList: extraAccountMetaListPDA,
        mint: mint.publicKey,
        wsolMint: NATIVE_MINT,
        tokenProgram: TOKEN_PROGRAM_ID,
        associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
      })
      .instruction();

    const transaction = new Transaction().add(
      initializeExtraAccountMetaListInstruction
    );

    const txSig = await sendAndConfirmTransaction(
      provider.connection,
      transaction,
      [wallet.payer],
      { skipPreflight: true, commitment : "confirmed"}
    );
    console.log("Transaction Signature:", txSig);
  });

  • Finally, we now transfer the first time the token, where the most important part is createTransferCheckedWithTransferHookInstruction, a helper account that gets all these accounts:

  it("Transfer Hook with Extra Account Meta", async () => {
    const amount = 1 * 10 ** decimals;
    const bigIntAmount = BigInt(amount);

    // Instruction for sender to fund their WSol token account
    const solTransferInstruction = SystemProgram.transfer({
      fromPubkey: wallet.publicKey,
      toPubkey: senderWSolTokenAccount,
      lamports: amount,
    });

    // Approve delegate PDA to transfer WSol tokens from sender wSOL token account
    const approveInstruction = createApproveInstruction(
      senderWSolTokenAccount,
      delegatePDA,
      wallet.publicKey,
      amount,
      [],
      TOKEN_PROGRAM_ID
    );

    // Sync sender wSOL token account
    const syncWrappedSolInstruction = createSyncNativeInstruction(
      senderWSolTokenAccount
    );

    // Standard token transfer instruction
    const transferInstruction = await createTransferCheckedWithTransferHookInstruction(
      connection,
      sourceTokenAccount,
      mint.publicKey,
      destinationTokenAccount,
      wallet.publicKey,
      bigIntAmount,
      decimals,
      [],
      "confirmed",
      TOKEN_2022_PROGRAM_ID
    );

    console.log("Pushed keys:", JSON.stringify(transferInstruction.keys));

    const transaction = new Transaction().add(
      solTransferInstruction,
      syncWrappedSolInstruction,
      approveInstruction,
      transferInstruction
    );
    
    const txSig = await sendAndConfirmTransaction(
      connection,
      transaction,
      [wallet.payer],
      { skipPreflight: true }
    );
    console.log("Transfer Signature:", txSig);

    const tokenAccount = await getAccount(connection, delegateWSolTokenAccount);
    
    assert.equal(Number(tokenAccount.amount), amount);
  });
});


Running the Tests


  • Build and run the tests:

anchor build
anchor test --detach

  • Find the programId: this should be inside of Anchor.toml, test/transfer-hooks-with-w-soi.ts, and programs/src/lib.rs (updating the programId after initialization of new Anchor projects is no longer necessary with new Anchor versions).

anchor keys list  

  • This test results in three transactions. The last one is the extra transfer, which you can see at the Solana Explorer (localhost).


References