ERC-4626: Vault tokenizzate

ERC-4626 è uno standard per vault tokenizzate. Su Solana, si implementa con programmi custom.

Caratteristiche principali

  • Single‑asset custody: Vault holds one ERC‑20; all math expressed in that asset.
  • Share accounting: totalAssets() ↔ totalSupply(); convertToShares() & convertToAssets() guarantee predictable ratios.
  • Composable interface: DEXes, aggregators, and front‑ends can integrate any compliant vault without custom adapters.
  • Strategy‑agnostic: Yield farming, lending, staking, or delta‑neutral - strategy lives behind the same facade.

Programma e Account

Separazione tra logica e stato

Su Solana, i programmi sono stateless e lo stato è memorizzato negli account.

latex
Program  ──┐
           └─> handles instructions
Accounts ───┘  (carry all mutable state)

Implementare un vault su Solana è simile a deployare uno smart contract.

Storage negli account

Lo stato del vault è memorizzato in account dedicati.

Program Derived Addresses (PDA)

Le PDA permettono ai programmi di controllare account in modo deterministico.

Cross-Program Invocation (CPI)

Le CPI permettono ai programmi di chiamare altri programmi.

Flusso autorevole

Deposito

  • Tx includes: user’s Token Account (signer), Vault Token Account (writable), Share Mint (writable), Vault State PDA (writable).
  • CPI #1 → spl_token::transfer user → vault.

Prelievo

  • Reverse order: burn shares → transfer underlying out.

Aggiornamento dello stato

Anteprima

Token Extension Program

Per funzionalità avanzate, utilizza Token Extensions.

Token Extensions fornisce hook per logica personalizzata.

Questo permette funzionalità simili a ERC-4626.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface ISpl20 {
    function mintTokens(address to, address mintAddr, uint256 amount) external;
    function transfer(address to, address mintAddr, uint256 amount) external;
    function getMint(address mintAddr) external view returns (uint8, uint256, address, address, address);
    function getTokenAccount(address owner, address mintAddr) external view returns (address, address, uint256, bool);
}

contract Spl4626Vault {
    ISpl20  public immutable spl20;
    address public immutable mintAddr;
    uint8   public immutable assetDecimals;
    uint256 public totalShareSupply;

    mapping(address => uint256) public shareBalance;
    mapping(address => mapping(address => uint256)) public shareAllowance;

    bool private locked;

    event Approval(address indexed owner, address indexed spender, uint256 value);
    event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares);
    event Withdraw(address indexed caller, address indexed receiver, address indexed owner, uint256 assets, uint256 shares);

    modifier nonReentrant() {
        require(!locked, "REENTRANCY");
        locked = true;
        _;
        locked = false;
    }

    constructor(ISpl20 _spl20, address _mintAddr) {
        spl20 = _spl20;
        mintAddr = _mintAddr;
        (uint8 dec,, , ,) = _spl20.getMint(_mintAddr);
        assetDecimals = dec;
    }

    function totalAssets() public view returns (uint256 assets) {
        (, , assets, ) = spl20.getTokenAccount(address(this), mintAddr);
    }

    function convertToShares(uint256 assets) public view returns (uint256) {
        return totalShareSupply == 0 ? assets : (assets * totalShareSupply) / totalAssets();
    }

    function convertToAssets(uint256 shares) public view returns (uint256) {
        return totalShareSupply == 0 ? shares : (shares * totalAssets()) / totalShareSupply;
    }

    function _mint(address to, uint256 amount) internal {
        totalShareSupply += amount;
        shareBalance[to] += amount;
    }

    function _burn(address from, uint256 amount) internal {
        shareBalance[from] -= amount;
        totalShareSupply -= amount;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        shareAllowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function deposit(uint256 assets, address receiver) external nonReentrant returns (uint256 shares) {
        require(assets > 0, "zero assets");
        spl20.transfer(address(this), mintAddr, assets);
        shares = convertToShares(assets);
        _mint(receiver, shares);
        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function redeem(uint256 shares, address receiver, address owner) external nonReentrant returns (uint256 assets) {
        require(shares > 0, "zero shares");
        if (msg.sender != owner) {
            uint256 allowed = shareAllowance[owner][msg.sender];
            require(allowed >= shares, "allowance too low");
            if (allowed != type(uint256).max) {
                shareAllowance[owner][msg.sender] = allowed - shares;
            }
        }
        assets = convertToAssets(shares);
        _burn(owner, shares);
        spl20.transfer(receiver, mintAddr, assets);
        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }
}

Come implementare

Mappa concettuale

PieceOn-chain ObjectPurpose
Underlying MintMint (already exists)The ERC-20-equivalent asset (e.g., USDC)
Share MintNew Mint (PDA authority)Tracks proportional ownership of the vault
Vault ATAToken Account (owned by PDA)Holds the underlying assets
Vault State PDASmall data accountStores share_mint, pda_bump, optional fees
Token Account Owner PDASigner PDAAuthority over Vault ATA and Share Mint

Tutto può avvenire in una singola transazione.

Flusso delle chiamate

1) deposit (assets → shares)

2) tx composer (wallet)

  • adds: user ata, vault ata, share mint, vault state, pda, token program
  • invokes program deposit(assets)

3) Program

  • token::transfer (user → vault)
  • compute shares = assets * total_supply / total_assets
  • token::mint_to (shares → user) using token_account_owner_pda as signer
  • update vault_state.total_assets (optional, can always recompute)
  • emit DepositEvt

4) wallet preview: because every account is explicit, it can show -X assets & +Y shares before signing.

5) redeem / withdraw (shares → assets) is the same sequence in reverse order.

Scrivi il programma

Implementa la logica del vault in un programma Solana.

rust
use anchor_lang::prelude::*;
use anchor_spl::token::{
    self, Mint, Token, TokenAccount,
    Transfer, MintTo, Burn,
};

declare_id!("VaUlt4626pHkYwWjSfERn6y6oAYcg4UH8zQcAXXXXXXXXX"); // replace after deploy

#[account]
pub struct VaultState {
    pub share_mint: Pubkey,
    pub pda_bump:   u8,
}
const VAULT_STATE_SIZE: usize = 8 + 32 + 1;

#[program]
pub mod spl_4626_vault {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let state        = &mut ctx.accounts.vault_state;
        state.share_mint = ctx.accounts.share_mint.key();
        state.pda_bump   = *ctx.bumps.get("token_account_owner_pda").unwrap();
        Ok(())
    }

    pub fn deposit(ctx: Context<Deposit>, assets: u64) -> Result<()> {
        require!(assets > 0, CustomError::ZeroAmount);
        token::transfer(ctx.accounts.transfer_into_vault_ctx(), assets)?;

        let supply        = ctx.accounts.share_mint.supply;
        let total_assets  = ctx.accounts.vault_token_account.amount;
        let shares = if supply == 0 {
            assets
        } else {
            (assets as u128 * supply as u128 / total_assets as u128) as u64
        };

        token::mint_to(ctx.accounts.mint_shares_ctx(), shares)?;

        emit!(DepositEvt {
            caller:   ctx.accounts.signer.key(),
            receiver: ctx.accounts.signer.key(),
            assets,
            shares,
        });
        Ok(())
    }

    pub fn redeem(ctx: Context<Redeem>, shares: u64) -> Result<()> {
        require!(shares > 0, CustomError::ZeroAmount);

        let supply        = ctx.accounts.share_mint.supply;
        let total_assets  = ctx.accounts.vault_token_account.amount;
        let assets        = (shares as u128 * total_assets as u128 / supply as u128) as u64;

        token::burn(ctx.accounts.burn_shares_ctx(), shares)?;
        token::transfer(ctx.accounts.transfer_out_ctx(), assets)?;

        emit!(WithdrawEvt {
            caller:   ctx.accounts.signer.key(),
            receiver: ctx.accounts.signer.key(),
            owner:    ctx.accounts.signer.key(),
            assets,
            shares,
        });
        Ok(())
    }
}

#[event]
pub struct DepositEvt {
    pub caller:   Pubkey,
    pub receiver: Pubkey,
    pub assets:   u64,
    pub shares:   u64,
}
#[event]
pub struct WithdrawEvt {
    pub caller:   Pubkey,
    pub receiver: Pubkey,
    pub owner:    Pubkey,
    pub assets:   u64,
    pub shares:   u64,
}

#[error_code]
pub enum CustomError {
    #[msg("amount must be > 0")]
    ZeroAmount,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer  = signer,
        seeds  = [b"vault_state"],
        bump,
        space  = VAULT_STATE_SIZE,
    )]
    pub vault_state: Account<'info, VaultState>,

    #[account(seeds = [b"token_account_owner_pda"], bump)]
    /// CHECK: program-derived signer
    pub token_account_owner_pda: AccountInfo<'info>,

    #[account(
        init,
        payer = signer,
        seeds = [b"share_mint"],
        bump,
        mint::decimals   = mint_of_token_being_sent.decimals,
        mint::authority  = token_account_owner_pda,
    )]
    pub share_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = signer,
        seeds = [b"token_vault", mint_of_token_being_sent.key().as_ref()],
        bump,
        token::mint      = mint_of_token_being_sent,
        token::authority = token_account_owner_pda,
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    pub mint_of_token_being_sent: Account<'info, Mint>,

    #[account(mut)] pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
    pub token_program:  Program<'info, Token>,
    pub rent:           Sysvar<'info, Rent>,
}

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(mut, seeds=[b"vault_state"], bump)]
    pub vault_state: Account<'info, VaultState>,
    #[account(seeds=[b"token_account_owner_pda"], bump)]
    /// CHECK:
    pub token_account_owner_pda: AccountInfo<'info>,

    #[account(mut,
        seeds=[b"token_vault", mint_of_token_being_sent.key().as_ref()],
        bump,
        token::mint      = mint_of_token_being_sent,
        token::authority = token_account_owner_pda
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    #[account(mut)] pub sender_token_account: Account<'info, TokenAccount>,
    #[account(mut)] pub sender_share_account: Account<'info, TokenAccount>,

    #[account(seeds=[b"share_mint"], bump)]
    pub share_mint: Account<'info, Mint>,
    pub mint_of_token_being_sent: Account<'info, Mint>,

    #[account(mut)] pub signer: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

#[derive(Accounts)]
pub struct Redeem<'info> {
    #[account(mut, seeds=[b"vault_state"], bump)]
    pub vault_state: Account<'info, VaultState>,
    #[account(seeds=[b"token_account_owner_pda"], bump)]
    /// CHECK:
    pub token_account_owner_pda: AccountInfo<'info>,

    #[account(mut,
        seeds=[b"token_vault", mint_of_token_being_sent.key().as_ref()],
        bump,
        token::mint      = mint_of_token_being_sent,
        token::authority = token_account_owner_pda
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    #[account(mut)] pub sender_token_account: Account<'info, TokenAccount>,
    #[account(mut)] pub sender_share_account: Account<'info, TokenAccount>,

    #[account(seeds=[b"share_mint"], bump)]
    pub share_mint: Account<'info, Mint>,
    pub mint_of_token_being_sent: Account<'info, Mint>,

    #[account(mut)] pub signer: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

impl<'info> Deposit<'info> {
    fn transfer_into_vault_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        CpiContext::new(
            self.token_program.to_account_info(),
            Transfer {
                from:      self.sender_token_account.to_account_info(),
                to:        self.vault_token_account.to_account_info(),
                authority: self.signer.to_account_info(),
            },
        )
    }
    fn mint_shares_ctx(&self) -> CpiContext<'_, '_, '_, 'info, MintTo<'info>> {
        let seeds = &[b"token_account_owner_pda", &[self.vault_state.pda_bump]];
        CpiContext::new_with_signer(
            self.token_program.to_account_info(),
            MintTo {
                mint:      self.share_mint.to_account_info(),
                to:        self.sender_share_account.to_account_info(),
                authority: self.token_account_owner_pda.to_account_info(),
            },
            &[seeds],
        )
    }
}

impl<'info> Redeem<'info> {
    fn burn_shares_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Burn<'info>> {
        let seeds = &[b"token_account_owner_pda", &[self.vault_state.pda_bump]];
        CpiContext::new_with_signer(
            self.token_program.to_account_info(),
            Burn {
                mint:      self.share_mint.to_account_info(),
                from:      self.sender_share_account.to_account_info(),
                authority: self.token_account_owner_pda.to_account_info(),
            },
            &[seeds],
        )
    }
    fn transfer_out_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        let seeds = &[b"token_account_owner_pda", &[self.vault_state.pda_bump]];
        CpiContext::new_with_signer(
            self.token_program.to_account_info(),
            Transfer {
                from:      self.vault_token_account.to_account_info(),
                to:        self.sender_token_account.to_account_info(),
                authority: self.token_account_owner_pda.to_account_info(),
            },
            &[seeds],
        )
    }
}
Guide EVM → SVM

Risorse aggiuntive

Gestito da

© 2026 Solana Foundation.
Tutti i diritti riservati.
Resta connesso
ERC-4626 su Solana: Vault tokenizzate | Solana