sRFC 00010: Program Trait - Transfer Spec

Code: GitHub - ngundotra/additional-accounts-request-transfer-spec: Proof of Concept for Additional Accounts Request for Transfers

SRFC 00010 - Program Trait - Transfer Spec

This spec is currently alpha and subject to change

Summary

A standard protocol to enable on-chain and client communication with Solana programs to “transfer” assets that allows target programs to require additional accounts.

Motivation

A standard protocol for enabling programs to support "transfer"ring assets while also allowing a flexible number of accounts into the program allows for a better user experience across apps and wallets in the Solana ecosystem.

By defining a protocol to resolve additional accounts required for programs to adhere to the same instruction interface, developers can build applications that are compatible with a wide range of programs.

Calling programs should ensure that called programs are using the additional accounts appropriately, or otherwise fail instruction execution.

Developers implementing this specification should be prepared to chew glass.

By standardizing a simple approach to solving program abstraction, we ensure basic compatibility of programs and clients so developers can focus on higher level abstractions.

Specification: Program Trait - Transfer

Executing a “transfer” instruction against a program that implements ProgramTraitTransferV1 requires two CPIs from the caller program to the callee program.
The first CPI from the caller to the callee is to determine which (if any) additional accounts are require for the 2nd CPI.
The second CPI from the caller to the callee is with the same list of accounts from the 1st call, but also passes the list of accounts requested by the first CPI.

The ProgramTraitTransferV1 trait requires that programs implement two instructions, described below.

use anchor_lang::prelude::*;

/// Accounts required by ProgramTraitTransferV1
#[derive(Accounts)]
pub struct ITransfer<'info> {
    /// CHECK:
    pub owner: AccountInfo<'info>,
    /// CHECK:
    pub to: AccountInfo<'info>,
    pub authority: Signer<'info>,
    /// CHECK:
    pub mint: AccountInfo<'info>,
}

#[derive(Accounts)]
pub struct MyProgramTransfer {
    /// CHECK:
    pub owner: AccountInfo<'info>,
    /// CHECK:
    pub to: AccountInfo<'info>,
    pub authority: Signer<'info>,
    /// CHECK:
    pub mint: AccountInfo<'info>,
    
    // Additional optional accounts follow here
    pub my_special_account: AccountInfo<'info>,
    // etc
}


#[program]
pub mod MyProgram {
    pub fn preflight_transfer(ctx: Context<ITransfer>, amount: u64) -> Result<()> {
        // Your code goes here 
        set_return_data(
            &PreflightPayload {
                accounts: vec![
                    IAccountMeta {
                        pubkey: *my_special_account_key,
                        // You cannot request additional signer accounts
                        signer: false, 
                        // You may however request additional writable or readonly accounts
                        writable: true,
                    },
                ]
            }.try_to_vec()?
        )?;
        Ok(())
    }

    pub fn transfer(ctx: Context<MyTransfer>, amount: u64) -> Result<()> {
        // Your code goes here
        Ok(())
    }
}
enum MyProgramInstruction {
    ...,
    PreflightTransfer(u64)=solana_program::hash::hash("global:preflight_transfer")[..8],
    Transfer(u64)=solana_program::hash::hash("global:transfer")[..8]
}

Executing “transfer” against a conforming program is interactive because optional accounts may be sent in the 2nd CPI. The optional accounts are derived from the 1st CPI by Borsh deserializing return data as Vec<AccountMeta>.

Accounts

The accounts list required for adhering to ProgramTraitTransferV1 is simply a list of account metas, that have no direct relationship to each other.

We overlay semantic descriptions to give advice on how this should be used, but ultimately we expect that there will be program implementations that abuse the
semantic descriptions.

Owner

  • isSigner: false
  • isWritable: false

This is the owner of the asset to be transferred.

To

  • isSigner: false
  • isWritable: false

This is the intended recipient of the transferred asset.

Authority

  • isSigner: true
  • isWritable: false

This is the account that has the authority to transfer from owner to the recipient. For example, this may be the same pubkey as owner.

Mint

  • isSigner: false
  • isWritable: false

This account was included for Token* compatability.
This account is meant to be your implementing program’s program id, so calling programs know which program to execute.
Or, it can be used as a token* Mint account, which allows programs to decide if they need to execute a token* CPI or a ProgramTraitTransferV1.

Instructions

The instructions formats are described below

Amount

Both instructions have a single parameter amount which must be serialized & deserialized as a little-endian u64.

preflight_transfer

This instruction’s data has an 8 byte discriminantor: [0x9d, 0x84, 0xf5, 0x5a, 0x61, 0xea, 0x7b, 0xe2], followed by u64 serialized in little-endian format.
And no other bytes.

The accounts to this instruction are:

vec![
    // owner
    AccountMeta {
        pubkey: owner,
        isSigner: false,
        isWritable: false,
    }
    // to
    AccountMeta {
        pubkey: to,
        isSigner: 
        isWritable:
    }
    // authority
    AccountMeta {
        pubkey: authority,
        isSigner: true,
        isWritable: false,
    }
    // mint
    AccountMeta {
        pubkey: mint
        isSigner: false,
        isWritable: false
    }

]

Return data for this instruction is a vector of AccountMetas, serialized as ReturnData.

#[derive(BorshSerialize, BorshDeserialize)]
pub struct IAccountMeta {
    pub pubkey: Pubkey,
    pub signer: bool,
    pub writable: bool,
}

pub type ReturnData = Vec<IAccountMeta>;

transfer

This instruction’s data has an 8 byte discriminantor: [0xa3, 0x34, 0xc8, 0xe7, 0x8c, 0x03, 0x45, 0xba], followed by u64 serialized in little-endian format.
And no other bytes.

The accounts to this instruction are:

vec![
    // owner
    AccountMeta {
        pubkey: owner,
        isSigner: false,
        isWritable: false,
    }
    // to
    AccountMeta {
        pubkey: to,
        isSigner: 
        isWritable:
    }
    // authority
    AccountMeta {
        pubkey: authority,
        isSigner: true,
        isWritable: false,
    }
    // mint
    AccountMeta {
        pubkey: mint
        isSigner: false,
        isWritable: false
    },
]

Additional account metas returned from the previous call to preflight_transfer must be appended to the list of accounts, in the order they were deserialized.

Off-Chain Usage

In order to craft a transfer TransactionInstruction to a program that adheres to ProgramTraitTransferV1, you can simulate the
preflight_transfer instruction with the required accounts, in order to get the list of additional AccountMetas.

Then you can append those AccountMetas to the remaining accounts.

Reference code is provided below, written using @coral-xyz/anchor.

import * as anchor from '@coral-xyz/anchor';

async function resolveRemainingAccounts<I extends anchor.Idl>(
  program: anchor.Program<I>,
  simulationResult: RpcResponseAndContext<SimulatedTransactionResponse>
): Promise<AccountMeta[]> {
  let coder = program.coder.types;

  let returnDataTuple = simulationResult.value.returnData;
  let [b64Data, encoding] = returnDataTuple["data"];
  if (encoding !== "base64") {
    throw new Error("Unsupported encoding: " + encoding);
  }
  let data = base64.decode(b64Data);

  // We start deserializing the Vec<IAccountMeta> from the 5th byte
  // The first 4 bytes are u32 for the Vec of the return data
  let numBytes = data.slice(0, 4);
  let numMetas = new anchor.BN(numBytes, null, "le");
  let offset = 4;

  let realAccountMetas: AccountMeta[] = [];
  const metaSize = 34;
  for (let i = 0; i < numMetas.toNumber(); i += 1) {
    const start = offset + i * metaSize;
    const end = start + metaSize;
    let meta = coder.decode("ExternalIAccountMeta", data.slice(start, end));
    realAccountMetas.push({
      pubkey: meta.pubkey,
      isWritable: meta.writable,
      isSigner: meta.signer,
    });
  }
  return realAccountMetas;
}

This is used like so:

// Simulate the `preflight_transfer` instruction
const preflightInstruction = await wrapper.methods
    .preflightTransfer(new anchor.BN(1))
    .accounts({
        to: destination,
        owner: wallet,
        authority: wallet,
        mint: iProgram.programId,
    })
    .remainingAccounts([])
    .instruction();

let message = MessageV0.compile({
    payerKey: wallet,
    instructions: [preflightInstruction],
    recentBlockhash: (
        await wrapper.provider.connection.getRecentBlockhash()
    ).blockhash,
});
let transaction = new VersionedTransaction(message);

// Deserialize the `AccountMeta`s from the return data
// We have to use VersionedTransactions to get `returnData`
// back from simulated transactions 
let keys = await resolveRemainingAccounts(
    wrapper,
    await wrapper.provider.connection.simulateTransaction(transaction)
);

// Send the actual `transfer` instruction with the required additional
// accounts
const tx = await wrapper.methods
    .transfer(new anchor.BN(1))
    .accounts({
        owner: wallet,
        to: destination,
        authority: wallet,
        mint: iProgram.programId,
    })
    .remainingAccounts(keys)
    .rpc({ skipPreflight: true });
console.log("Transferred with tx:", tx);

Compatability: SPL Token

SPL tokens are compatible with this format.
There is a provided program programs/token-wrapper that shows how to “wrap” tokenkeg to make it compatible with ProgramTraitTransferV1.

Limitations

When returning a vector of account metas in the preflight_transfer instruction, additional account metas must have isSigner: false.

Requiring additional signer account metas must come in the form of a new ProgramTrait specification.

Reference

There is a reference implementation of a program adhering to ProgramTraitTransferV1 under programs/token-program of a program that records which pubkey owns how much of a token in a singleton address.

Calling transfer on this program will change decrement the owner’s stored balance by amount and increment the recipient’s balance by amount.

Tests

To run a test against this program, run anchor test.

What’re your thoughts on adding the version of the implementing program you’re hitting?

For example, you could include the version of the program in the return data, so that PreflightPayload will capture the version of the implementing program.

This way, if developers introduce breaking changes to the program, you can validate the version upon preflight.

ie:

#[derive(Debug, Clone, AnchorDeserialize, AnchorSerialize)]
pub struct PreflightVersionPayload {
   major: u8,
   minor: u8,
}

#[derive(Debug, Clone, AnchorDeserialize, AnchorSerialize)]
pub struct PreflightPayload {
    pub version: PreflightVersionPayload
    pub accounts: Vec<IAccountMeta>,
}
1 Like

This looks really great. It addresses the most important use case for interfaces in a very approachable way.

In this example, it comes with the assumption that all information must be present between the 4 accounts provided and the program logic in order to derive the additional accounts.

This is fine for transfers that CPI once into a well-known program, but for transfer programs that CPI to another interface during transfer, it doesn’t address passing additional accounts in the preflight to derive the next level of additional accounts.

For example, if my transfer program CPIs into a “pausable” interface, and the pausable interface requires another account that gives the “paused” info, the preflight is insufficient. TLV structures will help, but if the “pausable” program ends up requiring a sysvar account, we’re kinda screwed.

This might be addressable by saying that the preflight gets the required accounts PLUS all “remaining accounts” in Anchor speak. What do you think?

A nit: the term owner is overloaded, why not just from or source or bag (j/k)? Especially for people already comfortable with Solana development, it’s confusing to change its meaning from “the address that can fully authorize transfers and delegations” to “the address of the token account”.

Also also: owner and to must be writable, correct?

1 Like