Repository: solana-labs/solana-program-library
Pull request #6100 (2024-01-10 04:45:00)

token 2022: add new offchain helper
This PR adds a new offchain helper for creating a `TransferChecked` instruction 
with the necessary accounts metas for a transfer hook `ExecuteInstruction` to 
Token2022, deprecating the old one.

As described in #6064, the original offchain helper in Token2022 was being used 
incorrectly to resolve extra account metas. Rather than providing the SPL 
Transfer Hook interface helper with a valid `ExecuteInstruction`, the original 
Token2022 helper was actually providing it with a transfer instruction, causing 
erroneous account resolution.

Taking advantage of the more secure SPL Transfer Hook interface offchain helper 
provided in #6099, this new offchain helper creates a `TransferChecked` 
instruction and calls `add_extra_account_metas_for_execute(..)`, providing the 
keys used to build the transfer instruction.

Note: unlike the deprecated helper in #6099, the deprecated offchain helper in 
Token2022 *is* in fact inaccurately resolving account metas for certain use 
cases, thus it should be vigilantly avoided.

buffalojoec: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6100?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

Current dependencies on/for this PR:
* `master`
  * **PR #6099** <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6099?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 
    * **PR #6100** <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6100?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>  👈

This [stack of pull requests](https://stacking.dev/?utm_source=stack-comment) is managed by [Graphite](https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6100?utm_source=stack-comment).
buffalojoec: ### Merge activity

* **Jan 11, 4:15 PM**: @buffalojoec started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6100).


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index d62b90e19..485b57914 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7350,6 +7350,7 @@ dependencies = [
  "solana-zk-token-sdk",
  "spl-memo 4.0.0",
  "spl-pod 0.1.0",
+ "spl-tlv-account-resolution 0.5.1",
  "spl-token 4.0.0",
  "spl-token-group-interface",
  "spl-token-metadata-interface 0.2.0",
diff --git a/token/client/src/token.rs b/token/client/src/token.rs
index 51232ac95..d09ca78a8 100644
--- a/token/client/src/token.rs
+++ b/token/client/src/token.rs
@@ -938,6 +938,7 @@ where
         if let Some(transfer_hook_accounts) = &self.transfer_hook_accounts {
             instruction.accounts.extend(transfer_hook_accounts.clone());
         } else {
+            #[allow(deprecated)]
             offchain::resolve_extra_transfer_account_metas(
                 &mut instruction,
                 |address| {
diff --git a/token/program-2022/Cargo.toml b/token/program-2022/Cargo.toml
index e3a1f3d73..ac0a8ff8a 100644
--- a/token/program-2022/Cargo.toml
+++ b/token/program-2022/Cargo.toml
@@ -45,6 +45,7 @@ proptest = "1.4"
 serial_test = "3.0.0"
 solana-program-test = "1.17.6"
 solana-sdk = "1.17.6"
+spl-tlv-account-resolution = { version = "0.5.0", path = "../../libraries/tlv-account-resolution" }
 serde_json = "1.0.111"
 
 [lib]
diff --git a/token/program-2022/src/offchain.rs b/token/program-2022/src/offchain.rs
index 921f23a46..654517721 100644
--- a/token/program-2022/src/offchain.rs
+++ b/token/program-2022/src/offchain.rs
@@ -7,6 +7,7 @@ use {
         state::Mint,
     },
     solana_program::{instruction::Instruction, program_error::ProgramError, pubkey::Pubkey},
+    spl_transfer_hook_interface::offchain::add_extra_account_metas_for_execute,
     std::future::Future,
 };
 
@@ -32,6 +33,10 @@ use {
 ///     &mint,
 /// ).await?;
 /// ```
+#[deprecated(
+    since = "1.1.0",
+    note = "Please use `create_transfer_checked_instruction_with_extra_metas` instead"
+)]
 pub async fn resolve_extra_transfer_account_metas<F, Fut>(
     instruction: &mut Instruction,
     fetch_account_data_fn: F,
@@ -57,3 +62,254 @@ where
     }
     Ok(())
 }
+
+/// Offchain helper to create a `TransferChecked` instruction with all
+/// additional required account metas for a transfer, including the ones
+/// required by the transfer hook.
+///
+/// To be client-agnostic and to avoid pulling in the full solana-sdk, this
+/// simply takes a function that will return its data as `Future<Vec<u8>>` for
+/// the given address. Can be called in the following way:
+///
+/// ```rust,ignore
+/// let instruction = create_transfer_checked_instruction_with_extra_metas(
+///     &spl_token_2022::id(),
+///     &source,
+///     &mint,
+///     &destination,
+///     &authority,
+///     &[],
+///     amount,
+///     decimals,
+///     |address| self.client.get_account(&address).map_ok(|opt| opt.map(|acc| acc.data)),
+/// )
+/// .await?
+/// ```
+#[allow(clippy::too_many_arguments)]
+pub async fn create_transfer_checked_instruction_with_extra_metas<F, Fut>(
+    token_program_id: &Pubkey,
+    source_pubkey: &Pubkey,
+    mint_pubkey: &Pubkey,
+    destination_pubkey: &Pubkey,
+    authority_pubkey: &Pubkey,
+    signer_pubkeys: &[&Pubkey],
+    amount: u64,
+    decimals: u8,
+    fetch_account_data_fn: F,
+) -> Result<Instruction, AccountFetchError>
+where
+    F: Fn(Pubkey) -> Fut,
+    Fut: Future<Output = AccountDataResult>,
+{
+    let mut transfer_instruction = crate::instruction::transfer_checked(
+        token_program_id,
+        source_pubkey,
+        mint_pubkey,
+        destination_pubkey,
+        authority_pubkey,
+        signer_pubkeys,
+        amount,
+        decimals,
+    )?;
+
+    let mint_data = fetch_account_data_fn(*mint_pubkey)
+        .await?
+        .ok_or(ProgramError::InvalidAccountData)?;
+    let mint = StateWithExtensions::<Mint>::unpack(&mint_data)?;
+
+    if let Some(program_id) = transfer_hook::get_program_id(&mint) {
+        add_extra_account_metas_for_execute(
+            &mut transfer_instruction,
+            &program_id,
+            source_pubkey,
+            mint_pubkey,
+            destination_pubkey,
+            authority_pubkey,
+            amount,
+            fetch_account_data_fn,
+        )
+        .await?;
+    }
+
+    Ok(transfer_instruction)
+}
+
+#[cfg(test)]
+mod tests {
+    use {
+        super::*,
+        crate::extension::{transfer_hook::TransferHook, ExtensionType, StateWithExtensionsMut},
+        solana_program::{instruction::AccountMeta, program_option::COption},
+        solana_program_test::tokio,
+        spl_pod::optional_keys::OptionalNonZeroPubkey,
+        spl_tlv_account_resolution::{
+            account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList,
+        },
+        spl_transfer_hook_interface::{
+            get_extra_account_metas_address, instruction::ExecuteInstruction,
+        },
+    };
+
+    const DECIMALS: u8 = 0;
+    const MINT_PUBKEY: Pubkey = Pubkey::new_from_array([1u8; 32]);
+    const TRANSFER_HOOK_PROGRAM_ID: Pubkey = Pubkey::new_from_array([2u8; 32]);
+    const EXTRA_META_1: Pubkey = Pubkey::new_from_array([3u8; 32]);
+    const EXTRA_META_2: Pubkey = Pubkey::new_from_array([4u8; 32]);
+
+    // Mock to return the mint data or the validation state account data
+    async fn mock_fetch_account_data_fn(address: Pubkey) -> AccountDataResult {
+        if address == MINT_PUBKEY {
+            let mint_len =
+                ExtensionType::try_calculate_account_len::<Mint>(&[ExtensionType::TransferHook])
+                    .unwrap();
+            let mut data = vec![0u8; mint_len];
+            let mut mint = StateWithExtensionsMut::<Mint>::unpack_uninitialized(&mut data).unwrap();
+
+            let extension = mint.init_extension::<TransferHook>(true).unwrap();
+            extension.program_id =
+                OptionalNonZeroPubkey::try_from(Some(TRANSFER_HOOK_PROGRAM_ID)).unwrap();
+
+            mint.base.mint_authority = COption::Some(Pubkey::new_unique());
+            mint.base.decimals = DECIMALS;
+            mint.base.is_initialized = true;
+            mint.base.freeze_authority = COption::None;
+            mint.pack_base();
+            mint.init_account_type().unwrap();
+
+            Ok(Some(data))
+        } else if address
+            == get_extra_account_metas_address(&MINT_PUBKEY, &TRANSFER_HOOK_PROGRAM_ID)
+        {
+            let extra_metas = vec![
+                ExtraAccountMeta::new_with_pubkey(&EXTRA_META_1, true, false).unwrap(),
+                ExtraAccountMeta::new_with_pubkey(&EXTRA_META_2, true, false).unwrap(),
+                ExtraAccountMeta::new_with_seeds(
+                    &[
+                        Seed::AccountKey { index: 0 }, // source
+                        Seed::AccountKey { index: 2 }, // destination
+                        Seed::AccountKey { index: 4 }, // validation state
+                    ],
+                    false,
+                    true,
+                )
+                .unwrap(),
+                ExtraAccountMeta::new_with_seeds(
+                    &[
+                        Seed::InstructionData {
+                            index: 8,
+                            length: 8,
+                        }, // amount
+                        Seed::AccountKey { index: 2 }, // destination
+                        Seed::AccountKey { index: 5 }, // extra meta 1
+                        Seed::AccountKey { index: 7 }, // extra meta 3 (PDA)
+                    ],
+                    false,
+                    true,
+                )
+                .unwrap(),
+            ];
+            let account_size = ExtraAccountMetaList::size_of(extra_metas.len()).unwrap();
+            let mut data = vec![0u8; account_size];
+            ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, &extra_metas)?;
+            Ok(Some(data))
+        } else {
+            Ok(None)
+        }
+    }
+
+    #[tokio::test]
+    async fn test_create_transfer_checked_instruction_with_extra_metas() {
+        let source = Pubkey::new_unique();
+        let destination = Pubkey::new_unique();
+        let authority = Pubkey::new_unique();
+        let amount = 100u64;
+
+        let validate_state_pubkey =
+            get_extra_account_metas_address(&MINT_PUBKEY, &TRANSFER_HOOK_PROGRAM_ID);
+        let extra_meta_3_pubkey = Pubkey::find_program_address(
+            &[
+                source.as_ref(),
+                destination.as_ref(),
+                validate_state_pubkey.as_ref(),
+            ],
+            &TRANSFER_HOOK_PROGRAM_ID,
+        )
+        .0;
+        let extra_meta_4_pubkey = Pubkey::find_program_address(
+            &[
+                amount.to_le_bytes().as_ref(),
+                destination.as_ref(),
+                EXTRA_META_1.as_ref(),
+                extra_meta_3_pubkey.as_ref(),
+            ],
+            &TRANSFER_HOOK_PROGRAM_ID,
+        )
+        .0;
+
+        let instruction = create_transfer_checked_instruction_with_extra_metas(
+            &crate::id(),
+            &source,
+            &MINT_PUBKEY,
+            &destination,
+            &authority,
+            &[],
+            amount,
+            DECIMALS,
+            mock_fetch_account_data_fn,
+        )
+        .await
+        .unwrap();
+
+        let check_metas = [
+            AccountMeta::new(source, false),
+            AccountMeta::new_readonly(MINT_PUBKEY, false),
+            AccountMeta::new(destination, false),
+            AccountMeta::new_readonly(authority, true),
+            AccountMeta::new_readonly(EXTRA_META_1, true),
+            AccountMeta::new_readonly(EXTRA_META_2, true),
+            AccountMeta::new(extra_meta_3_pubkey, false),
+            AccountMeta::new(extra_meta_4_pubkey, false),
+            AccountMeta::new_readonly(TRANSFER_HOOK_PROGRAM_ID, false),
+            AccountMeta::new_readonly(validate_state_pubkey, false),
+        ];
+
+        assert_eq!(instruction.accounts, check_metas);
+
+        // With additional signers
+        let signer_1 = Pubkey::new_unique();
+        let signer_2 = Pubkey::new_unique();
+        let signer_3 = Pubkey::new_unique();
+
+        let instruction = create_transfer_checked_instruction_with_extra_metas(
+            &crate::id(),
+            &source,
+            &MINT_PUBKEY,
+            &destination,
+            &authority,
+            &[&signer_1, &signer_2, &signer_3],
+            amount,
+            DECIMALS,
+            mock_fetch_account_data_fn,
+        )
+        .await
+        .unwrap();
+
+        let check_metas = [
+            AccountMeta::new(source, false),
+            AccountMeta::new_readonly(MINT_PUBKEY, false),
+            AccountMeta::new(destination, false),
+            AccountMeta::new_readonly(authority, false), // False because of additional signers
+            AccountMeta::new_readonly(signer_1, true),
+            AccountMeta::new_readonly(signer_2, true),
+            AccountMeta::new_readonly(signer_3, true),
+            AccountMeta::new_readonly(EXTRA_META_1, true),
+            AccountMeta::new_readonly(EXTRA_META_2, true),
+            AccountMeta::new(extra_meta_3_pubkey, false),
+            AccountMeta::new(extra_meta_4_pubkey, false),
+            AccountMeta::new_readonly(TRANSFER_HOOK_PROGRAM_ID, false),
+            AccountMeta::new_readonly(validate_state_pubkey, false),
+        ];
+
+        assert_eq!(instruction.accounts, check_metas);
+    }
+}


Test patch:
diff --git a/token/program-2022-test/tests/transfer_hook.rs b/token/program-2022-test/tests/transfer_hook.rs
index 4727f97c0..4346126c1 100644
--- a/token/program-2022-test/tests/transfer_hook.rs
+++ b/token/program-2022-test/tests/transfer_hook.rs
@@ -627,6 +627,7 @@ async fn success_downgrade_writable_and_signer_accounts() {
         .unwrap();
 }
 
+#[allow(deprecated)]
 #[tokio::test]
 async fn success_transfers_using_onchain_helper() {
     let authority = Pubkey::new_unique();