right-leftERC20 to SRC20: What Changes

See exactly what changes between a standard ERC20 and a private SRC20

This chapter puts a standard ERC20 and its SRC20 counterpart side by side so you can see exactly how little changes. To get privacy on Seismic, just change the types. Estimated time: ~10 minutes.

The standard ERC20

Here is a minimal but complete ERC20 contract. It covers the full interface -- name, symbol, decimals, totalSupply, balances, allowances, transfer, approve, and transferFrom:

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

contract ERC20 {
    string public name;
    string public symbol;
    uint8 public decimals = 18;
    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, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        totalSupply = _initialSupply;
        balanceOf[msg.sender] = _initialSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

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

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
        require(balanceOf[from] >= amount, "Insufficient balance");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

This is standard Solidity. Every balance, transfer amount, and allowance is a uint256, visible to anyone who queries the contract or reads the chain.

The SRC20 version

Here is the same contract converted to an SRC20. Changed lines are marked with comments:

That is the entire diff. The contract logic is structurally identical.

Line-by-line diff

Balances: uint256 to suint256

Two changes here. First, the value type changes from uint256 to suint256. This tells the Seismic compiler to emit CSTORE/CLOAD instead of SSTORE/SLOAD, which marks these storage slots as private. Second, the public visibility modifier is removed. Shielded types cannot be returned from public or external functions, so the automatic getter that public generates would not compile. We will add an explicit balance-checking function using signed reads in a later chapter.

Allowances: same pattern

The same change applies to the allowance mapping. The nested mapping's value type becomes suint256, and the public modifier is removed.

Function parameters: shielded amounts

The amount parameter changes to suint256. When a user calls this function through a Seismic transaction (type 0x4A), the amount is encrypted in the calldata before it leaves their machine. During execution inside the TEE, the amount is decrypted and used normally. Observers watching the mempool or block data see 0x00...0 in place of the amount.

The same change applies to approve and transferFrom.

Constructor: casting the initial supply

Since _initialSupply is a regular uint256 (it is the total supply, which is public) and balanceOf now stores suint256 values, an explicit cast is required. Seismic does not allow implicit casting between shielded and unshielded types.

Events: casting back to uint256

Events are stored in transaction logs, which are public. Shielded types cannot appear in event parameters. The simplest approach is to cast the amount back to uint256 before emitting. Note that this does reveal the amount in the event log. If you need the event data to also be private, the Encrypted Events chapter shows how to use AES-GCM precompiles to encrypt it.

What stays the same

A lot stays the same, which is the point:

  • Transfer logic -- The subtraction, addition, and require checks are identical. Arithmetic operations on suint256 work the same as on uint256.

  • Overflow protection -- Solidity 0.8+ overflow checks work with shielded types.

  • Function signatures -- The function names and return types are unchanged. The contract is still recognizable as an ERC20.

  • Address parameters -- The to, from, and spender parameters remain regular address types. Mapping keys cannot be shielded types, so these must stay as address.

  • totalSupply -- This stays as a regular uint256. The total supply is public information. Individual balances are private, but the aggregate is visible.

What you lose (and how to get it back)

There are two capabilities that the basic SRC20 loses compared to a standard ERC20:

1. Public balance queries

Since balanceOf cannot be public, there is no automatic getter. Users cannot call balanceOf(address) the way they would with a standard ERC20. The solution is signed reads -- a Seismic-specific mechanism where a user sends a signed eth_call (type 0x4A) to prove their identity, and the contract returns their balance only to them. This is covered in Signed Reads for Balance Checking.

2. Private event data

With the simple cast approach above, the amount appears in plaintext in the event log. If you need transfer amounts to be hidden in events as well, you can encrypt the data using Seismic's AES-GCM precompiles before emitting. This is covered in Encrypted Events.

Both of these are straightforward additions. The next chapters walk through each one.

Last updated