CPI를 통한 Token Program 호출

온체인 프로그램은 Token Program으로 Cross Program Invocation (CPI)을 발행하여 토큰을 이동시킵니다. 프로그램은 Token Program 명령어를 구성하고, 필요한 계정을 제공한 뒤 invoke(또는 Program Derived Address가 서명할 때는 invoke_signed)를 호출합니다. 그러면 Token Program이 프로그램이 부여한 권한으로 해당 명령어를 실행합니다.

이 페이지는 해당 흐름의 토큰 관련 부분을 다룹니다. CPI 메커니즘 자체 — invoke 및 *rsinvoke_signed*의 동작 방식, 서명자 및 쓰기 권한의 전파, CPI 비용 모델 — 에 대해서는 Cross Program Invocation을 참조하세요.

일반적인 패턴

대부분의 토큰 CPI는 동일한 구조를 따릅니다: 명령어를 구성하고, token account와 권한을 전달한 뒤 호출합니다. 아래 예제는 *rsTransferChecked*를 사용하여 토큰을 전송하며, 이는 전송의 일부로 민트와 소수점 자릿수를 검증합니다.

use anchor_lang::prelude::*;
use anchor_spl::token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked};
declare_id!("11111111111111111111111111111111");
#[program]
pub mod token_cpi {
use super::*;
pub fn transfer(ctx: Context<TokenTransfer>, amount: u64) -> Result<()> {
let cpi_accounts = TransferChecked {
from: ctx.accounts.source.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct TokenTransfer<'info> {
#[account(mut)]
pub source: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
pub destination: InterfaceAccount<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}

민팅, 소각, 계정 닫기도 동일한 패턴을 따르며, 다른 명령어를 사용합니다 — Anchor의 anchor_spl::token 모듈에서 MintTo, Burn, CloseAccount, 또는 pinocchio-token의 해당 빌더를 사용합니다. Anchor 및 네이티브 Rust 모두에서 Token Program으로 CPI를 수행하는 완전하고 실행 가능한 프로그램은 token program 예제를 참조하세요 — 특히 "Transfer Tokens" 예제를 확인하세요.

PDA 권한으로 서명하기

token account의 권한이 프로그램이 소유한 Program Derived Address인 경우, 프로그램은 *rsinvoke_signed*를 사용하여 PDA 시드를 서명자 시드로 전달하며 CPI에 직접 서명합니다. Anchor에서는 CpiContext::new 대신 시드와 함께 *rsCpiContext::new_with_signer*를 사용하세요. 명령어 자체는 위의 예제와 동일합니다. 전체 메커니즘은 PDA 서명자를 사용한 CPI를 참조하세요.

배치 처리

Batch 명령어는 단일 프로그램 호출 내에서 여러 Token Program 명령어를 실행합니다. 각 CPI마다 고정된 컴퓨팅 비용이 발생하기 때문에, 하나의 배치 CPI에서 여러 토큰 작업을 실행하면 작업마다 별도의 CPI를 발행하는 것보다 더 적은 컴퓨팅 유닛을 사용합니다 — CPI별 비용이 누적되는 방식은 CPI 비용 모델을 참조하세요.

컴퓨팅 사용량이 낮을수록 트랜잭션이 컴퓨팅 유닛당 지불하는 우선순위 수수료가 줄어들고 처리될 가능성이 높아집니다 — 이는 단일 명령어에서 많은 토큰 작업을 수행하는 프로그램에서 특히 중요합니다. 일반적인 사용 사례로는 여러 수신자에게 전송을 분산하거나, 단일 CPI에서 다단계 토큰 흐름(예: 동기화, 전송, 종료)을 실행하는 것이 있습니다.

*rsBatch*는 자식 요소로 Token Program 명령어만 허용하며, 배치 안에 또 다른 배치를 포함할 수 없습니다. 토큰을 이동하는 작업(전송, 민팅, 소각 등)의 경우, 프로그램은 실행 전에 해당 계정이 Token Program에 의해 소유되어 있는지 검증합니다.

소스 참조

항목설명소스
Batch배치 명령어 (식별자 255).소스
process_batch배치 처리기 로직.소스

CPI를 통한 배치 호출

pinocchio-token 크레이트는 pinocchio_token::instructions::Batch 아래에 Batch 빌더를 제공합니다. 각 자식 명령어를 배치의 버퍼에 준비한 후 *rsinvoke*를 통해 단일 CPI를 발행합니다.

*rsBatch*는 호출자가 제공하는 세 개의 버퍼를 기반으로 합니다: 직렬화된 instruction data용 버퍼, 자식 명령어별 계정 메타용 버퍼, 그리고 CPI에 전달되는 계정 뷰용 버퍼입니다. 이를 MaybeUninit 슬라이스로 구성하고 *rsBatch::new*에 전달한 후, 자식 요소를 추가하고 호출합니다:

Batch two transfers in one CPI
use core::mem::MaybeUninit;
use pinocchio::{account::AccountView, error::ProgramError, ProgramResult};
use pinocchio_token::instructions::{Batch, IntoBatch, Transfer};
/// Process SwapBatch.
///
/// Performs two transfers in a single batch CPI instead of two separate
/// `invoke()` calls.
///
/// Data layout:
/// [0..8] amount_a_to_b (u64 LE)
/// [8..16] amount_b_to_a (u64 LE)
///
/// Account layout:
/// [0] source_a (writable) — token account A
/// [1] source_b (writable) — token account B
/// [2] authority_a (signer) — authority for account A
/// [3] authority_b (signer) — authority for account B
/// [4] token_program — SPL Token program
pub fn process(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
if data.len() < 16 {
return Err(ProgramError::InvalidInstructionData);
}
if accounts.len() < 5 {
return Err(ProgramError::NotEnoughAccountKeys);
}
let amount_a_to_b = u64::from_le_bytes(data[0..8].try_into().unwrap());
let amount_b_to_a = u64::from_le_bytes(data[8..16].try_into().unwrap());
let source_a = &accounts[0];
let source_b = &accounts[1];
let authority_a = &accounts[2];
let authority_b = &accounts[3];
// Two child transfers, each with 3 accounts and 9 bytes of data
// (1-byte discriminator + 8-byte amount).
const NUM_CHILDREN: usize = 2;
const CHILD_ACCOUNTS: usize = 3;
const CHILD_DATA_LEN: usize = 9;
// Data buffer: 1-byte batch discriminator + per child (2-byte header + data).
const DATA_LEN: usize = 1 + NUM_CHILDREN * (2 + CHILD_DATA_LEN);
// Account buffers: total accounts across all children.
const ACCOUNTS_LEN: usize = NUM_CHILDREN * CHILD_ACCOUNTS;
let mut data_buf = [MaybeUninit::uninit(); DATA_LEN];
let mut ix_accounts_buf = [MaybeUninit::uninit(); ACCOUNTS_LEN];
let mut accounts_buf = [MaybeUninit::uninit(); ACCOUNTS_LEN];
let mut batch = Batch::new(&mut data_buf, &mut ix_accounts_buf, &mut accounts_buf)?;
Transfer::new(source_a, source_b, authority_a, amount_a_to_b).into_batch(&mut batch)?;
Transfer::new(source_b, source_a, authority_b, amount_b_to_a).into_batch(&mut batch)?;
batch.invoke()?;
Ok(())
}

*rsBatch::new*는 배치 판별자를 데이터 버퍼의 첫 번째 바이트에 기록하고, 각 버퍼가 얼마나 사용되었는지 추적하는 *rsBatch*를 반환합니다. 모든 Token Program instruction builder(예: Transfer, TransferChecked, MintTo, Burn)는 IntoBatch 트레이트를 구현하므로, *rsinto_batch*는 해당 instruction의 instruction data, 계정 메타, 계정 뷰를 배치에 추가합니다. *rsinvoke*를 호출하면 조합된 배치가 Token Program에 대한 단일 CPI로 실행됩니다.

버퍼는 배치의 용량을 제한합니다. 데이터 버퍼의 크기는 1 + Σ(2 + child_data_len) 바이트(판별자, 2바이트 헤더, 각 자식의 직렬화된 데이터 포함)로 설정하고, 각 계정 버퍼는 모든 자식에 걸친 총 계정 수로 설정하세요. 자식이 들어가지 않을 경우 *rsinto_batch*는 *rsProgramError::InvalidArgument*를 반환합니다. 참고로, *rsBatch::MAX_DATA_LEN*는 10 KiB이고 *rsBatch::MAX_ACCOUNTS_LEN*는 런타임의 최대 CPI 계정 수와 같습니다.

런타임에만 크기가 결정되는 배치의 경우, 크레이트의 alloc 피처를 활성화하고 *rsBatchState::new(accounts_len, data_len)*를 사용하여 힙에 버퍼를 할당한 다음, *rsas_batch*를 호출하여 *rsBatch*를 얻으세요.

자식 instruction의 권한이 프로그램이 소유한 PDA인 경우, invoke 대신 *rsinvoke_signed*를 사용하여 CPI에 서명하고, PDA 시드를 Signer 항목으로 전달하세요.

Instruction data 레이아웃

배치는 255 판별자 뒤에 자식들을 순서대로 인코딩합니다. 모든 계정은 순서대로 평탄하게 전달되며, 각 자식마다 num_accounts에 의해 슬라이싱됩니다. 빌더는 정확히 다음과 같은 와이어 포맷을 생성합니다:

Batch Wire Format
[255] // Batch discriminator
// For each child instruction, in order:
// [num_accounts: u8] // accounts this instruction consumes
// [data_len: u8] // length of this instruction's data
// [data: u8; data_len]// the instruction data (begins with its own discriminator)

배치의 용량은 *rsBatch::new*에 전달된 버퍼에 의해 제한됩니다. 데이터 버퍼는 판별자와 모든 자식의 헤더 및 데이터를 담을 수 있어야 하며, 계정 버퍼는 모든 자식의 계정을 담을 수 있어야 합니다. 가장 큰 배치를 기준으로 크기를 설정하거나, alloc 피처를 사용하여 *rsBatchState*로 런타임에 크기를 결정하세요. 결합된 CPI는 여전히 트랜잭션의 계정 및 크기 제한을 받으므로, 팬아웃이 매우 큰 경우 Address Lookup Tables를 사용하여 더 많은 계정을 수용하세요.

Is this page helpful?

목차

페이지 편집