⚠️ UNDER HEAVY DEVELOPMENT ⚠️
This documentation is actively being developed and may change frequently.
Introduction
Devnet SDK
The Devnet SDK is a comprehensive toolkit designed to standardize and simplify interactions with Optimism devnets. It provides a robust set of tools and interfaces for deploying, managing, and testing Optimism networks in development environments.
Core Components
1. Devnet Descriptors
The descriptors package defines a standard interface for describing and interacting with devnets. It provides:
- Structured representation of devnet environments including L1 and L2 chains
- Service discovery and endpoint management
- Wallet and address management
- Standardized configuration for chain components (nodes, services, endpoints)
2. Shell Integration
The shell package provides a preconfigured shell environment for interacting with devnets. For example, you can quickly:
- Launch a shell with all environment variables set and run commands like
cast balance <address>
that automatically use the correct RPC endpoints - Access chain-specific configuration like JWT secrets and contract addresses
This makes it easy to interact with your devnet without manually configuring tools or managing connection details.
3. System Interface
The system package provides a devnet-agnostic programmatic interface, constructed for example from the descriptors above, for interacting with Optimism networks. Key features include:
- Unified interface for L1 and L2 chain interactions
- Transaction management and processing
- Wallet management and operations
- Contract interaction capabilities
- Interoperability features between different L2 chains
Core interfaces include:
System
: Represents a complete Optimism system with L1 and L2 chainsChain
: Provides access to chain-specific operationsWallet
: Manages accounts, transactions, and signing operationsTransaction
: Handles transaction creation and processing
4. Testing Framework
The testing package provides a comprehensive framework for testing devnet deployments:
- Standardized testing utilities
- System test capabilities
- Integration test helpers
- Test fixtures and utilities
Devnet Descriptors
The devnet descriptor is a standardized format that describes the complete topology and configuration of an Optimism devnet deployment. This standard serves as a bridge between different devnet implementations and the higher-level tooling provided by the devnet-sdk.
Universal Descriptor Format
Both kurtosis-devnet
and netchef
emit the same descriptor format, despite being completely different devnet implementations:
- kurtosis-devnet: Uses Kurtosis to orchestrate containerized devnet deployments
- netchef: Provides a lightweight, local devnet deployment
This standardization enables a powerful ecosystem where tools can be built independently of the underlying devnet implementation.
Descriptor Structure
A devnet descriptor provides a complete view of a running devnet:
{
"l1": {
"name": "l1",
"id": "900",
"services": {
"geth": {
"name": "geth",
"endpoints": {
"rpc": {
"host": "localhost",
"port": 8545
}
}
}
},
"nodes": [...],
"addresses": {
"deployer": "0x...",
"admin": "0x..."
},
"wallets": {
"admin": {
"address": "0x...",
"private_key": "0x..."
}
}
},
"l2": [
{
"name": "op-sepolia",
"id": "11155420",
"services": {...},
"nodes": [...],
"addresses": {...},
"wallets": {...}
}
],
"features": ["eip1559", "shanghai"]
}
Enabling Devnet-Agnostic Tooling
The power of the descriptor format lies in its ability to make any compliant devnet implementation immediately accessible to the entire devnet-sdk toolset:
- Universal Interface: Any devnet that can emit this descriptor format can be managed through devnet-sdk's tools
- Automatic Integration: New devnet implementations only need to implement the descriptor format to gain access to:
- System interface for chain interaction
- Testing framework
- Shell integration tools
- Wallet management
- Transaction processing
Benefits
This standardization provides several key advantages:
- Portability: Tools built against the devnet-sdk work with any compliant devnet implementation
- Consistency: Developers get the same experience regardless of the underlying devnet
- Extensibility: New devnet implementations can focus on deployment mechanics while leveraging existing tooling
- Interoperability: Tools can be built that work across different devnet implementations
Implementation Requirements
To make a devnet implementation compatible with devnet-sdk, it needs to:
- Provide a mechanism to output the descriptor (typically as JSON)
- Ensure all required services and endpoints are properly described
Once these requirements are met, the devnet automatically gains access to the full suite of devnet-sdk capabilities.
Status
The descriptor format is currently in active development, particularly regarding endpoint specifications:
Endpoint Requirements
- Current State: The format does not strictly specify which endpoints must be included in a compliant descriptor
- Minimum Known Requirements:
- RPC endpoints are essential for basic chain interaction
- Other endpoints may be optional depending on use case
Implementation Notes
kurtosis-devnet
currently outputs all service endpoints by default, including many that may not be necessary for testing- Other devnet implementations can be more selective about which endpoints they expose
- Different testing scenarios may require different sets of endpoints
Future Development
We plan to develop more specific recommendations for:
- Required vs optional endpoints
- Standard endpoint naming conventions
- Service-specific endpoint requirements
- Best practices for endpoint exposure
Until these specifications are finalized, devnet implementations should:
- Always include RPC endpoints
- Document which additional endpoints they expose
- Consider their specific use cases when deciding which endpoints to include
Example Usage
Here's how a tool might use the descriptor to interact with any compliant devnet:
// Load descriptor from any compliant devnet
descriptor, err := descriptors.Load("devnet.json")
if err != nil {
log.Fatal(err)
}
// Use the descriptor with devnet-sdk tools
system, err := system.FromDescriptor(descriptor)
if err != nil {
log.Fatal(err)
}
// Now you can use all devnet-sdk features
l1 := system.L1()
l2 := system.L2(descriptor.L2[0].ID)
This standardization enables a rich ecosystem of tools that work consistently across different devnet implementations, making development and testing more efficient and reliable.
Shell Integration
The devnet-sdk provides powerful shell integration capabilities that allow developers to "enter" a devnet environment, making interactions with the network more intuitive and streamlined.
Devnet Shell Environment
Using a devnet's descriptor, we can create a shell environment that is automatically configured with all the necessary context to interact with the devnet:
# Enter a shell configured for your devnet
devnet-sdk shell --descriptor path/to/devnet.json
Automatic Configuration
When you enter a devnet shell, the environment is automatically configured with:
- Environment variables for RPC endpoints
- JWT authentication tokens where required
- Named wallet addresses
- Chain IDs
- Other devnet-specific configuration
Simplified Tool Usage
This automatic configuration enables seamless use of Ethereum development tools without explicit endpoint configuration:
# Without devnet shell
cast balance 0x123... --rpc-url http://localhost:8545 --jwt-secret /path/to/jwt
# With devnet shell
cast balance 0x123... # RPC and JWT automatically configured
Supported Tools
The shell environment enhances the experience with various Ethereum development tools:
cast
: For sending transactions and querying state
Environment Variables
The shell automatically sets up standard Ethereum environment variables based on the descriptor:
# Chain enpointpoit
export ETH_RPC_URL=...
export ETH_JWT_SECRET=...
Usage Examples
# Enter devnet shell
go run devnet-sdk/shell/cmd/enter/main.go --descriptor devnet.json --chain ...
# Now you can use tools directly
cast block latest
# Exit the shell
exit
Benefits
- Simplified Workflow: No need to manually configure RPC endpoints or authentication
- Consistent Environment: Same configuration across all tools and commands
- Reduced Error Risk: Eliminates misconfigurations and copy-paste errors
- Context Awareness: Shell knows about all chains and services in your devnet
Implementation Details
The shell integration:
- Reads the descriptor file
- Sets up environment variables based on the descriptor content
- Creates a new shell session with the configured environment
- Maintains the environment until you exit the shell
This feature makes it significantly easier to work with devnets by removing the need to manually manage connection details and authentication tokens.
System Interfaces
The devnet-sdk provides a set of Go interfaces that abstract away the specifics of devnet deployments, enabling automation solutions to work consistently across different deployment types and implementations.
Core Philosophy
While the Descriptor interfaces provide a common way to describe actual devnet deployments (like production-like or Kurtosis-based deployments), the System interfaces operate at a higher level of abstraction. They are designed to support both real deployments and lightweight testing environments.
The key principles are:
- Deployment-Agnostic Automation: Code written against these interfaces works with any implementation - from full deployments described by Descriptors to in-memory stacks or completely fake environments
- Flexible Testing Options: Enables testing against:
- Complete devnet deployments
- Partial mock implementations
- Fully simulated environments
- One-Way Abstraction: While Descriptors can be converted into System interfaces, System interfaces can represent additional constructs beyond what Descriptors describe
- Implementation Freedom: New deployment types or testing environments can be added without modifying existing automation code
Interface Purity
A critical design principle of these interfaces is their purity. This means that interfaces:
-
Only Reference Other Pure Interfaces: Each interface method can only return or accept:
- Other pure interfaces from this package
- Simple data objects that can be fully instantiated
- Standard Go types and primitives
-
Avoid Backend-Specific Types: The interfaces never expose types that would create dependencies on specific implementations:
// BAD: Creates dependency on specific client implementation func (c Chain) GetNodeClient() *specific.NodeClient // GOOD: Returns pure interface that can be implemented by any backend func (c Chain) Client() (ChainClient, error)
-
Use Generic Data Types: When complex data structures are needed, they are defined as pure data objects:
// Pure data type that any implementation can create type TransactionData interface { From() common.Address To() *common.Address Value() *big.Int Data() []byte }
Why Purity Matters
Interface purity is crucial because it:
- Preserves implementation freedom
- Prevents accidental coupling to specific backends
- Enables creation of new implementations without constraints
- Allows mixing different implementation types (e.g., partial fakes)
Example: Maintaining Purity
// IMPURE: Forces dependency on eth client
type Chain interface {
GetEthClient() *ethclient.Client // 👎 Locks us to specific client
}
// PURE: Allows any implementation
type Chain interface {
Client() (ChainClient, error) // 👍 Implementation-agnostic
}
type ChainClient interface {
BlockNumber(ctx context.Context) (uint64, error)
// ... other methods
}
Interface Hierarchy
System
The top-level interface representing a complete Optimism deployment:
type System interface {
// Unique identifier for this system
Identifier() string
// Access to L1 chain
L1() Chain
// Access to L2 chain(s)
L2(chainID uint64) Chain
}
Chain
Represents an individual chain (L1 or L2) within the system:
type Chain interface {
// Chain identification
RPCURL() string
ID() types.ChainID
// Core functionality
Client() (*ethclient.Client, error)
Wallets(ctx context.Context) ([]Wallet, error)
ContractsRegistry() interfaces.ContractsRegistry
// Chain capabilities
SupportsEIP(ctx context.Context, eip uint64) bool
// Transaction management
GasPrice(ctx context.Context) (*big.Int, error)
GasLimit(ctx context.Context, tx TransactionData) (uint64, error)
PendingNonceAt(ctx context.Context, address common.Address) (uint64, error)
}
Wallet
Manages accounts and transaction signing:
type Wallet interface {
// Account management
PrivateKey() types.Key
Address() types.Address
Balance() types.Balance
Nonce() uint64
// Transaction operations
Sign(tx Transaction) (Transaction, error)
Send(ctx context.Context, tx Transaction) error
// Convenience methods
SendETH(to types.Address, amount types.Balance) types.WriteInvocation[any]
Transactor() *bind.TransactOpts
}
Implementation Types
The interfaces can be implemented in various ways to suit different needs:
1. Real Deployments
- Kurtosis-based: Full containerized deployment
- Netchef: Remote devnet deployment
2. Testing Implementations
- In-memory: Fast, lightweight implementation for unit tests
- Mocks: Controlled behavior for specific test scenarios
- Recording: Record and replay real interactions
3. Specialized Implementations
- Partial: Combining pieces from fake and real deployments
- Filtered: Limited functionality for specific use cases
- Instrumented: Added logging/metrics for debugging
Usage Examples
Writing Tests
The System interfaces are primarily used through our testing framework. See the Testing Framework documentation for detailed examples and best practices.
Creating a Mock Implementation
type MockSystem struct {
l1 *MockChain
l2Map map[uint64]*MockChain
}
func NewMockSystem() *MockSystem {
return &MockSystem{
l1: NewMockChain(),
l2Map: make(map[uint64]*MockChain),
}
}
// Implement System interface...
Benefits
- Abstraction: Automation code is isolated from deployment details
- Flexibility: Easy to add new deployment types
- Testability: Support for various testing approaches
- Consistency: Same interface across all implementations
- Extensibility: Can add specialized implementations for specific needs
Best Practices
- Write Against Interfaces: Never depend on specific implementations
- Use Context: For proper cancellation and timeouts
- Handle Errors: All operations can fail
- Test Multiple Implementations: Ensure code works across different types
- Consider Performance: Choose appropriate implementation for use case
The System interfaces provide a powerful abstraction layer that enables writing robust, deployment-agnostic automation code while supporting a wide range of implementation types for different use cases.
Testing Framework
The devnet-sdk provides a comprehensive testing framework designed to make testing against Optimism devnets both powerful and developer-friendly.
Testing Philosophy
Our testing approach is built on several key principles:
1. Native Go Tests
Tests are written as standard Go tests, providing:
- Full IDE integration
- Native debugging capabilities
- Familiar testing patterns
- Integration with standard Go tooling
func TestSystemWrapETH(t *testing.T) {
// Standard Go test function
systest.SystemTest(t, wrapETHScenario(...))
}
2. Safe Test Execution
Tests are designed to be safe and self-aware:
- Tests verify their prerequisites before execution
- Tests skip gracefully when prerequisites aren't met
- Clear distinction between precondition failures and test failures
// Test will skip if the system doesn't support required features
walletGetter, fundsValidator := validators.AcquireL2WalletWithFunds(
chainIdx,
types.NewBalance(big.NewInt(1.0 * constants.ETH)),
)
3. Testable Scenarios
Test scenarios themselves are designed to be testable:
- Scenarios work against any compliant System implementation
- Mocks and fakes can be used for scenario validation
- Clear separation between test logic and system interaction
4. Framework Integration
The systest
package provides integration helpers that:
- Handle system acquisition and setup
- Manage test context and cleanup
- Provide precondition validation
- Enable consistent test patterns
Example Test
Here's a complete example showing these principles in action:
import (
"math/big"
"github.com/ethereum-optimism/optimism/devnet-sdk/contracts/constants"
"github.com/ethereum-optimism/optimism/devnet-sdk/system"
"github.com/ethereum-optimism/optimism/devnet-sdk/testing/systest"
"github.com/ethereum-optimism/optimism/devnet-sdk/testing/testlib/validators"
"github.com/ethereum-optimism/optimism/devnet-sdk/types"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
// Define test scenario as a function that works with any System implementation
func wrapETHScenario(chainIdx uint64, walletGetter validators.WalletGetter) systest.SystemTestFunc {
return func(t systest.T, sys system.System) {
ctx := t.Context()
logger := testlog.Logger(t, log.LevelInfo)
logger := logger.With("test", "WrapETH", "devnet", sys.Identifier())
// Get the L2 chain we want to test with
chain := sys.L2(chainIdx)
logger = logger.With("chain", chain.ID())
// Get a funded wallet for testing
user := walletGetter(ctx)
// Access contract registry
wethAddr := constants.SuperchainWETH
weth, err := chain.ContractsRegistry().SuperchainWETH(wethAddr)
require.NoError(t, err)
// Test logic using pure interfaces
funds := types.NewBalance(big.NewInt(0.5 * constants.ETH))
initialBalance, err := weth.BalanceOf(user.Address()).Call(ctx)
require.NoError(t, err)
require.NoError(t, user.SendETH(wethAddr, funds).Send(ctx).Wait())
finalBalance, err := weth.BalanceOf(user.Address()).Call(ctx)
require.NoError(t, err)
require.Equal(t, initialBalance.Add(funds), finalBalance)
}
}
func TestSystemWrapETH(t *testing.T) {
chainIdx := uint64(0) // First L2 chain
// Setup wallet with required funds - this acts as a precondition
walletGetter, fundsValidator := validators.AcquireL2WalletWithFunds(
chainIdx,
types.NewBalance(big.NewInt(1.0 * constants.ETH)),
)
// Run the test with system management handled by framework
systest.SystemTest(t,
wrapETHScenario(chainIdx, walletGetter),
fundsValidator,
)
}
Framework Components
1. Test Context Management
The framework provides context management through systest.T
:
- Proper test timeouts
- Cleanup handling
- Resource management
- Logging context
2. Precondition Validators
Validators ensure test prerequisites are met:
// Validator ensures required funds are available
fundsValidator := validators.AcquireL2WalletWithFunds(...)
3. System Acquisition
The framework handles system creation and setup:
systest.SystemTest(t, func(t systest.T, sys system.System) {
// System is ready to use
})
4. Resource Management
Resources are properly managed:
- Automatic cleanup
- Proper error handling
- Context cancellation
Best Practices
- Use Scenarios: Write reusable test scenarios that work with any System implementation
- Validate Prerequisites: Always check test prerequisites using validators
- Handle Resources: Use the framework's resource management
- Use Pure Interfaces: Write tests against the interfaces, not specific implementations
- Proper Logging: Use structured logging with test context
- Clear Setup: Keep test setup clear and explicit
- Error Handling: Always handle errors and provide clear failure messages
Introduction
The devnet-sdk DSL is a high level test library, specifically designed for end to end / acceptance testing of the OP Stack. It aims to make the development and maintenance of whole system tests faster and easier.
The high level API helps make the actual test read in a more declarative style and separate the technical details of how an action is actually performed. The intended result is that tests express the requirements, while the DSL provides the technical details of how those requirements are met. This ensures that as the technical details change, the DSL can be updated rather than requiring that each test be updated individual - significantly reducing the maintenance cost for a large test suite. Similarly, if there is flakiness in tests, it can often be solved by improving the DSL to properly wait for pre or post conditions or automatically perform required setup steps and that fix is automatically applied everywhere, including tests added in the future.
Guiding Principles
These guiding principles allow the test suite to evolve and grow over time in a way that ensures the tests are maintainable and continue to be easy to write. With multiple different teams contributing to tests, over a long time period, shared principles are required to avoid many divergent approaches and frameworks emerging which increase the cognitive load for developers writing tests and increase the maintenance costs for existing tests.
Keep It Simple
Avoid attempting to make the DSL read like plain English. This is a domain-specific language and the domain experts are actually the test developers, not non-technical users. Each statement should clearly describe what it is trying to do, but does not need to read like an English sentence.
Bias very strongly towards making the tests simpler, even if the DSL implementation then needs to be more complex. Complexity in tests will be duplicated for each test case whereas complexity in the DSL is more centralised and is encapsulated so it is much less likely to be a distraction.
Consistency
The "language" of the DSL emerges by being consistent in the structures and naming used for things. Take the time to refactor things to ensure that the same name is used consistently for a concept right across the DSL.
Bias towards following established patterns rather than doing something new. While introducing a new pattern might make things cleaner in a particular test, it introduces additional cognitive load for people to understand when working with the tests. It is usually (but not always) better to preserve consistency than to have a marginally nicer solution for one specific scenario.
The style guide defines a set of common patterns and guidelines that should be followed.
DSL Style Guide
This style guide outlines common patterns and anti-patterns used by the testing DSL. Following this guide not only improves consistency, it helps keep the separation of requirements (in test files) from implementation details (in DSL implementation), which in turn ensures tests are maintainable even as the number of tests keeps increasing over time.
Entry Points
What are the key entry points for the system? Nodes/services, users, contracts??
Action Methods
Methods that perform actions will typically have three steps:
- Check (and if needed, wait) for any required preconditions
- Perform the action, allowing components to fully process the effects of it
- Assert that the action completed. These are intended to be a sanity check to ensure tests fail fast if something doesn't work as expected. Options may be provided to perform more detailed or specific assertions
Verification Methods
Verification methods in the DSL provide additional assertions about the state of the system, beyond the minimal assertions performed by action methods.
Verification methods should include any required waiting or retrying.
Verification methods should generally only be used in tests to assert the specific behaviours the test is covering. Avoid adding additional verification steps in a test to assert that setup actions were performed correctly - such assertions should be built into the action methods. While sanity checking setup can be useful, adding additional verification method calls into tests makes it harder to see what the test is actually intending to cover and increases the number of places that need to be updated if the behaviour being verified changes in the future.
Avoid Getter Methods
The DSL generally avoids exposing methods that return data from the system state. Instead verification methods are exposed which combine the fetching and assertion of the data. This allows the DSL to handle any waiting or retrying that may be necessary (or become necessary). This avoids a common source of flakiness where tests assume an asynchronous operation will have completed instead of explicitly waiting for the expected final state.
// Avoid: the balance of an account is data from the system which changes over time
block := node.GetBalance(user)
// Good: use a verification method
node.VerifyBalance(user, 10 * constants.Eth)
// Better? Select the entry point to be as declarative as possible
user.VerifyBalacne(10 * constants.Eth) // implementation could verify balance on all nodes automatically
Note however that this doesn't mean that DSL methods never return anything. While returning raw data is avoided, returning objects that represent something in the system is ok. e.g.
claim := game.RootClaim()
// Waits for op-challenger to counter the root claim and returns a value representing that counter claim
// which can expose further verification or action methods.
counter := claim.VerifyCountered()
counter.VerifyClaimant(honestChallenger)
counter.Attack()
Method Arguments
Required inputs to methods are specified as normal parameters, so type checking enforces their presence.
Optional inputs to methods are specified by a config struct and accept a vararg of functions that can update that struct. This is roughly inline with the typical opts pattern in Golang but with significantly reduced boilerplate code since so many methods wil define their own config. With* methods are only provided for the most common optional args and tests will normally supply a custom function that sets all the optional values they need at once.
Logging
Include logging to indicate what the test is doing within the DSL methods.
Methods that wait should log what they are waiting for and the current state of the system on each poll cycle.
No Sleeps
Neither tests nor DSL code should use hard coded sleeps. CI systems tend to be under heavy and unpredictable load so short sleep times lead to flaky tests when the system is slower than expected. Long sleeps waste time, causing test runs to be too slow. By using a waiter pattern, a long timeout can be applied to avoid flakiness, while allowing the test to progress quickly once the condition is met.
// Avoid: arbitrary delays
node.DoSomething()
time.Sleep(2 * time.Minute)
node.VerifyResult()
// Good: build wait/retry loops into the testlib method
node.DoSomething()
node.VerifyResult() // Automatically waits
Test Smells
"Smells" are patterns that indicate there may be a problem. They aren't hard rules, but indicate that something may not be right and the developer should take a little time to consider if there are better alternatives.
Comment and Code Block
Where possible, test code should be self-explanatory with testlib method calls that are high level enough to not need comments explaining what they do in the test. When comments are required to explain simple setup, it's an indication that the testlib method is either poorly named or that a higher level method should be introduced.
// Smelly: Test code is far too low level and needs to be explained with a comment
// Deploy test contract
storeProgram := program.New().Sstore(0, 0xbeef).Bytes()
walletv2, err := system.NewWalletV2FromWalletAndChain(ctx, wallet, l2Chain)
require.NoError(t, err)
storeAddr, err := DeployProgram(ctx, walletv2, storeProgram)
require.NoError(t, err)
code, err := l2Client.CodeAt(ctx, storeAddr, nil)
require.NoError(t, err)
require.NotEmpty(t, code, "Store contract not deployed")
require.Equal(t, code, storeProgram, "Store contract code incorrect")
// Good: Introduce a testlib method to encapsulate the detail and keep the test high level
contract := contracts.SStoreContract.Deploy(l2Node, 0xbeef)
However, not all comments are bad:
// Good: Explain the calculations behind specific numbers
// operatorFeeCharged = gasUsed * operatorFeeScalar == 1000 * 5 == 5000
tx.VerifyOperatorFeeCharged(5000)