This repository demonstrates the differences between standard ERC20 tokens and SRC20 (Seismic's confidential ERC20 variant) with private balances and encrypted transfer amounts.
packages/
├── contracts/ # Solidity contracts (SRC20 + ERC20)
├── listener-ts/
│ ├── src/erc20/ # ERC20 listener (standard viem)
│ └── src/src20/ # SRC20 listener (seismic-viem)
├── sender-ts/
│ ├── src/erc20/ # ERC20 sender (standard viem)
│ └── src/src20/ # SRC20 sender (seismic-viem)
└── listener-go/ # Go listener (SRC20 only)| Function | ERC20 | SRC20 |
|---|---|---|
transfer(to, amount) |
uint256 amount - plaintext |
suint256 amount - shielded type |
approve(spender, amount) |
uint256 amount - plaintext |
suint256 amount - shielded type |
transferFrom(from, to, amount) |
uint256 amount - plaintext |
suint256 amount - shielded type |
balanceOf(account) |
Returns uint256 - public |
N/A - use balance() for own balance only |
balance() |
N/A | Returns caller's own balance (private to caller) |
allowance(owner, spender) |
Returns uint256 - public |
allowance(spender) - returns caller's allowance only |
mint(to, amount) |
uint256 amount - plaintext |
suint256 amount - shielded type |
burn(from, amount) |
uint256 amount - plaintext |
suint256 amount - shielded type |
| Event | ERC20 | SRC20 |
|---|---|---|
| Transfer | Transfer(from, to, amount) |
Transfer(from, to, encryptKeyHash, encryptedAmount) |
| Approval | Approval(owner, spender, amount) |
Approval(owner, spender, encryptKeyHash, encryptedAmount) |
Key difference: SRC20 events emit encrypted amounts with a key hash identifier, allowing only authorized parties to decrypt.
| Storage | ERC20 | SRC20 |
|---|---|---|
| Balances | mapping(address => uint256) |
mapping(address => suint256) |
| Allowances | mapping(address => mapping(address => uint256)) |
mapping(address => mapping(address => suint256)) |
| Total Supply | uint256 public totalSupply |
suint256 internal supply (private) |
| Library | Client Type | Code |
|---|---|---|
| viem (ERC20) | WalletClient |
createWalletClient({...}) |
| seismic-viem (SRC20) | ShieldedWalletClient |
createShieldedWalletClient({...}) |
| seismic-viem (SRC20 - read-only) | ShieldedPublicClient |
createShieldedPublicClient({...}) |
// ERC20: Standard viem
import { createWalletClient, createPublicClient, http } from "viem";
const walletClient = createWalletClient({ chain, account, transport: http() });
const publicClient = createPublicClient({ chain, transport: http() });
// SRC20: seismic-viem (async - performs key derivation)
import {
createShieldedWalletClient,
createShieldedPublicClient,
} from "seismic-viem";
const walletClient = await createShieldedWalletClient({
chain,
account,
transport: http(),
});
const publicClient = createShieldedPublicClient({ chain, transport: http() });| Library | Function | Code |
|---|---|---|
| viem (ERC20) | getContract({...}) |
getContract({abi, address, client}) |
| seismic-viem (SRC20) | getShieldedContract({...}) |
getShieldedContract({abi, address, client}) |
// ERC20: Standard viem - amounts sent in PLAINTEXT
import { getContract } from "viem";
const contract = getContract({
abi: ERC20Abi,
address: contractAddress,
client: { wallet: walletClient, public: publicClient },
});
// SRC20: seismic-viem - automatic encryption of suint256 params
import { getShieldedContract } from "seismic-viem";
const contract = getShieldedContract({
abi: SRC20Abi,
address: contractAddress,
client: walletClient,
});| Operation | ERC20 (viem) | SRC20 (seismic-viem) |
|---|---|---|
| Sender entry | erc20/index.ts |
src20/index.ts |
| Sender tx utils | erc20/util/tx.ts |
src20/util/tx.ts |
| Sender ABI | erc20/util/abi.ts |
src20/util/abi.ts |
| Listener entry | erc20/index.ts |
src20/index.ts |
| Listener logic | erc20/listener.ts |
src20/listener.ts |
A key difference between ERC20 and SRC20 is how balances are read:
| Aspect | ERC20 | SRC20 |
|---|---|---|
| Function | balanceOf(address) |
balance() |
| Who can read | Anyone can read ANY balance | Only owner can read their OWN balance |
| Method | Standard eth_call |
Signed Read |
| Privacy | ❌ Zero privacy | ✅ Full privacy |
In Ethereum, anyone can make an eth_call and specify any from address to impersonate that account. On Seismic, this is blocked—any standard eth_call has its from address overridden to zero.
To read data that depends on msg.sender (like your private balance), use a Signed Read. This sends a signed message proving your identity, allowing the contract to return your private data.
import { signedReadContract } from "seismic-viem";
// SRC20: Read your own private balance (requires signature)
const myBalance = await signedReadContract(client, {
abi: SRC20Abi,
address: contractAddress,
functionName: "balance",
args: [],
});Compare to ERC20 where anyone can read anyone's balance:
// ERC20: Read ANY address's balance (no signature needed)
const anyoneBalance = await client.readContract({
abi: ERC20Abi,
address: contractAddress,
functionName: "balanceOf",
args: [targetAddress], // Can be ANY address!
});The listener periodically polls balances to demonstrate this difference. See listener.ts for the SRC20 implementation using signedReadContract.
seismic-viem provides two helper functions for watching SRC20 events with automatic decryption:
| Function | Client Type | Key Source | Use Case |
|---|---|---|---|
watchSRC20Events |
ShieldedWalletClient |
Auto-fetches from Directory contract | Registered recipients listening for their own events |
watchSRC20EventsWithKey |
ShieldedPublicClient |
Explicit viewing key parameter | Designated listeners (e.g., intelligence providers) with a known AES key |
-
watchSRC20Events: Automatically retrieves the user's AES key from the Directory contract via a signed read, then filters and decrypts events encrypted to that key. See usage inattachWalletEventListener. -
watchSRC20EventsWithKey: Takes an explicit viewing key as a parameter, acting as a "designated" event listener for that key. Useful when you have the AES key directly (e.g., intelligence providers). See usage inattachPublicEventListener.
When an SRC20 transfer or approval occurs, the contract emits multiple encrypted events:
- To Intelligence Providers: The amount is encrypted to each registered intelligence provider's AES key
- To the Recipient/Spender: The amount is encrypted to the recipient's (for transfers) or spender's (for approvals) registered AES key
┌─────────────────────────────────────────────────────────────────┐
│ SRC20 Transfer Event │
├─────────────────────────────────────────────────────────────────┤
│ Event 1: Transfer(from, to, providerKeyHash, encryptedAmount) │
│ └─ Decryptable by: Intelligence Provider │
│ │
│ Event 2: Transfer(from, to, recipientKeyHash, encryptedAmount) │
│ └─ Decryptable by: Recipient only │
└─────────────────────────────────────────────────────────────────┘
| Role | Can Decrypt Transfers | Can Decrypt Approvals |
|---|---|---|
| Intelligence Provider | ✅ ALL transfers | ✅ ALL approvals |
| Recipient | ✅ Transfers TO them | ✅ Approvals TO them |
| Random Observer | ❌ | ❌ |
SRC20 relies on two predeployed system contracts:
Directory Contract (0x1000000000000000000000000000000000000004)
The Directory contract maintains a mapping of addresses to their AES encryption keys:
interface IDirectory {
function checkHasKey(address _addr) external view returns (bool); // checks if an address has a key registered
function keyHash(address to) external view returns (bytes32); // returns the hash for the key of an address
function encrypt(address to, bytes memory _plaintext) external returns (bytes memory); // encrypts the plaintext using the key corresponding to the `to` address
function setKey(suint256 _key) external; // Register your AES key
}Purpose: Allows users to register their AES keys so they can receive encrypted event data.
Intelligence Contract (0x1000000000000000000000000000000000000005)
The Intelligence contract manages intelligence providers who can decrypt all transfer/approval amounts:
interface IIntelligence {
function encryptToProviders(bytes memory _plaintext) external returns (bytes32[] memory, bytes[] memory); // encrypts data to all providers registered in the Intelligence contract
function addProvider(address _provider) external; // adds a provider
function removeProvider(address _provider) external; // removes a provider
function numProviders() external view returns (uint256); // returns number of providers
}Purpose: Intelligence providers (e.g., compliance services, analytics) can be granted access to decrypt all transaction amounts for regulatory or monitoring purposes.
You need our fork of the foundry development suite (sfoundry), specifically sforge, our tool to help build, test, deploy and debug Seismic smart contracts, to run contracts/, listener-ts, and sender-ts. See here for the installation command.
You then need to run bun install:all from the root to install all dependencies.
All packages read from a .env file in packages/contracts/. See packages/contracts/.env.example for the required variables.
cd packages/contracts
bash script/deploy.shThis deploys both MockSRC20 and MockERC20 and writes their addresses to out/deploy.json.
bun run sender:src20Output:
╔══════════════════════════════════════════════════════════════╗
║ SRC20 SENDER (seismic-viem) ║
║ All amounts are ENCRYPTED before sending ║
╚══════════════════════════════════════════════════════════════╝
bun run sender:erc20Output:
╔══════════════════════════════════════════════════════════════╗
║ ERC20 SENDER (Standard viem) ║
║ All amounts are sent in PLAINTEXT ║
╚══════════════════════════════════════════════════════════════╝
bun run listener:src20bun run listener:erc20The sender scripts cycle through the following operations between Alice, Bob, and Charlie:
| Operation | Probability | Description |
|---|---|---|
| Transfer | 100% | Alice → Bob → Charlie → Alice cycle |
| Approval | 50% | Random approvals between users |
| Mint | 40% | Alice mints to herself, Charlie mints to Bob |
| Burn | 30% | Bob burns from himself, Charlie burns from herself |
The SRC20 listener uses the encryptKeyHash field in events to filter and decrypt relevant transactions:
- Compute key hash: The listener computes
keccak256(AES_KEY)from your AES key - Filter events: Events are indexed by
encryptKeyHash, so the listener only receives events encrypted to your key - Decrypt amount: The
encryptedAmountis decrypted using your AES key viaAesGcmCrypto
┌───────────────────────────────────────────────────────────────────┐
│ SRC20 Listener Flow │
├───────────────────────────────────────────────────────────────────┤
│ 1. AES_KEY (from .env) ──► keccak256() ──► keyHash │
│ • INTELLIGENCE_AES_KEY for intelligence mode │
│ • RECIPIENT_AES_KEY for recipient mode │
│ │
│ 2. watchEvent({ args: { encryptKeyHash: keyHash } }) │
│ └─ Only receives events where encryptKeyHash matches │
│ │
│ 3. For each matching event: │
│ encryptedAmount ──► AesGcmCrypto.decrypt() ──► plaintext │
└───────────────────────────────────────────────────────────────────┘
| Mode | Environment Variable | What It Decrypts |
|---|---|---|
--intelligence |
INTELLIGENCE_AES_KEY |
All transfers & approvals (provider key) |
--recipient |
RECIPIENT_AES_KEY |
Only events TO your address |
See listener.ts for the implementation.
bun run listener:src20
# or
cd packages/listener-ts && bun dev:src20 -- --intelligence- Requires
INTELLIGENCE_AES_KEYin.env - Decrypts all transfer and approval amounts across all users
cd packages/listener-ts && bun dev:src20 -- --recipient- Requires
RECIPIENT_AES_KEYin.env(this will be Alice's AES encryption/decryption key since listener uses Alice's private key to initialize the shielded wallet client) - Only decrypts transfers TO you and approvals FOR you as spender
- Prompts to register your key in the Directory if not already registered
For running without interactive prompts (e.g., with supervisor):
bun dev:src20 -- --recipient --no-prompt| Script | Description |
|---|---|
bun run deploy |
Deploy both ERC20 and SRC20 contracts |
bun run sender:src20 |
Run SRC20 sender (encrypted amounts) |
bun run sender:erc20 |
Run ERC20 sender (plaintext amounts) |
bun run listener:src20 |
Run SRC20 listener (intelligence mode) |
bun run listener:erc20 |
Run ERC20 listener (plaintext events) |
cd packages/listener-go
go run ./━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[ERC20] Transfer - PLAINTEXT (visible to everyone on-chain)
from: 0xAlice...
to: 0xBob...
amount: 42 (plaintext - NO encryption)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Intelligence Provider] [SRC20] Transfer - ENCRYPTED (decrypted with AES key)
from: 0xAlice...
to: 0xBob...
amount: 42 (decrypted from encrypted bytes)