Commit 2a903ea4 authored by Yann Hodique's avatar Yann Hodique Committed by GitHub

feat(kurtosis-devnet): extract information from op-deployer artifact (#13492)

This is in service of outputting useful information at the end of the
deployment.

Here we get back some useful wallets and addresses for downstream
consumption.
parent 4c162a26
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis/sources/deployer"
)
func main() {
// Parse command line flags
enclave := flag.String("enclave", "", "Name of the Kurtosis enclave")
flag.Parse()
if *enclave == "" {
fmt.Fprintln(os.Stderr, "Error: --enclave flag is required")
flag.Usage()
os.Exit(1)
}
// Get deployer data
d := deployer.NewDeployer(*enclave)
ctx := context.Background()
data, err := d.ExtractData(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing deployer data: %v\n", err)
os.Exit(1)
}
// Encode as JSON and write to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
}
package deployer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/big"
"os/exec"
"strings"
)
const (
defaultDeployerArtifactName = "op-deployer-configs"
defaultWalletsName = "wallets.json"
defaultStateName = "state.json"
defaultGenesisArtifactName = "el_cl_genesis_data"
defaultMnemonicsName = "mnemonics.yaml"
)
// DeploymentAddresses maps contract names to their addresses
type DeploymentAddresses map[string]string
// DeploymentStateAddresses maps chain IDs to their contract addresses
type DeploymentStateAddresses map[string]DeploymentAddresses
// StateFile represents the structure of the state.json file
type StateFile struct {
OpChainDeployments []map[string]interface{} `json:"opChainDeployments"`
}
// Wallet represents a wallet with optional private key and name
type Wallet struct {
Address string
PrivateKey string
Name string
}
// WalletList holds a list of wallets
type WalletList []*Wallet
type DeployerData struct {
Wallets WalletList
State DeploymentStateAddresses
}
type Deployer struct {
enclave string
deployerArtifactName string
walletsName string
stateName string
genesisArtifactName string
mnemonicsName string
}
type DeployerOption func(*Deployer)
func WithArtifactName(name string) DeployerOption {
return func(d *Deployer) {
d.deployerArtifactName = name
}
}
func WithWalletsName(name string) DeployerOption {
return func(d *Deployer) {
d.walletsName = name
}
}
func WithStateName(name string) DeployerOption {
return func(d *Deployer) {
d.stateName = name
}
}
func WithGenesisArtifactName(name string) DeployerOption {
return func(d *Deployer) {
d.genesisArtifactName = name
}
}
func WithMnemonicsName(name string) DeployerOption {
return func(d *Deployer) {
d.mnemonicsName = name
}
}
func NewDeployer(enclave string, opts ...DeployerOption) *Deployer {
d := &Deployer{
enclave: enclave,
deployerArtifactName: defaultDeployerArtifactName,
walletsName: defaultWalletsName,
stateName: defaultStateName,
genesisArtifactName: defaultGenesisArtifactName,
mnemonicsName: defaultMnemonicsName,
}
for _, opt := range opts {
opt(d)
}
return d
}
// parseWalletsFile parses a JSON file containing wallet information
func parseWalletsFile(r io.Reader) (WalletList, error) {
// Read all data from reader
data, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("failed to read wallet file: %w", err)
}
// Unmarshal into a map first
var rawData map[string]string
if err := json.Unmarshal(data, &rawData); err != nil {
return nil, fmt.Errorf("failed to decode wallet file: %w", err)
}
// Create a map to store wallets by name
walletMap := make(map[string]Wallet)
// Process each key-value pair
for key, value := range rawData {
if strings.HasSuffix(key, "Address") {
name := strings.TrimSuffix(key, "Address")
wallet := walletMap[name]
wallet.Address = value
wallet.Name = name
walletMap[name] = wallet
} else if strings.HasSuffix(key, "PrivateKey") {
name := strings.TrimSuffix(key, "PrivateKey")
wallet := walletMap[name]
wallet.PrivateKey = value
wallet.Name = name
walletMap[name] = wallet
}
}
// Convert map to list
result := make(WalletList, 0, len(walletMap))
for _, wallet := range walletMap {
// Only include wallets that have at least an address
if wallet.Address != "" {
result = append(result, &wallet)
}
}
return result, nil
}
// downloadArtifact downloads a kurtosis artifact to a temporary directory
// TODO: reimplement this using the kurtosis SDK
func downloadArtifact(enclave, artifact, destDir string) error {
cmd := exec.Command("kurtosis", "files", "download", enclave, artifact, destDir)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to download artifact %s: %w", artifact, err)
}
return nil
}
// hexToDecimal converts a hex string (with or without 0x prefix) to a decimal string
func hexToDecimal(hex string) (string, error) {
// Remove 0x prefix if present
hex = strings.TrimPrefix(hex, "0x")
// Parse hex string to big.Int
n := new(big.Int)
if _, ok := n.SetString(hex, 16); !ok {
return "", fmt.Errorf("invalid hex string: %s", hex)
}
// Convert to decimal string
return n.String(), nil
}
// parseStateFile parses the state.json file and extracts addresses
func parseStateFile(r io.Reader) (DeploymentStateAddresses, error) {
var state StateFile
if err := json.NewDecoder(r).Decode(&state); err != nil {
return nil, fmt.Errorf("failed to decode state file: %w", err)
}
result := make(DeploymentStateAddresses)
for _, deployment := range state.OpChainDeployments {
// Get the chain ID
idValue, ok := deployment["id"]
if !ok {
continue
}
hexID, ok := idValue.(string)
if !ok {
continue
}
// Convert hex ID to decimal
id, err := hexToDecimal(hexID)
if err != nil {
continue
}
addresses := make(DeploymentAddresses)
// Look for address fields in the deployment map
for key, value := range deployment {
if strings.HasSuffix(key, "Address") {
key = strings.TrimSuffix(key, "Address")
addresses[key] = value.(string)
}
}
if len(addresses) > 0 {
result[id] = addresses
}
}
return result, nil
}
// ExtractData downloads and parses the op-deployer state
func (d *Deployer) ExtractData(ctx context.Context) (*DeployerData, error) {
fs, err := NewEnclaveFS(ctx, d.enclave)
if err != nil {
return nil, err
}
artifact, err := fs.GetArtifact(ctx, d.deployerArtifactName)
if err != nil {
return nil, err
}
stateBuffer := bytes.NewBuffer(nil)
walletsBuffer := bytes.NewBuffer(nil)
if err := artifact.ExtractFiles(
&ArtifactFileWriter{path: d.stateName, writer: stateBuffer},
&ArtifactFileWriter{path: d.walletsName, writer: walletsBuffer},
); err != nil {
return nil, err
}
state, err := parseStateFile(stateBuffer)
if err != nil {
return nil, err
}
wallets, err := parseWalletsFile(walletsBuffer)
if err != nil {
return nil, err
}
knownWallets, err := d.getKnownWallets(ctx, fs)
if err != nil {
return nil, err
}
wallets = append(wallets, knownWallets...)
return &DeployerData{State: state, Wallets: wallets}, nil
}
package deployer
import (
"os"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseStateFile(t *testing.T) {
stateJSON := `{
"opChainDeployments": [
{
"id": "0x000000000000000000000000000000000000000000000000000000000020d5e4",
"L1CrossDomainMessengerAddress": "0x123",
"L1StandardBridgeAddress": "0x456",
"L2OutputOracleAddress": "0x789"
},
{
"id": "0x000000000000000000000000000000000000000000000000000000000020d5e5",
"L1CrossDomainMessengerAddress": "0xabc",
"L1StandardBridgeAddress": "0xdef",
"someOtherField": 123,
"L2OutputOracleAddress": "0xghi"
}
]
}`
result, err := parseStateFile(strings.NewReader(stateJSON))
require.NoError(t, err, "Failed to parse state file")
tests := []struct {
chainID string
expected DeploymentAddresses
}{
{
chainID: "2151908",
expected: DeploymentAddresses{
"L1CrossDomainMessenger": "0x123",
"L1StandardBridge": "0x456",
"L2OutputOracle": "0x789",
},
},
{
chainID: "2151909",
expected: DeploymentAddresses{
"L1CrossDomainMessenger": "0xabc",
"L1StandardBridge": "0xdef",
"L2OutputOracle": "0xghi",
},
},
}
for _, tt := range tests {
chain, ok := result[tt.chainID]
require.True(t, ok, "Chain %s not found in result", tt.chainID)
for key, expected := range tt.expected {
actual := chain[key]
require.Equal(t, expected, actual, "Chain %s, %s: expected %s, got %s", tt.chainID, key, expected, actual)
}
}
}
func TestParseStateFileErrors(t *testing.T) {
tests := []struct {
name string
json string
wantErr bool
}{
{
name: "empty json",
json: "",
wantErr: true,
},
{
name: "invalid json",
json: "{invalid",
wantErr: true,
},
{
name: "missing deployments",
json: `{
"otherField": []
}`,
wantErr: false,
},
{
name: "invalid address type",
json: `{
"opChainDeployments": [
{
"id": "3151909",
"data": {
"L1CrossDomainMessengerAddress": 123
}
}
]
}`,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseStateFile(strings.NewReader(tt.json))
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestDownloadArtifact(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "test-artifact-*")
require.NoError(t, err, "Failed to create temp dir")
defer os.RemoveAll(tmpDir)
// Test with invalid enclave
err = downloadArtifact("invalid-enclave", "invalid-artifact", tmpDir)
require.Error(t, err, "Expected error for invalid enclave")
}
func TestParseWalletsFile(t *testing.T) {
tests := []struct {
name string
input string
want WalletList
wantErr bool
}{
{
name: "successful parse",
input: `{
"proposerPrivateKey": "0xe1ec816e9ad0372e458c474a06e1e6d9e7f7985cbf642a5e5fa44be639789531",
"proposerAddress": "0xDFfA3C478Be83a91286c04721d2e5DF9A133b93F",
"batcherPrivateKey": "0x557313b816b8fb354340883edf86627b3de680a9f3e15aa1f522cbe6f9c7b967",
"batcherAddress": "0x6bd90c2a1AE00384AD9F4BcD76310F54A9CcdA11"
}`,
want: WalletList{
{
Name: "proposer",
Address: "0xDFfA3C478Be83a91286c04721d2e5DF9A133b93F",
PrivateKey: "0xe1ec816e9ad0372e458c474a06e1e6d9e7f7985cbf642a5e5fa44be639789531",
},
{
Name: "batcher",
Address: "0x6bd90c2a1AE00384AD9F4BcD76310F54A9CcdA11",
PrivateKey: "0x557313b816b8fb354340883edf86627b3de680a9f3e15aa1f522cbe6f9c7b967",
},
},
wantErr: false,
},
{
name: "address only",
input: `{
"proposerAddress": "0xDFfA3C478Be83a91286c04721d2e5DF9A133b93F"
}`,
want: WalletList{
{
Name: "proposer",
Address: "0xDFfA3C478Be83a91286c04721d2e5DF9A133b93F",
},
},
wantErr: false,
},
{
name: "private key only - should be ignored",
input: `{
"proposerPrivateKey": "0xe1ec816e9ad0372e458c474a06e1e6d9e7f7985cbf642a5e5fa44be639789531"
}`,
want: WalletList{},
wantErr: false,
},
{
name: "invalid JSON",
input: `{invalid json}`,
want: nil,
wantErr: true,
},
{
name: "empty input",
input: `{}`,
want: WalletList{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.input)
got, err := parseWalletsFile(reader)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
// Sort wallets by name for consistent comparison
sortWallets := func(wallets WalletList) {
sort.Slice(wallets, func(i, j int) bool {
return wallets[i].Name < wallets[j].Name
})
}
sortWallets(got)
sortWallets(tt.want)
require.Equal(t, tt.want, got)
})
}
}
package deployer
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"path/filepath"
"github.com/kurtosis-tech/kurtosis/api/golang/core/lib/enclaves"
"github.com/kurtosis-tech/kurtosis/api/golang/engine/lib/kurtosis_context"
)
type EnclaveFS struct {
enclaveCtx *enclaves.EnclaveContext
}
func NewEnclaveFS(ctx context.Context, enclave string) (*EnclaveFS, error) {
kurtosisCtx, err := kurtosis_context.NewKurtosisContextFromLocalEngine()
if err != nil {
return nil, err
}
enclaveCtx, err := kurtosisCtx.GetEnclaveContext(ctx, enclave)
if err != nil {
return nil, err
}
return &EnclaveFS{enclaveCtx: enclaveCtx}, nil
}
type Artifact struct {
reader *tar.Reader
}
func (fs *EnclaveFS) GetArtifact(ctx context.Context, name string) (*Artifact, error) {
artifact, err := fs.enclaveCtx.DownloadFilesArtifact(ctx, name)
if err != nil {
return nil, err
}
buffer := bytes.NewBuffer(artifact)
zipReader, err := gzip.NewReader(buffer)
if err != nil {
return nil, err
}
tarReader := tar.NewReader(zipReader)
return &Artifact{reader: tarReader}, nil
}
type ArtifactFileWriter struct {
path string
writer io.Writer
}
func (a *Artifact) ExtractFiles(writers ...*ArtifactFileWriter) error {
paths := make(map[string]io.Writer)
for _, writer := range writers {
canonicalPath := filepath.Clean(writer.path)
canonicalPath = fmt.Sprintf("./%s", canonicalPath)
paths[canonicalPath] = writer.writer
}
for {
header, err := a.reader.Next()
if err == io.EOF {
break
}
if _, ok := paths[header.Name]; !ok {
continue
}
writer := paths[header.Name]
_, err = io.Copy(writer, a.reader)
if err != nil {
return err
}
}
return nil
}
package deployer
import (
"bytes"
"context"
"fmt"
"io"
"github.com/ethereum-optimism/optimism/op-chain-ops/devkeys"
"gopkg.in/yaml.v3"
)
const (
// TODO: can we figure out how many were actually funded?
numWallets = 21
)
func getMnemonics(r io.Reader) (string, error) {
type mnemonicConfig struct {
Mnemonic string `yaml:"mnemonic"`
Count int `yaml:"count"` // TODO: what does this mean? it seems much larger than the number of wallets
}
var config []mnemonicConfig
decoder := yaml.NewDecoder(r)
if err := decoder.Decode(&config); err != nil {
return "", fmt.Errorf("failed to decode mnemonic config: %w", err)
}
// TODO: what does this mean if there are multiple mnemonics in this file?
return config[0].Mnemonic, nil
}
func (d *Deployer) getKnownWallets(ctx context.Context, fs *EnclaveFS) ([]*Wallet, error) {
artifact, err := fs.GetArtifact(ctx, d.genesisArtifactName)
if err != nil {
return nil, err
}
mnemonicsBuffer := bytes.NewBuffer(nil)
if err := artifact.ExtractFiles(
&ArtifactFileWriter{path: d.mnemonicsName, writer: mnemonicsBuffer},
); err != nil {
return nil, err
}
mnemonics, err := getMnemonics(mnemonicsBuffer)
if err != nil {
return nil, err
}
m, _ := devkeys.NewMnemonicDevKeys(mnemonics)
knownWallets := make([]*Wallet, 0)
var keys []devkeys.Key
for i := 0; i < numWallets; i++ {
keys = append(keys, devkeys.UserKey(i))
}
for _, key := range keys {
addr, _ := m.Address(key)
sec, _ := m.Secret(key)
knownWallets = append(knownWallets, &Wallet{
Name: key.String(),
Address: addr.Hex(),
PrivateKey: fmt.Sprintf("%x", sec.D),
})
}
return knownWallets, nil
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment