Переглянути джерело

lang: Add `#[interface]` attribute for overriding the default discriminator (#2728)

Joe C 1 рік тому
батько
коміт
13fc0bb915

+ 2 - 0
.github/workflows/reusable-tests.yaml

@@ -385,6 +385,8 @@ jobs:
             path: spl/token-proxy
           - cmd: cd tests/spl/token-wrapper && anchor test --skip-lint
             path: spl/token-wrapper
+          - cmd: cd tests/spl/transfer-hook && anchor test --skip-lint
+            path: spl/transfer-hook
           - cmd: cd tests/multisig && anchor test --skip-lint
             path: tests/multisig
           # - cmd: cd tests/lockup && anchor test --skip-lint

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,8 @@ The minor version will be incremented upon a breaking change and the patch versi
 - cli: Add verifiable option when `deploy` ([#2705](https://github.com/coral-xyz/anchor/pull/2705)).
 - cli: Add support for passing arguments to the underlying `solana program deploy` command with `anchor deploy` ([#2709](https://github.com/coral-xyz/anchor/pull/2709)).
 - lang: Add `InstructionData::write_to` implementation ([#2733](https://github.com/coral-xyz/anchor/pull/2733)).
+- lang: Add `#[interface(..)]` attribute for instruction discriminator overrides ([#2728](https://github.com/coral-xyz/anchor/pull/2728)).
+- ts: Add `.interface(..)` method for instruction discriminator overrides ([#2728](https://github.com/coral-xyz/anchor/pull/2728)).
 
 ### Fixes
 

+ 1 - 0
lang/Cargo.toml

@@ -32,6 +32,7 @@ idl-build = [
     "anchor-syn/idl-build",
 ]
 init-if-needed = ["anchor-derive-accounts/init-if-needed"]
+interface-instructions = ["anchor-attribute-program/interface-instructions"]
 
 [dependencies]
 anchor-attribute-access-control = { path = "./attribute/access-control", version = "0.29.0" }

+ 1 - 0
lang/attribute/program/Cargo.toml

@@ -14,6 +14,7 @@ proc-macro = true
 [features]
 anchor-debug = ["anchor-syn/anchor-debug"]
 idl-build = ["anchor-syn/idl-build"]
+interface-instructions = ["anchor-syn/interface-instructions"]
 
 [dependencies]
 anchor-syn = { path = "../../syn", version = "0.29.0" }

+ 46 - 0
lang/attribute/program/src/lib.rs

@@ -14,3 +14,49 @@ pub fn program(
         .to_token_stream()
         .into()
 }
+
+/// The `#[interface]` attribute is used to mark an instruction as belonging
+/// to an interface implementation, thus transforming its discriminator to the
+/// proper bytes for that interface instruction.
+///
+/// # Example
+///
+/// ```rust,ignore
+/// use anchor_lang::prelude::*;
+///
+/// // SPL Transfer Hook Interface: `Execute` instruction.
+/// //
+/// // This instruction is invoked by Token-2022 when a transfer occurs,
+/// // if a mint has specified this program as its transfer hook.
+/// #[interface(spl_transfer_hook_interface::execute)]
+/// pub fn execute_transfer(ctx: Context<Execute>, amount: u64) -> Result<()> {
+///     // Check that all extra accounts were provided
+///     let data = ctx.accounts.extra_metas_account.try_borrow_data()?;
+///     ExtraAccountMetaList::check_account_infos::<ExecuteInstruction>(
+///         &ctx.accounts.to_account_infos(),
+///         &TransferHookInstruction::Execute { amount }.pack(),
+///         &ctx.program_id,
+///         &data,
+///     )?;
+///
+///     // Or maybe perform some custom logic
+///     if ctx.accounts.token_metadata.mint != ctx.accounts.token_account.mint {
+///         return Err(ProgramError::IncorrectAccount);
+///     }
+///
+///     Ok(())
+/// }
+/// ```
+#[cfg(feature = "interface-instructions")]
+#[proc_macro_attribute]
+pub fn interface(
+    _args: proc_macro::TokenStream,
+    input: proc_macro::TokenStream,
+) -> proc_macro::TokenStream {
+    // This macro itself is a no-op, but must be defined as a proc-macro
+    // attribute to be used on a function as the `#[interface]` attribute.
+    //
+    // The `#[program]` macro will detect this attribute and transform the
+    // discriminator.
+    input
+}

+ 6 - 0
lang/src/lib.rs

@@ -65,6 +65,9 @@ pub use anchor_attribute_event::{emit_cpi, event_cpi};
 #[cfg(feature = "idl-build")]
 pub use anchor_syn::{self, idl::build::IdlBuild};
 
+#[cfg(feature = "interface-instructions")]
+pub use anchor_attribute_program::interface;
+
 pub type Result<T> = std::result::Result<T, error::Error>;
 
 /// A data structure of validated accounts that can be deserialized from the
@@ -418,6 +421,9 @@ pub mod prelude {
 
     #[cfg(feature = "idl-build")]
     pub use super::IdlBuild;
+
+    #[cfg(feature = "interface-instructions")]
+    pub use super::interface;
 }
 
 /// Internal module used by macros and unstable apis.

+ 1 - 0
lang/syn/Cargo.toml

@@ -17,6 +17,7 @@ idl-build = ["idl-parse", "idl-types"]
 idl-parse = ["idl-types"]
 idl-types = []
 init-if-needed = []
+interface-instructions = []
 seeds = []
 
 [dependencies]

+ 3 - 1
lang/syn/src/codegen/program/instruction.rs

@@ -22,7 +22,9 @@ pub fn generate(program: &Program) -> proc_macro2::TokenStream {
                 })
                 .collect();
             let ix_data_trait = {
-                let sighash_arr = sighash(SIGHASH_GLOBAL_NAMESPACE, name);
+                let sighash_arr = ix
+                    .interface_discriminator
+                    .unwrap_or(sighash(SIGHASH_GLOBAL_NAMESPACE, name));
                 let sighash_tts: proc_macro2::TokenStream =
                     format!("{sighash_arr:?}").parse().unwrap();
                 quote! {

+ 2 - 0
lang/syn/src/lib.rs

@@ -66,6 +66,8 @@ pub struct Ix {
     pub returns: IxReturn,
     // The ident for the struct deriving Accounts.
     pub anchor_ident: Ident,
+    // The discriminator based on the `#[interface]` attribute.
+    pub interface_discriminator: Option<[u8; 8]>,
 }
 
 #[derive(Debug)]

+ 1 - 0
lang/syn/src/parser/mod.rs

@@ -3,6 +3,7 @@ pub mod context;
 pub mod docs;
 pub mod error;
 pub mod program;
+pub mod spl_interface;
 
 pub fn tts_to_string<T: quote::ToTokens>(item: T) -> String {
     let mut tts = proc_macro2::TokenStream::new();

+ 3 - 0
lang/syn/src/parser/program/instructions.rs

@@ -1,5 +1,6 @@
 use crate::parser::docs;
 use crate::parser::program::ctx_accounts_ident;
+use crate::parser::spl_interface;
 use crate::{FallbackFn, Ix, IxArg, IxReturn};
 use syn::parse::{Error as ParseError, Result as ParseResult};
 use syn::spanned::Spanned;
@@ -24,6 +25,7 @@ pub fn parse(program_mod: &syn::ItemMod) -> ParseResult<(Vec<Ix>, Option<Fallbac
         })
         .map(|method: &syn::ItemFn| {
             let (ctx, args) = parse_args(method)?;
+            let interface_discriminator = spl_interface::parse(&method.attrs);
             let docs = docs::parse(&method.attrs);
             let returns = parse_return(method)?;
             let anchor_ident = ctx_accounts_ident(&ctx.raw_arg)?;
@@ -34,6 +36,7 @@ pub fn parse(program_mod: &syn::ItemMod) -> ParseResult<(Vec<Ix>, Option<Fallbac
                 args,
                 anchor_ident,
                 returns,
+                interface_discriminator,
             })
         })
         .collect::<ParseResult<Vec<Ix>>>()?;

+ 63 - 0
lang/syn/src/parser/spl_interface.rs

@@ -0,0 +1,63 @@
+#[cfg(feature = "interface-instructions")]
+use syn::{Meta, NestedMeta, Path};
+
+#[cfg(not(feature = "interface-instructions"))]
+pub fn parse(_attrs: &[syn::Attribute]) -> Option<[u8; 8]> {
+    None
+}
+
+#[cfg(feature = "interface-instructions")]
+pub fn parse(attrs: &[syn::Attribute]) -> Option<[u8; 8]> {
+    let interfaces: Vec<[u8; 8]> = attrs
+        .iter()
+        .filter_map(|attr| {
+            if attr.path.is_ident("interface") {
+                if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
+                    if let Some(NestedMeta::Meta(Meta::Path(path))) = meta_list.nested.first() {
+                        return Some(parse_interface_instruction(path));
+                    }
+                }
+                panic!(
+                    "Failed to parse interface instruction:\n{}",
+                    quote::quote!(#attr)
+                );
+            }
+            None
+        })
+        .collect();
+    if interfaces.len() > 1 {
+        panic!("An instruction can only implement one interface instruction");
+    } else if interfaces.is_empty() {
+        None
+    } else {
+        Some(interfaces[0])
+    }
+}
+
+#[cfg(feature = "interface-instructions")]
+fn parse_interface_instruction(path: &Path) -> [u8; 8] {
+    if path.segments.len() != 2 {
+        // All interface instruction args are expected to be in the form
+        // <interface>::<instruction>
+        panic!(
+            "Invalid interface instruction: {}",
+            path.segments
+                .iter()
+                .map(|segment| segment.ident.to_string())
+                .collect::<Vec<String>>()
+                .join("::")
+        );
+    }
+    let interface = path.segments[0].ident.to_string();
+    if interface == "spl_transfer_hook_interface" {
+        let instruction = path.segments[1].ident.to_string();
+        if instruction == "initialize_extra_account_meta_list" {
+            return [43, 34, 13, 49, 167, 88, 235, 235]; // `InitializeExtraAccountMetaList`
+        } else if instruction == "execute" {
+            return [105, 37, 101, 197, 75, 251, 102, 26]; // `Execute`
+        } else {
+            panic!("Unsupported instruction: {}", instruction);
+        }
+    }
+    panic!("Unsupported interface: {}", interface);
+}

+ 1 - 0
tests/package.json

@@ -34,6 +34,7 @@
     "spl/metadata",
     "spl/token-proxy",
     "spl/token-wrapper",
+    "spl/transfer-hook",
     "swap",
     "system-accounts",
     "sysvars",

+ 17 - 0
tests/spl/transfer-hook/Anchor.toml

@@ -0,0 +1,17 @@
+[provider]
+cluster = "localnet"
+wallet = "~/.config/solana/id.json"
+
+[programs.localnet]
+transfer_hook = "9vaEfNU4HquQJuNQ6HYrpJW518a3n4wNUt5mAMY2UUHW"
+
+[scripts]
+test = "yarn run ts-mocha -t 1000000 tests/*.ts"
+
+[features]
+
+[test.validator]
+url = "https://api.mainnet-beta.solana.com"
+
+[[test.validator.clone]]
+address = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"

+ 8 - 0
tests/spl/transfer-hook/Cargo.toml

@@ -0,0 +1,8 @@
+[workspace]
+members = [
+    "programs/*"
+]
+resolver = "2"
+
+[profile.release]
+overflow-checks = true

+ 22 - 0
tests/spl/transfer-hook/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "transfer-hook",
+  "version": "0.29.0",
+  "license": "(MIT OR Apache-2.0)",
+  "homepage": "https://github.com/coral-xyz/anchor#readme",
+  "bugs": {
+    "url": "https://github.com/coral-xyz/anchor/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/coral-xyz/anchor.git"
+  },
+  "engines": {
+    "node": ">=11"
+  },
+  "scripts": {
+    "test": "anchor test"
+  },
+  "dependencies": {
+    "@solana/spl-token": "^0.3.9"
+  }
+}

+ 22 - 0
tests/spl/transfer-hook/programs/transfer-hook/Cargo.toml

@@ -0,0 +1,22 @@
+[package]
+name = "transfer-hook"
+version = "0.1.0"
+description = "Created with Anchor"
+rust-version = "1.60"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "lib"]
+name = "transfer_hook"
+
+[features]
+no-entrypoint = []
+no-idl = []
+cpi = ["no-entrypoint"]
+default = []
+
+[dependencies]
+anchor-lang = { path = "../../../../../lang", features = ["interface-instructions"] }
+anchor-spl = { path = "../../../../../spl" }
+spl-tlv-account-resolution = "0.4.0"
+spl-transfer-hook-interface = "0.3.0"

+ 2 - 0
tests/spl/transfer-hook/programs/transfer-hook/Xargo.toml

@@ -0,0 +1,2 @@
+[target.bpfel-unknown-unknown.dependencies.std]
+features = []

+ 173 - 0
tests/spl/transfer-hook/programs/transfer-hook/src/lib.rs

@@ -0,0 +1,173 @@
+//! An example of a transfer hook program.
+//!
+//! This program is intended to implement the SPL Transfer Hook interface,
+//! thus allowing Token2022 to call into this program when a transfer occurs.
+//!
+//! <https://spl.solana.com/token-2022/extensions#transfer-hook>
+
+use {
+    anchor_lang::prelude::*,
+    anchor_spl::{
+        token_2022::{
+            spl_token_2022::{
+                extension::{
+                    transfer_hook::TransferHookAccount, BaseStateWithExtensions,
+                    StateWithExtensions,
+                },
+                state::Account as Token2022Account,
+            },
+            ID as TOKEN_2022_PROGRAM_ID,
+        },
+        token_interface::{Mint, TokenAccount},
+    },
+    spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList},
+    spl_transfer_hook_interface::{
+        error::TransferHookError,
+        instruction::{ExecuteInstruction, TransferHookInstruction},
+    },
+};
+
+declare_id!("9vaEfNU4HquQJuNQ6HYrpJW518a3n4wNUt5mAMY2UUHW");
+
+fn check_token_account_is_transferring(account_data: &[u8]) -> Result<()> {
+    let token_account = StateWithExtensions::<Token2022Account>::unpack(account_data)?;
+    let extension = token_account.get_extension::<TransferHookAccount>()?;
+    if bool::from(extension.transferring) {
+        Ok(())
+    } else {
+        Err(Into::<ProgramError>::into(
+            TransferHookError::ProgramCalledOutsideOfTransfer,
+        ))?
+    }
+}
+
+#[program]
+pub mod transfer_hook {
+    use super::*;
+
+    #[interface(spl_transfer_hook_interface::initialize_extra_account_meta_list)]
+    pub fn initialize(ctx: Context<Initialize>, metas: Vec<AnchorExtraAccountMeta>) -> Result<()> {
+        let extra_metas_account = &ctx.accounts.extra_metas_account;
+        let mint = &ctx.accounts.mint;
+        let mint_authority = &ctx.accounts.mint_authority;
+
+        if mint_authority.key()
+            != mint.mint_authority.ok_or(Into::<ProgramError>::into(
+                TransferHookError::MintHasNoMintAuthority,
+            ))?
+        {
+            Err(Into::<ProgramError>::into(
+                TransferHookError::IncorrectMintAuthority,
+            ))?;
+        }
+
+        let metas: Vec<ExtraAccountMeta> = metas.into_iter().map(|meta| meta.into()).collect();
+        let mut data = extra_metas_account.try_borrow_mut_data()?;
+        ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, &metas)?;
+
+        Ok(())
+    }
+
+    #[interface(spl_transfer_hook_interface::execute)]
+    pub fn execute(ctx: Context<Execute>, amount: u64) -> Result<()> {
+        let source_account = &ctx.accounts.source_account;
+        let destination_account = &ctx.accounts.destination_account;
+
+        check_token_account_is_transferring(&source_account.to_account_info().try_borrow_data()?)?;
+        check_token_account_is_transferring(
+            &destination_account.to_account_info().try_borrow_data()?,
+        )?;
+
+        let data = ctx.accounts.extra_metas_account.try_borrow_data()?;
+        ExtraAccountMetaList::check_account_infos::<ExecuteInstruction>(
+            &ctx.accounts.to_account_infos(),
+            &TransferHookInstruction::Execute { amount }.pack(),
+            &ctx.program_id,
+            &data,
+        )?;
+
+        Ok(())
+    }
+}
+
+#[derive(Accounts)]
+#[instruction(metas: Vec<AnchorExtraAccountMeta>)]
+pub struct Initialize<'info> {
+    /// CHECK: This account's data is a buffer of TLV data
+    #[account(
+        init,
+        space = ExtraAccountMetaList::size_of(metas.len()).unwrap(),
+        // space = 8 + 4 + 2 * 35,
+        seeds = [b"extra-account-metas", mint.key().as_ref()],
+        bump,
+        payer = payer,
+    )]
+    pub extra_metas_account: UncheckedAccount<'info>,
+
+    #[account(
+        mint::token_program = TOKEN_2022_PROGRAM_ID,
+    )]
+    pub mint: Box<InterfaceAccount<'info, Mint>>,
+
+    #[account(mut)]
+    pub mint_authority: Signer<'info>,
+
+    pub system_program: Program<'info, System>,
+
+    #[account(mut)]
+    pub payer: Signer<'info>,
+}
+
+#[derive(Accounts)]
+pub struct Execute<'info> {
+    #[account(
+        token::mint = mint,
+        token::authority = owner_delegate,
+        token::token_program = TOKEN_2022_PROGRAM_ID,
+    )]
+    pub source_account: Box<InterfaceAccount<'info, TokenAccount>>,
+
+    #[account(
+        mint::token_program = TOKEN_2022_PROGRAM_ID,
+    )]
+    pub mint: Box<InterfaceAccount<'info, Mint>>,
+
+    #[account(
+        token::mint = mint,
+        token::token_program = TOKEN_2022_PROGRAM_ID,
+    )]
+    pub destination_account: Box<InterfaceAccount<'info, TokenAccount>>,
+
+    pub owner_delegate: SystemAccount<'info>,
+
+    /// CHECK: This account's data is a buffer of TLV data
+    #[account(
+        seeds = [b"extra-account-metas", mint.key().as_ref()],
+        bump,
+    )]
+    pub extra_metas_account: UncheckedAccount<'info>,
+
+    /// CHECK: Example extra PDA for transfer #1
+    pub secondary_authority_1: UncheckedAccount<'info>,
+
+    /// CHECK: Example extra PDA for transfer #2
+    pub secondary_authority_2: UncheckedAccount<'info>,
+}
+
+#[derive(AnchorSerialize, AnchorDeserialize)]
+pub struct AnchorExtraAccountMeta {
+    pub discriminator: u8,
+    pub address_config: [u8; 32],
+    pub is_signer: bool,
+    pub is_writable: bool,
+}
+impl From<AnchorExtraAccountMeta> for ExtraAccountMeta {
+    fn from(meta: AnchorExtraAccountMeta) -> Self {
+        Self {
+            discriminator: meta.discriminator,
+            address_config: meta.address_config,
+            is_signer: meta.is_signer.into(),
+            is_writable: meta.is_writable.into(),
+        }
+    }
+}

+ 304 - 0
tests/spl/transfer-hook/tests/transfer-hook.ts

@@ -0,0 +1,304 @@
+import * as anchor from "@coral-xyz/anchor";
+import { Program } from "@coral-xyz/anchor";
+import {
+  PublicKey,
+  Keypair,
+  SystemProgram,
+  sendAndConfirmTransaction,
+  Transaction,
+  AccountInfo,
+} from "@solana/web3.js";
+import {
+  getExtraAccountMetaAddress,
+  ExtraAccountMeta,
+  getMintLen,
+  ExtensionType,
+  createInitializeTransferHookInstruction,
+  createInitializeMintInstruction,
+  createAssociatedTokenAccountInstruction,
+  getAssociatedTokenAddressSync,
+  createMintToInstruction,
+  createTransferCheckedInstruction,
+  getAccount,
+  addExtraAccountsToInstruction,
+} from "@solana/spl-token";
+import { assert } from "chai";
+import { TransferHook } from "../target/types/transfer_hook";
+
+describe("transfer hook", () => {
+  const provider = anchor.AnchorProvider.env();
+  anchor.setProvider(provider);
+
+  const TOKEN_2022_PROGRAM_ID = new anchor.web3.PublicKey(
+    "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
+  );
+  const program = anchor.workspace.TransferHook as Program<TransferHook>;
+
+  const decimals = 2;
+  const mintAmount = 100;
+  const transferAmount = 10;
+
+  const payer = Keypair.generate();
+  const mintAuthority = Keypair.generate();
+  const mint = Keypair.generate();
+
+  const sourceAuthority = Keypair.generate();
+  const destinationAuthority = Keypair.generate().publicKey;
+  let source: PublicKey = null;
+  let destination: PublicKey = null;
+
+  let extraMetasAddress: PublicKey = null;
+  const validationLen = 8 + 4 + 4 + 2 * 35; // Discriminator, length, pod slice length, pod slice with 2 extra metas
+  const extraMetas: ExtraAccountMeta[] = [
+    {
+      discriminator: 0,
+      addressConfig: Keypair.generate().publicKey.toBuffer(),
+      isWritable: false,
+      isSigner: false,
+    },
+    {
+      discriminator: 0,
+      addressConfig: Keypair.generate().publicKey.toBuffer(),
+      isWritable: false,
+      isSigner: false,
+    },
+  ];
+
+  before(async () => {
+    const { programId } = program;
+    const extensions = [ExtensionType.TransferHook];
+    const mintLen = getMintLen(extensions);
+    const lamports =
+      await provider.connection.getMinimumBalanceForRentExemption(mintLen);
+
+    source = getAssociatedTokenAddressSync(
+      mint.publicKey,
+      sourceAuthority.publicKey,
+      false,
+      TOKEN_2022_PROGRAM_ID
+    );
+    destination = getAssociatedTokenAddressSync(
+      mint.publicKey,
+      destinationAuthority,
+      false,
+      TOKEN_2022_PROGRAM_ID
+    );
+
+    extraMetasAddress = getExtraAccountMetaAddress(mint.publicKey, programId);
+
+    const transaction = new Transaction().add(
+      SystemProgram.createAccount({
+        fromPubkey: payer.publicKey,
+        newAccountPubkey: mint.publicKey,
+        space: mintLen,
+        lamports,
+        programId: TOKEN_2022_PROGRAM_ID,
+      }),
+      createInitializeTransferHookInstruction(
+        mint.publicKey,
+        mintAuthority.publicKey,
+        programId,
+        TOKEN_2022_PROGRAM_ID
+      ),
+      createInitializeMintInstruction(
+        mint.publicKey,
+        decimals,
+        mintAuthority.publicKey,
+        mintAuthority.publicKey,
+        TOKEN_2022_PROGRAM_ID
+      ),
+      createAssociatedTokenAccountInstruction(
+        payer.publicKey,
+        source,
+        sourceAuthority.publicKey,
+        mint.publicKey,
+        TOKEN_2022_PROGRAM_ID
+      ),
+      createAssociatedTokenAccountInstruction(
+        payer.publicKey,
+        destination,
+        destinationAuthority,
+        mint.publicKey,
+        TOKEN_2022_PROGRAM_ID
+      ),
+      createMintToInstruction(
+        mint.publicKey,
+        source,
+        mintAuthority.publicKey,
+        mintAmount,
+        [],
+        TOKEN_2022_PROGRAM_ID
+      )
+    );
+
+    await provider.connection.confirmTransaction(
+      await provider.connection.requestAirdrop(payer.publicKey, 10000000000),
+      "confirmed"
+    );
+
+    await sendAndConfirmTransaction(provider.connection, transaction, [
+      payer,
+      mint,
+      mintAuthority,
+    ]);
+  });
+
+  it("can create an `InitializeExtraAccountMetaList` instruction with the proper discriminator", async () => {
+    const ix = await program.methods
+      .initialize(extraMetas as any[])
+      .interface("spl_transfer_hook_interface::initialize_extra_account_metas")
+      .accounts({
+        extraMetasAccount: extraMetasAddress,
+        mint: mint.publicKey,
+        mintAuthority: mintAuthority.publicKey,
+        systemProgram: SystemProgram.programId,
+      })
+      .instruction();
+    assert.equal(
+      ix.data.subarray(0, 8).compare(
+        Buffer.from([43, 34, 13, 49, 167, 88, 235, 235]) // SPL discriminator for `InitializeExtraAccountMetaList` from interface
+      ),
+      0
+    );
+    const { name, data } = new anchor.BorshInstructionCoder(program.idl).decode(
+      ix.data,
+      "hex",
+      "initialize"
+    );
+    assert.equal(name, "initialize");
+    assert.property(data, "metas");
+    assert.isArray(data.metas);
+    assert.equal(data.metas.length, extraMetas.length);
+  });
+
+  it("can create an `Execute` instruction with the proper discriminator", async () => {
+    const ix = await program.methods
+      .execute(new anchor.BN(transferAmount))
+      .interface("spl_transfer_hook_interface::execute")
+      .accounts({
+        sourceAccount: source,
+        mint: mint.publicKey,
+        destinationAccount: destination,
+        ownerDelegate: sourceAuthority.publicKey,
+        extraMetasAccount: extraMetasAddress,
+        secondaryAuthority1: new PublicKey(extraMetas[0].addressConfig),
+        secondaryAuthority2: new PublicKey(extraMetas[1].addressConfig),
+      })
+      .instruction();
+    assert.equal(
+      ix.data.subarray(0, 8).compare(
+        Buffer.from([105, 37, 101, 197, 75, 251, 102, 26]) // SPL discriminator for `Execute` from interface
+      ),
+      0
+    );
+    const { name, data } = new anchor.BorshInstructionCoder(program.idl).decode(
+      ix.data,
+      "hex",
+      "execute"
+    );
+    assert.equal(name, "execute");
+    assert.property(data, "amount");
+    assert.isTrue(anchor.BN.isBN(data.amount));
+    assert.isTrue(data.amount.eq(new anchor.BN(transferAmount)));
+  });
+
+  it("can transfer with extra account metas", async () => {
+    // Initialize the extra metas
+    await program.methods
+      .initialize(extraMetas as any[])
+      .interface("spl_transfer_hook_interface::initialize_extra_account_metas")
+      .accounts({
+        extraMetasAccount: extraMetasAddress,
+        mint: mint.publicKey,
+        mintAuthority: mintAuthority.publicKey,
+        systemProgram: SystemProgram.programId,
+      })
+      .signers([mintAuthority])
+      .rpc();
+
+    // Check the account data
+    await provider.connection
+      .getAccountInfo(extraMetasAddress)
+      .then((account: AccountInfo<Buffer>) => {
+        assert.equal(account.data.length, validationLen);
+        assert.equal(
+          account.data.subarray(0, 8).compare(
+            Buffer.from([105, 37, 101, 197, 75, 251, 102, 26]) // SPL discriminator for `Execute` from interface
+          ),
+          0
+        );
+        assert.equal(
+          account.data.subarray(8, 12).compare(
+            Buffer.from([74, 0, 0, 0]) // Little endian 74
+          ),
+          0
+        );
+        assert.equal(
+          account.data.subarray(12, 16).compare(
+            Buffer.from([2, 0, 0, 0]) // Little endian 2
+          ),
+          0
+        );
+        const extraMetaToBuffer = (extraMeta: ExtraAccountMeta) => {
+          const buf = Buffer.alloc(35);
+          buf.set(extraMeta.addressConfig, 1);
+          buf.writeUInt8(0, 33); // isSigner
+          buf.writeUInt8(0, 34); // isWritable
+          return buf;
+        };
+        assert.equal(
+          account.data
+            .subarray(16, 51)
+            .compare(extraMetaToBuffer(extraMetas[0])),
+          0
+        );
+        assert.equal(
+          account.data
+            .subarray(51, 86)
+            .compare(extraMetaToBuffer(extraMetas[1])),
+          0
+        );
+      });
+
+    const ix = await addExtraAccountsToInstruction(
+      provider.connection,
+      createTransferCheckedInstruction(
+        source,
+        mint.publicKey,
+        destination,
+        sourceAuthority.publicKey,
+        transferAmount,
+        decimals,
+        undefined,
+        TOKEN_2022_PROGRAM_ID
+      ),
+      mint.publicKey,
+      undefined,
+      TOKEN_2022_PROGRAM_ID
+    );
+
+    await sendAndConfirmTransaction(
+      provider.connection,
+      new Transaction().add(ix),
+      [payer, sourceAuthority]
+    );
+
+    // Check the resulting token balances
+    await getAccount(
+      provider.connection,
+      source,
+      undefined,
+      TOKEN_2022_PROGRAM_ID
+    ).then((account) => {
+      assert.equal(account.amount, BigInt(mintAmount - transferAmount));
+    });
+    await getAccount(
+      provider.connection,
+      destination,
+      undefined,
+      TOKEN_2022_PROGRAM_ID
+    ).then((account) => {
+      assert.equal(account.amount, BigInt(transferAmount));
+    });
+  });
+});

+ 11 - 0
tests/spl/transfer-hook/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "types": ["mocha", "chai", "node"],
+    "typeRoots": ["./node_modules/@types"],
+    "lib": ["es2015"],
+    "module": "commonjs",
+    "target": "es6",
+    "esModuleInterop": true,
+    "skipLibCheck": true
+  }
+}

+ 52 - 0
tests/yarn.lock

@@ -23,6 +23,13 @@
   dependencies:
     regenerator-runtime "^0.14.0"
 
+"@babel/runtime@^7.23.2":
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.5.tgz#11edb98f8aeec529b82b211028177679144242db"
+  integrity sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@metaplex-foundation/mpl-auction@^0.0.2":
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/@metaplex-foundation/mpl-auction/-/mpl-auction-0.0.2.tgz#3de3c982e88d6a88f0ef05be73453cf3cfaccf26"
@@ -175,6 +182,16 @@
     bn.js "^5.1.2"
     buffer-layout "^1.2.0"
 
+"@solana/buffer-layout-utils@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz#b45a6cab3293a2eb7597cceb474f229889d875ca"
+  integrity sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==
+  dependencies:
+    "@solana/buffer-layout" "^4.0.0"
+    "@solana/web3.js" "^1.32.0"
+    bigint-buffer "^1.1.5"
+    bignumber.js "^9.0.1"
+
 "@solana/buffer-layout@^4.0.0":
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.0.tgz#75b1b11adc487234821c81dfae3119b73a5fd734"
@@ -194,6 +211,15 @@
     buffer-layout "^1.2.0"
     dotenv "10.0.0"
 
+"@solana/spl-token@^0.3.9":
+  version "0.3.9"
+  resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.3.9.tgz#477e703c3638ffb17dd29b82a203c21c3e465851"
+  integrity sha512-1EXHxKICMnab35MvvY/5DBc/K/uQAOJCYnDZXw83McCAYUAfi+rwq6qfd6MmITmSTEhcfBcl/zYxmW/OSN0RmA==
+  dependencies:
+    "@solana/buffer-layout" "^4.0.0"
+    "@solana/buffer-layout-utils" "^0.2.0"
+    buffer "^6.0.3"
+
 "@solana/web3.js@^1.17.0", "@solana/web3.js@^1.21.0":
   version "1.64.0"
   resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.64.0.tgz#b7f5a976976039a0161242e94d6e1224ab5d30f9"
@@ -236,6 +262,27 @@
     rpc-websockets "^7.5.1"
     superstruct "^0.14.2"
 
+"@solana/web3.js@^1.32.0":
+  version "1.87.6"
+  resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.87.6.tgz#6744cfc5f4fc81e0f58241c0a92648a7320bb3bf"
+  integrity sha512-LkqsEBgTZztFiccZZXnawWa8qNCATEqE97/d0vIwjTclmVlc8pBpD1DmjfVHtZ1HS5fZorFlVhXfpwnCNDZfyg==
+  dependencies:
+    "@babel/runtime" "^7.23.2"
+    "@noble/curves" "^1.2.0"
+    "@noble/hashes" "^1.3.1"
+    "@solana/buffer-layout" "^4.0.0"
+    agentkeepalive "^4.3.0"
+    bigint-buffer "^1.1.5"
+    bn.js "^5.2.1"
+    borsh "^0.7.0"
+    bs58 "^4.0.1"
+    buffer "6.0.3"
+    fast-stable-stringify "^1.0.0"
+    jayson "^4.1.0"
+    node-fetch "^2.6.12"
+    rpc-websockets "^7.5.1"
+    superstruct "^0.14.2"
+
 "@solana/web3.js@^1.68.0":
   version "1.70.0"
   resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.70.0.tgz#14ad207f431861397db85921aad8df4e8374e7c8"
@@ -442,6 +489,11 @@ bigint-buffer@^1.1.5:
   dependencies:
     bindings "^1.3.0"
 
+bignumber.js@^9.0.1:
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c"
+  integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==
+
 binary-extensions@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"

+ 18 - 7
ts/packages/anchor/src/coder/borsh/instruction.ts

@@ -56,11 +56,15 @@ export class BorshInstructionCoder implements InstructionCoder {
   /**
    * Encodes a program instruction.
    */
-  public encode(ixName: string, ix: any): Buffer {
-    return this._encode(SIGHASH_GLOBAL_NAMESPACE, ixName, ix);
+  public encode(ixName: string, ix: any, discriminator?: Buffer): Buffer {
+    return this._encode(
+      ixName,
+      ix,
+      discriminator ?? sighash(SIGHASH_GLOBAL_NAMESPACE, ixName)
+    );
   }
 
-  private _encode(nameSpace: string, ixName: string, ix: any): Buffer {
+  private _encode(ixName: string, ix: any, discriminator: Buffer): Buffer {
     const buffer = Buffer.alloc(1000); // TODO: use a tighter buffer.
     const methodName = camelCase(ixName);
     const layout = this.ixLayout.get(methodName);
@@ -69,7 +73,7 @@ export class BorshInstructionCoder implements InstructionCoder {
     }
     const len = layout.encode(ix, buffer);
     const data = buffer.slice(0, len);
-    return Buffer.concat([sighash(nameSpace, ixName), data]);
+    return Buffer.concat([discriminator, data]);
   }
 
   private static parseIxLayout(idl: Idl): Map<string, Layout> {
@@ -92,14 +96,21 @@ export class BorshInstructionCoder implements InstructionCoder {
    */
   public decode(
     ix: Buffer | string,
-    encoding: "hex" | "base58" = "hex"
+    encoding: "hex" | "base58" = "hex",
+    ixName?: string
   ): Instruction | null {
     if (typeof ix === "string") {
       ix = encoding === "hex" ? Buffer.from(ix, "hex") : bs58.decode(ix);
     }
-    let sighash = bs58.encode(ix.slice(0, 8));
+    // Use the provided method name to get the sighash, ignoring the
+    // discriminator in the instruction data.
+    // This is useful for decoding instructions that have been encoded with a
+    // different namespace, such as an SPL interface.
+    let sighashKey = bs58.encode(
+      ixName ? sighash(SIGHASH_GLOBAL_NAMESPACE, ixName) : ix.slice(0, 8)
+    );
     let data = ix.slice(8);
-    const decoder = this.sighashLayouts.get(sighash);
+    const decoder = this.sighashLayouts.get(sighashKey);
     if (!decoder) {
       return null;
     }

+ 1 - 1
ts/packages/anchor/src/coder/index.ts

@@ -43,7 +43,7 @@ export interface AccountsCoder<A extends string = string> {
 }
 
 export interface InstructionCoder {
-  encode(ixName: string, ix: any): Buffer;
+  encode(ixName: string, ix: any, discriminator?: Buffer): Buffer;
 }
 
 export interface EventCoder {

+ 5 - 0
ts/packages/anchor/src/program/context.ts

@@ -50,6 +50,11 @@ export type Context<A extends Accounts = Accounts> = {
    * Commitment parameters to use for a transaction.
    */
   options?: ConfirmOptions;
+
+  /**
+   * An optional override for the default instruction discriminator.
+   */
+  discriminator?: Buffer;
 };
 
 /**

+ 2 - 1
ts/packages/anchor/src/program/namespace/index.ts

@@ -60,7 +60,8 @@ export default class NamespaceFactory {
     idl.instructions.forEach((idlIx) => {
       const ixItem = InstructionFactory.build<IDL, typeof idlIx>(
         idlIx,
-        (ixName, ix) => coder.instruction.encode(ixName, ix),
+        (ixName, ix, discriminator) =>
+          coder.instruction.encode(ixName, ix, discriminator),
         programId
       );
       const txItem = TransactionFactory.build(idlIx, ixItem);

+ 8 - 2
ts/packages/anchor/src/program/namespace/instruction.ts

@@ -41,6 +41,7 @@ export default class InstructionNamespaceFactory {
       ...args: InstructionContextFnArgs<IDL, I>
     ): TransactionInstruction => {
       const [ixArgs, ctx] = splitArgsAndCtx(idlIx, [...args]);
+      const { discriminator } = ctx;
       validateAccounts(idlIx.accounts, ctx.accounts);
       validateInstruction(idlIx, ...args);
 
@@ -57,7 +58,11 @@ export default class InstructionNamespaceFactory {
       return new TransactionInstruction({
         keys,
         programId,
-        data: encodeFn(idlIx.name, toInstruction(idlIx, ...ixArgs)),
+        data: encodeFn(
+          idlIx.name,
+          toInstruction(idlIx, ...ixArgs),
+          discriminator
+        ),
       });
     };
 
@@ -191,7 +196,8 @@ type IxProps<A extends Accounts> = {
 
 export type InstructionEncodeFn<I extends IdlInstruction = IdlInstruction> = (
   ixName: I["name"],
-  ix: any
+  ix: any,
+  discriminator?: Buffer
 ) => Buffer;
 
 // Throws error if any argument required for the `ix` is not given.

+ 22 - 0
ts/packages/anchor/src/program/namespace/methods.ts

@@ -108,6 +108,10 @@ export function flattenPartialAccounts<A extends IdlAccountItem>(
   return toReturn;
 }
 
+type SplInterface =
+  | "spl_transfer_hook_interface::initialize_extra_account_metas"
+  | "spl_transfer_hook_interface::execute";
+
 export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
   private readonly _accounts: AccountsGeneric = {};
   private _remainingAccounts: Array<AccountMeta> = [];
@@ -117,6 +121,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
   private _accountsResolver: AccountsResolver<IDL>;
   private _autoResolveAccounts: boolean = true;
   private _args: Array<any>;
+  private _discriminator?: Buffer;
 
   constructor(
     _args: Array<any>,
@@ -161,6 +166,20 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
     >;
   }
 
+  public interface(splInterface: SplInterface): MethodsBuilder<IDL, I> {
+    if (
+      splInterface ===
+      "spl_transfer_hook_interface::initialize_extra_account_metas"
+    ) {
+      this._discriminator = Buffer.from([43, 34, 13, 49, 167, 88, 235, 235]);
+    } else if (splInterface === "spl_transfer_hook_interface::execute") {
+      this._discriminator = Buffer.from([105, 37, 101, 197, 75, 251, 102, 26]);
+    } else {
+      throw new Error(`Unsupported interface: ${splInterface}`);
+    }
+    return this;
+  }
+
   public accounts(
     accounts: PartialAccounts<I["accounts"][number]>
   ): MethodsBuilder<IDL, I> {
@@ -216,6 +235,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
       preInstructions: this._preInstructions,
       postInstructions: this._postInstructions,
       options: options,
+      discriminator: this._discriminator,
     });
   }
 
@@ -280,6 +300,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
       remainingAccounts: this._remainingAccounts,
       preInstructions: this._preInstructions,
       postInstructions: this._postInstructions,
+      discriminator: this._discriminator,
     });
   }
 
@@ -311,6 +332,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
       remainingAccounts: this._remainingAccounts,
       preInstructions: this._preInstructions,
       postInstructions: this._postInstructions,
+      discriminator: this._discriminator,
     });
   }
 }