Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

PDA와 CPI: 프로그램 간 상호작용

PDA (Program Derived Address)

왜 PDA가 필요한가?

Solana에서 일반 Account는 개인키(private key)로 서명해야 트랜잭션을 보낼 수 있습니다. 그런데 프로그램이 “자신을 대신해서” Account를 제어하고 싶을 때 문제가 생깁니다.

시나리오: 에스크로(Escrow) 프로그램
─────────────────────────────────────
Alice가 100 SOL을 에스크로에 예치
→ Bob이 조건 이행 시 자동으로 SOL 받음

문제: 에스크로 Account의 SOL을 누가 전송하는가?
- Alice도 아니고 (이미 에스크로에 넣었으므로)
- Bob도 아니고 (조건 확인 전에 가져가면 안 되므로)
- 에스크로 프로그램이 직접 전송해야 함!

하지만 프로그램은 private key가 없으므로 서명 불가!
→ PDA가 해결책

PDA는 프로그램이 “소유“하고 “서명“할 수 있는 특수 주소입니다.


PDA 생성 원리

일반 Keypair는 ed25519 타원 곡선 위에 존재하는 점(point)입니다. PDA는 의도적으로 곡선 밖에 있는 주소를 사용합니다.

ed25519 타원 곡선:

    y
    │     ·  ·  ·
    │  ·           ·
    │ ·               ·   ← 곡선 위 점 = 일반 Keypair (private key 존재)
    │·                 ·
    ──────────────────────── x
    │·                 ·
    │ ·               ·
    │  ·           ·
    │     ·  ·  ·
    │
    │                    × ← 곡선 밖 점 = PDA (private key 없음!)

find_program_address(seeds, program_id):
  1. SHA256(seeds + program_id + bump) 계산
  2. 결과가 곡선 위에 있으면 bump 감소 후 재시도
  3. 결과가 곡선 밖이면 → 이것이 PDA!
  bump는 255부터 시작해서 감소 (canonical bump = 최초 성공 값)
// PDA 생성 코드
use solana_program::pubkey::Pubkey;

// find_program_address: canonical bump를 자동으로 찾아줌
let seeds = &[
    b"user-data",          // 문자열 시드
    user_pubkey.as_ref(),  // 사용자 주소 시드
];
let (pda, bump) = Pubkey::find_program_address(seeds, &program_id);

// 반환값:
// pda: 생성된 PDA 주소 (Pubkey)
// bump: 곡선 밖으로 밀어낸 값 (0~255)

// create_program_address: bump를 직접 지정 (검증용)
let pda_verify = Pubkey::create_program_address(
    &[b"user-data", user_pubkey.as_ref(), &[bump]],
    &program_id,
)?;
assert_eq!(pda, pda_verify);
// TypeScript에서 PDA 찾기
import { PublicKey } from '@solana/web3.js';

const PROGRAM_ID = new PublicKey("11111111111111111111111111111111");
const userPubkey = new PublicKey("So11111111111111111111111111111111111111112");

// seeds는 Buffer 또는 Uint8Array 배열
const [pda, bump] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("user-data"),  // 문자열 시드
    userPubkey.toBuffer(),     // 주소 시드
  ],
  PROGRAM_ID
);

console.log("PDA:", pda.toBase58());
console.log("Bump:", bump);

// 같은 seeds + program_id → 항상 같은 PDA (결정론적!)
// 즉, 클라이언트와 프로그램이 독립적으로 같은 주소를 계산할 수 있음

PDA의 실제 사용 패턴

패턴 1: 사용자별 데이터 계정

// 각 사용자마다 고유한 데이터 계정을 결정론적으로 생성
let seeds = &[
    b"player",
    user_pubkey.as_ref(),
    &[bump],
];
// → 같은 user는 항상 같은 PDA 주소
// → 클라이언트가 사전에 주소 계산 가능 (DB 조회 불필요!)

패턴 2: 글로벌 상태 계정

// 프로그램 전체의 설정/상태를 저장
let seeds = &[b"global-config", &[bump]];
// → 프로그램당 하나의 고정된 설정 계정

패턴 3: 에스크로 금고

// 특정 거래의 SOL/토큰을 보관하는 금고
let seeds = &[
    b"escrow",
    trade_id.as_ref(),
    &[bump],
];
// → 거래 ID마다 고유한 에스크로 계정

PDA Rust 코드 예제: 사용자 프로필 시스템

use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program::invoke_signed,
    program_error::ProgramError,
    pubkey::Pubkey,
    rent::Rent,
    system_instruction,
    sysvar::Sysvar,
};

#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct UserProfile {
    pub owner: Pubkey,
    pub username: String,
    pub score: u64,
    pub bump: u8,           // PDA bump 저장 (나중에 invoke_signed에 사용)
}

impl UserProfile {
    pub const MAX_USERNAME_LEN: usize = 32;
    pub const LEN: usize = 32 + 4 + Self::MAX_USERNAME_LEN + 8 + 1;
    // owner(32) + string_len(4) + username(32) + score(8) + bump(1)
}

entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    data: &[u8],
) -> ProgramResult {
    // username 파싱 (첫 바이트가 길이, 나머지가 UTF-8)
    let username_len = data[0] as usize;
    let username = std::str::from_utf8(&data[1..1 + username_len])
        .map_err(|_| ProgramError::InvalidInstructionData)?
        .to_string();

    let accounts_iter = &mut accounts.iter();
    let profile_account = next_account_info(accounts_iter)?;
    let user = next_account_info(accounts_iter)?;
    let system_program = next_account_info(accounts_iter)?;

    if !user.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // PDA 검증: 클라이언트가 올바른 PDA를 전달했는가?
    let (expected_pda, bump) = Pubkey::find_program_address(
        &[b"profile", user.key.as_ref()],
        program_id,
    );

    if expected_pda != *profile_account.key {
        msg!("잘못된 PDA 주소");
        return Err(ProgramError::InvalidArgument);
    }

    // 렌트 계산
    let rent = Rent::get()?;
    let required_lamports = rent.minimum_balance(UserProfile::LEN);

    // PDA Account 생성 (invoke_signed 사용!)
    // 일반 invoke와 달리, PDA seeds를 제공하여 프로그램이 서명
    invoke_signed(
        &system_instruction::create_account(
            user.key,
            profile_account.key,
            required_lamports,
            UserProfile::LEN as u64,
            program_id,
        ),
        &[user.clone(), profile_account.clone(), system_program.clone()],
        // signer_seeds: PDA를 "서명"하는 seeds
        &[&[b"profile", user.key.as_ref(), &[bump]]],
    )?;

    // 프로필 데이터 저장
    let profile = UserProfile {
        owner: *user.key,
        username,
        score: 0,
        bump,
    };
    profile.serialize(&mut *profile_account.data.borrow_mut())?;

    msg!("프로필 생성 완료!");
    Ok(())
}

CPI (Cross-Program Invocation)

CPI란?

CPI는 하나의 프로그램이 다른 프로그램을 호출하는 기능입니다. 이더리움의 external call 또는 NestJS에서 서비스가 다른 서비스를 호출하는 것과 유사합니다.

이더리움 external call:
─────────────────────────────
MyContract.foo() {
    // 다른 컨트랙트 호출
    IERC20(tokenAddress).transferFrom(from, to, amount);
}

NestJS 서비스 간 호출:
─────────────────────────────
@Injectable()
class OrderService {
  constructor(private paymentService: PaymentService) {}

  async createOrder() {
    await this.paymentService.charge(amount);  // 다른 서비스 호출
  }
}

Solana CPI:
─────────────────────────────
// 내 프로그램에서 Token Program의 transfer 호출
invoke(
    &token_instruction::transfer(...),
    &[from_account, to_account, authority],
)?;

invoke() vs invoke_signed()

// invoke(): 일반 CPI (PDA 서명 불필요)
// 사용 케이스: user가 서명자인 경우
invoke(
    &system_instruction::transfer(from_pubkey, to_pubkey, lamports),
    &[from_account.clone(), to_account.clone(), system_program.clone()],
)?;

// invoke_signed(): PDA가 서명자인 CPI
// 사용 케이스: 프로그램 자신의 PDA에서 자산 이동
invoke_signed(
    &system_instruction::transfer(pda_pubkey, to_pubkey, lamports),
    &[pda_account.clone(), to_account.clone(), system_program.clone()],
    &[&[b"vault", &[bump]]],  // PDA seeds로 서명
)?;

CPI 예제 1: System Program에 CPI로 SOL 전송

// 시나리오: 내 프로그램의 PDA(금고)에서 SOL을 사용자에게 전송

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint::ProgramResult,
    program::invoke_signed,
    pubkey::Pubkey,
    system_instruction,
};

pub fn withdraw_from_vault(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let vault_account = next_account_info(accounts_iter)?;   // PDA 금고
    let recipient = next_account_info(accounts_iter)?;        // 받는 사람
    let system_program = next_account_info(accounts_iter)?;   // System Program

    // PDA 검증
    let (vault_pda, bump) = Pubkey::find_program_address(
        &[b"vault"],
        program_id,
    );
    assert_eq!(vault_pda, *vault_account.key);

    // PDA(금고)에서 recipient에게 SOL 전송
    // vault_account는 private key가 없으므로 invoke_signed 필요
    invoke_signed(
        &system_instruction::transfer(
            vault_account.key,  // from (PDA)
            recipient.key,      // to
            amount,
        ),
        &[
            vault_account.clone(),
            recipient.clone(),
            system_program.clone(),
        ],
        // PDA의 seeds로 서명 (private key 없이도 서명 가능!)
        &[&[b"vault", &[bump]]],
    )?;

    Ok(())
}

CPI 예제 2: Token Program에 CPI로 토큰 전송

use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use anchor_lang::prelude::*;

// Anchor를 사용한 Token Program CPI
pub fn transfer_tokens<'info>(
    from: &Account<'info, TokenAccount>,
    to: &Account<'info, TokenAccount>,
    authority: &Signer<'info>,
    token_program: &Program<'info, Token>,
    amount: u64,
) -> Result<()> {
    let cpi_accounts = Transfer {
        from: from.to_account_info(),
        to: to.to_account_info(),
        authority: authority.to_account_info(),
    };

    let cpi_ctx = CpiContext::new(
        token_program.to_account_info(),
        cpi_accounts,
    );

    token::transfer(cpi_ctx, amount)?;
    Ok(())
}

// PDA가 authority인 경우 (invoke_signed 케이스)
pub fn transfer_tokens_with_pda<'info>(
    from: &Account<'info, TokenAccount>,
    to: &Account<'info, TokenAccount>,
    pda_authority: &AccountInfo<'info>,
    token_program: &Program<'info, Token>,
    amount: u64,
    seeds: &[&[u8]],  // PDA seeds
) -> Result<()> {
    let cpi_accounts = Transfer {
        from: from.to_account_info(),
        to: to.to_account_info(),
        authority: pda_authority.clone(),
    };

    let cpi_ctx = CpiContext::new_with_signer(
        token_program.to_account_info(),
        cpi_accounts,
        &[seeds],  // signer_seeds
    );

    token::transfer(cpi_ctx, amount)?;
    Ok(())
}

이더리움 external call과의 비교

이더리움 external call:
─────────────────────────────────────────────────────────────
// Re-entrancy 공격 가능!
// 악의적인 컨트랙트가 callback으로 재진입할 수 있음
contract Vulnerable {
    mapping(address => uint) balances;

    function withdraw() external {
        uint amount = balances[msg.sender];
        (bool success,) = msg.sender.call{value: amount}(""); // ← 위험!
        balances[msg.sender] = 0;  // 이미 늦음
    }
}

Solana CPI:
─────────────────────────────────────────────────────────────
// Re-entrancy가 구조적으로 불가능!
// 이유 1: 프로그램은 자신이 owner인 계정만 수정 가능
// 이유 2: 호출 스택에서 같은 프로그램의 재진입 금지
// 이유 3: 계정 잠금(borrow) 메커니즘

invoke(
    &token::transfer(...),
    accounts,
)?;
// CPI 완료 후에만 다음 코드 실행
// 중간에 재진입 불가

PDA + CPI 종합 예제: 에스크로 프로그램

// 완전한 에스크로 흐름:
// 1. Alice가 SOL을 에스크로 PDA에 예치
// 2. 조건 확인 후 프로그램이 Bob에게 자동 전송

#[derive(BorshSerialize, BorshDeserialize)]
pub struct EscrowData {
    pub depositor: Pubkey,   // Alice
    pub recipient: Pubkey,   // Bob
    pub amount: u64,
    pub condition_met: bool,
    pub bump: u8,
}

// Step 1: Alice가 에스크로 생성 및 SOL 예치
pub fn create_escrow(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
    recipient: Pubkey,
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let escrow_account = next_account_info(accounts_iter)?;
    let alice = next_account_info(accounts_iter)?;
    let system_program = next_account_info(accounts_iter)?;

    let (escrow_pda, bump) = Pubkey::find_program_address(
        &[b"escrow", alice.key.as_ref()],
        program_id,
    );

    // PDA 생성 (System Program에 CPI)
    invoke_signed(
        &system_instruction::create_account(
            alice.key,
            escrow_account.key,
            amount + rent_minimum,  // 예치금 + 렌트
            EscrowData::LEN as u64,
            program_id,
        ),
        &[alice.clone(), escrow_account.clone(), system_program.clone()],
        &[&[b"escrow", alice.key.as_ref(), &[bump]]],
    )?;

    // Alice의 SOL을 에스크로로 전송 (Alice가 서명자이므로 invoke 사용)
    invoke(
        &system_instruction::transfer(alice.key, escrow_account.key, amount),
        &[alice.clone(), escrow_account.clone(), system_program.clone()],
    )?;

    let data = EscrowData {
        depositor: *alice.key,
        recipient,
        amount,
        condition_met: false,
        bump,
    };
    data.serialize(&mut *escrow_account.data.borrow_mut())?;

    Ok(())
}

// Step 2: 조건 이행 후 Bob에게 SOL 전송
pub fn release_escrow(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let escrow_account = next_account_info(accounts_iter)?;
    let bob = next_account_info(accounts_iter)?;
    let system_program = next_account_info(accounts_iter)?;

    let mut data = EscrowData::try_from_slice(&escrow_account.data.borrow())?;

    // 수취인 검증
    if data.recipient != *bob.key {
        return Err(ProgramError::InvalidArgument);
    }

    // 에스크로 PDA에서 Bob에게 SOL 전송 (PDA가 서명자이므로 invoke_signed)
    invoke_signed(
        &system_instruction::transfer(
            escrow_account.key,
            bob.key,
            data.amount,
        ),
        &[escrow_account.clone(), bob.clone(), system_program.clone()],
        // PDA seeds로 서명! private key 없이도 가능
        &[&[b"escrow", data.depositor.as_ref(), &[data.bump]]],
    )?;

    Ok(())
}

PDA와 CPI 핵심 정리

PDA 요약:
┌────────────────────────────────────────────────────┐
│  PDA = seeds + program_id → 곡선 밖의 결정론적 주소 │
│                                                    │
│  특징:                                             │
│  • private key 없음 → 탈취 불가                    │
│  • 프로그램만 서명 가능 (invoke_signed)             │
│  • 결정론적 → 클라이언트가 미리 계산 가능           │
│  • 사용자별 데이터 계정, 금고, 에스크로 등에 활용   │
└────────────────────────────────────────────────────┘

CPI 요약:
┌────────────────────────────────────────────────────┐
│  invoke()        = 일반 호출 (user가 서명자)        │
│  invoke_signed() = PDA가 서명자인 호출              │
│                                                    │
│  이더리움 대비 장점:                                │
│  • Re-entrancy 공격 구조적 불가                    │
│  • 계정 접근 권한이 명확                           │
│  • Atomic 실행 보장                                │
└────────────────────────────────────────────────────┘

다음 장부터는 Anchor 프레임워크를 배워 이 모든 보일러플레이트를 대폭 줄이겠습니다.