Skip to content

Commit 248ddd0

Browse files
authored
fix(cosmos): audit and fix cosmos plugin genesis patching (#94)
* fix(cosmos): audit and fix cosmos plugin genesis patching * fix: gofmt alignment in GenesisPatchOptions struct
1 parent bc5f1b9 commit 248ddd0

8 files changed

Lines changed: 462 additions & 69 deletions

File tree

examples/cosmos-plugin/main.go

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ package main
1414

1515
import (
1616
"context"
17+
"encoding/json"
1718
"fmt"
1819
"time"
1920

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

196197
func (n *CosmosNetwork) ModifyGenesis(genesis []byte, opts network.GenesisOptions) ([]byte, error) {
197-
// This is a simplified example. In a real implementation,
198-
// you would parse the genesis JSON, modify parameters, and return.
199-
//
200-
// For example:
201-
// - Reduce unbonding time for faster testing
202-
// - Set governance parameters for quick proposals
203-
// - Configure staking parameters
204-
//
205-
// The genesis file is JSON bytes that can be parsed with encoding/json.
206-
return genesis, nil
198+
// Parse genesis JSON
199+
var gen map[string]interface{}
200+
if err := json.Unmarshal(genesis, &gen); err != nil {
201+
return nil, fmt.Errorf("failed to parse genesis: %w", err)
202+
}
203+
204+
// Patch chain_id if provided in options
205+
if opts.ChainID != "" {
206+
gen["chain_id"] = opts.ChainID
207+
}
208+
209+
// Apply devnet-friendly parameters from GenesisConfig
210+
cfg := n.GenesisConfig()
211+
if appState, ok := gen["app_state"].(map[string]interface{}); ok {
212+
// Set short voting period for quick governance proposals
213+
if gov, ok := appState["gov"].(map[string]interface{}); ok {
214+
if params, ok := gov["params"].(map[string]interface{}); ok {
215+
params["voting_period"] = fmt.Sprintf("%dns", cfg.VotingPeriod.Nanoseconds())
216+
}
217+
}
218+
219+
// Set short unbonding time for faster testing
220+
if staking, ok := appState["staking"].(map[string]interface{}); ok {
221+
if params, ok := staking["params"].(map[string]interface{}); ok {
222+
params["unbonding_time"] = fmt.Sprintf("%dns", cfg.UnbondingTime.Nanoseconds())
223+
}
224+
}
225+
}
226+
227+
// Marshal back to JSON
228+
return json.MarshalIndent(gen, "", " ")
207229
}
208230

209231
func (n *CosmosNetwork) GenerateDevnet(ctx context.Context, config network.GeneratorConfig, genesisFile string) error {

internal/daemon/provisioner/genesis_forker.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,19 @@ func (f *GenesisForker) Fork(ctx context.Context, opts ports.ForkOptions, progre
131131
return nil, fmt.Errorf("failed to apply patches: %w", err)
132132
}
133133

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

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

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

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

430-
// Patch chain_id
431-
if opts.ChainID != "" {
432-
gen["chain_id"] = opts.ChainID
433-
}
438+
gen["chain_id"] = opts.ChainID
434439

435440
return json.MarshalIndent(gen, "", " ")
436441
}

internal/daemon/provisioner/genesis_forker_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,65 @@ func TestGenesisForkerNoPatchOptions(t *testing.T) {
380380
}
381381
}
382382

383+
func TestGenesisForkerNilPluginWithPatchOptions(t *testing.T) {
384+
tempDir := t.TempDir()
385+
386+
// Create a test genesis file with gov and staking modules
387+
testGenesis := []byte(`{
388+
"chain_id": "test-chain",
389+
"app_state": {
390+
"gov": {"params": {"voting_period": "1209600s"}},
391+
"staking": {"params": {"unbonding_time": "1814400s"}}
392+
}
393+
}`)
394+
395+
genesisPath := filepath.Join(tempDir, "genesis.json")
396+
if err := os.WriteFile(genesisPath, testGenesis, 0644); err != nil {
397+
t.Fatalf("Failed to write test genesis: %v", err)
398+
}
399+
400+
config := GenesisForkerConfig{
401+
DataDir: tempDir,
402+
PluginGenesis: nil, // No plugin - VotingPeriod/UnbondingTime won't be applied
403+
}
404+
405+
forker := NewGenesisForker(config)
406+
407+
opts := ports.ForkOptions{
408+
Source: types.GenesisSource{
409+
Mode: types.GenesisModeLocal,
410+
LocalPath: genesisPath,
411+
},
412+
PatchOpts: types.GenesisPatchOptions{
413+
ChainID: "devnet-1",
414+
VotingPeriod: 30 * time.Second,
415+
UnbondingTime: 60 * time.Second,
416+
InflationRate: "0.0",
417+
},
418+
}
419+
420+
ctx := context.Background()
421+
result, err := forker.Fork(ctx, opts, ports.NilProgressReporter)
422+
if err != nil {
423+
t.Fatalf("Fork should succeed even without plugin, got: %v", err)
424+
}
425+
426+
// Chain ID should still be patched (handled by applyPatches, not plugin)
427+
if result.NewChainID != "devnet-1" {
428+
t.Errorf("Expected new chain ID 'devnet-1', got '%s'", result.NewChainID)
429+
}
430+
431+
// Genesis should contain the new chain_id
432+
if !strings.Contains(string(result.Genesis), `"devnet-1"`) {
433+
t.Error("Expected genesis to contain patched chain_id 'devnet-1'")
434+
}
435+
436+
// The original voting_period should remain unchanged (plugin wasn't available to patch it)
437+
if !strings.Contains(string(result.Genesis), "1209600s") {
438+
t.Error("Expected original voting_period to remain unchanged when no plugin is configured")
439+
}
440+
}
441+
383442
func TestGenesisForkerForkFromLocalRelativePathRejected(t *testing.T) {
384443
tempDir := t.TempDir()
385444

internal/infrastructure/genesis/fetcher.go

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -86,30 +86,52 @@ func (f *FetcherAdapter) exportFromBinary(ctx context.Context, homeDir string) (
8686

8787
// extractGenesisJSON extracts valid JSON from command output.
8888
// The export command might include warnings/logs before the actual JSON.
89+
// It validates the extracted JSON is actually a genesis object by checking
90+
// for characteristic fields (chain_id or app_state), skipping any JSON
91+
// log lines that may precede the genesis output.
8992
func extractGenesisJSON(output []byte) ([]byte, error) {
90-
// Find the start of JSON (first '{')
91-
jsonStart := -1
92-
for i, b := range output {
93-
if b == '{' {
94-
jsonStart = i
93+
searchFrom := 0
94+
for searchFrom < len(output) {
95+
// Find the next '{' starting from searchFrom
96+
jsonStart := -1
97+
for i := searchFrom; i < len(output); i++ {
98+
if output[i] == '{' {
99+
jsonStart = i
100+
break
101+
}
102+
}
103+
104+
if jsonStart == -1 {
95105
break
96106
}
97-
}
98107

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

103-
// Find the matching closing brace
104-
jsonData := output[jsonStart:]
110+
// Validate it's valid JSON
111+
var js json.RawMessage
112+
if err := json.Unmarshal(jsonData, &js); err != nil {
113+
// Not valid JSON from this position, skip past this '{' and try next
114+
searchFrom = jsonStart + 1
115+
continue
116+
}
117+
118+
// Check if this looks like a genesis object (has chain_id or app_state)
119+
var probe map[string]json.RawMessage
120+
if err := json.Unmarshal(jsonData, &probe); err == nil {
121+
if _, hasChainID := probe["chain_id"]; hasChainID {
122+
return jsonData, nil
123+
}
124+
if _, hasAppState := probe["app_state"]; hasAppState {
125+
return jsonData, nil
126+
}
127+
}
105128

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

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

115137
func (f *FetcherAdapter) exportFromDocker(ctx context.Context, homeDir string) ([]byte, error) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package genesis
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestExtractGenesisJSON(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
wantErr bool
13+
wantField string // field that should be present in result
14+
}{
15+
{
16+
name: "clean genesis output",
17+
input: `{"chain_id":"test-1","app_state":{}}`,
18+
wantErr: false,
19+
wantField: "chain_id",
20+
},
21+
{
22+
name: "genesis with preceding log lines",
23+
input: "WARNING: some log message\nINFO: starting export\n" + `{"chain_id":"test-1","app_state":{}}`,
24+
wantErr: false,
25+
wantField: "chain_id",
26+
},
27+
{
28+
name: "genesis with preceding JSON log line",
29+
input: `{"level":"info","msg":"starting export"}` + "\n" + `{"chain_id":"test-1","app_state":{}}`,
30+
wantErr: false,
31+
wantField: "chain_id",
32+
},
33+
{
34+
name: "only non-genesis JSON",
35+
input: `{"level":"info","msg":"export complete"}`,
36+
wantErr: true,
37+
},
38+
{
39+
name: "no JSON at all",
40+
input: "Some random output\nwith no JSON\n",
41+
wantErr: true,
42+
},
43+
{
44+
name: "empty output",
45+
input: "",
46+
wantErr: true,
47+
},
48+
{
49+
name: "genesis with only app_state (no chain_id at top level)",
50+
input: `{"app_state":{"bank":{},"staking":{}}}`,
51+
wantErr: false,
52+
wantField: "app_state",
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
result, err := extractGenesisJSON([]byte(tt.input))
59+
if tt.wantErr {
60+
if err == nil {
61+
t.Errorf("expected error, got nil with result: %s", string(result))
62+
}
63+
return
64+
}
65+
if err != nil {
66+
t.Fatalf("unexpected error: %v", err)
67+
}
68+
if tt.wantField != "" && !strings.Contains(string(result), tt.wantField) {
69+
t.Errorf("result should contain %q, got: %s", tt.wantField, string(result))
70+
}
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)