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

ERC-20 Token

Standard fungible token. Balance maps deleted; SPL Token does the work.

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

/// @title ExampleToken
/// @notice Standard ERC-20 with owner-gated mint, max supply cap, and self-burn.
/// @dev Hand-written for clarity; equivalent to OpenZeppelin ERC20 + Ownable + capped supply.
contract ExampleToken {
    // ---- Metadata ----
    string public name;
    string public symbol;
    uint8 public immutable decimals;

    // ---- Supply ----
    uint256 public totalSupply;
    uint256 public immutable maxSupply;

    // ---- Ownership ----
    address public owner;

    // ---- Balances & allowances ----
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // ---- Events ----
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    // ---- Errors ----
    error NotOwner();
    error ZeroAddress();
    error MaxSupplyExceeded();
    error InsufficientBalance();
    error InsufficientAllowance();

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

    // ---- Construction ----
    constructor(
        string memory _name,
        string memory _symbol,
        uint8 _decimals,
        uint256 _maxSupply
    ) {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;
        maxSupply = _maxSupply;
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    // ---- Core ERC-20 ----

    function transfer(address to, uint256 amount) external returns (bool) {
        if (to == address(0)) revert ZeroAddress();
        if (balanceOf[msg.sender] < amount) revert InsufficientBalance();

        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;

        emit Transfer(msg.sender, to, amount);
        return true;
    }

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

    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) external returns (bool) {
        if (to == address(0)) revert ZeroAddress();
        uint256 allowed = allowance[from][msg.sender];
        if (allowed < amount) revert InsufficientAllowance();
        if (balanceOf[from] < amount) revert InsufficientBalance();

        // Infinite-allowance optimization (standard ERC-20 idiom).
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - amount;
        }

        balanceOf[from] -= amount;
        balanceOf[to] += amount;

        emit Transfer(from, to, amount);
        return true;
    }

    // ---- Supply control ----

    function mint(address to, uint256 amount) external onlyOwner {
        if (to == address(0)) revert ZeroAddress();
        if (totalSupply + amount > maxSupply) revert MaxSupplyExceeded();

        totalSupply += amount;
        balanceOf[to] += amount;

        emit Transfer(address(0), to, amount);
    }

    function burn(uint256 amount) external {
        if (balanceOf[msg.sender] < amount) revert InsufficientBalance();

        balanceOf[msg.sender] -= amount;
        totalSupply -= amount;

        emit Transfer(msg.sender, address(0), amount);
    }

    // ---- Ownership ----

    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/my_token/src/lib.rsSOLANA RUST PROGRAM (ANCHOR)
// Pass 2: Solana-native refactor of ExampleToken.
//
// The actual token mechanics — transfer, approve, transferFrom, balances, allowances,
// totalSupply — are delegated to SPL Token. This program owns only the *governance*
// layer: the configuration that gates minting, the max-supply cap, and ownership of
// the mint authority.
//
// User-visible consequences:
// - `transfer`, `approve`, `transferFrom` are not instructions in this program.
//   Holders call SPL Token directly. SPL Token already emits Transfer/Approval events.
// - Balances live in Associated Token Accounts (ATAs), one per (holder, mint).
//   Transfers between disjoint pairs parallelize via Sealevel write locks.
// - The mint authority is a PDA owned by this program, so only `mint_to` (gated by
//   `Config.authority`) can issue new supply.

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

declare_id!("Eth2So1OptimizedTokenProgramAddrXXXXXXXXXX");

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

    /// Create the Mint, the program-owned mint authority PDA, and the Config PDA.
    /// Maps to Solidity `constructor(name, symbol, decimals, maxSupply)`.
    /// Note: `name`/`symbol` are not stored on-chain — they belong in Metaplex
    /// Token Metadata, not in this program's account.
    pub fn initialize(
        ctx: Context<Initialize>,
        _decimals: u8,
        max_supply: u64,
    ) -> Result<()> {
        let config = &mut ctx.accounts.config;
        config.authority = ctx.accounts.authority.key();
        config.mint = ctx.accounts.mint.key();
        config.max_supply = max_supply;
        config.bump = ctx.bumps.config;
        config.mint_authority_bump = ctx.bumps.mint_authority;
        Ok(())
    }

    /// Owner-gated mint. Maps to Solidity `mint(to, amount) onlyOwner`.
    /// The program signs for the mint authority PDA via `invoke_signed` (handled by
    /// Anchor's `CpiContext::new_with_signer`).
    pub fn mint_to(ctx: Context<MintToInstruction>, amount: u64) -> Result<()> {
        let config = &ctx.accounts.config;

        // Max-supply enforcement using SPL Mint's `supply`, not a self-tracked field.
        // Checked arithmetic — Rust release builds wrap silently otherwise.
        let new_supply = ctx
            .accounts
            .mint
            .supply
            .checked_add(amount)
            .ok_or(TokenError::Overflow)?;
        require!(
            new_supply <= config.max_supply,
            TokenError::MaxSupplyExceeded
        );

        let mint_key = ctx.accounts.mint.key();
        let seeds: &[&[u8]] = &[
            b"mint_authority",
            mint_key.as_ref(),
            &[config.mint_authority_bump],
        ];

        token::mint_to(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.key(),
                MintTo {
                    mint: ctx.accounts.mint.to_account_info(),
                    to: ctx.accounts.recipient.to_account_info(),
                    authority: ctx.accounts.mint_authority.to_account_info(),
                },
                &[seeds],
            ),
            amount,
        )?;
        Ok(())
    }

    /// User-initiated burn. Maps to Solidity `burn(amount)`.
    /// The holder is the signer; SPL Token verifies they own the ATA.
    pub fn burn(ctx: Context<BurnInstruction>, amount: u64) -> Result<()> {
        token::burn(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Burn {
                    mint: ctx.accounts.mint.to_account_info(),
                    from: ctx.accounts.holder_ata.to_account_info(),
                    authority: ctx.accounts.holder.to_account_info(),
                },
            ),
            amount,
        )?;
        Ok(())
    }

    /// Rotate governance authority. Maps to Solidity `transferOwnership(newOwner)`.
    /// Note this changes who can call `mint_to`; it does *not* change the on-chain
    /// SPL mint authority (which stays the program-owned PDA, as it must to keep the
    /// program in control of supply).
    pub fn set_authority(
        ctx: Context<SetAuthority>,
        new_authority: Pubkey,
    ) -> Result<()> {
        require_keys_neq!(new_authority, Pubkey::default(), TokenError::ZeroAddress);
        ctx.accounts.config.authority = new_authority;
        Ok(())
    }
}

// ---- Accounts ----

#[derive(Accounts)]
#[instruction(decimals: u8)]
pub struct Initialize<'info> {
    /// The deploying governance authority. Becomes `Config.authority`.
    #[account(mut)]
    pub authority: Signer<'info>,

    /// Singleton governance account, one per mint.
    #[account(
        init,
        payer = authority,
        space = 8 + Config::SIZE,
        seeds = [b"config", mint.key().as_ref()],
        bump,
    )]
    pub config: Account<'info, Config>,

    /// PDA that owns the Mint's `mint_authority`. The program signs for this PDA
    /// when minting; no off-chain key exists.
    /// CHECK: PDA, validated by seeds + bump. Never deserialized.
    #[account(
        seeds = [b"mint_authority", mint.key().as_ref()],
        bump,
    )]
    pub mint_authority: UncheckedAccount<'info>,

    /// The SPL Mint itself. `mint_authority` is the program's PDA above.
    #[account(
        init,
        payer = authority,
        mint::decimals = decimals,
        mint::authority = mint_authority,
    )]
    pub mint: Account<'info, Mint>,

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

#[derive(Accounts)]
pub struct MintToInstruction<'info> {
    /// has_one = mint + has_one = authority enforces both links automatically.
    #[account(
        seeds = [b"config", mint.key().as_ref()],
        bump = config.bump,
        has_one = mint,
        has_one = authority,
    )]
    pub config: Account<'info, Config>,

    /// Must sign. Anchor enforces is_signer; has_one above enforces identity.
    pub authority: Signer<'info>,

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

    /// CHECK: PDA, validated by seeds + cached bump.
    #[account(
        seeds = [b"mint_authority", mint.key().as_ref()],
        bump = config.mint_authority_bump,
    )]
    pub mint_authority: UncheckedAccount<'info>,

    /// Token account to receive freshly minted tokens. Must be for this mint.
    #[account(
        mut,
        token::mint = mint,
    )]
    pub recipient: Account<'info, TokenAccount>,

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

#[derive(Accounts)]
pub struct BurnInstruction<'info> {
    #[account(mut)]
    pub mint: Account<'info, Mint>,

    pub holder: Signer<'info>,

    /// The holder's ATA. token::authority constraint enforces ownership.
    #[account(
        mut,
        token::mint = mint,
        token::authority = holder,
    )]
    pub holder_ata: Account<'info, TokenAccount>,

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

#[derive(Accounts)]
pub struct SetAuthority<'info> {
    #[account(
        mut,
        seeds = [b"config", config.mint.as_ref()],
        bump = config.bump,
        has_one = authority,
    )]
    pub config: Account<'info, Config>,

    pub authority: Signer<'info>,
}

// ---- State ----

#[account]
pub struct Config {
    /// Governance authority — gates `mint_to` and `set_authority`.
    pub authority: Pubkey,
    /// The SPL Mint this config governs.
    pub mint: Pubkey,
    /// Hard cap on `mint.supply`. Checked at every `mint_to`.
    pub max_supply: u64,
    /// Canonical bump for the config PDA.
    pub bump: u8,
    /// Canonical bump for the mint_authority PDA.
    pub mint_authority_bump: u8,
}

impl Config {
    pub const SIZE: usize = 32 + 32 + 8 + 1 + 1;
}

// ---- Errors ----

#[error_code]
pub enum TokenError {
    #[msg("arithmetic overflow")]
    Overflow,
    #[msg("max supply exceeded")]
    MaxSupplyExceeded,
    #[msg("zero address")]
    ZeroAddress,
}

// Notes on what is intentionally NOT in this file:
//
// - `transfer`, `approve`, `transfer_from` instructions: clients call SPL Token directly.
//   Single delegate per ATA (SPL's `approve`) covers the common case; the rare case where
//   multi-spender allowances are required would justify a custom allowance PDA — out of
//   scope for the standard ERC-20 shape.
//
// - On-chain `name` / `symbol` / `decimals` storage: belongs in Metaplex Token Metadata.
//   SPL Mint stores `decimals` natively (set above via `mint::decimals = decimals`).
//
// - Custom Transfer/Approval events: SPL Token already emits them via program logs.
//   Duplicating costs CU and adds nothing.
//
// - A `total_supply` field on Config: redundant with `mint.supply`. Self-tracking would
//   also write-lock Config on every mint, defeating parallelism for governance reads.
//
// - A `paused` flag: SPL Token's `freeze_authority` already supports per-account freeze.
//   For a true global pause, switch to Token-2022 with the appropriate extension.

Why these changes

01

Replace TokenState monolith with a Config PDA + SPL Mint

state~18 KB of account size → 74 bytes

Solana state is per-account, not per-contract. A monolithic state account makes every operation that mutates any field a write-lock on every field. SPL Token has a separate Mint account already shaped this way, and we use it.

02

Eliminate the write-hot state PDA

mathTransfers between Alice→Bob and Carol→Dave write disjoint ATAs

Sealevel parallelizes transactions whose writable account sets are disjoint. A program with one shared writable account caps at one operation per slot, no matter the hardware.

03

Switch every arithmetic op to checked_*

authOverflow/underflow become explicit

Rust release builds wrap arithmetic silently — opposite of Solidity 0.8+, which checks by default. Porting + to + is a regression in safety, not a translation. Even the max-supply guard itself overflowed on adversarial input in the naive version.

04★ load-bearing

mint → CPI to spl_token::mint_to with PDA signer

cpi~25 lines of custom code → 1 CPI

Reusing SPL Token means we don't reimplement supply tracking or balance updates. The PDA-signer pattern is Solana's idiom for "a program controls this authority"; CpiContext::new_with_signer wires it without manual invoke_signed.

Tradeoff
A CPI costs ~5k CU. The naive version did the work in process at ~3k CU. Net: marginal CU regression, massive code-and-correctness improvement. Accept.
05

Account size reduced from ~18 KB to 74 bytes

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

Removing the in-account Vecs is the only state-size lever; the Mint account (separate, ~82 bytes) and per-holder ATAs (165 bytes each) are paid by holders, not by authority.

06

constructorinitialize instruction

observabilityRe-init protection is automatic — Anchor's init constraint fails if the accou…

No EVM-style deploy-time code. Initial state is always written by an explicit transaction signed by the deployer.