eth to sol
$npx skills add solana-foundation/eth-to-sol-skill

ERC-4626 Vault

Tokenized vault. Shares as an SPL Mint; rounding direction is type-checked.

Solidity LOC
239
Anchor LOC
750
Cost / tx
$2–50 → $0.00025
Framework
Anchor 1.0.2
Source · SolidityVault.solETHEREUM SOLIDITY CONTRACT
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
    function decimals() external view returns (uint8);
}

/// @title ExampleVault — minimal ERC-4626 with virtual-offset inflation defense and yield fee.
/// @notice Shares are themselves ERC-20-shaped (transfer/approve on the share token). This
///         contract focuses on the 4626-specific deposit/mint/withdraw/redeem/earn surface
///         and inherits the ERC-20 share-token plumbing from a base class. The naive Anchor
///         port omits the share-transfer/approve surface for brevity (already exercised by
///         the ERC-20 reference example); the optimized port reintroduces them via SPL Token.
/// @dev OpenZeppelin-style: virtualShares = 10**DECIMALS_OFFSET, virtualAssets = 1. The
///      conversion formula's +offset on numerator and denominator dilutes attacker-controlled
///      first deposits and bounds the donation-attack impact.
abstract contract ERC20Share {
    string public name;
    string public symbol;
    uint8  public immutable shareDecimals;

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);

    constructor(string memory _name, string memory _symbol, uint8 _decimals) {
        name = _name;
        symbol = _symbol;
        shareDecimals = _decimals;
    }

    function _mint(address to, uint256 amount) internal {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) internal {
        balanceOf[from] -= amount;
        totalSupply    -= amount;
        emit Transfer(from, address(0), amount);
    }

    function _spendAllowance(address owner_, address spender, uint256 amount) internal {
        if (owner_ != spender) {
            uint256 allowed = allowance[owner_][spender];
            if (allowed != type(uint256).max) {
                require(allowed >= amount, "InsufficientAllowance");
                allowance[owner_][spender] = allowed - amount;
            }
        }
    }
}

contract ExampleVault is ERC20Share {
    IERC20 public immutable asset;
    address public owner;

    uint16  public feeBps;            // fee on yield in basis points (10000 = 100%)
    address public feeRecipient;

    uint256 private _totalAssets;     // total underlying asset balance under management

    /// @dev Virtual-offset inflation defense.
    /// virtualShares = 10 ** DECIMALS_OFFSET, virtualAssets = 1.
    /// Larger offset = stronger defense (more rate dilution for attacker's seeding deposit)
    /// at the cost of dust precision at very small supply.
    uint8 public constant DECIMALS_OFFSET = 6;

    event Deposit(address indexed sender, address indexed receiver, uint256 assets, uint256 shares);
    event Withdraw(address indexed sender, address indexed receiver, address indexed owner_, uint256 assets, uint256 shares);
    event Earn(uint256 grossYield, uint256 feeShares);
    event FeeBpsUpdated(uint16 oldBps, uint16 newBps);
    event FeeRecipientUpdated(address oldRecipient, address newRecipient);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    error NotOwner();
    error ZeroAssets();
    error ZeroShares();
    error InvalidFee();
    error InsufficientLiquidity();
    error ZeroAddress();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor(address _asset, uint16 _feeBps, address _feeRecipient)
        ERC20Share("VaultShare", "vSHR", IERC20(_asset).decimals() + DECIMALS_OFFSET)
    {
        if (_asset == address(0) || _feeRecipient == address(0)) revert ZeroAddress();
        if (_feeBps > 10000) revert InvalidFee();
        asset = IERC20(_asset);
        feeBps = _feeBps;
        feeRecipient = _feeRecipient;
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    // ---- ERC-4626 views ----

    function totalAssets() public view returns (uint256) { return _totalAssets; }

    function _virtualShares() internal pure returns (uint256) { return 10 ** DECIMALS_OFFSET; }
    function _virtualAssets() internal pure returns (uint256) { return 1; }

    /// @dev assets → shares, ROUND DOWN (favors vault). Used by deposit and convertToShares.
    function convertToShares(uint256 assets) public view returns (uint256) {
        return (assets * (totalSupply + _virtualShares())) / (_totalAssets + _virtualAssets());
    }

    /// @dev shares → assets, ROUND DOWN (favors vault). Used by redeem and convertToAssets.
    function convertToAssets(uint256 shares) public view returns (uint256) {
        return (shares * (_totalAssets + _virtualAssets())) / (totalSupply + _virtualShares());
    }

    function previewDeposit(uint256 assets) public view returns (uint256) { return convertToShares(assets); }
    function previewRedeem(uint256 shares) public view returns (uint256) { return convertToAssets(shares); }

    /// @dev shares → assets needed to mint, ROUND UP (user pays a bit more, favors vault).
    function previewMint(uint256 shares) public view returns (uint256) {
        uint256 num = shares * (_totalAssets + _virtualAssets());
        uint256 den = totalSupply + _virtualShares();
        return (num + den - 1) / den;
    }

    /// @dev assets → shares to burn, ROUND UP (user burns a bit more, favors vault).
    function previewWithdraw(uint256 assets) public view returns (uint256) {
        uint256 num = assets * (totalSupply + _virtualShares());
        uint256 den = _totalAssets + _virtualAssets();
        return (num + den - 1) / den;
    }

    // ---- ERC-4626 actions ----

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        if (assets == 0) revert ZeroAssets();
        shares = previewDeposit(assets);
        if (shares == 0) revert ZeroShares();

        _totalAssets += assets;
        _mint(receiver, shares);
        require(asset.transferFrom(msg.sender, address(this), assets), "TransferFromFailed");

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function mint(uint256 shares, address receiver) external returns (uint256 assets) {
        if (shares == 0) revert ZeroShares();
        assets = previewMint(shares);

        _totalAssets += assets;
        _mint(receiver, shares);
        require(asset.transferFrom(msg.sender, address(this), assets), "TransferFromFailed");

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function withdraw(uint256 assets, address receiver, address owner_) external returns (uint256 shares) {
        if (assets == 0) revert ZeroAssets();
        if (assets > _totalAssets) revert InsufficientLiquidity();
        shares = previewWithdraw(assets);

        _spendAllowance(owner_, msg.sender, shares);
        _burn(owner_, shares);
        _totalAssets -= assets;
        require(asset.transfer(receiver, assets), "TransferFailed");

        emit Withdraw(msg.sender, receiver, owner_, assets, shares);
    }

    function redeem(uint256 shares, address receiver, address owner_) external returns (uint256 assets) {
        if (shares == 0) revert ZeroShares();
        assets = previewRedeem(shares);
        if (assets > _totalAssets) revert InsufficientLiquidity();

        _spendAllowance(owner_, msg.sender, shares);
        _burn(owner_, shares);
        _totalAssets -= assets;
        require(asset.transfer(receiver, assets), "TransferFailed");

        emit Withdraw(msg.sender, receiver, owner_, assets, shares);
    }

    // ---- Yield realization ----

    /// @notice Realize gross `yield` of underlying. In production a keeper or a strategy
    ///         contract pushes here after redeeming from the lending venue. For the example
    ///         the caller transfers `yield` underlying tokens to the vault and we mint fee
    ///         shares to feeRecipient at the *pre-yield* price.
    /// @dev Owner-gated for simplicity; in production this is callable by the strategy.
    function _earn(uint256 yield) external onlyOwner returns (uint256 feeShares) {
        if (yield == 0) return 0;

        if (feeBps > 0 && totalSupply > 0) {
            // Fee in asset units, taken from gross yield.
            uint256 feeAssets = (yield * feeBps) / 10000;
            // Mint shares to feeRecipient at the pre-yield price = totalAssets / totalSupply.
            // Using the same +offset formula so the math is consistent with deposits.
            feeShares = (feeAssets * (totalSupply + _virtualShares())) / (_totalAssets + _virtualAssets());
            _mint(feeRecipient, feeShares);
        }

        _totalAssets += yield;
        require(asset.transferFrom(msg.sender, address(this), yield), "TransferFromFailed");

        emit Earn(yield, feeShares);
    }

    // ---- Admin ----

    function setFeeBps(uint16 newBps) external onlyOwner {
        if (newBps > 10000) revert InvalidFee();
        uint16 old = feeBps;
        feeBps = newBps;
        emit FeeBpsUpdated(old, newBps);
    }

    function setFeeRecipient(address newRecipient) external onlyOwner {
        if (newRecipient == address(0)) revert ZeroAddress();
        address old = feeRecipient;
        feeRecipient = newRecipient;
        emit FeeRecipientUpdated(old, newRecipient);
    }

    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert ZeroAddress();
        address prev = owner;
        owner = newOwner;
        emit OwnershipTransferred(prev, newOwner);
    }
}
translate ↓
Output · Anchor / Rustprograms/vault/src/lib.rsSOLANA RUST PROGRAM (ANCHOR)
// Pass 2: Solana-native refactor of ExampleVault (ERC-4626).
//
// Structural moves:
// - Shares are an SPL Token Mint owned by the program via a `vault_authority`
//   PDA. `mint_to` / `burn` flow through SPL Token; share transfer/approve
//   happen on SPL Token directly (clients don't go through this program for
//   share ERC-20 mechanics).
// - `totalAssets` and `totalSupply` are NOT stored on the vault. They read
//   directly from `asset_reserve.amount` and `share_mint.supply` — the
//   sources of truth maintained atomically by SPL Token.
// - `Vault` PDA holds only governance: asset_mint, share_mint, authority,
//   fee_bps, fee_recipient, and cached bumps. Deposits/withdrawals do NOT
//   write the vault account, which is a major parallelism win — only the
//   share Mint and the asset reserve are write-locked per call, and those
//   are inherent to having a single pool.
// - `mul_div(a, b, c, rounding)` is the single arithmetic primitive for the
//   4626 conversion math. All call sites pass the explicit `Rounding`
//   direction required by the spec (deposit/redeem round Down; mint/withdraw
//   round Up — both favor the vault).
// - Withdraw/redeem accept either the share owner *or* an SPL Token delegate
//   as the signer. SPL Token's `burn` performs the authority check; our
//   program does not re-implement allowance state.
//
// Security stance documented in code comments and in `05-explanation.md`:
//   inflation-attack defense (virtual offset), rounding direction at every
//   conversion site, and Token-2022 transfer-hook handling (rejected at the
//   type level — see `DECISIONS.md`).

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

declare_id!("Vault4626Native1111111111111111111111111111");

// Virtual-offset inflation defense (OpenZeppelin pattern).
// virtual_shares = 10**DECIMALS_OFFSET, virtual_assets = 1.
const DECIMALS_OFFSET: u8 = 6;
const VIRTUAL_SHARES_OFFSET: u128 = 1_000_000; // 10^6
const VIRTUAL_ASSETS_OFFSET: u128 = 1;
const BPS_DENOMINATOR: u128 = 10_000;

/// Rounding direction for 4626 conversions. Always passed explicitly at the
/// call site — no defaults — so a code reviewer can verify direction matches
/// the spec without guessing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Rounding {
    Down,
    Up,
}

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

    pub fn initialize(
        ctx: Context<Initialize>,
        fee_bps: u16,
        fee_recipient: Pubkey,
        share_decimals: u8,
    ) -> Result<()> {
        require!(fee_bps <= 10_000, VaultError::InvalidFee);
        require_keys_neq!(fee_recipient, Pubkey::default(), VaultError::ZeroAddress);

        // Validate share decimals match the spec: share_decimals = asset_decimals + offset.
        let expected = ctx
            .accounts
            .asset_mint
            .decimals
            .checked_add(DECIMALS_OFFSET)
            .ok_or(VaultError::InvalidShareDecimals)?;
        require_eq!(
            share_decimals,
            expected,
            VaultError::InvalidShareDecimals
        );

        let vault = &mut ctx.accounts.vault;
        vault.asset_mint = ctx.accounts.asset_mint.key();
        vault.share_mint = ctx.accounts.share_mint.key();
        vault.authority = ctx.accounts.authority.key();
        vault.fee_bps = fee_bps;
        vault.fee_recipient = fee_recipient;
        vault.bump = ctx.bumps.vault;
        vault.vault_authority_bump = ctx.bumps.vault_authority;
        vault.asset_reserve_bump = ctx.bumps.asset_reserve;
        Ok(())
    }

    /// ERC-4626 `deposit(assets, receiver)`. Shares are rounded DOWN.
    pub fn deposit(ctx: Context<Deposit>, assets: u64) -> Result<()> {
        require!(assets > 0, VaultError::ZeroAssets);

        let total_supply = ctx.accounts.share_mint.supply;
        let total_assets = ctx.accounts.asset_reserve.amount;
        let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Down)?;
        require!(shares > 0, VaultError::ZeroShares);

        // Pull asset tokens from depositor.
        token::transfer(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.user_asset_ata.to_account_info(),
                    to: ctx.accounts.asset_reserve.to_account_info(),
                    authority: ctx.accounts.user.to_account_info(),
                },
            ),
            assets,
        )?;

        // Mint shares to receiver — vault_authority PDA signs.
        mint_shares(
            &ctx.accounts.share_mint,
            &ctx.accounts.receiver_share_ata,
            &ctx.accounts.vault_authority,
            &ctx.accounts.token_program,
            &ctx.accounts.vault,
            shares,
        )?;

        emit!(DepositEvent {
            sender: ctx.accounts.user.key(),
            receiver: ctx.accounts.receiver_share_ata.owner,
            assets,
            shares,
        });
        Ok(())
    }

    /// ERC-4626 `mint(shares, receiver)`. Assets are rounded UP.
    pub fn mint(ctx: Context<Deposit>, shares: u64) -> Result<()> {
        require!(shares > 0, VaultError::ZeroShares);

        let total_supply = ctx.accounts.share_mint.supply;
        let total_assets = ctx.accounts.asset_reserve.amount;
        let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Up)?;
        require!(assets > 0, VaultError::ZeroAssets);

        token::transfer(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.user_asset_ata.to_account_info(),
                    to: ctx.accounts.asset_reserve.to_account_info(),
                    authority: ctx.accounts.user.to_account_info(),
                },
            ),
            assets,
        )?;

        mint_shares(
            &ctx.accounts.share_mint,
            &ctx.accounts.receiver_share_ata,
            &ctx.accounts.vault_authority,
            &ctx.accounts.token_program,
            &ctx.accounts.vault,
            shares,
        )?;

        emit!(DepositEvent {
            sender: ctx.accounts.user.key(),
            receiver: ctx.accounts.receiver_share_ata.owner,
            assets,
            shares,
        });
        Ok(())
    }

    /// ERC-4626 `withdraw(assets, receiver, owner_)`. Shares are rounded UP.
    /// `signer` may be the share owner OR an SPL Token delegate of the share
    /// ATA. SPL Token's `burn` enforces the authority check.
    pub fn withdraw(ctx: Context<Withdraw>, assets: u64) -> Result<()> {
        require!(assets > 0, VaultError::ZeroAssets);
        require!(
            ctx.accounts.asset_reserve.amount >= assets,
            VaultError::InsufficientLiquidity
        );

        let total_supply = ctx.accounts.share_mint.supply;
        let total_assets = ctx.accounts.asset_reserve.amount;
        let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Up)?;
        require!(shares > 0, VaultError::ZeroShares);

        // Burn shares from owner's ATA — SPL Token verifies signer is owner or delegate.
        token::burn(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Burn {
                    mint: ctx.accounts.share_mint.to_account_info(),
                    from: ctx.accounts.owner_share_ata.to_account_info(),
                    authority: ctx.accounts.signer.to_account_info(),
                },
            ),
            shares,
        )?;

        // Transfer asset out — vault_authority PDA signs.
        transfer_asset_out(
            &ctx.accounts.asset_reserve,
            &ctx.accounts.receiver_asset_ata,
            &ctx.accounts.vault_authority,
            &ctx.accounts.token_program,
            &ctx.accounts.vault,
            assets,
        )?;

        emit!(WithdrawEvent {
            sender: ctx.accounts.signer.key(),
            receiver: ctx.accounts.receiver_asset_ata.owner,
            owner: ctx.accounts.owner_share_ata.owner,
            assets,
            shares,
        });
        Ok(())
    }

    /// ERC-4626 `redeem(shares, receiver, owner_)`. Assets are rounded DOWN.
    pub fn redeem(ctx: Context<Withdraw>, shares: u64) -> Result<()> {
        require!(shares > 0, VaultError::ZeroShares);

        let total_supply = ctx.accounts.share_mint.supply;
        let total_assets = ctx.accounts.asset_reserve.amount;
        let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Down)?;
        require!(assets > 0, VaultError::ZeroAssets);
        require!(
            ctx.accounts.asset_reserve.amount >= assets,
            VaultError::InsufficientLiquidity
        );

        token::burn(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Burn {
                    mint: ctx.accounts.share_mint.to_account_info(),
                    from: ctx.accounts.owner_share_ata.to_account_info(),
                    authority: ctx.accounts.signer.to_account_info(),
                },
            ),
            shares,
        )?;

        transfer_asset_out(
            &ctx.accounts.asset_reserve,
            &ctx.accounts.receiver_asset_ata,
            &ctx.accounts.vault_authority,
            &ctx.accounts.token_program,
            &ctx.accounts.vault,
            assets,
        )?;

        emit!(WithdrawEvent {
            sender: ctx.accounts.signer.key(),
            receiver: ctx.accounts.receiver_asset_ata.owner,
            owner: ctx.accounts.owner_share_ata.owner,
            assets,
            shares,
        });
        Ok(())
    }

    /// Push realized yield into the vault. Mints fee shares to fee_recipient
    /// at the pre-yield price.
    pub fn earn(ctx: Context<Earn>, yield_amount: u64) -> Result<()> {
        if yield_amount == 0 {
            return Ok(());
        }

        // Snapshot pre-yield totals BEFORE the asset transfer — the fee is
        // computed at the pre-yield price so existing holders capture the
        // net-of-fee appreciation.
        let total_supply_before = ctx.accounts.share_mint.supply;
        let total_assets_before = ctx.accounts.asset_reserve.amount;

        let mut fee_shares: u64 = 0;
        if ctx.accounts.vault.fee_bps > 0 && total_supply_before > 0 {
            // fee_assets = yield_amount * fee_bps / 10000, rounded DOWN (favor vault holders).
            let fee_assets_u128 = (yield_amount as u128)
                .checked_mul(ctx.accounts.vault.fee_bps as u128)
                .ok_or(VaultError::Overflow)?
                .checked_div(BPS_DENOMINATOR)
                .ok_or(VaultError::DivByZero)?;
            // fee_shares at pre-yield price, rounded DOWN.
            fee_shares = mul_div_u128_to_u64(
                fee_assets_u128,
                (total_supply_before as u128)
                    .checked_add(VIRTUAL_SHARES_OFFSET)
                    .ok_or(VaultError::Overflow)?,
                (total_assets_before as u128)
                    .checked_add(VIRTUAL_ASSETS_OFFSET)
                    .ok_or(VaultError::Overflow)?,
                Rounding::Down,
            )?;

            if fee_shares > 0 {
                mint_shares(
                    &ctx.accounts.share_mint,
                    &ctx.accounts.fee_recipient_share_ata,
                    &ctx.accounts.vault_authority,
                    &ctx.accounts.token_program,
                    &ctx.accounts.vault,
                    fee_shares,
                )?;
            }
        }

        // Transfer yield from the caller (authority) into the reserve.
        token::transfer(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.authority_asset_ata.to_account_info(),
                    to: ctx.accounts.asset_reserve.to_account_info(),
                    authority: ctx.accounts.authority.to_account_info(),
                },
            ),
            yield_amount,
        )?;

        emit!(EarnEvent {
            gross_yield: yield_amount,
            fee_shares,
        });
        Ok(())
    }

    pub fn set_fee_bps(ctx: Context<AdminAction>, new_bps: u16) -> Result<()> {
        require!(new_bps <= 10_000, VaultError::InvalidFee);
        let vault = &mut ctx.accounts.vault;
        let old = vault.fee_bps;
        vault.fee_bps = new_bps;
        emit!(FeeBpsUpdated { old, new_bps });
        Ok(())
    }

    pub fn set_fee_recipient(
        ctx: Context<AdminAction>,
        new_recipient: Pubkey,
    ) -> Result<()> {
        require_keys_neq!(new_recipient, Pubkey::default(), VaultError::ZeroAddress);
        let vault = &mut ctx.accounts.vault;
        let old = vault.fee_recipient;
        vault.fee_recipient = new_recipient;
        emit!(FeeRecipientUpdated {
            old,
            new_recipient,
        });
        Ok(())
    }

    pub fn set_authority(
        ctx: Context<AdminAction>,
        new_authority: Pubkey,
    ) -> Result<()> {
        require_keys_neq!(new_authority, Pubkey::default(), VaultError::ZeroAddress);
        ctx.accounts.vault.authority = new_authority;
        Ok(())
    }
}

// ---- 4626 conversion math ----

fn convert_to_shares(
    assets: u64,
    total_supply: u64,
    total_assets: u64,
    rounding: Rounding,
) -> Result<u64> {
    mul_div_u128_to_u64(
        assets as u128,
        (total_supply as u128)
            .checked_add(VIRTUAL_SHARES_OFFSET)
            .ok_or(VaultError::Overflow)?,
        (total_assets as u128)
            .checked_add(VIRTUAL_ASSETS_OFFSET)
            .ok_or(VaultError::Overflow)?,
        rounding,
    )
}

fn convert_to_assets(
    shares: u64,
    total_supply: u64,
    total_assets: u64,
    rounding: Rounding,
) -> Result<u64> {
    mul_div_u128_to_u64(
        shares as u128,
        (total_assets as u128)
            .checked_add(VIRTUAL_ASSETS_OFFSET)
            .ok_or(VaultError::Overflow)?,
        (total_supply as u128)
            .checked_add(VIRTUAL_SHARES_OFFSET)
            .ok_or(VaultError::Overflow)?,
        rounding,
    )
}

/// Compute `a * b / c` in u128, rounding per direction, then narrow to u64
/// with an explicit bounds check. Every step is checked; no silent wrap or
/// truncation is possible.
fn mul_div_u128_to_u64(a: u128, b: u128, c: u128, rounding: Rounding) -> Result<u64> {
    require!(c > 0, VaultError::DivByZero);
    let product = a.checked_mul(b).ok_or(VaultError::Overflow)?;
    let result_u128 = match rounding {
        Rounding::Down => product.checked_div(c).ok_or(VaultError::DivByZero)?,
        Rounding::Up => {
            // ceil(product / c) = (product + c - 1) / c
            let c_minus_one = c.checked_sub(1).ok_or(VaultError::Overflow)?;
            let raised = product.checked_add(c_minus_one).ok_or(VaultError::Overflow)?;
            raised.checked_div(c).ok_or(VaultError::DivByZero)?
        }
    };
    require!(
        result_u128 <= u64::MAX as u128,
        VaultError::Overflow
    );
    Ok(result_u128 as u64)
}

// ---- CPI helpers — vault_authority signs ----

fn mint_shares<'info>(
    share_mint: &Account<'info, Mint>,
    receiver_share_ata: &Account<'info, TokenAccount>,
    vault_authority: &UncheckedAccount<'info>,
    token_program: &Program<'info, Token>,
    vault: &Account<'info, Vault>,
    amount: u64,
) -> Result<()> {
    let vault_key = vault.key();
    let bump = vault.vault_authority_bump;
    let signer_seeds: &[&[u8]] = &[b"vault_authority", vault_key.as_ref(), &[bump]];
    token::mint_to(
        CpiContext::new_with_signer(
            token_program.key(),
            MintTo {
                mint: share_mint.to_account_info(),
                to: receiver_share_ata.to_account_info(),
                authority: vault_authority.to_account_info(),
            },
            &[signer_seeds],
        ),
        amount,
    )
}

fn transfer_asset_out<'info>(
    asset_reserve: &Account<'info, TokenAccount>,
    receiver_asset_ata: &Account<'info, TokenAccount>,
    vault_authority: &UncheckedAccount<'info>,
    token_program: &Program<'info, Token>,
    vault: &Account<'info, Vault>,
    amount: u64,
) -> Result<()> {
    let vault_key = vault.key();
    let bump = vault.vault_authority_bump;
    let signer_seeds: &[&[u8]] = &[b"vault_authority", vault_key.as_ref(), &[bump]];
    token::transfer(
        CpiContext::new_with_signer(
            token_program.key(),
            Transfer {
                from: asset_reserve.to_account_info(),
                to: receiver_asset_ata.to_account_info(),
                authority: vault_authority.to_account_info(),
            },
            &[signer_seeds],
        ),
        amount,
    )
}

// ---- Accounts ----

#[derive(Accounts)]
#[instruction(fee_bps: u16, fee_recipient: Pubkey, share_decimals: u8)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + Vault::SIZE,
        seeds = [b"vault", asset_mint.key().as_ref()],
        bump,
    )]
    pub vault: Account<'info, Vault>,

    /// Asset underlying. Classic SPL Token only — Token-2022 mints fail the
    /// `Mint` deserialization (different owning program). This is the
    /// intentional Token-2022 / transfer-hook rejection; see DECISIONS.md.
    pub asset_mint: Account<'info, Mint>,

    /// CHECK: PDA, signs both share-mint and asset-reserve operations.
    /// Validated by seeds + bump; never deserialized.
    #[account(
        seeds = [b"vault_authority", vault.key().as_ref()],
        bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        init,
        payer = authority,
        mint::decimals = share_decimals,
        mint::authority = vault_authority,
        seeds = [b"share_mint", asset_mint.key().as_ref()],
        bump,
    )]
    pub share_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = authority,
        token::mint = asset_mint,
        token::authority = vault_authority,
        seeds = [b"asset_reserve", vault.key().as_ref()],
        bump,
    )]
    pub asset_reserve: Account<'info, TokenAccount>,

    #[account(mut)]
    pub authority: Signer<'info>,

    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub rent: Sysvar<'info, Rent>,
}

/// Shared by `deposit` and `mint`. Vault is read-only — no field on the vault
/// changes during a deposit (`fee_bps`, `fee_recipient`, etc. are admin-only),
/// so cross-user deposits don't write-conflict on the vault account.
#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(
        seeds = [b"vault", asset_mint.key().as_ref()],
        bump = vault.bump,
        has_one = asset_mint,
        has_one = share_mint,
    )]
    pub vault: Account<'info, Vault>,

    pub asset_mint: Account<'info, Mint>,

    #[account(mut)]
    pub share_mint: Account<'info, Mint>,

    /// CHECK: PDA, validated by seeds + cached bump. Signer for mint_to CPI.
    #[account(
        seeds = [b"vault_authority", vault.key().as_ref()],
        bump = vault.vault_authority_bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"asset_reserve", vault.key().as_ref()],
        bump = vault.asset_reserve_bump,
        token::mint = asset_mint,
        token::authority = vault_authority,
    )]
    pub asset_reserve: Account<'info, TokenAccount>,

    #[account(mut, token::mint = asset_mint, token::authority = user)]
    pub user_asset_ata: Account<'info, TokenAccount>,

    #[account(mut, token::mint = share_mint)]
    pub receiver_share_ata: Account<'info, TokenAccount>,

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

/// Shared by `withdraw` and `redeem`. Vault is read-only. `signer` may be
/// the share-ATA owner or an SPL Token delegate — SPL Token's `burn` enforces.
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        seeds = [b"vault", asset_mint.key().as_ref()],
        bump = vault.bump,
        has_one = asset_mint,
        has_one = share_mint,
    )]
    pub vault: Account<'info, Vault>,

    pub asset_mint: Account<'info, Mint>,

    #[account(mut)]
    pub share_mint: Account<'info, Mint>,

    /// CHECK: PDA. Signer for transfer-out CPI.
    #[account(
        seeds = [b"vault_authority", vault.key().as_ref()],
        bump = vault.vault_authority_bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"asset_reserve", vault.key().as_ref()],
        bump = vault.asset_reserve_bump,
        token::mint = asset_mint,
        token::authority = vault_authority,
    )]
    pub asset_reserve: Account<'info, TokenAccount>,

    /// Source of the burn. SPL Token verifies `signer` is `owner_share_ata.owner`
    /// or `owner_share_ata.delegate` (with sufficient delegated_amount).
    /// No `token::authority` constraint here — both paths must be allowed.
    #[account(mut, token::mint = share_mint)]
    pub owner_share_ata: Account<'info, TokenAccount>,

    #[account(mut, token::mint = asset_mint)]
    pub receiver_asset_ata: Account<'info, TokenAccount>,

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

#[derive(Accounts)]
pub struct Earn<'info> {
    #[account(
        seeds = [b"vault", asset_mint.key().as_ref()],
        bump = vault.bump,
        has_one = asset_mint,
        has_one = share_mint,
        has_one = authority,
        constraint = fee_recipient_share_ata.owner == vault.fee_recipient
            @ VaultError::FeeRecipientMismatch,
    )]
    pub vault: Account<'info, Vault>,

    pub asset_mint: Account<'info, Mint>,

    #[account(mut)]
    pub share_mint: Account<'info, Mint>,

    /// CHECK: PDA, signs share mint_to.
    #[account(
        seeds = [b"vault_authority", vault.key().as_ref()],
        bump = vault.vault_authority_bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"asset_reserve", vault.key().as_ref()],
        bump = vault.asset_reserve_bump,
        token::mint = asset_mint,
        token::authority = vault_authority,
    )]
    pub asset_reserve: Account<'info, TokenAccount>,

    #[account(mut, token::mint = asset_mint, token::authority = authority)]
    pub authority_asset_ata: Account<'info, TokenAccount>,

    #[account(mut, token::mint = share_mint)]
    pub fee_recipient_share_ata: Account<'info, TokenAccount>,

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

#[derive(Accounts)]
pub struct AdminAction<'info> {
    #[account(
        mut,
        seeds = [b"vault", vault.asset_mint.as_ref()],
        bump = vault.bump,
        has_one = authority,
    )]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
}

// ---- State ----

#[account]
pub struct Vault {
    pub asset_mint: Pubkey,
    pub share_mint: Pubkey,
    /// Governance authority — gates fee setters and `earn` (the yield source).
    pub authority: Pubkey,
    pub fee_bps: u16,
    pub fee_recipient: Pubkey, // raw owner pubkey; ATA passed at call sites
    pub bump: u8,
    pub vault_authority_bump: u8,
    pub asset_reserve_bump: u8,
}

impl Vault {
    pub const SIZE: usize = 32 + 32 + 32 + 2 + 32 + 1 + 1 + 1; // 133 bytes
}

// ---- Events ----

#[event]
pub struct DepositEvent {
    pub sender: Pubkey,
    pub receiver: Pubkey,
    pub assets: u64,
    pub shares: u64,
}

#[event]
pub struct WithdrawEvent {
    pub sender: Pubkey,
    pub receiver: Pubkey,
    pub owner: Pubkey,
    pub assets: u64,
    pub shares: u64,
}

#[event]
pub struct EarnEvent {
    pub gross_yield: u64,
    pub fee_shares: u64,
}

#[event]
pub struct FeeBpsUpdated {
    pub old: u16,
    pub new_bps: u16,
}

#[event]
pub struct FeeRecipientUpdated {
    pub old: Pubkey,
    pub new_recipient: Pubkey,
}

// ---- Errors ----

#[error_code]
pub enum VaultError {
    #[msg("zero address")]
    ZeroAddress,
    #[msg("zero assets")]
    ZeroAssets,
    #[msg("zero shares")]
    ZeroShares,
    #[msg("invalid fee — must be ≤ 10000 bps")]
    InvalidFee,
    #[msg("share decimals must equal asset decimals + DECIMALS_OFFSET")]
    InvalidShareDecimals,
    #[msg("insufficient asset liquidity in the vault")]
    InsufficientLiquidity,
    #[msg("arithmetic overflow")]
    Overflow,
    #[msg("division by zero")]
    DivByZero,
    #[msg("fee recipient ATA owner does not match vault.fee_recipient")]
    FeeRecipientMismatch,
}

Why these changes

01

Replace Vec<BalanceEntry> shares with an SPL Token Mint

stateHolder count unbounded

Same lesson as the ERC-20 example: per-user fungible-token balances belong in SPL Token accounts, not in a Vec inside the program's state. The 4626 vault is itself a token issuer, so the same architectural move applies to the share token.

02

Vault is READ-ONLY on every user-facing 4626 operation

mathCross-user deposit/withdraw don't write-conflict on the vault account at all

With total_assets/total_supply deleted (§S2) and balances moved to SPL Token (§S1), there is nothing left on the vault for deposit/withdraw to mutate. The vault stores only governance fields (fee_bps, authority, bumps), which are admin-rate, not transaction-rate.

03

Every arithmetic op uses checked_*; one mul_div_u128_to_u64 helper centralizes the 4626 math

authEvery arithmetic failure is now a typed error (Overflow

ERC-4626 conversion math is the single most security-sensitive code in a vault. Solidity 0.8+ checks; Rust release builds wrap silently. A silent overflow in mul_div doesn't fail — it returns a wrong number that the depositor accepts. The naive port had silent truncation on every preview (02-naive-port.rs:317, :324, :332, :339), silent overflow on the ceiling addition (:331:332, :339), and silent wrap on the balance updates (:114, etc.). Each could individually be exploited.

04★ load-bearing

Share mint/burn via SPL Token CPI

cpiNo custom mint/burn arithmetic

Same architectural move as ERC-20 example §C1 — use audited SPL Token for token mechanics. The vault retains ONLY the conversion math + governance gating; the token movements are SPL Token's responsibility.

Tradeoff
~5k CU per CPI. Acceptable; the conversion math is the costly part, not the CPIs.
05

Vault size: ~4 KB → 132 bytes

math~12× rent savings on the protocol-paid account

Removing the 100-entry Vec saves 4000 bytes; storing only governance + cached bumps takes ~130.

06

Pure conversion helpers + Rounding enum

observabilitySee diff §Sec2

See diff §Sec2. The structural and security wins are linked.