[Solana] SPL Token 프로그램 예제

드림보이즈·2025년 4월 7일

2025 솔라나 해커톤

목록 보기
7/7

목표 : SPL Token 생성,전송 프로그램 개발해보기

키워드 : SPL-Token,MPL-Token, Associated Token Account


1. SPL vs MPL Token

1-1. SPL Token (Solana Program Library token)

솔라나의 표준 토큰 인터페이스로, 마치 ERC-20,ERC-721같은 역할이다.
기본적으로는 대체 가능한 토큰을 의미하지만, 구현에 따라 NFT도 구현할 수 있다.

1-2. MPL Token (Metaplex Token Metadata token)

NFT 표준을 정의하는 프로그램 라이브러리로, SPL을 확장해 "메타데이터"관리가 가능하다.

이더리움과 달리, 개발자가 표준 인터페이스를 알아서 import해 각각 배포되는 것과 달리,
솔라나는 SPL 토큰 생성, 전송 등을 규정하는 프로그램이 배포가 되어 있는 것이다.

즉 개발자는 이미 배포된 프로그램을 활용한다는 것이 포인트다.


2. Associated Token Account

특정 지갑 주소와 특정 토큰의 관계를 정의하기 위한 계정이다.
이더리움은 한 컨트랙트 안에, 각 계정들의 NFT들이 모두 들어가 있다.
그러나 솔라나는 컨트랙트(코드) 따로, 데이터 따로 Account로 관리된다고 했다.
또한, 사용자의 지갑 Account는, SOL을 보관하는 주소이지, 다른 데이터(토큰)들을 저장하기 위한 지갑이 아니라는 것이다.
즉 '영주 주소 - 호날두 NFT'를 위한 지갑이 따로 필요하다. 여기까진 오케이?
이러한 주소를 ATA, Associated Token Account라고 한다.
이 때 주소는 내 주소와 NFT 프로그램 주소를 사용해서 결정론적으로 생성된다.


3. 프로그램(Fungable) 시각화

3-1. 함수

딱 두 기능밖에 없다.

a. 토큰 발행

토큰을 생성한다.

b. 토큰 전송

발행된 토큰을 다른 계정으로 전송한다.

3-2. 필요 계정

a. Signer : 사용자 계정

b. Vault_data(PDA) : 저장소 데이터 계정

c. new_mint (PDA) : 토큰 Mint 계정

d. new_vault (ATA) : 토큰 Account

진정하자. 당신이 어떤 언어를 공부했던 머리속이 혼란한 것이 당연하다.
흐름을 순서대로 보면서, 왜 이런 account가 필요한지 이해해보자.

Init 로직

Signer A(사용자)가 프로그램에서 토큰 발급(Init)을 실행했다.
그럼 사용자의 주소를 활용해 먼저 Vault_data라는 PDA를 만든다.
Vault_data는 토큰 발행 권한을 가지고, 토큰에 대한 데이터도 가진다.
그 후에 Vault_data를 이용해, 새로운 토큰을 발행하고 양을 관리하는 new_mint를 PDA로 만든다.

Signer와 프로그램이 각각 토큰 권한과 발행 로직을 책임지는 것이 아니라,
토큰 권한과 데이터를 위한 PDA인 Vault_data,
이 Valut_data를 바탕으로 토큰 발행을 책임질 PDA인 new_mint를 "Signer"마다 만드는 것이다.

프로그램 안에서 모든 토큰 발행, 관리를 책임지는 것이 아니라,
이렇게 여러 계정을 만들어서 유저마다 권한따로, 발행 따로, 토큰 저장 따로 처리하는 것이다.
아직도 혼란스러울 것을 안다.

그리고 New_mint를 통해 토큰을 발행하면 다시, signer-프로그램를 바탕으로 ATA인 new_vault가 생성된다. 실제 토큰의 저장소는 여기다.

Transfer 로직

Signer A가 B에게 토큰을 주고 싶다.
A의 토큰은 실제로 A의 new_vault에 저장되어 있겠지.
new_vault에 접근을 하려면? 저 계정을 만들 때 seed로 사용한
vault_data를 통해 권한을 확인하고,
new_mint를 통해 어떤 토큰을 전송하는지 확인한다.
그리고 B의 실제 토큰 저장소인 ATA에 저장하는 것이다.


4. 프로그램

코드

use anchor_lang::prelude::*;
use anchor_spl::{ associated_token::AssociatedToken, token::{ Mint, Token, TokenAccount } };

declare_id!("7nLFD23KKdVb82fJDJEDgQ1N6ZBy1AsW5e5R9Vizc6VF");

#[program]
pub mod spl_example {
    use anchor_spl::token::{ mint_to, transfer, MintTo, MintToBumps, Transfer };

    use super::*;

    pub fn initialize(ctx: Context<Initialize>, uri: String) -> Result<()> {
        msg!("Greetings from: init");

        let valut_data = &mut ctx.accounts.valut_data;
        valut_data.bump = ctx.bumps.valut_data;
        valut_data.creator = ctx.accounts.signer.key();
        valut_data.uri = uri;

        let bump = ctx.bumps.valut_data;
        let signer_key = ctx.accounts.signer.key();

        let signer_seeds: &[&[&[u8]]] = &[&[b"valut_data", signer_key.as_ref(), &[bump]]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.new_mint.to_account_info(),
                to: ctx.accounts.new_valut.to_account_info(),
                authority: ctx.accounts.valut_data.to_account_info(),
            },
            signer_seeds
        );

        mint_to(cpi_context, 1)?;

        Ok(())
    }

    pub fn grab(ctx: Context<Grab>) -> Result<()> {
        msg!("Greetings from: grab");
        let valut_data = &ctx.accounts.valut_data;

        let bump = valut_data.bump;
        let signer_key = valut_data.creator;

        let signer_seeds: &[&[&[u8]]] = &[&[b"valut_data", signer_key.as_ref(), &[bump]]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.new_valut.to_account_info(),
                to: ctx.accounts.signer_vault.to_account_info(),
                authority: ctx.accounts.valut_data.to_account_info(),
            },
            signer_seeds
        );

        transfer(cpi_context, 1)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(
        init,
        payer = signer,
        space = 8 + ValutData::INIT_SPACE, // Anchor discriminator (8 bytes) + 구조체 크기
        seeds = [b"valut_data", signer.key().as_ref()],
        bump
    )]
    pub valut_data: Account<'info, ValutData>,

    #[account(
        init,
        payer = signer,
        seeds = [b"mint", signer.key().as_ref()],
        bump,
        mint::decimals = 0,
        mint::authority = valut_data
    )]
    pub new_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = signer,
        associated_token::mint = new_mint,
        associated_token::authority = valut_data
    )]
    pub new_valut: Account<'info, TokenAccount>,
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
}

#[derive(Accounts)]
pub struct Grab<'info> {
    pub signer: Signer<'info>,

    #[account(seeds = [b"valut_data", valut_data.creator.key().as_ref()], bump = valut_data.bump)]
    pub valut_data: Account<'info, ValutData>,

    #[account(
        seeds = [b"mint", valut_data.creator.as_ref()],
        bump,
        mint::decimals = 0,
        mint::authority = valut_data
    )]
    pub mint: Account<'info, Mint>,
    #[account(mut, associated_token::mint = mint, associated_token::authority = valut_data)]
    pub new_valut: Account<'info, TokenAccount>,

    #[account(mut, associated_token::mint = mint, associated_token::authority = signer)]
    pub signer_vault: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
}

#[account]
pub struct ValutData {
    pub creator: Pubkey, // 32 bytes
    pub bump: u8, // 1 byte
    pub uri: String, // 4 bytes (length prefix) + 200 bytes (최대 길이)
}

impl ValutData {
    pub const INIT_SPACE: usize = 32 + 1 + 4 + 200; // 총 크기: 237 bytes
}

Init (발행)

pub fn initialize(ctx: Context<Initialize>, uri: String) -> Result<()> {
        msg!("Greetings from: init");

        let valut_data = &mut ctx.accounts.valut_data;
        valut_data.bump = ctx.bumps.valut_data;
        valut_data.creator = ctx.accounts.signer.key();
        valut_data.uri = uri;

        let bump = ctx.bumps.valut_data;
        let signer_key = ctx.accounts.signer.key();

        let signer_seeds: &[&[&[u8]]] = &[&[b"valut_data", signer_key.as_ref(), &[bump]]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.new_mint.to_account_info(),
                to: ctx.accounts.new_valut.to_account_info(),
                authority: ctx.accounts.valut_data.to_account_info(),
            },
            signer_seeds
        );

        mint_to(cpi_context, 1)?;

        Ok(())
    }

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(
        init,
        payer = signer,
        space = 8 + ValutData::INIT_SPACE, // Anchor discriminator (8 bytes) + 구조체 크기
        seeds = [b"valut_data", signer.key().as_ref()],
        bump
    )]
    pub valut_data: Account<'info, ValutData>,

    #[account(
        init,
        payer = signer,
        seeds = [b"mint", signer.key().as_ref()],
        bump,
        mint::decimals = 0,
        mint::authority = valut_data
    )]
    pub new_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = signer,
        associated_token::mint = new_mint,
        associated_token::authority = valut_data
    )]
    pub new_valut: Account<'info, TokenAccount>,
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
}

토큰 발행하려고 한다. 인자로 뭘 받아야 할까?

  • Signer : 사용자
  • vault_data (PDA) : 함수 내부에서 만들어줘야 됨.
  • new_mint (PDA) : 함수 내부에서 만들어줘야 됨.
  • new_vault (ATA) : 함수 내부에서 만들어줘야 됨.
  • system_program : 솔라나 내부 프로그램
  • token_program : SPL 기능을 위한 프로그램
  • associated_program : ATA를 위한 위한 프로그램

참 그지같은게, 솔라나 프로그램 사용을 위한 것도 인자로,
아직 생성을 안한 주소들도 다 적어줘야 해서 어려워 보이는 것이다.
그래도 이즘이면 많이 익숙해졌다고 생각한다.
함수 호출하면 SPL 프로그램, 타 프로그램을 호출해야 하니 CPI까지 호출해서,
vault_data, new_mint, new_vault를 만들어야 한다.

나는 vault_data에 메타데이터 비스무리를 넣고 싶어서, uri를 인자에 추가했다.

Grab(토큰 옮기기)

pub fn grab(ctx: Context<Grab>) -> Result<()> {
        msg!("Greetings from: grab");
        let valut_data = &ctx.accounts.valut_data;

        let bump = valut_data.bump;
        let signer_key = valut_data.creator;

        let signer_seeds: &[&[&[u8]]] = &[&[b"valut_data", signer_key.as_ref(), &[bump]]];

        let cpi_context = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.new_valut.to_account_info(),
                to: ctx.accounts.signer_vault.to_account_info(),
                authority: ctx.accounts.valut_data.to_account_info(),
            },
            signer_seeds
        );

        transfer(cpi_context, 1)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Grab<'info> {
    pub signer: Signer<'info>,

    #[account(seeds = [b"valut_data", valut_data.creator.key().as_ref()], bump = valut_data.bump)]
    pub valut_data: Account<'info, ValutData>,

    #[account(
        seeds = [b"mint", valut_data.creator.as_ref()],
        bump,
        mint::decimals = 0,
        mint::authority = valut_data
    )]
    pub mint: Account<'info, Mint>,
    #[account(mut, associated_token::mint = mint, associated_token::authority = valut_data)]
    pub new_valut: Account<'info, TokenAccount>,

    #[account(mut, associated_token::mint = mint, associated_token::authority = signer)]
    pub signer_vault: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
}

아까 흐름표를 보면서 생각해보면, 인자로 뭐가 필요할지 감 잡을 수 있다.

  • signer : 뺏기는 애
  • vault_data => mint => new_vault : 위에놈 토큰 주소
  • signer_vault : 받는 애 ATA 주소
  • token_program : SPL 기능 사용 위한 프로그램 주소

signer의 주소로 부터 vault_data => mint => new_vault를 따라가며
Transfer를 위한 from을 계산하고,
받는 애 ATA를 to로 계산해 전송한다.


느낀점

역시 모든 런닝 커브는, 시작은 기분 좋고, 갈수록 어려워지고, 그 구간을 넘으면 감이 조금씩 잡힌다.
아쉽지만 여기까지 학습 내용을 바탕으로 해커톤에 참여했고, 최소 1년은 솔라나 프로그램을 작성할 일이 없을 것 같다.
아니 하나도 아쉽지 않다.
마지막 솔라나 해커톤 참여기 후기로 만납시다. 아디오스.

profile
시리즈 클릭하셔서 카테고리 별로 편하게 보세용

0개의 댓글