From c3f24f432634265098363af0c0244921dd959c19 Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Fri, 23 Jan 2026 10:27:51 +0100 Subject: [PATCH 1/7] feat(supernova): add mixed runtime with percentage for each kind of txs --- cmd/root.go | 11 +- internal/config.go | 14 ++ internal/pipeline.go | 24 ++- internal/runtime/mix_ratio.go | 151 ++++++++++++++ internal/runtime/mix_ratio_test.go | 230 +++++++++++++++++++++ internal/runtime/mixed.go | 313 +++++++++++++++++++++++++++++ internal/runtime/runtime.go | 34 +++- internal/runtime/runtime_test.go | 10 +- internal/runtime/type.go | 12 ++ internal/runtime/type_test.go | 54 +++++ 10 files changed, 840 insertions(+), 13 deletions(-) create mode 100644 internal/runtime/mix_ratio.go create mode 100644 internal/runtime/mix_ratio_test.go create mode 100644 internal/runtime/mixed.go diff --git a/cmd/root.go b/cmd/root.go index 5f06e9d..1a1e623 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,11 +64,18 @@ func registerFlags(fs *flag.FlagSet, c *internal.Config) { "mode", runtime.RealmDeployment.String(), fmt.Sprintf( - "the mode for the stress test. Possible modes: [%s, %s, %s]", - runtime.RealmDeployment.String(), runtime.PackageDeployment.String(), runtime.RealmCall.String(), + "the mode for the stress test. Possible modes: [%s, %s, %s, %s]", + runtime.RealmDeployment.String(), runtime.PackageDeployment.String(), runtime.RealmCall.String(), runtime.Mixed.String(), ), ) + fs.StringVar( + &c.MixRatio, + "mix-ratio", + "", + "transaction mix ratios for MIXED mode, e.g., \"REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10\"", + ) + fs.StringVar( &c.Output, "output", diff --git a/internal/config.go b/internal/config.go index 224dac7..7b5b28f 100644 --- a/internal/config.go +++ b/internal/config.go @@ -2,6 +2,7 @@ package internal import ( "errors" + "fmt" "regexp" "github.com/gnolang/gno/tm2/pkg/crypto/bip39" @@ -15,6 +16,7 @@ var ( errInvalidSubaccounts = errors.New("invalid number of subaccounts specified") errInvalidTransactions = errors.New("invalid number of transactions specified") errInvalidBatchSize = errors.New("invalid batch size specified") + errMixRatioRequired = errors.New("mix-ratio is required for MIXED mode") ) var ( @@ -31,6 +33,7 @@ type Config struct { ChainID string // the chain ID of the cluster Mnemonic string // the mnemonic for the keyring Mode string // the stress test mode + MixRatio string // transaction mix ratios for MIXED mode Output string // output path for results JSON, if any SubAccounts uint64 // the number of sub-accounts in the run @@ -71,5 +74,16 @@ func (cfg *Config) Validate() error { return errInvalidBatchSize } + // Validate mix ratio for MIXED mode + if runtime.Type(cfg.Mode) == runtime.Mixed { + if cfg.MixRatio == "" { + return errMixRatioRequired + } + + if _, err := runtime.ParseMixRatio(cfg.MixRatio); err != nil { + return fmt.Errorf("invalid mix-ratio: %w", err) + } + } + return nil } diff --git a/internal/pipeline.go b/internal/pipeline.go index 0569198..50630ff 100644 --- a/internal/pipeline.go +++ b/internal/pipeline.go @@ -56,12 +56,22 @@ func NewPipeline(cfg *Config) (*Pipeline, error) { // Execute runs the entire pipeline process func (p *Pipeline) Execute(ctx context.Context) error { - var ( - mode = runtime.Type(p.cfg.Mode) + mode := runtime.Type(p.cfg.Mode) + + // Setup the context for mixed mode + if mode == runtime.Mixed { + mixConfig, _ := runtime.ParseMixRatio(p.cfg.MixRatio) + ctx = runtime.WithMixConfig(ctx, mixConfig) + } + txRuntime, err := runtime.GetRuntime(ctx, mode) + if err != nil { + return fmt.Errorf("unable to get runtime: %w", err) + } + + var ( txBatcher = batcher.NewBatcher(ctx, p.cli) txCollector = collector.NewCollector(ctx, p.cli) - txRuntime = runtime.GetRuntime(ctx, mode) ) // Initialize the accounts for the runtime @@ -229,7 +239,13 @@ func prepareRuntime( signCB := runtime.SignTransactionsCb(chainID, deployer, deployerKey) - if mode != runtime.RealmCall { + needsPredeploy := mode == runtime.RealmCall + if mode == runtime.Mixed { + mixConfig := runtime.GetMixConfig(ctx) + needsPredeploy = mixConfig != nil && mixConfig.HasType(runtime.RealmCall) + } + + if !needsPredeploy { return txRuntime.CalculateRuntimeCosts(deployer, cli.EstimateGas, signCB, currentMaxGas, gasPrice, transactions) } diff --git a/internal/runtime/mix_ratio.go b/internal/runtime/mix_ratio.go new file mode 100644 index 0000000..7df5dc2 --- /dev/null +++ b/internal/runtime/mix_ratio.go @@ -0,0 +1,151 @@ +package runtime + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +var ( + errEmptyMixRatio = errors.New("mix ratio cannot be empty") + errInvalidRatioFormat = errors.New("invalid ratio format, expected TYPE:PERCENTAGE") + errInvalidPercentage = errors.New("percentage must be a positive integer between 1 and 100") + errUnknownType = errors.New("unknown runtime type in mix ratio") + errDuplicateType = errors.New("duplicate runtime type in mix ratio") + errMixedInMix = errors.New("MIXED type cannot be used in mix ratio") + errRatioSumNot100 = errors.New("mix ratio percentages must sum to 100") + errInsufficientTypes = errors.New("mix ratio must contain at least 2 types") +) + +type MixRatio struct { + Type Type + Percentage int +} + +type MixConfig struct { + Ratios []MixRatio +} + +// ParseMixRatio parses a mix ratio string into a MixConfig +// Example input: "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10" +func ParseMixRatio(input string) (*MixConfig, error) { + if strings.TrimSpace(input) == "" { + return nil, errEmptyMixRatio + } + + parts := strings.Split(input, ",") + if len(parts) < 2 { + return nil, errInsufficientTypes + } + + config := &MixConfig{ + Ratios: make([]MixRatio, 0, len(parts)), + } + + seenTypes := make(map[Type]bool) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + ratio, err := parseRatioPart(part) + if err != nil { + return nil, err + } + + if seenTypes[ratio.Type] { + return nil, fmt.Errorf("%w: %s", errDuplicateType, ratio.Type) + } + seenTypes[ratio.Type] = true + + config.Ratios = append(config.Ratios, ratio) + } + + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil +} + +// parseRatioPart parses a single TYPE:PERCENTAGE part of the mix ratio +// Example input: "REALM_CALL:70" and ensures validity +func parseRatioPart(part string) (MixRatio, error) { + colonIdx := strings.LastIndex(part, ":") + if colonIdx == -1 { + return MixRatio{}, fmt.Errorf("%w: %s", errInvalidRatioFormat, part) + } + + typeName := strings.TrimSpace(part[:colonIdx]) + percentageStr := strings.TrimSpace(part[colonIdx+1:]) + + percentage, err := strconv.Atoi(percentageStr) + if err != nil || percentage < 1 || percentage > 100 { + return MixRatio{}, fmt.Errorf("%w: %s", errInvalidPercentage, percentageStr) + } + + runtimeType := Type(typeName) + + if runtimeType == Mixed { + return MixRatio{}, errMixedInMix + } + + if !IsMixableRuntime(runtimeType) { + return MixRatio{}, fmt.Errorf("%w: %s", errUnknownType, typeName) + } + + return MixRatio{ + Type: runtimeType, + Percentage: percentage, + }, nil +} + +// Validate checks the MixConfig for correctness +// ensuring at least two types and that percentages sum to 100 +func (mc *MixConfig) Validate() error { + if len(mc.Ratios) < 2 { + return errInsufficientTypes + } + + sum := 0 + for _, ratio := range mc.Ratios { + sum += ratio.Percentage + } + + if sum != 100 { + return fmt.Errorf("%w: got %d", errRatioSumNot100, sum) + } + + return nil +} + +func (mc *MixConfig) HasType(t Type) bool { + for _, ratio := range mc.Ratios { + if ratio.Type == t { + return true + } + } + return false +} + +// CalculateTransactionCounts computes the number of transactions +// for each runtime type based on the total and the defined ratios +func (mc *MixConfig) CalculateTransactionCounts(total uint64) map[Type]uint64 { + counts := make(map[Type]uint64) + var allocated uint64 + + for i, ratio := range mc.Ratios { + if i == len(mc.Ratios)-1 { + counts[ratio.Type] = total - allocated + } else { + count := (total * uint64(ratio.Percentage)) / 100 + counts[ratio.Type] = count + allocated += count + } + } + + return counts +} diff --git a/internal/runtime/mix_ratio_test.go b/internal/runtime/mix_ratio_test.go new file mode 100644 index 0000000..92b462e --- /dev/null +++ b/internal/runtime/mix_ratio_test.go @@ -0,0 +1,230 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMixRatio_Valid(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + input string + expected []MixRatio + }{ + { + "three-way split", + "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10", + []MixRatio{ + {RealmCall, 70}, + {RealmDeployment, 20}, + {PackageDeployment, 10}, + }, + }, + { + "two-way split", + "REALM_CALL:50,REALM_DEPLOYMENT:50", + []MixRatio{ + {RealmCall, 50}, + {RealmDeployment, 50}, + }, + }, + { + "with spaces", + "REALM_CALL:70, REALM_DEPLOYMENT:30", + []MixRatio{ + {RealmCall, 70}, + {RealmDeployment, 30}, + }, + }, + } + + for _, tc := range testTable { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + config, err := ParseMixRatio(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, config.Ratios) + }) + } +} + +func TestParseMixRatio_Invalid(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + input string + expectedErr error + }{ + { + "empty string", + "", + errEmptyMixRatio, + }, + { + "whitespace only", + " ", + errEmptyMixRatio, + }, + { + "single type", + "REALM_CALL:100", + errInsufficientTypes, + }, + { + "missing colon", + "REALM_CALL70,REALM_DEPLOYMENT30", + errInvalidRatioFormat, + }, + { + "invalid percentage - negative", + "REALM_CALL:-10,REALM_DEPLOYMENT:110", + errInvalidPercentage, + }, + { + "invalid percentage - zero", + "REALM_CALL:0,REALM_DEPLOYMENT:100", + errInvalidPercentage, + }, + { + "invalid percentage - over 100", + "REALM_CALL:101,REALM_DEPLOYMENT:0", + errInvalidPercentage, + }, + { + "invalid percentage - not a number", + "REALM_CALL:abc,REALM_DEPLOYMENT:50", + errInvalidPercentage, + }, + { + "unknown type", + "UNKNOWN_TYPE:50,REALM_DEPLOYMENT:50", + errUnknownType, + }, + { + "duplicate type", + "REALM_CALL:50,REALM_CALL:50", + errDuplicateType, + }, + { + "MIXED in mix", + "MIXED:50,REALM_CALL:50", + errMixedInMix, + }, + { + "sum not 100 - under", + "REALM_CALL:40,REALM_DEPLOYMENT:40", + errRatioSumNot100, + }, + { + "sum not 100 - over", + "REALM_CALL:60,REALM_DEPLOYMENT:60", + errRatioSumNot100, + }, + } + + for _, tc := range testTable { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseMixRatio(tc.input) + require.Error(t, err) + assert.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +func TestMixConfig_HasType(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []MixRatio{ + {RealmCall, 70}, + {RealmDeployment, 30}, + }, + } + + assert.True(t, config.HasType(RealmCall)) + assert.True(t, config.HasType(RealmDeployment)) + assert.False(t, config.HasType(PackageDeployment)) + assert.False(t, config.HasType(Mixed)) +} + +func TestMixConfig_CalculateTransactionCounts(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + ratios []MixRatio + total uint64 + expected map[Type]uint64 + }{ + { + "exact division", + []MixRatio{ + {RealmCall, 70}, + {RealmDeployment, 20}, + {PackageDeployment, 10}, + }, + 100, + map[Type]uint64{ + RealmCall: 70, + RealmDeployment: 20, + PackageDeployment: 10, + }, + }, + { + "with rounding - remainder goes to last", + []MixRatio{ + {RealmCall, 70}, + {RealmDeployment, 30}, + }, + 10, + map[Type]uint64{ + RealmCall: 7, + RealmDeployment: 3, + }, + }, + { + "small total with rounding", + []MixRatio{ + {RealmCall, 33}, + {RealmDeployment, 33}, + {PackageDeployment, 34}, + }, + 10, + map[Type]uint64{ + RealmCall: 3, + RealmDeployment: 3, + PackageDeployment: 4, + }, + }, + { + "two-way 50/50", + []MixRatio{ + {RealmCall, 50}, + {PackageDeployment, 50}, + }, + 100, + map[Type]uint64{ + RealmCall: 50, + PackageDeployment: 50, + }, + }, + } + + for _, tc := range testTable { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + config := &MixConfig{Ratios: tc.ratios} + counts := config.CalculateTransactionCounts(tc.total) + assert.Equal(t, tc.expected, counts) + }) + } +} diff --git a/internal/runtime/mixed.go b/internal/runtime/mixed.go new file mode 100644 index 0000000..c08d75b --- /dev/null +++ b/internal/runtime/mixed.go @@ -0,0 +1,313 @@ +package runtime + +import ( + "context" + "fmt" + "math/rand" + "time" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/gnolang/supernova/internal/common" + "github.com/gnolang/supernova/internal/signer" + "github.com/schollz/progressbar/v3" +) + +type mixedRuntime struct { + ctx context.Context + config *MixConfig + realmPath string +} + +func newMixedRuntime(ctx context.Context, config *MixConfig) *mixedRuntime { + return &mixedRuntime{ + ctx: ctx, + config: config, + } +} + +func (m *mixedRuntime) Initialize( + account std.Account, + signFn SignFn, + estimateFn EstimateGasFn, + currentMaxGas int64, + gasPrice std.GasPrice, +) ([]*std.Tx, error) { + if !m.config.HasType(RealmCall) { + return nil, nil + } + + m.realmPath = fmt.Sprintf( + "%s/%s/stress_%d", + realmPathPrefix, + account.GetAddress().String(), + time.Now().Unix(), + ) + + msg := vm.MsgAddPackage{ + Creator: account.GetAddress(), + Package: &std.MemPackage{ + Name: packageName, + Path: m.realmPath, + Files: []*std.MemFile{ + { + Name: gnomodFileName, + Body: gnomodBody, + }, + { + Name: realmFileName, + Body: realmBody, + }, + }, + }, + } + + tx := &std.Tx{ + Msgs: []std.Msg{msg}, + Fee: common.CalculateFeeInRatio(currentMaxGas, gasPrice), + } + + err := signFn(tx) + if err != nil { + return nil, fmt.Errorf("unable to sign initialize transaction, %w", err) + } + + gasWanted, err := estimateFn(m.ctx, tx) + if err != nil { + return nil, fmt.Errorf("unable to estimate gas: %w", err) + } + + tx.Signatures = make([]std.Signature, 0) + tx.Fee = common.CalculateFeeInRatio(gasWanted+gasBuffer, gasPrice) + + err = signFn(tx) + if err != nil { + return nil, fmt.Errorf("unable to sign initialize transaction, %w", err) + } + + return []*std.Tx{tx}, nil +} + +func (m *mixedRuntime) CalculateRuntimeCosts( + account std.Account, + estimateFn EstimateGasFn, + signFn SignFn, + currentMaxGas int64, + gasPrice std.GasPrice, + transactions uint64, +) (std.Coin, error) { + fmt.Printf("\nā³ Estimating Gas ā³\n") + + txCounts := m.config.CalculateTransactionCounts(transactions) + var totalGas int64 + + for txType, count := range txCounts { + if count == 0 { + continue + } + + txFee := common.CalculateFeeInRatio(currentMaxGas, gasPrice) + msg := m.getMsgForType(txType, account, 0) + + tx := &std.Tx{ + Msgs: []std.Msg{msg}, + Fee: txFee, + } + + err := signFn(tx) + if err != nil { + return std.Coin{}, fmt.Errorf("unable to sign transaction for %s, %w", txType, err) + } + + estimatedGas, err := estimateFn(m.ctx, tx) + if err != nil { + return std.Coin{}, fmt.Errorf("unable to estimate gas for %s, %w", txType, err) + } + + totalGas += int64(count) * estimatedGas + } + + return std.Coin{ + Denom: common.Denomination, + Amount: totalGas, + }, nil +} + +func (m *mixedRuntime) ConstructTransactions( + keys []crypto.PrivKey, + accounts []std.Account, + transactions uint64, + maxGas int64, + gasPrice std.GasPrice, + chainID string, + estimateFn EstimateGasFn, +) ([]*std.Tx, error) { + txCounts := m.config.CalculateTransactionCounts(transactions) + typeSequence := m.generateShuffledSequence(txCounts) + + gasEstimates := make(map[Type]int64) + + fmt.Printf("\nā³ Estimating Gas Per Type ā³\n") + + for txType, count := range txCounts { + if count == 0 { + continue + } + + txFee := common.CalculateFeeInRatio(maxGas, gasPrice) + msg := m.getMsgForType(txType, accounts[0], 0) + + tx := &std.Tx{ + Msgs: []std.Msg{msg}, + Fee: txFee, + } + + cfg := signer.SignCfg{ + ChainID: chainID, + AccountNumber: accounts[0].GetAccountNumber(), + Sequence: accounts[0].GetSequence(), + } + + if err := signer.SignTx(tx, keys[0], cfg); err != nil { + return nil, fmt.Errorf("unable to sign transaction for %s, %w", txType, err) + } + + gasWanted, err := estimateFn(m.ctx, tx) + if err != nil { + return nil, fmt.Errorf("unable to estimate gas for %s, %w", txType, err) + } + + gasEstimates[txType] = gasWanted + gasBuffer + fmt.Printf("Estimated Gas for %s: %d\n", txType, gasEstimates[txType]) + } + + fmt.Printf("\nšŸ”Ø Constructing Transactions šŸ”Ø\n\n") + + txs := make([]*std.Tx, transactions) + nonceMap := make(map[uint64]uint64) + typeCounters := make(map[Type]int) + + bar := progressbar.Default(int64(transactions), "constructing txs") + + for i, txType := range typeSequence { + creator := accounts[i%len(accounts)] + creatorKey := keys[i%len(accounts)] + accountNumber := creator.GetAccountNumber() + + typeIndex := typeCounters[txType] + typeCounters[txType]++ + + msg := m.getMsgForType(txType, creator, typeIndex) + txFee := common.CalculateFeeInRatio(gasEstimates[txType], gasPrice) + + tx := &std.Tx{ + Msgs: []std.Msg{msg}, + Fee: txFee, + } + + nonce, found := nonceMap[accountNumber] + if !found { + nonce = creator.GetSequence() + nonceMap[accountNumber] = nonce + } + + cfg := signer.SignCfg{ + ChainID: chainID, + AccountNumber: accountNumber, + Sequence: nonce, + } + + if err := signer.SignTx(tx, creatorKey, cfg); err != nil { + return nil, fmt.Errorf("unable to sign transaction, %w", err) + } + + nonceMap[accountNumber] = nonce + 1 + txs[i] = tx + _ = bar.Add(1) + } + + fmt.Printf("āœ… Successfully constructed %d transactions\n", transactions) + + return txs, nil +} + +func (m *mixedRuntime) generateShuffledSequence(txCounts map[Type]uint64) []Type { + var sequence []Type + for txType, count := range txCounts { + for i := uint64(0); i < count; i++ { + sequence = append(sequence, txType) + } + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(sequence), func(i, j int) { + sequence[i], sequence[j] = sequence[j], sequence[i] + }) + + return sequence +} + +func (m *mixedRuntime) getMsgForType(txType Type, creator std.Account, index int) std.Msg { + timestamp := time.Now().Unix() + + switch txType { + case RealmCall: + return vm.MsgCall{ + Caller: creator.GetAddress(), + PkgPath: m.realmPath, + Func: methodName, + Args: []string{fmt.Sprintf("Account-%d", index)}, + } + case RealmDeployment: + return vm.MsgAddPackage{ + Creator: creator.GetAddress(), + Package: &std.MemPackage{ + Name: packageName, + Path: fmt.Sprintf( + "%s/%s/stress_%d_%d", + realmPathPrefix, + creator.GetAddress().String(), + timestamp, + index, + ), + Files: []*std.MemFile{ + { + Name: gnomodFileName, + Body: gnomodBody, + }, + { + Name: realmFileName, + Body: realmBody, + }, + }, + }, + } + case PackageDeployment: + return vm.MsgAddPackage{ + Creator: creator.GetAddress(), + Package: &std.MemPackage{ + Name: packageName, + Path: fmt.Sprintf( + "%s/%s/stress_%d_%d", + packagePathPrefix, + creator.GetAddress().String(), + timestamp, + index, + ), + Files: []*std.MemFile{ + { + Name: gnomodFileName, + Body: gnomodBody, + }, + { + Name: packageFileName, + Body: packageBody, + }, + }, + }, + } + default: + return nil + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8edc01e..4699599 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,16 +2,33 @@ package runtime import ( "context" + "errors" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/std" ) +var ( + errMissingMixConfig = errors.New("mix config is required for MIXED mode") + errUnknownRuntime = errors.New("unknown runtime type") +) + const ( realmPathPrefix = "gno.land/r" packagePathPrefix = "gno.land/p" ) +type mixConfigKey struct{} + +func WithMixConfig(ctx context.Context, config *MixConfig) context.Context { + return context.WithValue(ctx, mixConfigKey{}, config) +} + +func GetMixConfig(ctx context.Context) *MixConfig { + config, _ := ctx.Value(mixConfigKey{}).(*MixConfig) + return config +} + // EstimateGasFn is the gas estimation callback type EstimateGasFn func(ctx context.Context, tx *std.Tx) (int64, error) @@ -59,15 +76,22 @@ type Runtime interface { } // GetRuntime fetches the specified runtime, if any -func GetRuntime(ctx context.Context, runtimeType Type) Runtime { +// Returns an error if the runtime type is unknown or if the mix config is missing for mixed runtimes +func GetRuntime(ctx context.Context, runtimeType Type) (Runtime, error) { switch runtimeType { case RealmCall: - return newRealmCall(ctx) + return newRealmCall(ctx), nil case RealmDeployment: - return newRealmDeployment(ctx) + return newRealmDeployment(ctx), nil case PackageDeployment: - return newPackageDeployment(ctx) + return newPackageDeployment(ctx), nil + case Mixed: + config := GetMixConfig(ctx) + if config == nil { + return nil, errMissingMixConfig + } + return newMixedRuntime(ctx, config), nil default: - return nil + return nil, errUnknownRuntime } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 52b3ebe..af68884 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -71,7 +71,10 @@ func TestRuntime_CommonDeployment(t *testing.T) { ) // Get the runtime - r := GetRuntime(context.Background(), testCase.mode) + r, err := GetRuntime(context.Background(), testCase.mode) + if err != nil { + t.Fatalf("unable to get runtime: %v", err) + } // Make sure there is no initialization logic initialTxs, err := r.Initialize( @@ -127,7 +130,10 @@ func TestRuntime_RealmCall(t *testing.T) { ) // Get the runtime - r := GetRuntime(context.Background(), RealmCall) + r, err := GetRuntime(context.Background(), RealmCall) + if err != nil { + t.Fatalf("unable to get runtime: %v", err) + } // Make sure the initialization logic is present initialTxs, err := r.Initialize( diff --git a/internal/runtime/type.go b/internal/runtime/type.go index d62e371..0328cb5 100644 --- a/internal/runtime/type.go +++ b/internal/runtime/type.go @@ -6,12 +6,22 @@ const ( RealmDeployment Type = "REALM_DEPLOYMENT" PackageDeployment Type = "PACKAGE_DEPLOYMENT" RealmCall Type = "REALM_CALL" + Mixed Type = "MIXED" unknown Type = "UNKNOWN" ) // IsRuntime checks if the passed in runtime // is a supported runtime type func IsRuntime(runtime Type) bool { + return runtime == RealmCall || + runtime == RealmDeployment || + runtime == PackageDeployment || + runtime == Mixed +} + +// IsMixableRuntime checks if the passed in runtime +// can be part of a mixed runtime configuration +func IsMixableRuntime(runtime Type) bool { return runtime == RealmCall || runtime == RealmDeployment || runtime == PackageDeployment @@ -27,6 +37,8 @@ func (r Type) String() string { return string(PackageDeployment) case RealmCall: return string(RealmCall) + case Mixed: + return string(Mixed) default: return string(unknown) } diff --git a/internal/runtime/type_test.go b/internal/runtime/type_test.go index 740f757..99ef081 100644 --- a/internal/runtime/type_test.go +++ b/internal/runtime/type_test.go @@ -29,6 +29,11 @@ func TestType_IsRuntime(t *testing.T) { RealmCall, true, }, + { + "Mixed", + Mixed, + true, + }, { "Dummy mode", Type("Dummy mode"), @@ -68,6 +73,11 @@ func TestType_String(t *testing.T) { RealmCall, string(RealmCall), }, + { + "Mixed", + Mixed, + string(Mixed), + }, { "Dummy mode", Type("Dummy mode"), @@ -83,3 +93,47 @@ func TestType_String(t *testing.T) { }) } } + +func TestType_IsMixableRuntime(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + mode Type + isMixable bool + }{ + { + "Realm Deployment", + RealmDeployment, + true, + }, + { + "Package Deployment", + PackageDeployment, + true, + }, + { + "Realm Call", + RealmCall, + true, + }, + { + "Mixed", + Mixed, + false, + }, + { + "Dummy mode", + Type("Dummy mode"), + false, + }, + } + + for _, testCase := range testTable { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, testCase.isMixable, IsMixableRuntime(testCase.mode)) + }) + } +} From f8566e31740917c4db56ba42916ae16d6c193b2b Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Fri, 23 Jan 2026 14:03:53 +0100 Subject: [PATCH 2/7] feat(supernova): add mixed runtime with percentage for each kind of txs --- internal/config_test.go | 67 ++++++++ internal/runtime/mix_ratio.go | 18 +-- internal/runtime/mix_ratio_test.go | 20 +-- internal/runtime/runtime.go | 2 + internal/runtime/runtime_test.go | 249 +++++++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 internal/config_test.go diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..4923d90 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "testing" + + "github.com/gnolang/supernova/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validBaseConfig returns a config with all fields valid except Mode/MixRatio +func validBaseConfig() *Config { + return &Config{ + URL: "http://localhost:26657", + ChainID: "dev", + Mnemonic: "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast", + SubAccounts: 10, + Transactions: 100, + BatchSize: 100, + } +} + +func TestConfig_Validate_MixedModeRequiresRatio(t *testing.T) { + t.Parallel() + + cfg := validBaseConfig() + cfg.Mode = runtime.Mixed.String() + cfg.MixRatio = "" + + err := cfg.Validate() + require.Error(t, err) + assert.ErrorIs(t, err, errMixRatioRequired) +} + +func TestConfig_Validate_MixedModeInvalidRatio(t *testing.T) { + t.Parallel() + + cfg := validBaseConfig() + cfg.Mode = runtime.Mixed.String() + cfg.MixRatio = "REALM_CALL:50" // only one type, needs at least 2 + + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mix-ratio") +} + +func TestConfig_Validate_MixedModeValidRatio(t *testing.T) { + t.Parallel() + + cfg := validBaseConfig() + cfg.Mode = runtime.Mixed.String() + cfg.MixRatio = "REALM_CALL:70,REALM_DEPLOYMENT:30" + + err := cfg.Validate() + assert.NoError(t, err) +} + +func TestConfig_Validate_NonMixedModeIgnoresRatio(t *testing.T) { + t.Parallel() + + cfg := validBaseConfig() + cfg.Mode = runtime.RealmCall.String() + cfg.MixRatio = "this is invalid but should be ignored" + + err := cfg.Validate() + assert.NoError(t, err) +} diff --git a/internal/runtime/mix_ratio.go b/internal/runtime/mix_ratio.go index 7df5dc2..4ec646e 100644 --- a/internal/runtime/mix_ratio.go +++ b/internal/runtime/mix_ratio.go @@ -18,13 +18,13 @@ var ( errInsufficientTypes = errors.New("mix ratio must contain at least 2 types") ) -type MixRatio struct { +type mixRatio struct { Type Type Percentage int } type MixConfig struct { - Ratios []MixRatio + Ratios []mixRatio } // ParseMixRatio parses a mix ratio string into a MixConfig @@ -40,7 +40,7 @@ func ParseMixRatio(input string) (*MixConfig, error) { } config := &MixConfig{ - Ratios: make([]MixRatio, 0, len(parts)), + Ratios: make([]mixRatio, 0, len(parts)), } seenTypes := make(map[Type]bool) @@ -73,10 +73,10 @@ func ParseMixRatio(input string) (*MixConfig, error) { // parseRatioPart parses a single TYPE:PERCENTAGE part of the mix ratio // Example input: "REALM_CALL:70" and ensures validity -func parseRatioPart(part string) (MixRatio, error) { +func parseRatioPart(part string) (mixRatio, error) { colonIdx := strings.LastIndex(part, ":") if colonIdx == -1 { - return MixRatio{}, fmt.Errorf("%w: %s", errInvalidRatioFormat, part) + return mixRatio{}, fmt.Errorf("%w: %s", errInvalidRatioFormat, part) } typeName := strings.TrimSpace(part[:colonIdx]) @@ -84,20 +84,20 @@ func parseRatioPart(part string) (MixRatio, error) { percentage, err := strconv.Atoi(percentageStr) if err != nil || percentage < 1 || percentage > 100 { - return MixRatio{}, fmt.Errorf("%w: %s", errInvalidPercentage, percentageStr) + return mixRatio{}, fmt.Errorf("%w: %s", errInvalidPercentage, percentageStr) } runtimeType := Type(typeName) if runtimeType == Mixed { - return MixRatio{}, errMixedInMix + return mixRatio{}, errMixedInMix } if !IsMixableRuntime(runtimeType) { - return MixRatio{}, fmt.Errorf("%w: %s", errUnknownType, typeName) + return mixRatio{}, fmt.Errorf("%w: %s", errUnknownType, typeName) } - return MixRatio{ + return mixRatio{ Type: runtimeType, Percentage: percentage, }, nil diff --git a/internal/runtime/mix_ratio_test.go b/internal/runtime/mix_ratio_test.go index 92b462e..a3695f3 100644 --- a/internal/runtime/mix_ratio_test.go +++ b/internal/runtime/mix_ratio_test.go @@ -13,12 +13,12 @@ func TestParseMixRatio_Valid(t *testing.T) { testTable := []struct { name string input string - expected []MixRatio + expected []mixRatio }{ { "three-way split", "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10", - []MixRatio{ + []mixRatio{ {RealmCall, 70}, {RealmDeployment, 20}, {PackageDeployment, 10}, @@ -27,7 +27,7 @@ func TestParseMixRatio_Valid(t *testing.T) { { "two-way split", "REALM_CALL:50,REALM_DEPLOYMENT:50", - []MixRatio{ + []mixRatio{ {RealmCall, 50}, {RealmDeployment, 50}, }, @@ -35,7 +35,7 @@ func TestParseMixRatio_Valid(t *testing.T) { { "with spaces", "REALM_CALL:70, REALM_DEPLOYMENT:30", - []MixRatio{ + []mixRatio{ {RealmCall, 70}, {RealmDeployment, 30}, }, @@ -143,7 +143,7 @@ func TestMixConfig_HasType(t *testing.T) { t.Parallel() config := &MixConfig{ - Ratios: []MixRatio{ + Ratios: []mixRatio{ {RealmCall, 70}, {RealmDeployment, 30}, }, @@ -160,13 +160,13 @@ func TestMixConfig_CalculateTransactionCounts(t *testing.T) { testTable := []struct { name string - ratios []MixRatio + ratios []mixRatio total uint64 expected map[Type]uint64 }{ { "exact division", - []MixRatio{ + []mixRatio{ {RealmCall, 70}, {RealmDeployment, 20}, {PackageDeployment, 10}, @@ -180,7 +180,7 @@ func TestMixConfig_CalculateTransactionCounts(t *testing.T) { }, { "with rounding - remainder goes to last", - []MixRatio{ + []mixRatio{ {RealmCall, 70}, {RealmDeployment, 30}, }, @@ -192,7 +192,7 @@ func TestMixConfig_CalculateTransactionCounts(t *testing.T) { }, { "small total with rounding", - []MixRatio{ + []mixRatio{ {RealmCall, 33}, {RealmDeployment, 33}, {PackageDeployment, 34}, @@ -206,7 +206,7 @@ func TestMixConfig_CalculateTransactionCounts(t *testing.T) { }, { "two-way 50/50", - []MixRatio{ + []mixRatio{ {RealmCall, 50}, {PackageDeployment, 50}, }, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4699599..c527b8c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -20,10 +20,12 @@ const ( type mixConfigKey struct{} +// WithMixConfig attaches the mix config to the context func WithMixConfig(ctx context.Context, config *MixConfig) context.Context { return context.WithValue(ctx, mixConfigKey{}, config) } +// GetMixConfig retrieves the mix config from the context, if any func GetMixConfig(ctx context.Context) *MixConfig { config, _ := ctx.Value(mixConfigKey{}).(*MixConfig) return config diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index af68884..522f9a5 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -9,6 +9,7 @@ import ( "github.com/gnolang/supernova/internal/common" testutils "github.com/gnolang/supernova/internal/testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // verifyDeployTxCommon does common transaction verification @@ -211,3 +212,251 @@ func TestRuntime_RealmCall(t *testing.T) { ) } } + +func TestRuntime_Mixed_InitializeWithRealmCall(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmCall, 70}, + {RealmDeployment, 30}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + accounts := generateAccounts(1) + + initialTxs, err := r.Initialize( + accounts[0], + func(_ *std.Tx) error { return nil }, + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + 1_000_000, + common.DefaultGasPrice, + ) + require.NoError(t, err) + require.Len(t, initialTxs, 1) + + // Verify it's a realm deployment + msg, ok := initialTxs[0].Msgs[0].(vm.MsgAddPackage) + require.True(t, ok) + assert.Contains(t, msg.Package.Path, realmPathPrefix) + assert.Len(t, msg.Package.Files, 2) +} + +func TestRuntime_Mixed_InitializeWithoutRealmCall(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmDeployment, 60}, + {PackageDeployment, 40}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + accounts := generateAccounts(1) + + initialTxs, err := r.Initialize( + accounts[0], + func(_ *std.Tx) error { return nil }, + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + 1_000_000, + common.DefaultGasPrice, + ) + require.NoError(t, err) + assert.Nil(t, initialTxs) +} + +func TestRuntime_Mixed_ConstructTransactions_TypeDistribution(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmCall, 70}, + {RealmDeployment, 20}, + {PackageDeployment, 10}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + var ( + transactions = uint64(100) + accounts = generateAccounts(10) + accountKeys = testutils.GenerateAccounts(t, 10) + ) + + // Initialize first to set up realmPath for REALM_CALL + _, err = r.Initialize( + accounts[0], + func(_ *std.Tx) error { return nil }, + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + 1_000_000, + common.DefaultGasPrice, + ) + require.NoError(t, err) + + txs, err := r.ConstructTransactions( + accountKeys, + accounts, + transactions, + 1_000_000, + common.DefaultGasPrice, + "dummy", + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + ) + require.NoError(t, err) + require.Len(t, txs, int(transactions)) + + // Count transaction types + var realmCalls, realmDeploys, pkgDeploys int + + for _, tx := range txs { + require.Len(t, tx.Msgs, 1) + switch msg := tx.Msgs[0].(type) { + case vm.MsgCall: + realmCalls++ + case vm.MsgAddPackage: + if assert.NotNil(t, msg.Package) { + if len(msg.Package.Path) > 0 && msg.Package.Path[:len(packagePathPrefix)] == packagePathPrefix { + pkgDeploys++ + } else { + realmDeploys++ + } + } + } + } + + assert.Equal(t, 70, realmCalls) + assert.Equal(t, 20, realmDeploys) + assert.Equal(t, 10, pkgDeploys) +} + +func TestRuntime_Mixed_ConstructTransactions_NonceManagement(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmCall, 50}, + {RealmDeployment, 50}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + var ( + transactions = uint64(20) + accounts = generateAccounts(2) + accountKeys = testutils.GenerateAccounts(t, 2) + ) + + // Initialize to set up realmPath + _, err = r.Initialize( + accounts[0], + func(_ *std.Tx) error { return nil }, + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + 1_000_000, + common.DefaultGasPrice, + ) + require.NoError(t, err) + + txs, err := r.ConstructTransactions( + accountKeys, + accounts, + transactions, + 1_000_000, + common.DefaultGasPrice, + "dummy", + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + ) + require.NoError(t, err) + require.Len(t, txs, int(transactions)) + + // Verify each transaction was signed + for i, tx := range txs { + assert.Len(t, tx.Signatures, 1, "tx %d should have exactly 1 signature", i) + } +} + +func TestRuntime_Mixed_RealmCallUsesPredeployedPath(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmCall, 50}, + {PackageDeployment, 50}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + accounts := generateAccounts(1) + + // Initialize sets the realmPath + _, err = r.Initialize( + accounts[0], + func(_ *std.Tx) error { return nil }, + func(_ context.Context, _ *std.Tx) (int64, error) { return 1_000_000, nil }, + 1_000_000, + common.DefaultGasPrice, + ) + require.NoError(t, err) + + // Access the mixed runtime to verify realmPath is set and used + mr := r.(*mixedRuntime) + assert.NotEmpty(t, mr.realmPath) + assert.Contains(t, mr.realmPath, realmPathPrefix) + + // Verify getMsgForType produces a MsgCall targeting the predeployed path + msg := mr.getMsgForType(RealmCall, accounts[0], 0) + callMsg, ok := msg.(vm.MsgCall) + require.True(t, ok) + assert.Equal(t, mr.realmPath, callMsg.PkgPath) + assert.Equal(t, methodName, callMsg.Func) +} + +func TestRuntime_Mixed_DeploymentsHaveUniquePaths(t *testing.T) { + t.Parallel() + + config := &MixConfig{ + Ratios: []mixRatio{ + {RealmDeployment, 50}, + {PackageDeployment, 50}, + }, + } + + ctx := WithMixConfig(context.Background(), config) + r, err := GetRuntime(ctx, Mixed) + require.NoError(t, err) + + mr := r.(*mixedRuntime) + accounts := generateAccounts(1) + + paths := make(map[string]bool) + + for i := range 5 { + msg := mr.getMsgForType(RealmDeployment, accounts[0], i) + deployMsg := msg.(vm.MsgAddPackage) + assert.False(t, paths[deployMsg.Package.Path], "duplicate realm path at index %d", i) + paths[deployMsg.Package.Path] = true + } + + for i := range 5 { + msg := mr.getMsgForType(PackageDeployment, accounts[0], i) + deployMsg := msg.(vm.MsgAddPackage) + assert.False(t, paths[deployMsg.Package.Path], "duplicate package path at index %d", i) + paths[deployMsg.Package.Path] = true + } +} From 09508e32d74e66a1d8cd847db339b42982dba0ae Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Fri, 23 Jan 2026 14:43:16 +0100 Subject: [PATCH 3/7] feat(supernova): add mixed runtime with percentage for each kind of txs --- internal/runtime/mix_ratio.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/runtime/mix_ratio.go b/internal/runtime/mix_ratio.go index 4ec646e..cbf0514 100644 --- a/internal/runtime/mix_ratio.go +++ b/internal/runtime/mix_ratio.go @@ -122,6 +122,9 @@ func (mc *MixConfig) Validate() error { return nil } +// HasType checks if the MixConfig includes the specified runtime type +// For example in: REALM_CALL:70,REALM_DEPLOYMENT:30, HasType(REALM_CALL) returns true +// but HasType(PACKAGE_DEPLOYMENT) returns false func (mc *MixConfig) HasType(t Type) bool { for _, ratio := range mc.Ratios { if ratio.Type == t { From 85997e247cdedd02bd5742ce1536eaa0e5d962ae Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Wed, 28 Jan 2026 16:29:36 +0100 Subject: [PATCH 4/7] fix: lint errors --- cmd/root.go | 3 +- internal/config_test.go | 7 +- internal/runtime/mix_ratio.go | 3 + internal/runtime/mix_ratio_test.go | 132 ++++++++++++++--------------- internal/runtime/mixed.go | 9 +- internal/runtime/runtime.go | 2 + internal/runtime/runtime_test.go | 3 +- 7 files changed, 84 insertions(+), 75 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 1a1e623..c761886 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,7 +65,8 @@ func registerFlags(fs *flag.FlagSet, c *internal.Config) { runtime.RealmDeployment.String(), fmt.Sprintf( "the mode for the stress test. Possible modes: [%s, %s, %s, %s]", - runtime.RealmDeployment.String(), runtime.PackageDeployment.String(), runtime.RealmCall.String(), runtime.Mixed.String(), + runtime.RealmDeployment.String(), runtime.PackageDeployment.String(), + runtime.RealmCall.String(), runtime.Mixed.String(), ), ) diff --git a/internal/config_test.go b/internal/config_test.go index 4923d90..f86d6f9 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -11,9 +11,10 @@ import ( // validBaseConfig returns a config with all fields valid except Mode/MixRatio func validBaseConfig() *Config { return &Config{ - URL: "http://localhost:26657", - ChainID: "dev", - Mnemonic: "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast", + URL: "http://localhost:26657", + ChainID: "dev", + Mnemonic: "source bonus chronic canvas draft south burst lottery " + + "vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast", SubAccounts: 10, Transactions: 100, BatchSize: 100, diff --git a/internal/runtime/mix_ratio.go b/internal/runtime/mix_ratio.go index cbf0514..db18fd7 100644 --- a/internal/runtime/mix_ratio.go +++ b/internal/runtime/mix_ratio.go @@ -59,6 +59,7 @@ func ParseMixRatio(input string) (*MixConfig, error) { if seenTypes[ratio.Type] { return nil, fmt.Errorf("%w: %s", errDuplicateType, ratio.Type) } + seenTypes[ratio.Type] = true config.Ratios = append(config.Ratios, ratio) @@ -131,6 +132,7 @@ func (mc *MixConfig) HasType(t Type) bool { return true } } + return false } @@ -138,6 +140,7 @@ func (mc *MixConfig) HasType(t Type) bool { // for each runtime type based on the total and the defined ratios func (mc *MixConfig) CalculateTransactionCounts(total uint64) map[Type]uint64 { counts := make(map[Type]uint64) + var allocated uint64 for i, ratio := range mc.Ratios { diff --git a/internal/runtime/mix_ratio_test.go b/internal/runtime/mix_ratio_test.go index a3695f3..c9d101b 100644 --- a/internal/runtime/mix_ratio_test.go +++ b/internal/runtime/mix_ratio_test.go @@ -16,26 +16,26 @@ func TestParseMixRatio_Valid(t *testing.T) { expected []mixRatio }{ { - "three-way split", - "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10", - []mixRatio{ + name: "three-way split", + input: "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10", + expected: []mixRatio{ {RealmCall, 70}, {RealmDeployment, 20}, {PackageDeployment, 10}, }, }, { - "two-way split", - "REALM_CALL:50,REALM_DEPLOYMENT:50", - []mixRatio{ + name: "two-way split", + input: "REALM_CALL:50,REALM_DEPLOYMENT:50", + expected: []mixRatio{ {RealmCall, 50}, {RealmDeployment, 50}, }, }, { - "with spaces", - "REALM_CALL:70, REALM_DEPLOYMENT:30", - []mixRatio{ + name: "with spaces", + input: "REALM_CALL:70, REALM_DEPLOYMENT:30", + expected: []mixRatio{ {RealmCall, 70}, {RealmDeployment, 30}, }, @@ -57,74 +57,74 @@ func TestParseMixRatio_Invalid(t *testing.T) { t.Parallel() testTable := []struct { + expectedErr error name string input string - expectedErr error }{ { - "empty string", - "", - errEmptyMixRatio, + name: "empty string", + input: "", + expectedErr: errEmptyMixRatio, }, { - "whitespace only", - " ", - errEmptyMixRatio, + name: "whitespace only", + input: " ", + expectedErr: errEmptyMixRatio, }, { - "single type", - "REALM_CALL:100", - errInsufficientTypes, + name: "single type", + input: "REALM_CALL:100", + expectedErr: errInsufficientTypes, }, { - "missing colon", - "REALM_CALL70,REALM_DEPLOYMENT30", - errInvalidRatioFormat, + name: "missing colon", + input: "REALM_CALL70,REALM_DEPLOYMENT30", + expectedErr: errInvalidRatioFormat, }, { - "invalid percentage - negative", - "REALM_CALL:-10,REALM_DEPLOYMENT:110", - errInvalidPercentage, + name: "invalid percentage - negative", + input: "REALM_CALL:-10,REALM_DEPLOYMENT:110", + expectedErr: errInvalidPercentage, }, { - "invalid percentage - zero", - "REALM_CALL:0,REALM_DEPLOYMENT:100", - errInvalidPercentage, + name: "invalid percentage - zero", + input: "REALM_CALL:0,REALM_DEPLOYMENT:100", + expectedErr: errInvalidPercentage, }, { - "invalid percentage - over 100", - "REALM_CALL:101,REALM_DEPLOYMENT:0", - errInvalidPercentage, + name: "invalid percentage - over 100", + input: "REALM_CALL:101,REALM_DEPLOYMENT:0", + expectedErr: errInvalidPercentage, }, { - "invalid percentage - not a number", - "REALM_CALL:abc,REALM_DEPLOYMENT:50", - errInvalidPercentage, + name: "invalid percentage - not a number", + input: "REALM_CALL:abc,REALM_DEPLOYMENT:50", + expectedErr: errInvalidPercentage, }, { - "unknown type", - "UNKNOWN_TYPE:50,REALM_DEPLOYMENT:50", - errUnknownType, + name: "unknown type", + input: "UNKNOWN_TYPE:50,REALM_DEPLOYMENT:50", + expectedErr: errUnknownType, }, { - "duplicate type", - "REALM_CALL:50,REALM_CALL:50", - errDuplicateType, + name: "duplicate type", + input: "REALM_CALL:50,REALM_CALL:50", + expectedErr: errDuplicateType, }, { - "MIXED in mix", - "MIXED:50,REALM_CALL:50", - errMixedInMix, + name: "MIXED in mix", + input: "MIXED:50,REALM_CALL:50", + expectedErr: errMixedInMix, }, { - "sum not 100 - under", - "REALM_CALL:40,REALM_DEPLOYMENT:40", - errRatioSumNot100, + name: "sum not 100 - under", + input: "REALM_CALL:40,REALM_DEPLOYMENT:40", + expectedErr: errRatioSumNot100, }, { - "sum not 100 - over", - "REALM_CALL:60,REALM_DEPLOYMENT:60", - errRatioSumNot100, + name: "sum not 100 - over", + input: "REALM_CALL:60,REALM_DEPLOYMENT:60", + expectedErr: errRatioSumNot100, }, } @@ -160,58 +160,58 @@ func TestMixConfig_CalculateTransactionCounts(t *testing.T) { testTable := []struct { name string + expected map[Type]uint64 ratios []mixRatio total uint64 - expected map[Type]uint64 }{ { - "exact division", - []mixRatio{ + name: "exact division", + ratios: []mixRatio{ {RealmCall, 70}, {RealmDeployment, 20}, {PackageDeployment, 10}, }, - 100, - map[Type]uint64{ + total: 100, + expected: map[Type]uint64{ RealmCall: 70, RealmDeployment: 20, PackageDeployment: 10, }, }, { - "with rounding - remainder goes to last", - []mixRatio{ + name: "with rounding - remainder goes to last", + ratios: []mixRatio{ {RealmCall, 70}, {RealmDeployment, 30}, }, - 10, - map[Type]uint64{ + total: 10, + expected: map[Type]uint64{ RealmCall: 7, RealmDeployment: 3, }, }, { - "small total with rounding", - []mixRatio{ + name: "small total with rounding", + ratios: []mixRatio{ {RealmCall, 33}, {RealmDeployment, 33}, {PackageDeployment, 34}, }, - 10, - map[Type]uint64{ + total: 10, + expected: map[Type]uint64{ RealmCall: 3, RealmDeployment: 3, PackageDeployment: 4, }, }, { - "two-way 50/50", - []mixRatio{ + name: "two-way 50/50", + ratios: []mixRatio{ {RealmCall, 50}, {PackageDeployment, 50}, }, - 100, - map[Type]uint64{ + total: 100, + expected: map[Type]uint64{ RealmCall: 50, PackageDeployment: 50, }, diff --git a/internal/runtime/mixed.go b/internal/runtime/mixed.go index c08d75b..471133c 100644 --- a/internal/runtime/mixed.go +++ b/internal/runtime/mixed.go @@ -97,11 +97,11 @@ func (m *mixedRuntime) CalculateRuntimeCosts( gasPrice std.GasPrice, transactions uint64, ) (std.Coin, error) { + var totalGas int64 + fmt.Printf("\nā³ Estimating Gas ā³\n") txCounts := m.config.CalculateTransactionCounts(transactions) - var totalGas int64 - for txType, count := range txCounts { if count == 0 { continue @@ -224,7 +224,7 @@ func (m *mixedRuntime) ConstructTransactions( nonceMap[accountNumber] = nonce + 1 txs[i] = tx - _ = bar.Add(1) + _ = bar.Add(1) //nolint:errcheck // No need to check } fmt.Printf("āœ… Successfully constructed %d transactions\n", transactions) @@ -234,13 +234,14 @@ func (m *mixedRuntime) ConstructTransactions( func (m *mixedRuntime) generateShuffledSequence(txCounts map[Type]uint64) []Type { var sequence []Type + for txType, count := range txCounts { for i := uint64(0); i < count; i++ { sequence = append(sequence, txType) } } - rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec // G404: Weak random number is acceptable here rng.Shuffle(len(sequence), func(i, j int) { sequence[i], sequence[j] = sequence[j], sequence[i] }) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c527b8c..016841d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -28,6 +28,7 @@ func WithMixConfig(ctx context.Context, config *MixConfig) context.Context { // GetMixConfig retrieves the mix config from the context, if any func GetMixConfig(ctx context.Context) *MixConfig { config, _ := ctx.Value(mixConfigKey{}).(*MixConfig) + return config } @@ -92,6 +93,7 @@ func GetRuntime(ctx context.Context, runtimeType Type) (Runtime, error) { if config == nil { return nil, errMissingMixConfig } + return newMixedRuntime(ctx, config), nil default: return nil, errUnknownRuntime diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 522f9a5..a8e3f8e 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -321,12 +321,13 @@ func TestRuntime_Mixed_ConstructTransactions_TypeDistribution(t *testing.T) { for _, tx := range txs { require.Len(t, tx.Msgs, 1) + switch msg := tx.Msgs[0].(type) { case vm.MsgCall: realmCalls++ case vm.MsgAddPackage: if assert.NotNil(t, msg.Package) { - if len(msg.Package.Path) > 0 && msg.Package.Path[:len(packagePathPrefix)] == packagePathPrefix { + if msg.Package.Path != "" && msg.Package.Path[:len(packagePathPrefix)] == packagePathPrefix { pkgDeploys++ } else { realmDeploys++ From 575c42b3d077cf462dba08961d7aba1c45e79e33 Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Wed, 28 Jan 2026 16:32:00 +0100 Subject: [PATCH 5/7] fix: lint errors --- internal/pipeline.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/pipeline.go b/internal/pipeline.go index 50630ff..9a7b403 100644 --- a/internal/pipeline.go +++ b/internal/pipeline.go @@ -60,7 +60,11 @@ func (p *Pipeline) Execute(ctx context.Context) error { // Setup the context for mixed mode if mode == runtime.Mixed { - mixConfig, _ := runtime.ParseMixRatio(p.cfg.MixRatio) + mixConfig, err := runtime.ParseMixRatio(p.cfg.MixRatio) + if err != nil { + return fmt.Errorf("unable to parse mix ratio: %w", err) + } + ctx = runtime.WithMixConfig(ctx, mixConfig) } @@ -240,6 +244,7 @@ func prepareRuntime( signCB := runtime.SignTransactionsCb(chainID, deployer, deployerKey) needsPredeploy := mode == runtime.RealmCall + if mode == runtime.Mixed { mixConfig := runtime.GetMixConfig(ctx) needsPredeploy = mixConfig != nil && mixConfig.HasType(runtime.RealmCall) From 7e705341da304c204dfb40006ed827d8471cd18f Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Mon, 2 Feb 2026 16:38:34 +0100 Subject: [PATCH 6/7] fix: use existing code --- internal/runtime/mixed.go | 123 ++++--------------------------- internal/runtime/runtime_test.go | 9 ++- 2 files changed, 20 insertions(+), 112 deletions(-) diff --git a/internal/runtime/mixed.go b/internal/runtime/mixed.go index 471133c..3e893f6 100644 --- a/internal/runtime/mixed.go +++ b/internal/runtime/mixed.go @@ -6,7 +6,6 @@ import ( "math/rand" "time" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/supernova/internal/common" @@ -15,15 +14,19 @@ import ( ) type mixedRuntime struct { - ctx context.Context - config *MixConfig - realmPath string + ctx context.Context + config *MixConfig + realmCallRT *realmCall // for Initialize + RealmCall msgs + realmDeployRT *realmDeployment // for RealmDeployment msgs + packageDeployRT *packageDeployment // for PackageDeployment msgs } func newMixedRuntime(ctx context.Context, config *MixConfig) *mixedRuntime { return &mixedRuntime{ - ctx: ctx, - config: config, + ctx: ctx, + config: config, + realmDeployRT: newRealmDeployment(ctx), + packageDeployRT: newPackageDeployment(ctx), } } @@ -38,55 +41,10 @@ func (m *mixedRuntime) Initialize( return nil, nil } - m.realmPath = fmt.Sprintf( - "%s/%s/stress_%d", - realmPathPrefix, - account.GetAddress().String(), - time.Now().Unix(), - ) - - msg := vm.MsgAddPackage{ - Creator: account.GetAddress(), - Package: &std.MemPackage{ - Name: packageName, - Path: m.realmPath, - Files: []*std.MemFile{ - { - Name: gnomodFileName, - Body: gnomodBody, - }, - { - Name: realmFileName, - Body: realmBody, - }, - }, - }, - } - - tx := &std.Tx{ - Msgs: []std.Msg{msg}, - Fee: common.CalculateFeeInRatio(currentMaxGas, gasPrice), - } - - err := signFn(tx) - if err != nil { - return nil, fmt.Errorf("unable to sign initialize transaction, %w", err) - } - - gasWanted, err := estimateFn(m.ctx, tx) - if err != nil { - return nil, fmt.Errorf("unable to estimate gas: %w", err) - } + // Delegate to realmCall runtime for initialization + m.realmCallRT = newRealmCall(m.ctx) - tx.Signatures = make([]std.Signature, 0) - tx.Fee = common.CalculateFeeInRatio(gasWanted+gasBuffer, gasPrice) - - err = signFn(tx) - if err != nil { - return nil, fmt.Errorf("unable to sign initialize transaction, %w", err) - } - - return []*std.Tx{tx}, nil + return m.realmCallRT.Initialize(account, signFn, estimateFn, currentMaxGas, gasPrice) } func (m *mixedRuntime) CalculateRuntimeCosts( @@ -250,64 +208,13 @@ func (m *mixedRuntime) generateShuffledSequence(txCounts map[Type]uint64) []Type } func (m *mixedRuntime) getMsgForType(txType Type, creator std.Account, index int) std.Msg { - timestamp := time.Now().Unix() - switch txType { case RealmCall: - return vm.MsgCall{ - Caller: creator.GetAddress(), - PkgPath: m.realmPath, - Func: methodName, - Args: []string{fmt.Sprintf("Account-%d", index)}, - } + return m.realmCallRT.getMsgFn(creator, index) case RealmDeployment: - return vm.MsgAddPackage{ - Creator: creator.GetAddress(), - Package: &std.MemPackage{ - Name: packageName, - Path: fmt.Sprintf( - "%s/%s/stress_%d_%d", - realmPathPrefix, - creator.GetAddress().String(), - timestamp, - index, - ), - Files: []*std.MemFile{ - { - Name: gnomodFileName, - Body: gnomodBody, - }, - { - Name: realmFileName, - Body: realmBody, - }, - }, - }, - } + return m.realmDeployRT.getMsgFn(creator, index) case PackageDeployment: - return vm.MsgAddPackage{ - Creator: creator.GetAddress(), - Package: &std.MemPackage{ - Name: packageName, - Path: fmt.Sprintf( - "%s/%s/stress_%d_%d", - packagePathPrefix, - creator.GetAddress().String(), - timestamp, - index, - ), - Files: []*std.MemFile{ - { - Name: gnomodFileName, - Body: gnomodBody, - }, - { - Name: packageFileName, - Body: packageBody, - }, - }, - }, - } + return m.packageDeployRT.getMsgFn(creator, index) default: return nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index a8e3f8e..ab9593d 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -415,16 +415,17 @@ func TestRuntime_Mixed_RealmCallUsesPredeployedPath(t *testing.T) { ) require.NoError(t, err) - // Access the mixed runtime to verify realmPath is set and used + // Access the mixed runtime to verify realmPath is set via the composed realmCallRT mr := r.(*mixedRuntime) - assert.NotEmpty(t, mr.realmPath) - assert.Contains(t, mr.realmPath, realmPathPrefix) + require.NotNil(t, mr.realmCallRT) + assert.NotEmpty(t, mr.realmCallRT.realmPath) + assert.Contains(t, mr.realmCallRT.realmPath, realmPathPrefix) // Verify getMsgForType produces a MsgCall targeting the predeployed path msg := mr.getMsgForType(RealmCall, accounts[0], 0) callMsg, ok := msg.(vm.MsgCall) require.True(t, ok) - assert.Equal(t, mr.realmPath, callMsg.PkgPath) + assert.Equal(t, mr.realmCallRT.realmPath, callMsg.PkgPath) assert.Equal(t, methodName, callMsg.Func) } From 7ab57ae6b822f3185cf0a1833c2889aab10444b8 Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Mon, 2 Feb 2026 16:51:25 +0100 Subject: [PATCH 7/7] fix(supernova): add seed for reproduce txs ordering --- README.md | 27 +++++++++++++++++++++++++-- cmd/root.go | 7 +++++++ internal/config.go | 1 + internal/pipeline.go | 1 + internal/runtime/mix_ratio.go | 1 + internal/runtime/mixed.go | 9 ++++++++- 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e8f45fb..796efe8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ and report on node performance by executing transactions and measuring response- ## Key Features - šŸš€ Batch transactions to make stress testing easier to orchestrate -- šŸ›  Multiple stress testing modes: REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, and REALM_CALL +- šŸ›  Multiple stress testing modes: REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL, and MIXED - šŸ’° Distributed transaction stress testing through subaccounts - šŸ’ø Automatic subaccount fund top-up - šŸ“Š Detailed statistics calculation @@ -57,7 +57,9 @@ FLAGS -batch 100 the batch size of JSON-RPC transactions -chain-id dev the chain ID of the Gno blockchain -mnemonic string the mnemonic used to generate sub-accounts - -mode REALM_DEPLOYMENT the mode for the stress test. Possible modes: [REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL] + -mode REALM_DEPLOYMENT the mode for the stress test. Possible modes: [REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL, MIXED] + -mix-ratio string transaction mix ratios for MIXED mode, e.g., "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10" + -mix-seed 0 optional seed for reproducible transaction shuffling in MIXED mode (0 = random) -output string the output path for the results JSON -sub-accounts 10 the number of sub-accounts that will send out transactions -transactions 100 the total number of transactions to be emitted @@ -80,3 +82,24 @@ deploy a package. The `REALM_CALL` mode deploys a `Realm` to the Gno blockchain network being tested before starting the cycle run. When the cycle run begins, the transactions that are sent out are method calls. + +### MIXED + +The `MIXED` mode allows combining multiple transaction types in a single stress test run. You specify the ratio of each +transaction type using the `-mix-ratio` flag. The percentages must sum to 100. + +Example: +```bash +./build/supernova -mode MIXED -mix-ratio "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10" ... +``` + +This will generate 70% REALM_CALL transactions, 20% REALM_DEPLOYMENT transactions, and 10% PACKAGE_DEPLOYMENT transactions, +shuffled randomly throughout the run. + +For reproducible runs (useful for debugging or benchmarking), use the `-mix-seed` flag: +```bash +./build/supernova -mode MIXED -mix-ratio "REALM_CALL:70,REALM_DEPLOYMENT:30" -mix-seed 12345 ... +``` + +When running without a seed (or with `-mix-seed 0`), the tool logs the randomly generated seed so you can reproduce +the exact same transaction ordering later. diff --git a/cmd/root.go b/cmd/root.go index c761886..c12c4cf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,6 +77,13 @@ func registerFlags(fs *flag.FlagSet, c *internal.Config) { "transaction mix ratios for MIXED mode, e.g., \"REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10\"", ) + fs.Int64Var( + &c.MixSeed, + "mix-seed", + 0, + "optional seed for reproducible transaction shuffling in MIXED mode (0 = random)", + ) + fs.StringVar( &c.Output, "output", diff --git a/internal/config.go b/internal/config.go index 7b5b28f..d2fa3e8 100644 --- a/internal/config.go +++ b/internal/config.go @@ -34,6 +34,7 @@ type Config struct { Mnemonic string // the mnemonic for the keyring Mode string // the stress test mode MixRatio string // transaction mix ratios for MIXED mode + MixSeed int64 // optional seed for reproducible shuffling in MIXED mode Output string // output path for results JSON, if any SubAccounts uint64 // the number of sub-accounts in the run diff --git a/internal/pipeline.go b/internal/pipeline.go index 9a7b403..5cea6b2 100644 --- a/internal/pipeline.go +++ b/internal/pipeline.go @@ -65,6 +65,7 @@ func (p *Pipeline) Execute(ctx context.Context) error { return fmt.Errorf("unable to parse mix ratio: %w", err) } + mixConfig.Seed = p.cfg.MixSeed ctx = runtime.WithMixConfig(ctx, mixConfig) } diff --git a/internal/runtime/mix_ratio.go b/internal/runtime/mix_ratio.go index db18fd7..2e3473b 100644 --- a/internal/runtime/mix_ratio.go +++ b/internal/runtime/mix_ratio.go @@ -25,6 +25,7 @@ type mixRatio struct { type MixConfig struct { Ratios []mixRatio + Seed int64 // Optional seed for reproducible shuffling (0 = use current time) } // ParseMixRatio parses a mix ratio string into a MixConfig diff --git a/internal/runtime/mixed.go b/internal/runtime/mixed.go index 3e893f6..8983d60 100644 --- a/internal/runtime/mixed.go +++ b/internal/runtime/mixed.go @@ -199,7 +199,14 @@ func (m *mixedRuntime) generateShuffledSequence(txCounts map[Type]uint64) []Type } } - rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec // G404: Weak random number is acceptable here + seed := m.config.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + + fmt.Printf("Using shuffle seed: %d (use --mix-seed=%d to reproduce)\n", seed, seed) + + rng := rand.New(rand.NewSource(seed)) //nolint:gosec // G404: Weak random number is acceptable here rng.Shuffle(len(sequence), func(i, j int) { sequence[i], sequence[j] = sequence[j], sequence[i] })