lockEncrypted Events

Emit transfer events with encrypted amounts using AES precompiles

In the previous chapter, we cast suint256 amounts to uint256 before emitting events. This works, but it reveals the amount in the public event log. This chapter shows how to encrypt event data so that only the intended recipients can read it. Estimated time: ~20 minutes.

The problem

Events in Ethereum (and Seismic) are stored in transaction logs, which are public. You cannot use shielded types directly in event parameters:

// This will NOT compile
event Transfer(address indexed from, address indexed to, suint256 amount);

The compiler rejects this because event data is written to public logs, and shielded types are only meaningful in contract storage. If you cast to uint256 and emit, the amount appears in plaintext in the log -- defeating the purpose of shielding it in the first place.

The solution

Use Seismic's AES-GCM precompiles to encrypt the sensitive data before emitting it. The event carries opaque bytes that only the intended recipient can decrypt.

The modified event signature uses bytes instead of uint256 for the amount:

event Transfer(address indexed from, address indexed to, bytes encryptedAmount);

The from and to addresses remain as indexed parameters. These are public -- observers can see who is transacting with whom. Only the amount is encrypted. If you need to hide the participants as well, you can encrypt those too, but that is less common for a token.

Step by step

The encryption flow uses three of Seismic's precompiles:

Step 1: Derive a shared secret with ECDH

The ECDH precompile at address 0x65 performs Elliptic Curve Diffie-Hellman key agreement. Given a private key and a public key, it produces a shared secret that both parties can independently derive.

For event encryption, the contract needs a keypair. The private key must not be passed as a constructor argument -- constructor calldata is not encrypted (CREATE/CREATE2 transactions are standard Ethereum transaction types), so the key would leak in the deployment data.

Instead, set the key after deployment via a Seismic transaction (type 0x4A), which encrypts calldata:

Because setContractKey is called via a Seismic transaction, the private key is encrypted in the calldata before it leaves the caller's machine. The key is stored as sbytes32, so the compiler routes it through shielded storage — observers see 0x00...0 instead of the actual key.

To derive a shared secret with a specific recipient, the contract calls the ECDH precompile with its own private key and the recipient's public key:

Step 2: Derive an encryption key with HKDF

The raw ECDH shared secret should not be used directly as an encryption key. The HKDF precompile at address 0x68 derives a proper cryptographic key from the shared secret:

The second argument is a context string (sometimes called "info" in HKDF terminology). Using a unique context string for each purpose ensures that the same shared secret produces different keys for different uses.

Step 3: Encrypt with AES-GCM

The AES-GCM Encrypt precompile at address 0x66 encrypts the data:

Step 4: Emit the encrypted event

Putting it all together in an internal helper:

Full implementation

Here is the updated transfer function using encrypted events:

Users register their public key by calling registerPublicKey once. After that, any transfer they receive will emit an event encrypted to their key.

Decrypting off-chain

The recipient can decrypt the event data by performing the reverse of the encryption flow:

  1. Take the contract's public key (stored on-chain and readable by anyone).

  2. Combine it with their own private key using ECDH to derive the same shared secret.

  3. Run HKDF with the same context string ("src20-transfer-event") to derive the same encryption key.

  4. Decrypt the encryptedAmount from the event log using AES-GCM Decrypt.

Decrypt events client-side using the ECDH shared secret and AES-GCM decryption. See the seismic-viem precompiles documentation for the cryptographic primitives.

Who can read what

Here is the visibility breakdown for each piece of data in a Transfer event:

Data
Who can see it
Why

from address

Everyone

Indexed parameter, stored in public log topics

to address

Everyone

Indexed parameter, stored in public log topics

Transfer amount

Recipient only

Encrypted to recipient's public key

A transfer happened

Everyone

The event emission itself is visible

The sender can also decrypt the amount because they know the plaintext -- they created the transaction. If you need the sender to be able to decrypt from the event log as well (for example, for transaction history), you can emit a second event encrypted to the sender's key, or encrypt to both keys and include both ciphertexts.

circle-info

Encrypted events add gas cost for the precompile calls. For applications where the event amount being public is acceptable, the simpler uint256(amount) cast from the previous chapter is more gas-efficient. Choose the approach that matches your privacy requirements.

Last updated