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 5f06e9d..c12c4cf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,11 +64,26 @@ 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.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 224dac7..d2fa3e8 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,8 @@ 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 + 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 @@ -71,5 +75,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/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..f86d6f9 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,68 @@ +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/pipeline.go b/internal/pipeline.go index 0569198..5cea6b2 100644 --- a/internal/pipeline.go +++ b/internal/pipeline.go @@ -56,12 +56,27 @@ 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, err := runtime.ParseMixRatio(p.cfg.MixRatio) + if err != nil { + return fmt.Errorf("unable to parse mix ratio: %w", err) + } + + mixConfig.Seed = p.cfg.MixSeed + 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 +244,14 @@ 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..2e3473b --- /dev/null +++ b/internal/runtime/mix_ratio.go @@ -0,0 +1,158 @@ +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 + Seed int64 // Optional seed for reproducible shuffling (0 = use current time) +} + +// 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 +} + +// 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 { + 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..c9d101b --- /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 + }{ + { + name: "three-way split", + input: "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10", + expected: []mixRatio{ + {RealmCall, 70}, + {RealmDeployment, 20}, + {PackageDeployment, 10}, + }, + }, + { + name: "two-way split", + input: "REALM_CALL:50,REALM_DEPLOYMENT:50", + expected: []mixRatio{ + {RealmCall, 50}, + {RealmDeployment, 50}, + }, + }, + { + name: "with spaces", + input: "REALM_CALL:70, REALM_DEPLOYMENT:30", + expected: []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 { + expectedErr error + name string + input string + }{ + { + name: "empty string", + input: "", + expectedErr: errEmptyMixRatio, + }, + { + name: "whitespace only", + input: " ", + expectedErr: errEmptyMixRatio, + }, + { + name: "single type", + input: "REALM_CALL:100", + expectedErr: errInsufficientTypes, + }, + { + name: "missing colon", + input: "REALM_CALL70,REALM_DEPLOYMENT30", + expectedErr: errInvalidRatioFormat, + }, + { + name: "invalid percentage - negative", + input: "REALM_CALL:-10,REALM_DEPLOYMENT:110", + expectedErr: errInvalidPercentage, + }, + { + name: "invalid percentage - zero", + input: "REALM_CALL:0,REALM_DEPLOYMENT:100", + expectedErr: errInvalidPercentage, + }, + { + name: "invalid percentage - over 100", + input: "REALM_CALL:101,REALM_DEPLOYMENT:0", + expectedErr: errInvalidPercentage, + }, + { + name: "invalid percentage - not a number", + input: "REALM_CALL:abc,REALM_DEPLOYMENT:50", + expectedErr: errInvalidPercentage, + }, + { + name: "unknown type", + input: "UNKNOWN_TYPE:50,REALM_DEPLOYMENT:50", + expectedErr: errUnknownType, + }, + { + name: "duplicate type", + input: "REALM_CALL:50,REALM_CALL:50", + expectedErr: errDuplicateType, + }, + { + name: "MIXED in mix", + input: "MIXED:50,REALM_CALL:50", + expectedErr: errMixedInMix, + }, + { + name: "sum not 100 - under", + input: "REALM_CALL:40,REALM_DEPLOYMENT:40", + expectedErr: errRatioSumNot100, + }, + { + name: "sum not 100 - over", + input: "REALM_CALL:60,REALM_DEPLOYMENT:60", + expectedErr: 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 + expected map[Type]uint64 + ratios []mixRatio + total uint64 + }{ + { + name: "exact division", + ratios: []mixRatio{ + {RealmCall, 70}, + {RealmDeployment, 20}, + {PackageDeployment, 10}, + }, + total: 100, + expected: map[Type]uint64{ + RealmCall: 70, + RealmDeployment: 20, + PackageDeployment: 10, + }, + }, + { + name: "with rounding - remainder goes to last", + ratios: []mixRatio{ + {RealmCall, 70}, + {RealmDeployment, 30}, + }, + total: 10, + expected: map[Type]uint64{ + RealmCall: 7, + RealmDeployment: 3, + }, + }, + { + name: "small total with rounding", + ratios: []mixRatio{ + {RealmCall, 33}, + {RealmDeployment, 33}, + {PackageDeployment, 34}, + }, + total: 10, + expected: map[Type]uint64{ + RealmCall: 3, + RealmDeployment: 3, + PackageDeployment: 4, + }, + }, + { + name: "two-way 50/50", + ratios: []mixRatio{ + {RealmCall, 50}, + {PackageDeployment, 50}, + }, + total: 100, + expected: 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..8983d60 --- /dev/null +++ b/internal/runtime/mixed.go @@ -0,0 +1,228 @@ +package runtime + +import ( + "context" + "fmt" + "math/rand" + "time" + + "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 + 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, + realmDeployRT: newRealmDeployment(ctx), + packageDeployRT: newPackageDeployment(ctx), + } +} + +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 + } + + // Delegate to realmCall runtime for initialization + m.realmCallRT = newRealmCall(m.ctx) + + return m.realmCallRT.Initialize(account, signFn, estimateFn, currentMaxGas, gasPrice) +} + +func (m *mixedRuntime) CalculateRuntimeCosts( + account std.Account, + estimateFn EstimateGasFn, + signFn SignFn, + currentMaxGas int64, + gasPrice std.GasPrice, + transactions uint64, +) (std.Coin, error) { + var totalGas int64 + + fmt.Printf("\nā³ Estimating Gas ā³\n") + + txCounts := m.config.CalculateTransactionCounts(transactions) + 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) //nolint:errcheck // No need to check + } + + 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) + } + } + + 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] + }) + + return sequence +} + +func (m *mixedRuntime) getMsgForType(txType Type, creator std.Account, index int) std.Msg { + switch txType { + case RealmCall: + return m.realmCallRT.getMsgFn(creator, index) + case RealmDeployment: + return m.realmDeployRT.getMsgFn(creator, index) + case PackageDeployment: + return m.packageDeployRT.getMsgFn(creator, index) + default: + return nil + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8edc01e..016841d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,16 +2,36 @@ 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{} + +// 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 +} + // EstimateGasFn is the gas estimation callback type EstimateGasFn func(ctx context.Context, tx *std.Tx) (int64, error) @@ -59,15 +79,23 @@ 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..ab9593d 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 @@ -71,7 +72,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 +131,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( @@ -205,3 +212,253 @@ 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 msg.Package.Path != "" && 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 via the composed realmCallRT + mr := r.(*mixedRuntime) + 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.realmCallRT.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 + } +} 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)) + }) + } +}