Only this pageAll pages
Powered by GitBook
1 of 35

General

Loading...

introduction

Loading...

Loading...

Loading...

onboarding

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

core

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Appendix

Loading...

Why

A vision-centric overview of Seismic

The traditional financial system processes over $100 trillion in sensitive payment volume annually. The scale is clear even when considering just the top three categories: payroll distributions ($57 trillion), commercial revenue ($53 trillion), and housing payments ($24 trillion). Including healthcare, insurance, and treasury management pushes the figure even higher.

Public blockchains are poorly suited to handle this sensitive volume due to their reliance on transparency for consensus. Critical details— such as account balances, transfer amounts, and participant identities— are visible to all observers.

Such transparency is commercially and socially untenable. Businesses lose competitive edge, narrative control, and bargaining power. Consumers, likewise, are exposed to reputational and interpersonal losses.

Developers have long sought to resolve this issue by creating custom protocols that encrypt payment data. These offerings tend to focus on one of two value propositions:

  1. Privacy. Your financial activity cannot be monitored by any third party. Not your payment processor, not your government.

  2. Efficiency. Your transactions can settle in under a second, cost less than a cent, and have global reach.

The challenge is that, for mass market users, traditional financial services are sufficiently private and efficient. While encrypted stablecoin payments offer clear advantages, they are not compelling enough on their own to overcome the network effects of legacy payment systems.

The missing piece is a broader DeFi ecosystem around these encrypted payments. A business is unlikely to become an early adopter if the benefit is a 1% cost reduction on 1% of point-of-sale volume. However, if going on-chain enables access to DeFi services— such as converting revenue into BTC, then borrowing against the position— the experience begins to approach an order-of-magnitude improvement. This is why companies that want to target sensitive payments can’t focus solely on payments. They need to build markets around them.

The shift in perspective has implications beyond payments. It reframes the role of encrypted blockchains altogether. Historically, encryption has struggled to find product-market fit. Many attribute this to timing, or argue that encryption is a feature rather than a product. These critiques hold when encryption is applied to existing crypto volume, primarily one-off stablecoin transfers or memecoin trades, which genuinely don’t need encryption.

But encryption isn’t valuable because it improves services for today’s volume. It’s valuable because it unlocks new volume, the $100 trillion in sensitive TradFi transactions that public blockchains cannot reach. To reach that volume, product development must be centered around markets that provide value to sensitive payments.

This is challenging. Developers looking to act on this insight today must build complex, custom infrastructure using technologies such as zero-knowledge proofs (ZK), multi-party computation (MPC), fully homomorphic encryption (FHE), and trusted execution environments (TEE). Effectively leveraging these tools requires orchestrating provers, managing specialized indexers, distributing heavyweight client-side SDKs, and maintaining relayer clusters.

The technical overhead presents a significant barrier to entry. Today, only a handful of teams possess the expertise required to build encrypted DeFi protocols. And even for these teams, the need to invest months of engineering effort and tens of thousands of dollars in audits imposes meaningful friction on product development and growth.

How

A tech-centric overview of Seismic

An open-source stack

We've restructured the modern blockchain stack around secure hardware. The major components are as follows:

Built around secure enclaves

At the core of this stack is the secure enclave. The term describes a set of hardware components that provide confidentiality measures over data in use, protecting it from being read by any outside entity, including the host machine. Our secure enclave of choice is Intel TDX.

Our system uses TDX by cloning all the primary memory segments of the EVM. This results in a set of encrypted segments and a set of transparent segments, where the former can leverage the confidentiality properties of the underlying hardware.

Data flows between these segments with cloned storage opcodes. For example, the vanilla EVM has SLOAD / SSTORE to manage elements in storage, and our EVM adds CLOAD / CSTORE to manage elements in encrypted storage. The same pattern is applied to calldata, transient storage, and memory.

These segments allow us to know whether every element added to the stack is considered transparent or encrypted. Then it becomes a matter of tracking these elements to enforce rules over how they interact and how they should be handled by the execution loop.

Our github repositories hold the v0 implementation for this. Like every v0, it's a far cry from the final spec. Notably, the current implementation only clones the storage segment and does not keep track of element status in the stack. It compensates by treating all memory segments as encrypted. This heavy handed approach has major drawbacks in both UX and liveness. However, it's enough to test our core hypothesis around encrypting protocols while decreasing our time to market, which makes it a great fit for our first release.

With these trust assumptions

We believe these enclaves are sufficient to support entire blockchain ecosystems. And with the current momentum behind confidential compute, we expect the rate of improvement only increases from here.

The language is a fork of . We added stype.

The execution client is a fork of , , and . We added encrypted storage and relevant opcodes.

The consensus middleware is . We used it off-the-shelf.

The consensus client is . We used it off-the-shelf.

The secure hardware build is a fork of manifests from flashbots. We added proxies.

The testing framework is a fork of . We added encrypted storage, along with the relevant opcodes.

The wallet client is an extension of . We added transaction types.

Notice, 99% of our stack is code written by the OS community. We're making sure to maintain this standard, which is why are fully open-source under an MIT License.

Our dependency on secure enclaves comes with strong trust assumptions. The most prominent one is on hardware confidentiality, which leaves us wary of . Our short-term mitigation for this is the restriction to cloud-based validators.

Though this assumption has led to in the past, we're cautiously optimistic about the most recent generation of VM-based enclaves like Intel's TDX and AMD's SEV-SNP. They function with an untrusted hypervisor and patch up major flaws present in the first generation.

solidity
reth
revm
alloy
omni
cometbft
yocto
foundry
viem
all of our repositories
side-channel attacks
many issues

Installation

Setting up your local machine to develop with Seismic

sforge, sanvil, and ssolc

We currently support devices with x84_64/arm64 architectures running MacOS, Ubuntu or Windows.

The local development suite uses sforge as the testing framework, sanvil as the local node, and ssolc as the compiler.

# install rust and cargo
curl https://sh.rustup.rs -sSf | sh
  1. Download and execute the sfoundryup installation script.

curl -L \
     -H "Accept: application/vnd.github.v3.raw" \
     "https://api.github.com/repos/SeismicSystems/seismic-foundry/contents/sfoundryup/install?ref=seismic" | bash
source ~/.zshenv  # or ~/.bashrc or ~/.zshrc
  1. Install sforge, sanvil, ssolc. Expect this to take between 5-20 minutes depending on your machine.

sfoundryup
source ~/.zshenv  # or ~/.bashrc or ~/.zshrc
  1. Remove old build artifacts in existing projects.

sforge clean  # run in your project's contract directory

VSCode extension

Install [ / / / ] on your machine if you don't already have them. Default installations for all work well.

We also recommend adding syntax highlighting via the extension from the VSCode marketplace. If you have the solidity extension, you'll have to disable it while writing Seismic code.

rust
cargo
brew
jq
seismic

What

A product-centric overview of Seismic

Seismic enables everyday developers to build encrypted DeFi.

Seismic is an L1 blockchain capable of encrypting arbitrary smart contracts. Deploying a single Solidity contract is sufficient to launch an encrypted DeFi protocol, no custom infrastructure required.

We achieved this by restructuring the modern blockchain stack around TEEs. This involved forking key components such as Solidity, Reth, and Foundry. By doing so, we were able to fully leverage the confidentiality guarantees of secure enclaves and eliminate the need for additional infrastructure.

This architecture unlocks the three core properties necessary to encrypt arbitrary smart contracts:

  1. Encrypted global state. Allows functions to operate on encrypted state owned by multiple users. Essential for use cases such as matching orders in a dark pool or liquidating encrypted margin positions in a lending protocol.

  2. Encrypted memory access. Enables the modification of elements in a collection without revealing their locations in memory. Essential for use cases like placing bids in a blind auction and transferring money in a shielded pool.

  3. Encrypted orchestration. Supports the consolidation of all protocol logic within a single VM. Without it, developers must split logic between transparent and encrypted components across different environments, introducing significant engineering complexity.

These properties together restore powerful capabilities that are standard in vanilla DeFi, but notoriously difficult to achieve in encrypted environments. This is why developers building on Seismic can encrypt existing EVM contracts with minimal changes to the original.

Welcome

Seismic is an encrypted blockchain

If you're interested in learning, we suggest:

If you're interested in building, we suggest:

  • Asking a friend (or @seismicMatt) to add you to our invite-only developer TG group.

If you're interested in community, we suggest:

Starting with high-level context on the , , and behind Seismic.

Understanding the for interacting with Seismic.

Reading through our implementation on .

Setting up your local via , then deploying via .

Deriving inspiration from our early .

Talking to us in our channel.

If you end up writing a contract with Seismic, please send the github link to on TG! I'd love to chat.

why
what
how
core concepts
github
installation
quickstart
prototype contracts
discord
@lyronc

Setting Up Your Walnut App Project

In this chapter, you’ll set up the foundation for the Walnut App using a clean and modular monorepo-style workspace. This structure separates concerns into distinct areas, making your project easier to navigate and maintain. You’ll create a contracts directory to house your Seismic smart contracts and tests, and a cli directory to serve as the command-line interface for interacting with those contracts. By the end of this chapter, you’ll have a fully initialized project, complete with dependencies, formatting tools, and a seamless environment for both contract development and interaction.

Initialize the CLI subdirectory

Now that the contracts subdirectory has been initialized, you should now initialize the cli subdirectory that will be used to interact with the deployed contracts.

  1. Navigate to the contracts subdirectory:

# Assuming you are currently in the contracts directory
cd ../cli
  1. Initialize a new bun project

bun init -y
  1. Now, create an src/ folder and move index.ts there.

mkdir -p src && mv -t src index.ts
  1. Now, edit package.json to be the following:

{
    "name": "walnut-cli",
    "license": "MIT License",
    "type": "module",
    "scripts": {
        "dev": "bun run src/index.ts"
    },
    "dependencies": {
        "dotenv": "^16.4.7",
        "seismic-viem": "1.0.9",
        "viem": "^2.22.3"
    },
    "devDependencies": {
        "@types/node": "^22.7.6",
        "typescript": "^5.6.3"
    }
}
  1. Edit .gitignore to be:

node_modules

Your environment is now set!

Verify devtool installation

• sforge: Framework for testing and deploying smart contracts.

• sanvil : Local Seismic node.

• ssolc: The Seismic Solidity compiler.

Run each of the above commands in your terminal with a --version flag to ensure that they're installed correctly.

Before continuing, ensure that you have completed the steps in the section to install all necessary Seismic developer tools:

Also ensure that you have installed on your machine. If you do not have bun installed, follow the instructions to install it on your machine.

Installation
bun
here

Create project structure and monorepo workspace

  1. Create the project folder and navigate into it:

mkdir walnut-app
cd walnut-app
  1. Create the packages directory with subdirectories for contracts and cli

mkdir -p packages/contracts packages/cli

The contracts subdirectory will house the Seismic smart contract(s) and test(s) for the project, while the cli will house the interface to interact with the contracts.

  1. Initialize a bun project in the root directory:

bun init -y && rm index.ts && rm tsconfig.json && touch .prettierrc && touch .gitmodules

We remove the default index.ts and tsconfig.json files created by bun init -y to keep the root directory clean and focused on managing the monorepo structure rather than containing code. We also create a .prettierrc file for consistent code formatting and a .gitmodules file to manage contract submodules.

  1. Replace the default package.json with the following content for a monorepo setup:

{
  "workspaces": [
    "packages/**"
  ],
  "dependencies": {},
  "devDependencies": {
    "@trivago/prettier-plugin-sort-imports": "^5.2.1",
    "prettier": "^3.4.2"
  }
}
  1. Add the following to the .prettierrc file for consistent code formatting:

{
  "semi": false,
  "tabWidth": 2,
  "singleQuote": true,
  "printWidth": 80,
  "trailingComma": "es5",
  "plugins": ["@trivago/prettier-plugin-sort-imports"],
  "importOrder": [
    "<TYPES>^(?!@)([^.].*$)</TYPES>",
    "<TYPES>^@(.*)$</TYPES>",
    "<TYPES>^[./]</TYPES>",
    "^(?!@)([^.].*$)",
    "^@(.*)$",
    "^[./]"
  ],
  "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
  "importOrderSeparation": true,
  "importOrderSortSpecifiers": true
}
  1. Replace the.gitignore file with:

# Compiler files
cache/
out/

# Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/

# Docs
docs/

# Dotenv file
.env

node_modules/
  1. Add the following to the .gitmodules file to track git submodules (in our case, only the Forge standard library, forge-std):

[submodule "packages/contracts/lib/forge-std"]
	path = packages/contracts/lib/forge-std
	url = https://github.com/foundry-rs/forge-std

Quickstart

You're two commands away from running an encrypted protocol

git clone "https://git@github.com/SeismicSystems/seismic-starter.git"
cd seismic-starter/packages/contracts
sforge test -vv

You can play around with stype using our . This assumes you went through everything in .

starter repository
Installation

Initialize the contracts subdirectory

  1. Navigate to the contracts subdirectory:

cd packages/contracts
  1. Initialize a project with sforge :

sforge init --no-commit && rm -rf .github

This command will:

  • Create the contract project structure (e.g., src/ , test/, foundry.toml).

  • Automatically install the Forge standard library (forge-std ) as a submodule.

  • Remove the .github workflow folder (not required)

  1. Edit the .gitignore file to be the following:

.env
broadcast/
cache/
  1. Delete the default contract, test and script files (Counter.sol and Counter.t.sol and Counter.s.sol ) and replace them with their Walnut counterparts (Walnut.sol , Walnut.t.sol and Walnut.s.sol ):

# Remove the Counter files
rm -f src/Counter.sol test/Counter.t.sol script/Counter.s.sol

# Create empty Walnut files in the same locations
touch src/Walnut.sol test/Walnut.t.sol script/Walnut.s.sol

These files are empty for now, but we will add to them as we go along.

Writing, testing and deploying the contract

This section dives into the heart of the Walnut App—the shielded smart contract that powers its functionality. You’ll start by building the foundational pieces of the Walnut, including the kernel and the protective shell, before implementing more advanced features like rounds, reset mechanisms, and contributor-based access control. By the end of this section, you’ll have a fully functional, round-based Walnut contract that is secure, fair, and replayable.

What You'll Learn

In this section, you’ll:

• Define and initialize the kernel, the hidden value inside the Walnut.

• Build the shell, the protective layer that hides the kernel, and implement a hit() function to help crack it, and a shake function to increment the kernel value by an encrypted amount.

• Add a look() function to conditionally reveal the kernel.

• Implement a reset mechanism to restart the Walnut for multiple rounds.

• Track player contributions in each round, ensuring that only contributors can access the kernel.

Overview of Chapters

You’ll define the kernel using a shielded state variable (suint256) and implement a shake() function to increment its value. This chapter introduces shielded writes.

Learn how to build the shell, which protects the kernel from being accessed prematurely. You’ll implement the hit()function to crack the shell and the look() function to reveal the kernel once conditions are met.

This chapter introduces a reset mechanism to enable multiple rounds of gameplay. You’ll track contributions per round and ensure that only players who helped crack the Walnut in a specific round can reveal the kernel. This chapter introduces shielded reads.

Chapter 1: Making the Kernel
Chapter 2: Making the Shell and Revealing the Kernel
Chapter 3: Reset Mechanism, Rounds, and a more conditional Kernel Reveal

Tutorial

Learn how to build, deploy, and play with the Walnut App, your first Seismic-powered shielded contract game, in this hands-on tutorial.

Deploying your contract

In this chapter, you’ll deploy your Walnut contract to a local Seismic node for testing. By the end of this guide, you’ll have a fully deployed contract that you can interact with using your CLI or scripts. Estimated Time: ~15 minutes.

Writing the deploy script

Navigate to the script folder in your Walnut App and open the Walnut.s.sol file located at:

packages/contracts/script

and add the following to it:

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

import {Script, console} from "forge-std/Script.sol";
import {Walnut} from "../src/Walnut.sol";

contract WalnutScript is Script {
    Walnut public walnut;

    function run() public {
        uint256 deployerPrivateKey = vm.envUint("PRIVKEY");

        vm.startBroadcast(deployerPrivateKey);
        walnut = new Walnut(3, suint256(0));
        vm.stopBroadcast();
    }
}

This script will deploy a new instance of the Walnut contract with an initial shell strength of 3 and an initial kernel value of 0.

Deploying the contract

  1. In a separate terminal window, run

sanvil

in order to spin up a local Seismic node.

  1. In packages/contracts , create a .env file and add the following to it:

RPC_URL=http://127.0.0.1:8545
PRIVKEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

The RPC_URL denotes the port on which sanvil is running and the PRIVKEY is one of the nine standard sanvil testing private keys.

  1. Now, from packages/contracts, run

source .env
sforge script script/Walnut.s.sol:WalnutScript \
    --rpc-url $RPC_URL \
    --broadcast

Your contract should be up and deployed to your local Seismic node!

Chapter 3: Reset Mechanism, Rounds, and a more conditional Kernel Reveal

In this chapter, we’ll implement a reset mechanism that allows the Walnut to be reused in multiple rounds, ensuring each game session starts fresh. We’ll also track contributors per round so that only players who participated in cracking the Walnut can call look(). By the end, we’ll have a fully functional round-based walnut game where the kernel remains shielded until conditions are met! Estimated time: ~15 minutes.

The need for a Reset mechanism

Right now, once the Walnut is cracked, there’s no way to reset it. If a game session were to continue, we’d have no way to start fresh—the shell would remain at 0, and the kernel would be permanently revealed.

To solve this, we need to introduce:

✅ A reset function that restores the Walnut to its original state.

✅ Round tracking, so each reset creates a new round.

The need for a contributor check

While the reset mechanism and round tracking allow us to restart the Walnut for continuous gameplay, they still don’t address who should be allowed to call the look() function.

Right now, any player can call look() once the shell is cracked, even if they didn’t participate in hitting it during the current round. This creates the following issues:

  • Fairness: Players who didn’t contribute should not be able to reap the benefits of seeing the kernel.

  • Incentivizing Contribution: The game needs to encourage active participation by ensuring that only those who helped crack the Walnut in a specific round are rewarded with access to the kernel.

The solution to this is implementing a conditional check on look() which allows only those players who contributed in hitting the shell for a particular round (i.e., players whose hit count is >0 for that round) to view the kernel after the walnut is cracked.

Implementing the Reset Mechanism

The reset mechanism allows the Walnut to be reused for multiple rounds, with each round starting fresh. It restores the Walnut’s shell and kernel to their original states and increments the round counter to mark the beginning of a new round.

Here’s how we can implement the reset function:

    // The current round number.
    uint256 round; 
    
    // Event to log resets.
    event Reset(uint256 indexed newRound, uint256 shellStrength);
    
    function reset() public requireCracked {
        shellStrength = initialShellStrength; // Restore the shell strength.
        kernel = initialKernel; // Reset the kernel to its original value.
        round++; // Increment the round counter.
        emit Reset(round, shellStrength); // Log the reset action.
    }

What’s Happening Here?

  • Condition for Reset (requireCracked): The reset function can only be called once the Walnut’s shell is cracked, enforced by the requireCracked modifier.

  • Restoring Initial State: The shell strength and kernel are reset to their original values (initialShellStrength and initialKernel), ensuring the Walnut starts afresh for the next round.

  • Round Tracking: The round counter increments each time the Walnut is reset, allowing us to distinguish between rounds.

Modifying hit() to track contributions

To enforce fair access to the kernel, we’ll track the number of hits each player contributes in a given round. This is achieved using the hitsPerRound mapping:

    // Mapping to track contributions: hitsPerRound[round][player] → number of hits.
    mapping(uint256 => mapping(address => uint256)) hitsPerRound;

Every time a player calls the hit() function, we update their contribution in the current round:

    function hit() public requireIntact {
        shellStrength--; // Decrease the shell strength.
        hitsPerRound[round][msg.sender]++; // Record the player's contribution for the current round.
        emit Hit(round, msg.sender, shellStrength); // Log the hit event.
    }

What’s Happening Here?

  • Tracking Contributions: The hitsPerRound mapping records each player’s hits in the current round. This ensures we can verify who participated when the Walnut was cracked.

  • Replayable Rounds: Because contributions are tracked by round, the game can fairly reset and start fresh without losing player data from previous rounds.

Restricting look() with a contributor check

To ensure only contributors can reveal the kernel, we’ll use a modifier called onlyContributor:

     modifier onlyContributor() {
        require(hitsPerRound[round][msg.sender] > 0, "NOT_A_CONTRIBUTOR"); // Check if the caller contributed in the current round.
        _;
    }

We’ll then apply this modifier to the look() function:

    // Look at the kernel if the shell is cracked and the caller contributed.
    function look() public view requireCracked onlyContributor returns (uint256) {
        return uint256(kernel); // Return the kernel value.
    }

Congratulations! You made it through to writing the entire shielded smart contract for a multiplayer, multi-round, walnut app!

Final Walnut contract

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

contract Walnut {
    uint256 initialShellStrength; // The initial shell strength for resets.
    uint256 shellStrength; // The current shell strength.
    uint256 round; // The current round number.

    suint256 initialKernel; // The initial hidden kernel value for resets.
    suint256 kernel; // The current hidden kernel value.

    // Tracks the number of hits per player per round.
    mapping(uint256 => mapping(address => uint256)) hitsPerRound;

    // Events to log hits, shakes, and resets.

    // Event to log hits.
    event Hit(uint256 indexed round, address indexed hitter, uint256 remaining);
    // Event to log shakes.
    event Shake(uint256 indexed round, address indexed shaker);
    // Event to log resets.
    event Reset(uint256 indexed newRound, uint256 shellStrength);

    constructor(uint256 _shellStrength, suint256 _kernel) {
        initialShellStrength = _shellStrength; // Set the initial shell strength.
        shellStrength = _shellStrength; // Initialize the shell strength.

        initialKernel = _kernel; // Set the initial kernel value.
        kernel = _kernel; // Initialize the kernel value.

        round = 1; // Start with the first round.
    }

    // Get the current shell strength.
    function getShellStrength() public view returns (uint256) {
        return shellStrength;
    }

    // Hit the Walnut to reduce its shell strength.
    function hit() public requireIntact {
        shellStrength--; // Decrease the shell strength.
        hitsPerRound[round][msg.sender]++; // Record the player's hit for the current round.
        emit Hit(round, msg.sender, shellStrength); // Log the hit.
    }

    // Shake the Walnut to increase the kernel value.
    function shake(suint256 _numShakes) public requireIntact {
        kernel += _numShakes; // Increment the kernel value.
        emit Shake(round, msg.sender); // Log the shake.
    }

    // Reset the Walnut for a new round.
    function reset() public requireCracked {
        shellStrength = initialShellStrength; // Reset the shell strength.
        kernel = initialKernel; // Reset the kernel value.
        round++; // Move to the next round.
        emit Reset(round, shellStrength); // Log the reset.
    }

    // Look at the kernel if the shell is cracked and the caller contributed.
    function look() public view requireCracked onlyContributor returns (uint256) {
        return uint256(kernel); // Return the kernel value.
    }

    // Set the kernel to a specific value.
    function set_number(suint _kernel) public {
        kernel = _kernel;
    }

    // Modifier to ensure the shell is fully cracked.
    modifier requireCracked() {
        require(shellStrength == 0, "SHELL_INTACT");
        _;
    }

    // Modifier to ensure the shell is not cracked.
    modifier requireIntact() {
        require(shellStrength > 0, "SHELL_ALREADY_CRACKED");
        _;
    }

    // Modifier to ensure the caller has contributed in the current round.
    modifier onlyContributor() {
        require(hitsPerRound[round][msg.sender] > 0, "NOT_A_CONTRIBUTOR");
        _;
    }
}

Now, onto testing the contract!

Chapter 4: Testing your Walnut contract

In this chapter, you’ll write tests to verify that the Walnut contract behaves as expected under various scenarios. Testing ensures the functionality, fairness, and access control mechanisms of your contract work seamlessly, particularly in multi-round gameplay. Estimated Time: ~15 minutes.

Getting Started

Navigate to the test folder in your Walnut App and open the Walnut.t.sol file located at:

packages/contracts/test/Walnut.t.sol

This file is where you’ll write all the test cases for the Walnut contract. Start with the following base code:

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

import {Test, console} from "forge-std/Test.sol";
import {Walnut} from "../src/Walnut.sol";

contract WalnutTest is Test {
    Walnut public walnut;

    function setUp() public {
        // Initialize a Walnut with shell strength = 2 and kernel = 0
        walnut = new Walnut(2, suint256(0));
    }
}

The setUp() function initializes the Walnut contract for use in all test cases.

Writing Test Cases

Start off with testing the basic functionalities, hit , shake , look and reset

Core functionalities

  1. Basic hit functionality

Ensures the Walnut’s shell can be cracked by shellStrength number of hits.

function test_Hit() public {
    walnut.hit(); // Decrease shell strength by 1
    walnut.hit(); // Fully crack the shell
    assertEq(walnut.look(), 0); // Kernel should still be 0 since no shakes
}
  1. Basic shake functionality

Validates that shaking the Walnut increments the kernel value.

function test_Shake() public {
    walnut.shake(suint256(10)); // Shake the Walnut, increasing the kernel
    walnut.hit(); // Decrease shell strength by 1
    walnut.hit(); // Fully crack the shell
    assertEq(walnut.look(), 10); // Kernel should be 10 after 10 shakes
}
  1. Reset functionality

function test_Reset() public {
    walnut.hit(); // Decrease shell strength by 1
    walnut.shake(suint256(2)); // Shake the Walnut
    walnut.hit(); // Fully crack the shell
    walnut.reset(); // Reset the Walnut

    assertEq(walnut.getShellStrength(), 2); // Shell strength should reset to initial value
    walnut.hit(); // Start hitting again
    walnut.shake(suint256(5)); // Shake the Walnut again
    walnut.hit(); // Fully crack the shell again
    assertEq(walnut.look(), 5); // Kernel should reflect the shakes in the new round
}

Now, test for the restrictive/conditional nature of these basic functionalities.

Restricting Actions

  1. Preventing hit when shell is cracked

Ensures that hitting a cracked shell is not allowed.

function test_CannotHitWhenCracked() public {
    walnut.hit(); // Decrease shell strength by 1
    walnut.hit(); // Fully crack the shell
    vm.expectRevert("SHELL_ALREADY_CRACKED"); // Expect revert when hitting an already cracked shell
    walnut.hit();
}
  1. Preventing shake when shell is cracked

Ensures that shaking the Walnut after the shell is cracked is not allowed.

function test_CannotShakeWhenCracked() public {
    walnut.hit(); // Decrease shell strength by 1
    walnut.shake(suint256(1)); // Shake the Walnut
    walnut.hit(); // Fully crack the shell
    vm.expectRevert("SHELL_ALREADY_CRACKED"); // Expect revert when shaking an already cracked shell
    walnut.shake(suint256(1));
}
  1. Preventing look when shell is intact

Ensures that the kernel cannot be revealed unless the shell is fully cracked.

function test_CannotLookWhenIntact() public {
    walnut.hit(); // Partially crack the shell
    walnut.shake(suint256(1)); // Shake the Walnut
    vm.expectRevert("SHELL_INTACT"); // Expect revert when trying to look at the kernel with the shell intact
    walnut.look();
}
  1. Preventing reset when shell is intact

Validates that the Walnut cannot be reset unless the shell is fully cracked.

function test_CannotResetWhenIntact() public {
    walnut.hit(); // Partially crack the shell
    walnut.shake(suint256(1)); // Shake the Walnut
    vm.expectRevert("SHELL_INTACT"); // Expect revert when trying to reset without cracking the shell
    walnut.reset();
}

Now, test for more complex scenarios.

Complex scenarios

  1. Sequence of Multiple Actions

Ensures that the Walnut behaves correctly under a sequence of hits and shakes.

function test_ManyActions() public {
    uint256 shakes = 0;
    for (uint256 i = 0; i < 50; i++) {
        if (walnut.getShellStrength() > 0) {
            if (i % 25 == 0) {
                walnut.hit(); // Hit the shell every 25 iterations
            } else {
                uint256 numShakes = (i % 3) + 1; // Random shakes between 1 and 3
                walnut.shake(suint256(numShakes));
                shakes += numShakes;
            }
        }
    }
    assertEq(walnut.look(), shakes); // Kernel should match the total number of shakes
}
  1. Prevent Non-Contributors From Using look()

Ensures that only contributors in the current round can call look() .

function test_RevertWhen_NonContributorTriesToLook() public {
    address nonContributor = address(0xabcd);

    walnut.hit(); // Decrease shell strength by 1
    walnut.shake(suint256(3)); // Shake the Walnut
    walnut.hit(); // Fully crack the shell

    vm.prank(nonContributor); // Impersonate a non-contributor
    vm.expectRevert("NOT_A_CONTRIBUTOR"); // Expect revert when non-contributor calls `look()`
    walnut.look();
}
  1. Contributor Tracking Across Rounds

Validates that contributions are tracked independently for each round. The test has one contributor hit both times and crack the shell in the first round, and a different contributor hit and crack the shell in the second round. We check for the fact the second round contributor cannot see the kernel after the first round and the first round contributor cannot see the kernel after the second.

function test_ContributorInRound2() public {
    address contributorRound2 = address(0xabcd); // Contributor for round 2

    // Round 1: Cracked by address(this)
    walnut.hit(); // Hit 1
    walnut.hit(); // Hit 2
    assertEq(walnut.look(), 0); // Confirm kernel value

    walnut.reset(); // Start Round 2

    // Round 2: ContributorRound2 cracks the Walnut
    vm.prank(contributorRound2);
    walnut.hit();

    vm.prank(contributorRound2);
    walnut.shake(suint256(5)); // Shake kernel 5 times

    vm.prank(contributorRound2);
    walnut.hit();

    vm.prank(contributorRound2);
    assertEq(walnut.look(), 5); // Kernel value is 5 for contributorRound2

    vm.expectRevert("NOT_A_CONTRIBUTOR"); // address(this) cannot look in round 2
    walnut.look();
}

Test out the file by running the following inside the packages/contracts directory:

sforge build
sforge test

The contract has been tested, time to deploy it!

You can find the entire test file .

here

Chapter 2: Making the Shell and revealing the Kernel

In this chapter, you’ll build the shell, the protective layer that hides the kernel. You’ll initialize the shell’s strength and implement a hit function to decrement it. Additionally, you’ll add a look() function with a requiredCracked modifier to ensure the kernel can only be viewed once the shell is fully broken. Estimated Time: ~10 minutes.

Defining the shell

The shell determines the Walnut’s resilience. It has an integer strength (shellStrength), which represents how many hits it can withstand before cracking. Let’s define the shell and initialize it in the constructor:

    uint256 shellStrength; // The strength of the Walnut's shell.

    constructor(uint256 _shellStrength, suint256 _kernel) {
        shellStrength = _shellStrength; // Set the initial shell strength.
        kernel = _kernel; // Initialize the kernel.
    }

Adding the hit function

Each time the Walnut is hit, the shell strength decreases, simulating damage to the protective shell. This is crucial for revealing the kernel, as the shell must be fully broken for the kernel to be accessed:

    // Event to log hits
    event Hit(address indexed hitter, uint256 remainingShellStrength);
    
    // Function to hit the walnut shell
    function hit() public {
        shellStrength--; // Decrease the shell strength.
        emit Hit(msg.sender, shellStrength); // Log the hit event.
    }

    // Modifier to ensure the shell is not cracked.
    modifier requireIntact() {
        require(shellStrength > 0, "SHELL_ALREADY_CRACKED");
        _;
    }

What's happening here?

  • The requireIntact modifier: Ensures that the function cannot be called if the Walnut’s shell is already broken (shellStrength == 0). This prevents unnecessary calls after the shell is fully cracked. We can now also add this modifier to the shake function in order to restrict shake being called even after the shell is broken:

    function shake(suint256 _numShakes) public requireIntact {
        kernel += _numShakes; // Increment the kernel value using the shielded parameter.
        emit Shake(msg.sender); // Log the shake event.
    }
  • Decrementing the shell: Each call to hitdecreases the shell’s strength (shellStrength) by one.

  • Logging the action: The Hit event records the hitter’s address (msg.sender) and the remaining shell strength.

Example call:

Here’s how calling the hit function works in practice:

• Initial State: The shell strength is set to 5.

• First Hit: A player calls hit(). The shell strength decreases to 4.

• Subsequent Hits: Each additional hit reduces the shell strength by 1 until it reaches 0

Revealing the Kernel

Now that we have implemented the shell’s durability and the ability to break it using the hit function, we can introduce a new condition: the kernel should only be revealed once the shell is fully cracked.

Currently, there is no way to access the kernel’s value. However, now that we have a shell with a decreasing strength, we can apply a condition that restricts when the kernel can be seen. Specifically:

• The kernel should remain hidden while the shell is intact.

• The kernel can only be revealed once the shell’s strength reaches zero, i.e. when it is cracked.

To enforce this, we will create a function called look() , which will return the kernel’s value, but only if the Walnut has been fully cracked.

Here’s how we define look() with a requireCracked modifier:

    // Function to reveal the kernel if the shell is fully cracked.
    function look() public view requireCracked returns (uint256) {
        return uint256(kernel); // Reveal the kernel as a standard uint256.
    }
    
    // Modifier to ensure the shell is fully cracked before revealing the kernel.
    modifier requireCracked() {
        require(shellStrength == 0, "SHELL_INTACT"); // Ensure the shell is broken before revealing the kernel.
        _;
    }

What's happening here?

  • Restricting Access with a Condition: The requireCracked modifier ensures that look() can only be called if shellStrength == 0, meaning the Walnut has been fully cracked.

  • Revealing the Kernel: Once the condition is met, look() returns the unshielded value of the kernel.

  • Preventing Premature Access: If look() is called before the shell is broken, the function will revert with the error "SHELL_INTACT".

Updated contract with hit, shake and look

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

contract Walnut {
    uint256 shellStrength; // The strength of the Walnut's shell.
    suint256 kernel; // The hidden kernel (number inside the Walnut).

    // Events
    event Hit(address indexed hitter, uint256 remainingShellStrength); // Logs when the Walnut is hit.
    event Shake(address indexed shaker); // Logs when the Walnut is shaken.

    // Constructor to initialize the shell and kernel.
    constructor(uint256 _shellStrength, suint256 _kernel) {
        shellStrength = _shellStrength; // Set the initial shell strength.
        kernel = _kernel; // Initialize the kernel.
    }

    // Function to hit the Walnut and reduce its shell strength.
    function hit() public requireIntact {
        shellStrength--; // Decrease the shell strength.
        emit Hit(msg.sender, shellStrength); // Log the hit action.
    }

    // Function to shake the Walnut and increment the kernel.
    function shake(suint256 _numShakes) public requireIntact {
        kernel += _numShakes; // Increment the kernel by the given number of shakes.
        emit Shake(msg.sender); // Log the shake action.
    }
    
    // Function to reveal the kernel if the shell is fully cracked.
    function look() public view requireCracked returns (uint256) {
        return uint256(kernel); // Reveal the kernel as a standard uint256.
    }
    
    // Modifier to ensure the shell is fully cracked before revealing the kernel.
    modifier requireCracked() {
        require(shellStrength == 0, "SHELL_INTACT"); // Ensure the shell is broken before revealing the kernel.
        _;
    }
    
    // Modifier to ensure the shell is not cracked.
    modifier requireIntact() {
        require(shellStrength > 0, "SHELL_ALREADY_CRACKED");
        _;
    }
    
}

Chapter 1: Making the Kernel

In this chapter, you’ll learn to create and initialize the kernel, a hidden value inside the Walnut, and increment it by implementing a shake function. Estimated time: ~10 minutes.

Defining the kernel

The kernel is the hidden number inside the Walnut. Using Seismic’s suint256 type, the kernel is shielded on-chain. Open up packages/contracts/Walnut.sol and define the kernel as a state variable and initialize it in the constructor:

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

contract Walnut {
    suint256 kernel; // The shielded kernel (number inside the Walnut)

    // Constructor to initialize the kernel
    constructor(suint256 _kernel) {
        kernel = _kernel;
    }
}

Add a shake function

Next, let’s implement a function to increment the kernel. The shake function takes an suint256 parameter, _numShakes which specifies the amount to increment the kernel by.

function shake(suint256 _numShakes) public {
    kernel += _numShakes; // Increment the kernel value using the shielded parameter.
    emit Shake(msg.sender); // Log the shake event.
}

What's happening here?

Since shake takes in an stype as one of its parameters, it is key that no information about this parameter (in this case, the number of shakes) is leaked at any time during the function call. This means that the value of _numShakes is known only to the function caller and is encrypted on-chain.

The function also updates a state variable (kernel ) and hence constitutes a state transition, which makes a call to this function a shielded write.

Interacting with the contract via a CLI

In this section, you will write a CLI to interact with the deployed Walnut smart contract from scratch, simulating a multiplayer, multi-round game consisting of two players, Alice and Bob. At the end of this section, you will be able to see a full game play out on your local Seismic node! Estimated time: ~30 minutes

Chapter 1: Defining the constants and utilities

In this chapter, you will learn about defining and constants and utility functions which we will frequently use throughout the project. Estimated time: ~10 minutes

First, navigate to the root of the directory/monorepo and run bun install to install all the dependencies.

Now, navigate to make a lib folder inside packages/cliwith the files constants.ts and utils.ts and navigate to it:

mkdir -p packages/cli/lib
touch packages/cli/lib/constants.ts packages/cli/lib/utils.ts
cd packages/cli/lib

constants.ts will contain the constants we use throughout the project with utils.ts will contain the necessary utility functions.

Add the following to constants.ts :

import { join } from 'path'

const CONTRACT_NAME = 'Walnut'
const CONTRACT_DIR = join(__dirname, '../../contracts')

export { CONTRACT_NAME, CONTRACT_DIR }

This file centralizes key project constants:

• CONTRACT_NAME: The Walnut contract name.

• CONTRACT_DIR: Path to the contracts directory.

Now, add the following to utils.ts :

import fs from 'fs'
import { type ShieldedWalletClient, getShieldedContract } from 'seismic-viem'
import { Abi, Address } from 'viem'

async function getShieldedContractWithCheck(
  walletClient: ShieldedWalletClient,
  abi: Abi,
  address: Address
) {
  const contract = getShieldedContract({
    abi: abi,
    address: address,
    client: walletClient,
  })

  const code = await walletClient.getCode({
    address: address,
  })
  if (!code) {
    throw new Error('Please deploy contract before running this script.')
  }

  return contract
}

function readContractAddress(broadcastFile: string): `0x${string}` {
  const broadcast = JSON.parse(fs.readFileSync(broadcastFile, 'utf8'))
  if (!broadcast.transactions?.[0]?.contractAddress) {
    throw new Error('Invalid broadcast file format')
  }
  return broadcast.transactions[0].contractAddress
}

function readContractABI(abiFile: string): Abi {
  const abi = JSON.parse(fs.readFileSync(abiFile, 'utf8'))
  if (!abi.abi) {
    throw new Error('Invalid ABI file format')
  }
  return abi.abi
}

export { getShieldedContractWithCheck, readContractAddress, readContractABI }

This file contains utility functions to interact with your Walnut contract:

• getShieldedContractWithCheck: Ensures the contract is deployed and returns a shielded contract instance.

• readContractAddress: Reads the deployed contract’s address from a broadcast file.

• readContractABI: Parses the contract’s ABI from an ABI file for use in interactions.

Quick primer: seismic-viem

Shielded wallet client

The shielded wallet client is the shielded/Seismic counterpart of the wallet client in viem . It is used to enable extended functionality for interacting with shielded blockchain features, wallet operations, and encryption. It can be initialized using the createShieldedWalletClient function as follows:

const walletClient = await createShieldedWalletClient({
  chain: seismicChain,
  transport: httpTransport,
  privateKey: '0xabcdef...',
})

createShieldedWalletClient takes in the following parameters:

  1. transport : the method of transport of interacting with the chain (http /ws along with the corresponding RPC URL)

  2. privateKey: the private key to create the client for

Shielded contract

A shielded contract instance provides an interface to interact with a shielded contract onchain. It has extended functionality for performing shielded write operations, signed reads, and contract interaction for a specific contract performed by a specific wallet client that it is initialized with. It can be initialized with the getShieldedContract as follows:

const contract = getShieldedContract({
  abi: myContractAbi,
  address: '0x1234...',
  client: shieldedWalletClient,
})

It takes in the following parameters:

  1. abi : the ABI of the contract it is interacting with.

  2. address : the address of the deployed contract it is interacting with.

  3. client : the shielded wallet client that the interactions are to be performed by.

This function extends the base getContract functionality by adding:

  • Shielded write actions for nonpayable and payable functions.

  • Signed read actions for pure and view functions.

  • Proxy-based access to dynamically invoke contract methods.

// Perform a shielded write
await contract.write.myFunction([arg1, arg2], { gas: 50000n })
 
// Perform a signed read
const value = await contract.read.getValue()
console.log('Value:', value)

We will extensively use shielded writes (for shake ) and shielded reads (for look()) in our CLI.

Before proceeding to write the CLI, you need to be acquainted with some of the functions and utilities used to enable Seismic primitives (e.g. shielded reads, shielded writes etc.) through our client library, seismic-viem , which we will be using heavily to write the CLI. The detailed docs for seismic-viem can be found . Estimated time: ~15 minutes

chain : a well-defined object

Once initialized, it can then be used to perform wallet operations or shielded-specific

here
Chain
actions.

Chapter 3: Bringing it all together

Now that we’ve built the core logic for interacting with the Walnut contract, it’s time to tie everything together into a CLI that runs a multiplayer game session with two players - Alice and Bob. In this chapter, you’ll set up the environment variables for multiple players and write index.ts to simulate gameplay. Estimated time: ~20 minutes

Set Up Environment Variables

Before running the Walnut game, we need to define environment variables that store important configurations such as the RPC URL, chain ID, and player private keys.

Create a .env in packages/cli :

touch .env

Open .env and paste the following:

CHAIN_ID=31337
RPC_URL=http://127.0.0.1:8545
ALICE_PRIVKEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
BOB_PRIVKEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

What’s Happening Here?

• CHAIN_ID=31337 : 31337is the default chain ID for sanvil(your local Seismic node).

• RPC_URL=http://127.0.0.1:8545 : This is the RPC URL for interacting with the local Seismic node.

• ALICE_PRIVKEYand BOB_PRIVKEY : These are Alice and Bob’s private keys, allowing them to play the game. (These again are standard test keys provided by sanvil)

Write index.ts

Now, we’ll create the main entry point for our game session. This file will simulate gameplay, initializing players and having them interact with the Walnut contract. Open packages/cli/src/index.tsand follow these steps:

Import Dependencies

Import the required libraries to read environment variables, define network configurations, and interact with the Walnut contract.

import dotenv from 'dotenv'
import { join } from 'path'
import { seismicDevnet } from 'seismic-viem'
import { anvil } from 'viem/chains'

import { CONTRACT_DIR, CONTRACT_NAME } from '../lib/constants'
import { readContractABI, readContractAddress } from '../lib/utils'
import { App } from './app'

// Load environment variables from .env file
dotenv.config()

Define the main() function

This function initializes the contract and player wallets, then runs the game session.

async function main() {
  if (!process.env.CHAIN_ID || !process.env.RPC_URL) {
    console.error('Please set your environment variables.')
    process.exit(1)
  }

Read Contract Details

The contract’s ABI and deployed address are read from files generated during deployment.

  const broadcastFile = join(
    CONTRACT_DIR,
    'broadcast',
    `${CONTRACT_NAME}.s.sol`,
    process.env.CHAIN_ID,
    'run-latest.json'
  )
  const abiFile = join(
    CONTRACT_DIR,
    'out',
    `${CONTRACT_NAME}.sol`,
    `${CONTRACT_NAME}.json`
  )

Select the blockchain network

Determine whether to use the local sanvilnode (31337) or the Seismic devnet.

  const chain =
    process.env.CHAIN_ID === anvil.id.toString() ? anvil : seismicDevnet

Define players

Assign Alice and Bob as players with private keys stored in .env .

  const players = [
    { name: 'Alice', privateKey: process.env.ALICE_PRIVKEY! },
    { name: 'Bob', privateKey: process.env.BOB_PRIVKEY! },
  ]

Initialize the Game App

Create an App instance to interact with the Walnut contract.

  const app = new App({
    players,
    wallet: {
      chain,
      rpcUrl: process.env.RPC_URL!,
    },
    contract: {
      abi: readContractABI(abiFile),
      address: readContractAddress(broadcastFile),
    },
  })

  await app.init()

Simulate the game round by round

The following logic executes two rounds of gameplay between Alice and Bob.

Round 1 - Alice Plays

  console.log('=== Round 1 ===')
  await app.reset('Alice')
  await app.shake('Alice', 2)
  await app.hit('Alice')
  await app.shake('Alice', 4)
  await app.hit('Alice')
  await app.shake('Alice', 1)
  await app.hit('Alice')
  await app.look('Alice')

Round 2 - Bob Plays

  console.log('=== Round 2 ===')
  await app.reset('Bob')
  await app.hit('Bob')
  await app.shake('Bob', 1)
  await app.hit('Bob')
  await app.shake('Bob', 1)
  await app.hit('Bob')

  // Bob looks at the number in round 2
  await app.look('Bob')

Alice Tries to Look in Round 2 (we expect this to fail since she has contributed in round 1 but not round 2)

  // Alice tries to look in round 2, should fail by reverting
  console.log('=== Testing Access Control ===')
  console.log("Attempting Alice's look() in Bob's round (should revert)")
  try {
    await app.look('Alice')
    console.error('❌ Expected look() to revert but it succeeded')
    process.exit(1)
  } catch (error) {
    console.log('✅ Received expected revert')
  }
}

Execute the main() function

This ensures that the script runs when executed.

main()

Running the CLI

Now, run the CLI from packages/cli by running:

bun dev

You should see something like this as the output:

=== Round 1 ===
- Player Alice writing shake()
- Player Alice writing hit()
- Player Alice writing shake()
- Player Alice writing hit()
- Player Alice writing shake()
- Player Alice writing hit()
- Player Alice reading look()
- Player Alice sees number: 7n
=== Round 2 ===
- Player Bob writing reset()
- Player Bob writing hit()
- Player Bob writing shake()
- Player Bob writing hit()
- Player Bob writing shake()
- Player Bob writing hit()
- Player Bob reading look()
- Player Bob sees number: 3n
=== Testing Access Control ===
- Attempting Alice's look() in Bob's round (should revert)
✅ Received expected revert

This output logs the events during two rounds of gameplay in the Walnut contract, showing interactions by Alice and Bob, along with a revert error when Alice attempts to call look()in Round 2.

The entire index.ts file can be found

Congratulations! You've reached the end of the tutorial. You can find the code for the entire project .

here
here

Chapter 2: Writing the core app

In this chapter, you’ll write the core logic to interact with the Walnut contract by creating an App class. This class will initialize player-specific wallet clients and contracts, and provide easy-to-use functions like hit, shake, reset, and look. Estimated time: ~20 minutes

Now, navigate to packages/cli/src/ and create a file called app.ts which will contain the core logic for the CLI:

# Assuming you are in packages/cli/lib
cd ../src
touch app.ts

Import required dependencies

Start by importing all the necessary modules and functions at the top of app.ts:

import {
  type ShieldedContract,
  type ShieldedWalletClient,
  createShieldedWalletClient,
  getShieldedContract,
} from 'seismic-viem'
import { Abi, Address, Chain, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { getShieldedContractWithCheck } from '../lib/utils'

Define the app configuration

The AppConfig interface organizes all settings for the Walnut App, including player info, wallet setup, and contract details. It supports a multiplayer environment, with multiple players having distinct private keys and contract interactions.

interface AppConfig {
  players: Array<{
    name: string // Name of the player
    privateKey: string // Private key for the player’s wallet
  }>
  wallet: {
    chain: Chain // Blockchain network (e.g., Seismic Devnet or Anvil)
    rpcUrl: string // RPC URL for blockchain communication
  }
  contract: {
    abi: Abi // The contract's ABI for interaction
    address: Address // The contract's deployed address
  }
}

Create the App class

The App class manages player-specific wallet clients and contract instances, providing an easy-to-use interface for multiplayer gameplay.

export class App {
  private config: AppConfig // Holds all app configuration
  private playerClients: Map<string, ShieldedWalletClient> = new Map() // Maps player names to their wallet clients
  private playerContracts: Map<string, ShieldedContract> = new Map() // Maps player names to their contract instances

  constructor(config: AppConfig) {
    this.config = config
  }
}

Add initialization logic to App

The init()method sets up individual wallet clients and contract instances for each player, enabling multiplayer interactions. Each player gets their own wallet client and a direct connection to the contract.

async init() {
  for (const player of this.config.players) {
    // Create a wallet client for the player
    const walletClient = await createShieldedWalletClient({
      chain: this.config.wallet.chain,
      transport: http(this.config.wallet.rpcUrl),
      account: privateKeyToAccount(player.privateKey as `0x${string}`),
    })
    this.playerClients.set(player.name, walletClient) // Map the client to the player

    // Initialize the player's contract instance and ensure the contract is deployed
    const contract = await getShieldedContractWithCheck(
      walletClient,
      this.config.contract.abi,
      this.config.contract.address
    )
    this.playerContracts.set(player.name, contract) // Map the contract to the player
  }
}

Add helper methods to App

These helper methods ensure that the app fetches the correct wallet client or contract instance for a specific player, supporting multiplayer scenarios.

getWalletClient :

private getWalletClient(playerName: string): ShieldedWalletClient {
  const client = this.playerClients.get(playerName)
  if (!client) {
    throw new Error(`Wallet client for player ${playerName} not found`)
  }
  return client
}

getPlayerContract :

private getPlayerContract(playerName: string): ShieldedContract {
  const contract = this.playerContracts.get(playerName)
  if (!contract) {
    throw new Error(`Shielded contract for player ${playerName} not found`)
  }
  return contract
}

Implement Contract Interaction Methods

reset

Resets the Walnut for the next round. The reset is player-specific and resets the shell and kernel values.

async reset(playerName: string) {
  console.log(`- Player ${playerName} writing reset()`)
  const contract = this.getPlayerContract(playerName)
  const walletClient = this.getWalletClient(playerName)
  await walletClient.waitForTransactionReceipt({
    hash: await contract.write.reset([], { gas: 100000n })
  })
}

shake

Allows a player to shake the Walnut, incrementing the kernel. This supports multiplayer scenarios where each player’s shakes impact the Walnut. Uses signed writes.

async shake(playerName: string, numShakes: number) {
  console.log(`- Player ${playerName} writing shake()`)
  const contract = this.getPlayerContract(playerName)
  const walletClient = this.getWalletClient(playerName)
  await contract.write.shake([numShakes], { gas: 50000n }) // signed write
  })
}

hit :

A player can hit the Walnut to reduce the shell’s strength. Each hit is logged for the respective player.

async hit(playerName: string) {
  console.log(`- Player ${playerName} writing hit()`)
  const contract = this.getPlayerContract(playerName)
  const walletClient = this.getWalletClient(playerName)
  await contract.write.hit([], { gas: 100000n })
}

look :

Reveals the kernel for a specific player if they contributed to cracking the shell. This ensures fairness in multiplayer gameplay. Uses signed reads.

async look(playerName: string) {
  console.log(`- Player ${playerName} reading look()`)
  const contract = this.getPlayerContract(playerName)
  const result = await contract.read.look() // signed read
  console.log(`- Player ${playerName} sees number:`, result)
}

Basics

A handle on stype unlocks all shielded computation and storage

Mental model

Developers communicate to Seismic through the stype. A thorough understanding of this one concept unlocks all shielded computation and storage. The stype consists of three elementary types:

  • suint / sint: shielded integer

  • sbool: shielded boolean

  • saddress: shielded address

The primary difference between them and their vanilla counterparts is that they're shielded. Any operations you apply to them are carried out as expected, but the values won't be visible to external observers.

There are special considerations unique to each individual type. These are covered in the next three sections. For now, we'll develop a general understanding of stype that applies to all its component types.

Here's the mental model you should have for shielded contracts. Whenever a tx is broadcasted by a user, it goes through the same submission, execution, and storage phases as a tx in a regular blockchain. The only difference is that when you look at the tx at these different stages- whether it's as a calldata payload during submission, a trace during execution, or as leaves in the MPT tree during storage- any bytes that represent stype variables are replaced with 0x000.

mapping(address => suint256) public balanceOf;  // shielded balance

function transfer(address to, suint256 amount) public {  // shielded transfer amount
    balanceOf[msg.sender] -= amount;
    balanceOf[to] += amount;
}

Shielding user balances is done by changing the values of the balanceOf array to suint256. Shielding transfer amounts is done by changing the amount parameter in transfer() to suint256. Now we can see what happens at every stage of the tx lifecycle:

  1. Submit. The tx is sitting in the mempool. You know that you're sending 12 tokens to your friend. Observers can look at the calldata and figure out that your friend is the recipient, but will see 0x000 instead of the number 12.

  2. Execute. The tx is processed by a full node, and its trace is open. You know that 12 tokens were removed from your balance and 12 were added to your friend's. Observers know that the same number that was deducted from your balance was added to your friend's, but they see 0x000 instead of the number 12.

  3. Store. The effects of the tx are applied to the state tree of all full nodes. You know that your new balance goes down by 12, to 200. You know that your friend's balance went up by 12, but you only see 0x000 for what its final state is. Observers know that your new balance is down the same amount that your friend's new balance is up, but they see 0x000 for both balances.

Seismic currently shields a lot more than just the bytes representing stype variables, so the above model is more granular than you technically need to be. However, this will soon stop being the case. You should not fit your contracts to this temporary discrepancy.

Casting

You can cast stype variables to their unshielded counterparts, and vice-versa. Only explicit casting is allowed- no implicit. Note that whenever you do this, observers can look at the trace to figure out either the initial (if going from not stype to stype) or final (if going from stype to not stype) value.

uint256 number = 100;
suint256 sNumber = suint256(number);

Restrictions

There are two restrictions in how you can use stype variables:

  1. You can't return them in public or external functions. This also means stype contract variables can't be public, since this automatically generates a getter. If you want to return one, you'll have to cast it into its unshielded counterpart.

/*
 * Throws a compiler error
 */
suint256 public v;

// ==========

/*
 * Throws a compiler error
 */
function f() external view returns (suint256) {}
  1. You can't use them as constants.

/*
 * Throws a compiler error
 */
suint256 constant MY_CONSTANT = 42;

saddress

shielded address

An saddress variable has all address operations supported. As for members, it supports call, delegatecall, staticcall, code, and codehash only. You cannot have saddress payable or have saddress as a transaction signer.

saddress a = saddress(0x123);
saddress b = saddress(0x456);

// == VALID EXAMPLES
a == b  // false
b.call()

// == INVALID EXAMPLES
a.balance
payable(a)

We assume familiarity with .

Let's step through a concrete example. We'll follow the lifecycle of a transfer() tx for an variant. This variant shields user balances and transfer amounts:

The universal casting rules and restrictions described in apply.

Solidity
ERC20
Basics

suint / sint

shielded unsigned integer / shielded integer

suint256 a = suint256(10)
suint256 b = suint256(3)

// == EXAMPLES
a > b  // true
a | b  // 11
a << 2  // 40
a % b  // 1

All comparisons and operators for suint / sint are functionally identical to uint / int. The universal casting rules and restrictions described in apply.

Basics

Understanding the Walnut contract

Imagine you’re holding a walnut, an unassuming object. Inside it lies a number, a secret only revealed when you crack the shell. There are primarily two actions you can take on this walnut: either shaking it or hitting it. Shaking the walnut some n number of times increments the number inside by n, while hitting the walnut brings it one step closer to the shell cracking. You can only see the number inside if you have contributed to the cracking the shell, i.e., you have hit the walnut at least once. This collaborative challenge is the heart of the Walnut App, and it’s all powered by the Walnut smart contract. Let’s dive into the inner workings of the contract and uncover how each part fuels this game.

The contract can be found in the packages/contracts/Walnut.sol file of the starter repo.

State variables

startShell and shell

Think of the shell as the Walnut’s durability. startShell is the Walnut’s starting strength, and shell tracks how much of it remains as players hit it.

startNumber and number

These are the secret numbers at the heart of the Walnut. startNumber initializes the hidden number, while number evolves as players shake the Walnut. Being suint256 (shielded integers), these numbers remain encrypted on-chain—visible only to authorized participants.

round

A counter that increments with each new round/reset, ensuring every round has a fresh Walnut to crack.

hitsPerRound

A mapping that records every player’s contribution to the current round, ensuring only participants can peek at the Walnut’s secret.

Functions

hit ( )

This function allows a player to hit the Walnut, reducing its durability and bringing it one step closer to cracking:

What happens:

  • Checks if the shell is intact (shell>0 )

  • If it is, decrements shell by 1

  • Increases the player who called hit() 's contribution in the current round (hitsPerRound[round][playerAddress]) by 1

  • Emits the Hit event to update all participants.

shake (suint256 _numShakes)

This function allows a player to shake the walnut _numShakes number of times. Since this is a write function that takes in an stype as one of its parameters, calling this function would constitute a Seismic write.

What happens:

  • Adds _numShakes to number

  • Emits the Shake event.

look ( )

This function allows contributors to the current round to view the number inside the walnut. Since this is a view function that reveals an stype, calling this function would constitute a Seismic read.

What happens:

  • Requires the shell to be cracked (requireCracked modifier)

  • Ensures the function caller contributed to the cracking the walnut for this round (onlyContributor modifier)

  • Returns ("reveals") the number inside the walnut.

Modifiers

Modifiers enforce the rules of the game:

requireCracked

Ensures that look() can only be called if the Walnut’s shell is completely cracked.

requireIntact

Ensures that shake() and hit() can only be called if the Walnut’s shell is intact.

onlyContributor

Restricts access to look() , and hence the number being revealed, only to players who contributed at least one hit in the current round.

Collections

Using stype variables in arrays and maps

suint256[] a;  // stype as value
function f(suint256 idx) {
    a[idx]  // stype as key
    // ...
}

// ==========

mapping(saddress => suint256) m;  // stype as key and value
function d(suint256 k) {
    m[k]
}

What's special here is that you can hold on to a[idx] and m[k] without observers knowing which values in the collection they refer to. You can read from these references:

sbool b = a[idx] < 10;
suint256 s = m[k] + 10;

You can write to these references:

a[idx] *= 3;
m[k] += a[idx];

Observers for any of these operations will not know which elements were read from / written to.

In the previous section, we only knew how to shield what was happening for certain elements. Now, we know how to shield which elements are being modified in the first place.

mapping(saddress => suint256) public balanceOf;  // key is now saddress

function transfer(saddress to, suint256 amount) public {  // recipient now saddress
    balanceOf[msg.sender] -= amount;
    balanceOf[to] += amount;
}

All stype variables can be stored in Solidity collections, much like their unshielded counterparts. They behave normally (as outlined in ) when used as values in these collections. It's when they're used as both the keys and values where it gets interesting. This applies to arrays and maps in particular:

We can take the ERC20 variant discussed in the section and extend it further to shielded balances, transfer amounts, and now recipients.

Basics
Basics

sbool

shielded boolean

sbool a = sbool(true)
sbool b = sbool(false)

// == EXAMPLES
a && b  // false
!b  // true

All comparisons and operators for sbool function identically to bool. The universal casting rules and restrictions described in apply.

We recommend reading the point on conditional execution in prior to using sbool since it's easy to accidentally leak information with this type.

Basics
Common mistakes

Devnet

Try out the developer testnet

Deploy an encrypted contract

1. Install Rust

curl https://sh.rustup.rs -sSf | sh  # choose default, just press enter
. "$HOME/.cargo/env"

2. Install jq

brew install jq

3. Install sfoundryup

curl -L \
     -H "Accept: application/vnd.github.v3.raw" \
     "https://api.github.com/repos/SeismicSystems/seismic-foundry/contents/sfoundryup/install?ref=seismic" | bash
source ~/.bashrc

4. Run sfoundryup

sfoundryup  # takes between 5m to 60m, and stalling for a while at 98% normal

5. Clone repository

git clone --recurse-submodules https://github.com/SeismicSystems/try-devnet.git
cd try-devnet/packages/contract/

6. Deploy contract

bash script/deploy.sh

Interact with an encrypted contract

1. Install Bun

curl -fsSL https://bun.sh/install | bash

2. Install node dependencies

cd try-devnet/packages/cli/
bun install

3. Send transactions

bash script/transact.sh

FAQ

What if I'm on Windows?
wsl --install

Now restart your computer. After booting back up, you should be able to run the below command and follow the rest of the steps like normal

wsl
I'm stuck at 1108/1112 when running sfoundryup .
I'm getting Command failed: cargo build --bins --release.

Means your machine doesn't have cargo. If you're on Linux, run

sudo apt update && sudo apt install -y build-essential
sudo apt install cargo -y
I'm getting jq (command not found).
sudo apt-get install jq
I'm getting Address not funded. Please check if your faucet transaction went...
I'm getting Command 'brew' not found.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
I'm getting linker 'cc' not found.

You can resolve by running

sudo apt update && sudo apt install -y build-essential
sudo apt install cargo -y
I'm getting command not found: sfoundryup .
I'm getting info: aborting installation .

Means you aren't selecting an option for your Rust installation. Run the curl command again, and press Enter.

I'm getting Command: 'bun' not found.

You need to add bun to your PATH. You can either do this temporarily in your current terminal via the below command (you'll have to do it for every new window):

export PATH="/home/$(whoami)/.bun/bin:$PATH"

Or set it properly, by opening up your ~/.bashrc and adding

PATH="/home/$(whoami)/.bun/bin:$PATH"

View official links

Item
Value

Network Name

Seismic devnet

Currency Symbol

ETH

Chain ID

5124

RPC URL (HTTP)

RPC URL (WS)

Explorer

Faucet

Starter Repo

NOTE: This is a testnet with known decryption keys. Please don't put real information on it!

Welcome! This walkthrough is quick. It only requires a minute of actual attention, while the rest is waiting. If you run into any issues, please check if it's one of the 10 common errors resolved in the section. You can also hop in and ask questions in the #devnet channel.

If you end up deploying your own custom contract, please send the github link to on TG! Also note, this is not an incentivized testnet.

Works on Mac, Linux, and Windows via WSL (see ).

For Mac. See instructions for your machine . Only step that isn't OS agnostic.

We recommend using to run commands as if you were on a Linux machine. Run

Some machines take up to an hour to do this step. If it takes longer, ask a question in #devnet channel.

Means didn't work. If you're on Linux, run

Means your wallet has no testnet ETH. Please go to the , enter the address the script gave you, and wait for the green confirmation.

Means your machine doesn't have the package manager. Run

If this comes up even after you complete successfully, restart your terminal. Should be able to run it after.

@lyronc
here
WSL
our discord's
faucet
Homebrew
our discord
FAQ
FAQ
step #2
step #3
https://node-2.seismicdev.net/rpc
wss://node-2.seismicdev.net/ws
https://explorer-2.seismicdev.net/
https://faucet-2.seismicdev.net/
https://github.com/SeismicSystems/seismic-starter

Clients

Constructing transactions with stype variables

Observers see 0x000 in place of stype variables during transaction submission, execution, and storage.
Using an stype as the key and value to a collection shields which element you're using.

Our viem docs can be found .

here