What is ERC20 on Solana?

If you’re coming from the EVM ecosystem, one thing you know well is ERC20. Tokens are the heart of almost any smart contract and they’re usually one of the first things you learn how to deploy. On Solana, what is the ERC20 equivalent and how does it differ?

ERC20 sets a standard for Fungible Tokens, which means that each token is the same as another token (both in type and value). For example, an ERC20 Token works similarly to the ETH token, where 1 Token is always equal to any other Token.

The Token Program

The Token Program on Solana is the single smart contract that is used for most token operations. This is different from Ethereum, where you deploy a new smart contract for every token. Why is there only one main smart contract for tokens on Solana?

The Solana Account Model separates the state from execution logic on Solana, making interfaces very difficult to create like ERC20 does on Ethereum. Instead of multiple smart contracts, the single Token Program handles tokens in separate “accounts” known as “mint accounts”, while user tokens are stored in individual “token accounts”. The account model gives a significant benefit in program reusability and predetermined definition, avoiding unknown side effects of anyone being able to write their own ERC20 token functions.

토큰은 컨트랙트 주소가 없고 민트 주소만 있습니다

이더리움에서는 각 토큰이 컨트랙트 주소로 고유하게 식별됩니다. 솔라나에서는 각 토큰이 민트 주소로 고유하게 식별됩니다. 기본 개념은 유사합니다. 이것은 해당 특정 토큰에 대한 온체인 포인터입니다. 하지만 솔라나 Token Program은 범용이기 때문에 별도의 "컨트랙트 주소"를 배포하지 않습니다. 대신 솔라나 런타임에 새로운 mint account를 제공합니다.

No Approval Flow Needed

On Ethereum, transferring ERC20 tokens between two addresses often requires approve + transferFrom if you're doing it on behalf of a user. On Solana, each user already has an ATA (Associated token accounts) recognized by the Token Program. A single Solana transaction can atomically call multiple instructions, so no separate approval step is required. You can directly transfer from one user’s token account to another in one go.

What Does A Solana Token Program Look Like?

A very rough translation of the Solana Token Program would look like this:

solidity
// SPDX-License-Identifier: MIT license
pragma solidity =0.8.28;

struct Mint {
    uint8 decimals;
    uint256 supply;
    address mintAuthority;
    address freezeAuthority;
    address mintAddress;
}

struct TokenAccount {
    address mintAddress;
    address owner;
    uint256 balance;
    bool isFrozen;
}

contract Spl20 {
    mapping(address => Mint) public mints;
    mapping(address => TokenAccount) public tokenAccounts;
    mapping(address => bool) public mintAddresses;
    mapping(address => bool) public tokenAddresses;

    function initializeMint(uint8 decimals, address mintAuthority, address freezeAuthority, address mintAddress)
        public
        returns (Mint memory)
    {
        require(mintAddresses[mintAddress] == false, "Mint already exists");
        mints[mintAddress] = Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
        mintAddresses[mintAddress] = true;
        return Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
    }

    function mintTokens(address toMintTokens, address mintAddress, uint256 amount) public {
        require(mints[mintAddress].mintAuthority == msg.sender, "Only the mint authority can mint tokens");
        require(mints[mintAddress].mintAddress != address(0), "Token does not exist");
        require(mints[mintAddress].supply + amount <= type(uint256).max, "Supply overflow");

        mints[mintAddress].supply += amount;

        address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(toMintTokens, mintAddress)))));

        if (tokenAccounts[tokenAddress].mintAddress == address(0)) {
            tokenAccounts[tokenAddress] = TokenAccount(mintAddress, toMintTokens, 0, false);
            tokenAddresses[tokenAddress] = true;
        }
        tokenAccounts[tokenAddress].balance += amount;
        tokenAccounts[tokenAddress].owner = toMintTokens;
    }

    function transfer(address to, address mintAddress, uint256 amount) public {
        address toTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(to, mintAddress)))));
        address fromTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, mintAddress)))));

        require(tokenAccounts[fromTokenAddress].balance >= amount, "Insufficient balance");
        require(tokenAccounts[toTokenAddress].balance + amount <= type(uint256).max, "Supply overflow");
        require(tokenAccounts[fromTokenAddress].owner == msg.sender, "fromToken owner is not msg.sender");
        require(tokenAccounts[fromTokenAddress].isFrozen == false, "fromToken is frozen");
        require(tokenAccounts[toTokenAddress].isFrozen == false, "toToken is frozen");

        if (tokenAccounts[toTokenAddress].mintAddress == address(0)) {
            tokenAccounts[toTokenAddress] = TokenAccount(mintAddress, to, 0, false);
            tokenAddresses[toTokenAddress] = true;
        }

        tokenAccounts[fromTokenAddress].balance -= amount;
        tokenAccounts[toTokenAddress].balance += amount;
    }

    function getMint(address token) public view returns (Mint memory) {
        return mints[token];
    }

    function getTokenAccount(address owner, address token) public view returns (TokenAccount memory) {
        return tokenAccounts[address(uint160(uint256(keccak256(abi.encodePacked(owner, token)))))];
    }
}

As you can see in the contract, instead of an approval flow, there is only a transfer function to move tokens. On Solana, a single transaction can call multiple smart contract functions atomically, removing the need for the approval flow. Another difference is that everything is stored in a token account on Solana, keeping the details of the owner’s tokens separate from the other state.

Where’s the Token Metadata?

One thing you may have noticed in the code is that there wasn’t any token metadata in the smart contract itself. Today this metadata is handled by the Token Metadata Program on Solana, allowing metadata to be extended as people wish to have more fields associated with their token. This does mean there’s a separate function to add to a transaction(remember, SVM can handle multiple in order function calls in a single transaction) to add name, symbol, description and other fields to your Token.

Tradeoffs of Each Approach

Now that you have a general idea of how the token program on Solana works, let’s go through some of the tradeoffs of each token standard.

ERC20

  • Good for interfaces
  • Flexibility on each smart contract
  • Hard to index all tokens
  • Requires approval flow for transferring tokens
  • New smart contract deployment required for each token

SPL-Token

  • Single smart contract means you know what to expect from the code
  • New tokens are created with transactions
  • Indexing the tokens is simple
  • Newer token standards require a new smart contract
  • Reusable by developers

High-Level Comparison

Below is a table comparing the basic steps of deploying an ERC-20 token on Ethereum vs. creating an SPL token on Solana. (On Solana, you can freely mint SPL tokens by using the spl-token command in the command-line tool.)

단계이더리움 (ERC-20)솔라나 (SPL 토큰)
1. 토큰 코드 준비일반적으로 OpenZeppelin을 사용합니다. Solidity 파일을 생성합니다 (예: MyToken.sol).커스텀 컨트랙트가 필요 없습니다. Token Program은 이미 배포되어 있습니다. Mint Account만 생성하면 됩니다.
2. 컴파일 및 배포Hardhat/Truffle을 사용하여 컴파일 및 배포합니다 (예: npx hardhat run deploy.js --network ...).spl-token create-token을 사용합니다. 별도의 컨트랙트 배포가 없습니다. 단일 RPC 호출로 프로세스가 완료됩니다.
3. 초기 발행컨트랙트의 생성자 또는 mint() 함수를 호출합니다. 가변적인 가스 수수료가 발생합니다.spl-token mint <MINT_ADDRESS> <AMOUNT>를 실행합니다. 일반적으로 트랜잭션 수수료가 매우 낮습니다.
4. 수신자 생성일반적으로 일반 이더리움 주소입니다. 사용자는 지갑에 컨트랙트 주소를 수동으로 추가해야 합니다.Associated Token Account (ATA)는 Phantom이나 Solflare와 같은 지갑에서 자동으로 인식됩니다.
5. 결과 확인Etherscan에서 확인합니다. 사용자는 종종 지갑에 컨트랙트 주소를 수동으로 추가합니다.solana balance <ADDRESS> 또는 spl-token accounts를 실행합니다. Solana Explorer에서 민트 주소로 검색할 수도 있습니다.
6. 코드 업데이트주요 업그레이드의 경우 프록시 패턴을 사용하거나 컨트랙트를 재배포할 수 있습니다.Token Program은 고정되어 있습니다. 확장 기능을 활성화하거나 더 복잡한 로직을 위해 별도의 온체인 프로그램을 작성할 수 있습니다.
7. 감사 요구사항각 컨트랙트는 일반적으로 감사가 필요하며, 특히 커스텀 로직을 추가하는 경우 더욱 그렇습니다.메인 Token Program은 이미 여러 차례 감사를 받았습니다. 순수하게 토큰 발행만 하는 경우 추가 감사가 필요한 경우는 드뭅니다.

Want to mint your own token on Solana? Check out this guide!

How to do ERC20 on Solana

Let's explore how to replicate key ERC20-like functions on Solana, using the Token Program and @solana/web3.js.

On Solana, tokens are identified by a Mint Address rather than a custom contract address. You can query the token’s metadata, supply, decimals, and other information using various RPC endpoints and libraries, as shown in the examples below.

How to do name()

bash
function name() public view returns (string) // Returns the name of the token

You can use @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata to fetch and parse an NFT’s off-chain metadata from its mint address.

jsx
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { fetchDigitalAsset, mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import { PublicKey } from "@metaplex-foundation/js";
const mintAddress = new PublicKey("Token Address");

async function name() {
  try {
    const umi = createUmi("https://api.devnet.solana.com");
    umi.use(mplTokenMetadata());
    const digitalAsset = await fetchDigitalAsset(umi, mintAddress);
    return digitalAsset.metadata.name;
  } catch (error) {
    console.error("Error fetching token name:", error);
    return null;
  }
}

name().then(name => {
    console.log("token Name:", name);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How to do symbol()

bash
function symbol() public view returns (string) // Returns the symbol of the token

You can also retrieve the symbol in the same way as name(), using @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata.

jsx
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { fetchDigitalAsset, mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import { PublicKey } from "@metaplex-foundation/js";
const mintAddress = new PublicKey("Token Address");

async function symbol() {
  try {
    const umi = createUmi("https://api.devnet.solana.com");
    umi.use(mplTokenMetadata());
    const asset = await fetchDigitalAsset(umi, mintAddress);
    return asset.metadata.symbol;
  } catch (error) {
    console.error("Error fetching NFT symbol:", error);
    return null;
  }
}

symbol().then(symbol => {
  console.log("Symbol:", symbol);
}).catch(error => {
  console.error("Error:", error);
});

How to do decimals()

bash
function decimals() public view returns (uint8) // Returns the number of decimals the token use

You can check decimals using getTokenSupply from @solana/web3.js

jsx
import { Connection, PublicKey } from "@solana/web3.js";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const mintAddress = new PublicKey("Token Address");

async function decimals() {
    let response = await connection.getTokenSupply(mintAddress);
    return response.value.decimals;
}

decimals().then(decimals => {
  console.log(decimals);
}).catch(error => {
  console.error("Error:", error);
});

How to do balanceOf()

bash
function balanceOf(address _owner) public view returns (uint256 balance) // Returns the number of tokens in owner's account
jsx
import { Connection, PublicKey } from "@solana/web3.js";
import { AccountLayout }from "@solana/spl-token";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const mintAddress = new PublicKey("Token Address");
const ownerAddress = new PublicKey("Your Wallet Address");

async function balanceOf(_owner){
  let response = await connection.getTokenAccountsByOwner(_owner, { mint: mintAddress });
  const accountInfo = AccountLayout.decode(response.value[0].account.data);
  return accountInfo.amount;
}

balanceOf(ownerAddress).then(balance => {
  console.log(balance); // need to multiply the result by the decimal precision to get the correct value.
}).catch(error => {
  console.error("Error:", error);
});

We can check the balance of the requested account using getTokenAccountsByOwner from @solana/spl-token

How to do totalSupply()

bash
function totalSupply() public view returns (uint256) // Returns the total issuance of tokens

We can check decimals using getTokenSupply from @solana/web3.js

jsx
import { Connection, PublicKey } from "@solana/web3.js";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const mintAddress = new PublicKey("Token Address");

async function totalSupply() {
    let response = await connection.getTokenSupply(mintAddress);
    return response.value.amount;
}

totalSupply().then(supply => {
  console.log(supply); // need to multiply the result by the decimal precision to get the correct value
}).catch(error => {
  console.error("Error:", error);
});

How to do transfer()

solidity
function transfer(address _to, uint256 _value) public returns (bool success) // Moves a value amount of tokens from the caller’s account to _to 

First, Let’s look at this code for understanding logic:

On Solana, each token is identified by its unique Mint address, and users store their balances in an Associated Token Account (ATA). In a transaction, the ATA address is used as the sender or receiver for transferring tokens. Unlike on Ethereum, where you directly call an ERC-20 contract, Solana handles token transfer logic through its native system program.

We can make a transaction’s transfer instruction through createTransferCheckedInstruction and send it through sendTransaction from @solana/spl-token.

jsx
import { Keypair, Transaction, Connection, PublicKey } from "@solana/web3.js";
import { createTransferCheckedInstruction } from "@solana/spl-token";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");

// Must contain your private key as a Uint8Array
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));

const receiverAddress = new PublicKey("Receiver's Wallet Address");
const mintAddress = new PublicKey("Token Address");
const ownerTokenAccount = new PublicKey("Your Associated Token Account Address");
const receiverTokenAccount = new PublicKey("Receiver's Associated Token Account Address");

// For a token with 9 decimals, transferring 1 => 1 * 10^9
const amount = 1;

async function transfer(_to, _value) {
  try {
    // Create a transaction with the transfer instruction
    const tx = new Transaction().add(
      createTransferCheckedInstruction(
        ownerTokenAccount,
        mintAddress,
        receiverTokenAccount,
        ownerPrivatekeypair.publicKey,
        _value * Math.pow(10, 9), // Decimal correction
        9 // decimals
      )
    );

    // Send the transaction (simplified, no explicit blockhash or feePayer set)
    await connection.sendTransaction(tx, [ownerPrivatekeypair]);
    return true;
  } catch (error) {
    console.error("Error in transfer:", error);
    return false;
  }
}

transfer(receiverAddress, amount)
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How we can check receiverTokenAddress through receiverAddress? We can use getOrCreateAssociatedTokenAccount. It will retrieve the associated token account, or create it if it doesn't exist.

jsx
import { Keypair, Transaction, Connection, PublicKey } from "@solana/web3.js";
import { createTransferCheckedInstruction, getOrCreateAssociatedTokenAccount } from "@solana/spl-token";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");

// Must contain your private key as a Uint8Array
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));

const receiverAddress = new PublicKey("Receiver's Wallet Address");
const mintAddress = new PublicKey("Token Address");
const amount = 1; // Amount to transfer

async function transfer(_to, _value) {
  try {
    // Get or create the sender's ATA
    const ownerTokenAccount = await getOrCreateAssociatedTokenAccount(
      connection,
      ownerPrivatekeypair, // Fee payer
      mintAddress,
      ownerPrivatekeypair.publicKey
    );

    // Get or create the receiver's ATA
    const receiverTokenAccount = await getOrCreateAssociatedTokenAccount(
      connection,
      ownerPrivatekeypair, // Fee payer
      mintAddress,
      _to
    );

    // Build the transaction
    const tx = new Transaction().add(
      createTransferCheckedInstruction(
        ownerTokenAccount.address,
        mintAddress,
        receiverTokenAccount.address,
        ownerPrivatekeypair.publicKey,
        _value * Math.pow(10, 9), // Decimal correction (9 decimals)
        9 // decimals
      )
    );

    // Send the transaction (simple version)
    await connection.sendTransaction(tx, [ownerPrivatekeypair]);
    return true;
  } catch (error) {
    console.error("Error in transfer:", error);
    return false;
  }
}

// Execute the transfer function
transfer(receiverAddress, amount)
  .then(result => {
    console.log("Transaction result:", result);
  })
  .catch(error => {
    console.error("Error:", error);
  });

Want to explore more features? Check out this guide!

Node storing all data and participating in consensus

  • Ethereum: Archive Node
  • Solana: [n/a]

Node storing some data and participating in consensus

  • Ethereum: Full Node
  • Solana: Consensus Node

Node storing some data and not participating in consensus

  • Ethereum: Light Node
  • Solana: RPC Node
EVM TO SVM

Start building on Solana

관리자

© 2026 솔라나 재단.
모든 권리 보유.
연결하기
What is ERC20 on Solana? | Solana