Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Learn how to build, deploy, and play with the Walnut App, your first Seismic-powered shielded contract game, in this hands-on tutorial.
Seismic is an encrypted blockchain
If you're interested in learning, we suggest:
Understanding the core concepts for interacting with Seismic.
Reading through our implementation on github.
If you're interested in building, we suggest:
Setting up your local via installation, then deploying via quickstart.
Asking a friend (or @seismicMatt) to add you to our invite-only developer TG group.
Deriving inspiration from our early prototype contracts.
If you're interested in community, we suggest:
Talking to us in our discord channel.
If you end up writing a contract with Seismic, please send the github link to @lyronc on TG! I'd love to chat.
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.
You're two commands away from running an encrypted protocol
You can play around with stype
using our starter repository. This assumes you went through everything in Installation.
git clone "https://[email protected]/SeismicSystems/seismic-starter.git"
cd seismic-starter/packages/contracts
sforge test -vv
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
A tech-centric overview of Seismic
We've restructured the modern blockchain stack around secure hardware. The major components are as follows:
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.
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.
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.
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.
Setting up your local machine to develop with Seismic
The local development suite uses sforge
as the testing framework, sanvil
as the local node, and ssolc
as the compiler.
Install [ / / / ] on your machine if you don't already have them. Default installations for all work well.
Download and execute the sfoundryup installation script.
Install sforge
, sanvil
, ssolc
. Expect this to take between 5-20 minutes depending on your machine.
Remove old build artifacts in existing projects.
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.
Navigate to the contracts subdirectory:
Initialize a project with sforge
:
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)
Edit the .gitignore
file to be the following:
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 ):
These files are empty for now, but we will add to them as we go along.
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.
Navigate to the contracts subdirectory:
Initialize a new bun project
Now, create an src/
folder and move index.ts
there.
Now, edit package.json
to be the following:
Edit .gitignore
to be:
Your environment is now set!
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.
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.
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.
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.
The universal casting rules and restrictions described in apply.
cd packages/contracts
sforge init --no-commit && rm -rf .github
.env
broadcast/
cache/
# 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
# Assuming you are currently in the contracts directory
cd ../cli
bun init -y
mkdir -p src && mv -t src index.ts
{
"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"
}
}
node_modules
# install rust and cargo
curl https://sh.rustup.rs -sSf | sh
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
sfoundryup
source ~/.zshenv # or ~/.bashrc or ~/.zshrc
sforge clean # run in your project's contract directory
saddress a = saddress(0x123);
saddress b = saddress(0x456);
// == VALID EXAMPLES
a == b // false
b.call()
// == INVALID EXAMPLES
a.balance
payable(a)
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.
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.
}
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");
_;
}
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 hit
decreases 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.
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
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"
.
// 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");
_;
}
}
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.
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.
In a separate terminal window, run
sanvil
in order to spin up a local Seismic node.
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.
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!
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.
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.
shielded unsigned integer / shielded integer
All comparisons and operators for suint
/ sint
are functionally identical to uint
/ int
. The universal casting rules and restrictions described in Basics apply.
suint256 a = suint256(10)
suint256 b = suint256(3)
// == EXAMPLES
a > b // true
a | b // 11
a << 2 // 40
a % b // 1
Before continuing, ensure that you have completed the steps in the Installation section to install all necessary Seismic developer tools:
• 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.
Also ensure that you have bun installed on your machine. If you do not have bun
installed, follow the instructions here to install it on your machine.
shielded boolean
All comparisons and operators for sbool
function identically to bool
. The universal casting rules and restrictions described in Basics apply.
We recommend reading the point on conditional execution in Common mistakes prior to using sbool
since it's easy to accidentally leak information with this type.
sbool a = sbool(true)
sbool b = sbool(false)
// == EXAMPLES
a && b // false
!b // true
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/cli
with the files constants.ts
and utils.ts
and navigate to it:
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
:
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
:
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.
mkdir -p packages/cli/lib
touch packages/cli/lib/constants.ts packages/cli/lib/utils.ts
cd packages/cli/lib
import { join } from 'path'
const CONTRACT_NAME = 'Walnut'
const CONTRACT_DIR = join(__dirname, '../../contracts')
export { CONTRACT_NAME, CONTRACT_DIR }
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 }
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.
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.
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.
A counter that increments with each new round/reset, ensuring every round has a fresh Walnut to crack.
A mapping that records every player’s contribution to the current round, ensuring only participants can peek at the Walnut’s secret.
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.
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.
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 enforce the rules of the game:
Ensures that look()
can only be called if the Walnut’s shell is completely cracked.
Ensures that shake()
and hit()
can only be called if the Walnut’s shell is intact.
Restricts access to look()
, and hence the number
being revealed, only to players who contributed at least one hit in the current round.
Create the project folder and navigate into it:
mkdir walnut-app
cd walnut-app
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.
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.
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"
}
}
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
}
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/
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
A handle on stype unlocks all shielded computation and storage
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
.
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:
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:
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.
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.
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.
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.
There are two restrictions in how you can use stype
variables:
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.
You can't use them as constants.
mapping(address => suint256) public balanceOf; // shielded balance
function transfer(address to, suint256 amount) public { // shielded transfer amount
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
}
uint256 number = 100;
suint256 sNumber = suint256(number);
/*
* Throws a compiler error
*/
suint256 public v;
// ==========
/*
* Throws a compiler error
*/
function f() external view returns (suint256) {}
/*
* Throws a compiler error
*/
suint256 constant MY_CONSTANT = 42;
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.
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.
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.
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.
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.
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!
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
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'
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
}
}
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
}
}
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
}
}
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
}
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)
}
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:
Privacy. Your financial activity cannot be monitored by any third party. Not your payment processor, not your government.
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.
Using stype variables in arrays and maps
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:
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:
You can write to these references:
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.
We can take the ERC20 variant discussed in the section and extend it further to shielded balances, transfer amounts, and now recipients.
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]
}
sbool b = a[idx] < 10;
suint256 s = m[k] + 10;
a[idx] *= 3;
m[k] += a[idx];
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;
}
stype
as the key and value to a collection shields which element you're using.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
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
: 31337
is 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_PRIVKEY
and 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
)
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.ts
and follow these steps:
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()
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)
}
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`
)
Determine whether to use the local sanvil
node (31337) or the Seismic devnet.
const chain =
process.env.CHAIN_ID === anvil.id.toString() ? anvil : seismicDevnet
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! },
]
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()
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')
}
}
This ensures that the script runs when executed.
main()
The entire index.ts
file can be found here
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.
Congratulations! You've reached the end of the tutorial. You can find the code for the entire project here.
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.
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.
Start off with testing the basic functionalities, hit
, shake
, look
and reset
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
}
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
}
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.
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();
}
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));
}
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();
}
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.
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
}
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();
}
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();
}
You can find the entire test file here.
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!
Try out the developer testnet
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 FAQ section. You can also hop in our discord and ask questions in the #devnet
channel.
If you end up deploying your own custom contract, please send the github link to @lyronc on TG! Also note, this is not an incentivized testnet.
Works on Mac, Linux, and Windows via WSL (see FAQ).
curl https://sh.rustup.rs -sSf | sh # choose default, just press enter
. "$HOME/.cargo/env"
For Mac. See instructions for your machine here. Only step that isn't OS agnostic.
brew install jq
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
sfoundryup # takes between 5m to 60m, and stalling for a while at 98% normal
git clone --recurse-submodules https://github.com/SeismicSystems/try-devnet.git
cd try-devnet/packages/contract/
bash script/deploy.sh
curl -fsSL https://bun.sh/install | bash
cd try-devnet/packages/cli/
bun install
bash script/transact.sh
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!
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
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:
createShieldedWalletClient
takes in the following parameters:
chain
: a well-defined object
transport
: the method of transport of interacting with the chain (http
/ws
along with the corresponding RPC URL)
privateKey
: the private key to create the client for
Once initialized, it can then be used to perform wallet operations or shielded-specific
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:
It takes in the following parameters:
abi
: the ABI of the contract it is interacting with.
address
: the address of the deployed contract it is interacting with.
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.
We will extensively use shielded writes (for shake
) and shielded reads (for look()
) in our CLI.
const walletClient = await createShieldedWalletClient({
chain: seismicChain,
transport: httpTransport,
privateKey: '0xabcdef...',
})
const contract = getShieldedContract({
abi: myContractAbi,
address: '0x1234...',
client: shieldedWalletClient,
})
// 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)
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:
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.
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.
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.