Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions examples/cosmos-plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package main

import (
"context"
"encoding/json"
"fmt"
"time"

Expand Down Expand Up @@ -194,16 +195,37 @@ func (n *CosmosNetwork) ExportCommand(homeDir string) []string {
// ============================================

func (n *CosmosNetwork) ModifyGenesis(genesis []byte, opts network.GenesisOptions) ([]byte, error) {
// This is a simplified example. In a real implementation,
// you would parse the genesis JSON, modify parameters, and return.
//
// For example:
// - Reduce unbonding time for faster testing
// - Set governance parameters for quick proposals
// - Configure staking parameters
//
// The genesis file is JSON bytes that can be parsed with encoding/json.
return genesis, nil
// Parse genesis JSON
var gen map[string]interface{}
if err := json.Unmarshal(genesis, &gen); err != nil {
return nil, fmt.Errorf("failed to parse genesis: %w", err)
}

// Patch chain_id if provided in options
if opts.ChainID != "" {
gen["chain_id"] = opts.ChainID
}

// Apply devnet-friendly parameters from GenesisConfig
cfg := n.GenesisConfig()
if appState, ok := gen["app_state"].(map[string]interface{}); ok {
// Set short voting period for quick governance proposals
if gov, ok := appState["gov"].(map[string]interface{}); ok {
if params, ok := gov["params"].(map[string]interface{}); ok {
params["voting_period"] = fmt.Sprintf("%dns", cfg.VotingPeriod.Nanoseconds())
}
}

// Set short unbonding time for faster testing
if staking, ok := appState["staking"].(map[string]interface{}); ok {
if params, ok := staking["params"].(map[string]interface{}); ok {
params["unbonding_time"] = fmt.Sprintf("%dns", cfg.UnbondingTime.Nanoseconds())
}
}
}

// Marshal back to JSON
return json.MarshalIndent(gen, "", " ")
}

func (n *CosmosNetwork) GenerateDevnet(ctx context.Context, config network.GeneratorConfig, genesisFile string) error {
Expand Down
19 changes: 12 additions & 7 deletions internal/daemon/provisioner/genesis_forker.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,19 @@ func (f *GenesisForker) Fork(ctx context.Context, opts ports.ForkOptions, progre
return nil, fmt.Errorf("failed to apply patches: %w", err)
}

// Apply plugin-specific patches
// Apply plugin-specific patches (voting period, unbonding time, inflation rate, etc.)
if f.config.PluginGenesis != nil {
patched, err = f.config.PluginGenesis.PatchGenesis(patched, opts.PatchOpts)
if err != nil {
reportStep(progress, "Applying genesis patches", "failed", err.Error())
return nil, fmt.Errorf("plugin patch failed: %w", err)
}
} else if opts.PatchOpts.VotingPeriod > 0 || opts.PatchOpts.UnbondingTime > 0 || opts.PatchOpts.InflationRate != "" {
f.logger.Warn("genesis patch options specify network parameters but no plugin is configured to apply them",
"votingPeriod", opts.PatchOpts.VotingPeriod,
"unbondingTime", opts.PatchOpts.UnbondingTime,
"inflationRate", opts.PatchOpts.InflationRate,
)
}
reportStep(progress, "Applying genesis patches", "completed", "")

Expand Down Expand Up @@ -416,9 +422,11 @@ func (f *GenesisForker) fetchGenesisHTTP(ctx context.Context, url string) ([]byt
return body, nil
}

// applyPatches applies generic patches to genesis
// applyPatches applies generic patches to genesis.
// This only handles chain_id patching. Network-specific patches (voting period,
// unbonding time, inflation rate) are handled by the plugin's PatchGenesis method.
func (f *GenesisForker) applyPatches(genesis []byte, opts types.GenesisPatchOptions) ([]byte, error) {
if opts.ChainID == "" && opts.VotingPeriod == 0 && opts.UnbondingTime == 0 {
if opts.ChainID == "" {
return genesis, nil
}

Expand All @@ -427,10 +435,7 @@ func (f *GenesisForker) applyPatches(genesis []byte, opts types.GenesisPatchOpti
return nil, fmt.Errorf("failed to parse genesis: %w", err)
}

// Patch chain_id
if opts.ChainID != "" {
gen["chain_id"] = opts.ChainID
}
gen["chain_id"] = opts.ChainID

return json.MarshalIndent(gen, "", " ")
}
Expand Down
59 changes: 59 additions & 0 deletions internal/daemon/provisioner/genesis_forker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,65 @@ func TestGenesisForkerNoPatchOptions(t *testing.T) {
}
}

func TestGenesisForkerNilPluginWithPatchOptions(t *testing.T) {
tempDir := t.TempDir()

// Create a test genesis file with gov and staking modules
testGenesis := []byte(`{
"chain_id": "test-chain",
"app_state": {
"gov": {"params": {"voting_period": "1209600s"}},
"staking": {"params": {"unbonding_time": "1814400s"}}
}
}`)

genesisPath := filepath.Join(tempDir, "genesis.json")
if err := os.WriteFile(genesisPath, testGenesis, 0644); err != nil {
t.Fatalf("Failed to write test genesis: %v", err)
}

config := GenesisForkerConfig{
DataDir: tempDir,
PluginGenesis: nil, // No plugin - VotingPeriod/UnbondingTime won't be applied
}

forker := NewGenesisForker(config)

opts := ports.ForkOptions{
Source: types.GenesisSource{
Mode: types.GenesisModeLocal,
LocalPath: genesisPath,
},
PatchOpts: types.GenesisPatchOptions{
ChainID: "devnet-1",
VotingPeriod: 30 * time.Second,
UnbondingTime: 60 * time.Second,
InflationRate: "0.0",
},
}

ctx := context.Background()
result, err := forker.Fork(ctx, opts, ports.NilProgressReporter)
if err != nil {
t.Fatalf("Fork should succeed even without plugin, got: %v", err)
}

// Chain ID should still be patched (handled by applyPatches, not plugin)
if result.NewChainID != "devnet-1" {
t.Errorf("Expected new chain ID 'devnet-1', got '%s'", result.NewChainID)
}

// Genesis should contain the new chain_id
if !strings.Contains(string(result.Genesis), `"devnet-1"`) {
t.Error("Expected genesis to contain patched chain_id 'devnet-1'")
}

// The original voting_period should remain unchanged (plugin wasn't available to patch it)
if !strings.Contains(string(result.Genesis), "1209600s") {
t.Error("Expected original voting_period to remain unchanged when no plugin is configured")
}
}

func TestGenesisForkerForkFromLocalRelativePathRejected(t *testing.T) {
tempDir := t.TempDir()

Expand Down
54 changes: 38 additions & 16 deletions internal/infrastructure/genesis/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,30 +86,52 @@ func (f *FetcherAdapter) exportFromBinary(ctx context.Context, homeDir string) (

// extractGenesisJSON extracts valid JSON from command output.
// The export command might include warnings/logs before the actual JSON.
// It validates the extracted JSON is actually a genesis object by checking
// for characteristic fields (chain_id or app_state), skipping any JSON
// log lines that may precede the genesis output.
func extractGenesisJSON(output []byte) ([]byte, error) {
// Find the start of JSON (first '{')
jsonStart := -1
for i, b := range output {
if b == '{' {
jsonStart = i
searchFrom := 0
for searchFrom < len(output) {
// Find the next '{' starting from searchFrom
jsonStart := -1
for i := searchFrom; i < len(output); i++ {
if output[i] == '{' {
jsonStart = i
break
}
}

if jsonStart == -1 {
break
}
}

if jsonStart == -1 {
return nil, fmt.Errorf("no JSON found in export output: %s", string(output))
}
jsonData := output[jsonStart:]

// Find the matching closing brace
jsonData := output[jsonStart:]
// Validate it's valid JSON
var js json.RawMessage
if err := json.Unmarshal(jsonData, &js); err != nil {
// Not valid JSON from this position, skip past this '{' and try next
searchFrom = jsonStart + 1
continue
}

// Check if this looks like a genesis object (has chain_id or app_state)
var probe map[string]json.RawMessage
if err := json.Unmarshal(jsonData, &probe); err == nil {
if _, hasChainID := probe["chain_id"]; hasChainID {
return jsonData, nil
}
if _, hasAppState := probe["app_state"]; hasAppState {
return jsonData, nil
}
}

// Validate it's valid JSON
var js json.RawMessage
if err := json.Unmarshal(jsonData, &js); err != nil {
return nil, fmt.Errorf("invalid JSON in export output: %w", err)
// Valid JSON but not genesis, skip past this object and try next
searchFrom = jsonStart + len(jsonData)
}

return jsonData, nil
return nil, fmt.Errorf("no genesis JSON found in export output (looked for chain_id or app_state): %s",
string(output[:min(len(output), 500)]))
}

func (f *FetcherAdapter) exportFromDocker(ctx context.Context, homeDir string) ([]byte, error) {
Expand Down
73 changes: 73 additions & 0 deletions internal/infrastructure/genesis/fetcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package genesis

import (
"strings"
"testing"
)

func TestExtractGenesisJSON(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
wantField string // field that should be present in result
}{
{
name: "clean genesis output",
input: `{"chain_id":"test-1","app_state":{}}`,
wantErr: false,
wantField: "chain_id",
},
{
name: "genesis with preceding log lines",
input: "WARNING: some log message\nINFO: starting export\n" + `{"chain_id":"test-1","app_state":{}}`,
wantErr: false,
wantField: "chain_id",
},
{
name: "genesis with preceding JSON log line",
input: `{"level":"info","msg":"starting export"}` + "\n" + `{"chain_id":"test-1","app_state":{}}`,
wantErr: false,
wantField: "chain_id",
},
{
name: "only non-genesis JSON",
input: `{"level":"info","msg":"export complete"}`,
wantErr: true,
},
{
name: "no JSON at all",
input: "Some random output\nwith no JSON\n",
wantErr: true,
},
{
name: "empty output",
input: "",
wantErr: true,
},
{
name: "genesis with only app_state (no chain_id at top level)",
input: `{"app_state":{"bank":{},"staking":{}}}`,
wantErr: false,
wantField: "app_state",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractGenesisJSON([]byte(tt.input))
if tt.wantErr {
if err == nil {
t.Errorf("expected error, got nil with result: %s", string(result))
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantField != "" && !strings.Contains(string(result), tt.wantField) {
t.Errorf("result should contain %q, got: %s", tt.wantField, string(result))
}
})
}
}
Loading
Loading