arrow-right-arrow-leftShielded Balances and Transfers

Implement transfer() and transferFrom() with suint256

This chapter walks through the full implementation of shielded transfers, allowances, and minting. You will also write tests using sforge to verify everything works. Estimated time: ~20 minutes.

Shielded balanceOf

The core change is in the mapping declaration:

mapping(address => suint256) balanceOf;

At the storage level, this is where Seismic's FlaggedStorage comes in. Each storage slot is a tuple of (value, is_private). When the compiler sees suint256, it emits CSTORE to write and CLOAD to read, setting the is_private flag to true. This means:

  • eth_getStorageAt calls for these slots will fail. External observers cannot read the raw storage.

  • Only CLOAD can access private slots. The standard SLOAD opcode cannot reach them.

  • Anyone inspecting the state trie, transaction traces, or block data sees 0x00...0 in place of the actual balance.

The developer does not interact with FlaggedStorage directly. The type annotation handles everything.

transfer()

Here is the full transfer implementation with shielded amounts:

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

What happens at each stage

  1. Calldata submission -- The user sends a Seismic transaction (type 0x4A). The amount parameter is encrypted before it leaves their machine. Observers watching the mempool see 0x00...0 in place of the amount.

  2. Execution inside the TEE -- The Seismic node, running inside Intel TDX, decrypts the calldata. The require check runs against the shielded balance. The subtraction and addition execute normally. All intermediate values involving suint256 are shielded in the trace.

  3. Storage update -- The new balances are written via CSTORE. Both the sender's and recipient's balance slots have is_private = true.

  4. Observer view -- Anyone querying the contract or reading the block sees 0x00...0 for the amount, the sender's balance, and the recipient's balance.

Comparisons (>=) and arithmetic (-=, +=) work the same on suint256 as on uint256. Solidity 0.8+ overflow checks also work, so if a user tries to transfer more than their balance, the transaction reverts as expected.

transferFrom()

The full implementation with shielded allowances:

The allowance mapping stores suint256 values:

The pattern is identical to a standard ERC20 transferFrom. The only difference is the type. The allowance check, the allowance deduction, and the balance updates all use shielded arithmetic. An observer cannot see how much allowance was granted, how much was consumed, or how much remains.

approve()

Setting shielded allowances:

The approved amount is stored as suint256. No one can query how much a spender is authorized to transfer on behalf of the owner -- that information is shielded in storage. The Approval event above casts the amount to uint256 for the log. If you need the approved amount to be private in the event as well, see the Encrypted Events chapter.

The mint pattern

Here is a mint function that assigns new tokens:

There is a design decision here: totalSupply is public. It is a regular uint256, so the aggregate supply is visible. This is usually desirable -- users and markets want to know how many tokens exist. However, individual balances remain shielded. An observer knows the total supply increased, but cannot see which address received the tokens or how they are distributed.

If you want even the total supply to be private, you can change it to suint256:

But keep in mind that suint256 state variables cannot be public, so you would need to provide a view function that determines who can view it via signed reads.

Constructor minting

The simplest approach is to mint the entire initial supply in the constructor:

The explicit cast suint256(_initialSupply) is required because _initialSupply is a regular uint256. Seismic enforces explicit casting between shielded and unshielded types.

Testing with sforge

Create a test file at test/SRC20.t.sol:

Test: basic transfer

circle-info

In sforge tests, the test contract runs inside the same execution context. You can read shielded values by adding an internal helper function to your contract (or using the test contract's access). In production, shielded values are only accessible through signed reads.

To support this test, add a test-only helper to your contract:

Test: transfer reverts on insufficient balance

Test: approve and transferFrom

Test: transferFrom reverts on insufficient allowance

Running the tests

Build and run from your contracts directory:

You should see all tests passing. The shielded types behave identically to their unshielded counterparts in terms of arithmetic and comparison logic -- the privacy is handled transparently at the storage layer.

Last updated