diff --git a/go.mod b/go.mod index df5a1ac7a..1cfef4f2f 100644 --- a/go.mod +++ b/go.mod @@ -12,18 +12,18 @@ require ( github.com/getsentry/sentry-go v0.40.0 github.com/gosuri/uilive v0.0.4 github.com/logrusorgru/aurora/v4 v4.0.0 - github.com/onflow/cadence v1.9.4 - github.com/onflow/cadence-tools/languageserver v1.9.0 - github.com/onflow/cadence-tools/lint v1.7.1 - github.com/onflow/cadence-tools/test v1.10.1 + github.com/onflow/cadence v1.9.5 + github.com/onflow/cadence-tools/languageserver v1.9.1-0.20260116223002-65e051a511cf + github.com/onflow/cadence-tools/lint v1.7.2-0.20260116223002-65e051a511cf + github.com/onflow/cadence-tools/test v1.10.2-0.20260120064827-9986e0d83e4b github.com/onflow/fcl-dev-wallet v0.9.1 github.com/onflow/flixkit-go/v2 v2.7.1 github.com/onflow/flow-core-contracts/lib/go/contracts v1.9.2 github.com/onflow/flow-core-contracts/lib/go/templates v1.9.2 - github.com/onflow/flow-emulator v1.15.3 + github.com/onflow/flow-emulator v1.16.0 github.com/onflow/flow-evm-gateway v1.4.5-0.20260109131900-576c361f7c70 - github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260109014156-2abea7b5732a - github.com/onflow/flow-go-sdk v1.9.10 + github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260113150225-6d5c1e97a834 + github.com/onflow/flow-go-sdk v1.9.11 github.com/onflow/flow/protobuf/go/flow v0.4.19 github.com/onflow/flowkit/v2 v2.10.1 github.com/onflowser/flowser/v3 v3.2.1-0.20240131200229-7d4d22715f48 diff --git a/go.sum b/go.sum index 293936db4..44272e787 100644 --- a/go.sum +++ b/go.sum @@ -774,14 +774,14 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onflow/atree v0.12.0 h1:X7/UEPyCaaEQ1gCg11KDvfyEtEeQLhtRtxMHjAiH/Co= github.com/onflow/atree v0.12.0/go.mod h1:qdZcfLQwPirHcNpLiK+2t3KAo+SAb9Si6TqurE6pykE= -github.com/onflow/cadence v1.9.4 h1:ndRoFD6XDCY1+1CuUIOtJmpCTVwox34MN8AkiXUIHUE= -github.com/onflow/cadence v1.9.4/go.mod h1:MlJsCwhCZwdnAUd24XHzcsizZfG7a2leab1PztabUsE= -github.com/onflow/cadence-tools/languageserver v1.9.0 h1:wBO+u9i2ZPkTqIsBG8yn2i8N7ngy1YUvWYExpu2A+g0= -github.com/onflow/cadence-tools/languageserver v1.9.0/go.mod h1:j/umIUqZ2pF6f59KjIwTe8uCtAolx/e27rSjPF2w4Sg= -github.com/onflow/cadence-tools/lint v1.7.1 h1:R0CKqbJ8+fB9ZQDSLfJ5sgx9aryu26fz2DNRvW9fp68= -github.com/onflow/cadence-tools/lint v1.7.1/go.mod h1:Qtpheqe2x/LHhjFn8MdUL7kC4akF6v/7STirUbftHVE= -github.com/onflow/cadence-tools/test v1.10.1 h1:ilq6NIIf9xSP3Hrk+9ITWmaopSHXvyV+XqcWGvS1Shk= -github.com/onflow/cadence-tools/test v1.10.1/go.mod h1:hUge6L4WPfvYTYNTtxsPgLUh23f41mmaA3Pryl4hpRk= +github.com/onflow/cadence v1.9.5 h1:m82RsERxvrknL69J9YxzOEKFhiHARBQkvNIFTUATGNk= +github.com/onflow/cadence v1.9.5/go.mod h1:MlJsCwhCZwdnAUd24XHzcsizZfG7a2leab1PztabUsE= +github.com/onflow/cadence-tools/languageserver v1.9.1-0.20260116223002-65e051a511cf h1:o9wgojwwzZBsrjmEkHWszNguQdoRdrMBy4gmCRXZ/WA= +github.com/onflow/cadence-tools/languageserver v1.9.1-0.20260116223002-65e051a511cf/go.mod h1:j/umIUqZ2pF6f59KjIwTe8uCtAolx/e27rSjPF2w4Sg= +github.com/onflow/cadence-tools/lint v1.7.2-0.20260116223002-65e051a511cf h1:2riNRgjZ3SOfKqjBcLvZxIl9PJeOBIXQeS/HWUj9kjM= +github.com/onflow/cadence-tools/lint v1.7.2-0.20260116223002-65e051a511cf/go.mod h1:LR9L+nmtlTSY8LPyNZCIO0qHSai8noplanhWYCFT0+M= +github.com/onflow/cadence-tools/test v1.10.2-0.20260120064827-9986e0d83e4b h1:Xso4PDiLRHp6VxcYXcLhW2/I0MnjDcpuiHNTzi/ElIw= +github.com/onflow/cadence-tools/test v1.10.2-0.20260120064827-9986e0d83e4b/go.mod h1:KQSRZpSfKmsgIXhl4Dmi+lAP/uev4Xq0Y8bYE1nJPqU= github.com/onflow/crypto v0.25.3 h1:XQ3HtLsw8h1+pBN+NQ1JYM9mS2mVXTyg55OldaAIF7U= github.com/onflow/crypto v0.25.3/go.mod h1:+1igaXiK6Tjm9wQOBD1EGwW7bYWMUGKtwKJ/2QL/OWs= github.com/onflow/fcl-dev-wallet v0.9.1 h1:FmDH8dkRdIXluedaFy+sCk23FuwCgycWuJAbcsEP974= @@ -794,8 +794,8 @@ github.com/onflow/flow-core-contracts/lib/go/contracts v1.9.2 h1:mkd1NSv74+OnCHw github.com/onflow/flow-core-contracts/lib/go/contracts v1.9.2/go.mod h1:jBDqVep0ICzhXky56YlyO4aiV2Jl/5r7wnqUPpvi7zE= github.com/onflow/flow-core-contracts/lib/go/templates v1.9.2 h1:semxeVLwC6xFG1G/7egUmaf7F1C8eBnc7NxNTVfBHTs= github.com/onflow/flow-core-contracts/lib/go/templates v1.9.2/go.mod h1:twSVyUt3rNrgzAmxtBX+1Gw64QlPemy17cyvnXYy1Ug= -github.com/onflow/flow-emulator v1.15.3 h1:temoo0GH4FF/EJHbDd+/JUX/9UWgBubdZv62CGe+/RA= -github.com/onflow/flow-emulator v1.15.3/go.mod h1:6pQ7hawYNwh+ojyUSr7QgNEwvknYBn+xgVNyfAYMG5Y= +github.com/onflow/flow-emulator v1.16.0 h1:X6XHiLoGTxkBED4SBER/iFhbKgY2c5GBORVPlWztM2g= +github.com/onflow/flow-emulator v1.16.0/go.mod h1:euOj6XyAc6O2AhG8d0w5/NxaeAa0jysQsHfFkXD0+6I= github.com/onflow/flow-evm-bridge v0.1.0 h1:7X2osvo4NnQgHj8aERUmbYtv9FateX8liotoLnPL9nM= github.com/onflow/flow-evm-bridge v0.1.0/go.mod h1:5UYwsnu6WcBNrwitGFxphCl5yq7fbWYGYuiCSTVF6pk= github.com/onflow/flow-evm-gateway v1.4.5-0.20260109131900-576c361f7c70 h1:TlUw1qmodv+5BFrVShJQKHk3FBTGtGZQ1EffgdDktJE= @@ -804,10 +804,10 @@ github.com/onflow/flow-ft/lib/go/contracts v1.0.1 h1:Ts5ob+CoCY2EjEd0W6vdLJ7hLL3 github.com/onflow/flow-ft/lib/go/contracts v1.0.1/go.mod h1:PwsL8fC81cjnUnTfmyL/HOIyHnyaw/JA474Wfj2tl6A= github.com/onflow/flow-ft/lib/go/templates v1.0.1 h1:FDYKAiGowABtoMNusLuRCILIZDtVqJ/5tYI4VkF5zfM= github.com/onflow/flow-ft/lib/go/templates v1.0.1/go.mod h1:uQ8XFqmMK2jxyBSVrmyuwdWjTEb+6zGjRYotfDJ5pAE= -github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260109014156-2abea7b5732a h1:mJbVpoYrr53pEOima8ageY3PKKjfMASAM8abrT7cJok= -github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260109014156-2abea7b5732a/go.mod h1:X2ySZctFIcMt1f5zn8A0uY5kr7C5WIMuSK+OajzZohk= -github.com/onflow/flow-go-sdk v1.9.10 h1:rsl8LsSGnD7OGYCuNND2kN01yPqE7FRiSEZfwGGDpRo= -github.com/onflow/flow-go-sdk v1.9.10/go.mod h1:pAkdLvbVP5HDpNygReIQDCSk7yTsoCUZQOjmtXDh4yM= +github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260113150225-6d5c1e97a834 h1:NnOXzrHQYio/eVfs2vWsXh8IE2Qphb/Efg0x2yGCEr8= +github.com/onflow/flow-go v0.45.0-experimental-cadence-v1.8.7.0.20260113150225-6d5c1e97a834/go.mod h1:X2ySZctFIcMt1f5zn8A0uY5kr7C5WIMuSK+OajzZohk= +github.com/onflow/flow-go-sdk v1.9.11 h1:glzxLIV4cZv+/0NGRaP/PCX6iepYZIgU/M0CAAgxsAs= +github.com/onflow/flow-go-sdk v1.9.11/go.mod h1:1xk5ZOC8VPv2ecN+1Zjgv6PZRjxuzz7EOq6PwAMQc2o= github.com/onflow/flow-nft/lib/go/contracts v1.3.0 h1:DmNop+O0EMyicZvhgdWboFG57xz5t9Qp81FKlfKyqJc= github.com/onflow/flow-nft/lib/go/contracts v1.3.0/go.mod h1:eZ9VMMNfCq0ho6kV25xJn1kXeCfxnkhj3MwF3ed08gY= github.com/onflow/flow-nft/lib/go/templates v1.3.0 h1:uGIBy4GEY6Z9hKP7sm5nA5kwvbvLWW4nWx5NN9Wg0II= diff --git a/internal/transactions/profile.go b/internal/transactions/profile.go new file mode 100644 index 000000000..64ba7a476 --- /dev/null +++ b/internal/transactions/profile.go @@ -0,0 +1,451 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package transactions + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/onflow/cadence/runtime" + "github.com/onflow/flow-go/fvm" + "github.com/onflow/flow-go/fvm/environment" + reusableRuntime "github.com/onflow/flow-go/fvm/runtime" + fvmStorage "github.com/onflow/flow-go/fvm/storage" + fvmState "github.com/onflow/flow-go/fvm/storage/state" + flowgo "github.com/onflow/flow-go/model/flow" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/onflow/flow-emulator/convert" + "github.com/onflow/flow-emulator/storage/remote" + "github.com/onflow/flow-emulator/storage/sqlite" + flowsdk "github.com/onflow/flow-go-sdk" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/output" + + "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" +) + +const ( + minProfileableBlockHeight = 2 // Cannot fork from genesis (0) or block 1 + profileFilePrefix = "profile-" + profileFileSuffix = ".pb.gz" + txIDDisplayLength = 8 +) + +type flagsProfile struct { + Output string `default:"" flag:"output,o" info:"Output file path for profile data (default: profile-{tx_id}.pb.gz)"` +} + +type profilingResult struct { + txID flowsdk.Identifier + tx *flowsdk.Transaction + result *flowsdk.TransactionResult + networkName string + blockHeight uint64 + profileFile string + computationUsed uint64 +} + +var profileFlags = flagsProfile{} + +var profileCommand = &command.Command{ + Cmd: &cobra.Command{ + Use: "profile ", + Short: "Profile a transaction's execution", + Example: "flow transactions profile 07a8...b433 -n mainnet", + Args: cobra.ExactArgs(1), + }, + Flags: &profileFlags, + RunS: profile, +} + +func profile( + args []string, + globalFlags command.GlobalFlags, + logger output.Logger, + flow flowkit.Services, + state *flowkit.State, +) (command.Result, error) { + inputTxID := flowsdk.HexToID(strings.TrimPrefix(args[0], "0x")) + + networkName := globalFlags.Network + if networkName == "" { + return nil, fmt.Errorf("network must be specified with --network flag") + } + + network, err := state.Networks().ByName(networkName) + if err != nil { + return nil, fmt.Errorf("network %q not found in flow.json", networkName) + } + + logger.StartProgress(fmt.Sprintf("Fetching transaction %s from %s...", inputTxID.String(), networkName)) + + tx, result, err := flow.GetTransactionByID(context.Background(), inputTxID, true) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get transaction %s: %w", inputTxID.String(), err) + } + + txID := tx.ID() + + if result.Status != flowsdk.TransactionStatusSealed { + logger.StopProgress() + return nil, fmt.Errorf("transaction is not sealed (status: %s)", result.Status) + } + + logger.Info(fmt.Sprintf("✓ Transaction found in block %d", result.BlockHeight)) + + block, err := flow.GetBlock(context.Background(), flowkit.BlockQuery{Height: result.BlockHeight}) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get block at height %d: %w", result.BlockHeight, err) + } + + allTxs, _, err := flow.GetTransactionsByBlockID(context.Background(), block.ID) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get transactions for block %s: %w", block.ID.String(), err) + } + + targetIdx := findTransactionIndex(allTxs, txID) + if targetIdx == -1 { + logger.StopProgress() + return nil, fmt.Errorf("target transaction %s not found in block %d", txID.String()[:txIDDisplayLength], block.Height) + } + + targetTx := allTxs[targetIdx] + isSystemTx := isSystemTransaction(targetTx) + priorUserTxs, priorSystemTxs := separateTransactionsByType(allTxs[:targetIdx]) + + totalPrior := len(priorUserTxs) + len(priorSystemTxs) + if totalPrior > 0 { + logger.StartProgress(fmt.Sprintf("Forking state from block %d and replaying %d transactions...", block.Height-1, totalPrior)) + } else { + logger.StartProgress(fmt.Sprintf("Forking state from block %d...", block.Height-1)) + } + + profile, computationUsed, err := profileTransactionWithFVM( + state, + network, + block, + priorUserTxs, + priorSystemTxs, + targetTx, + isSystemTx, + logger, + ) + if err != nil { + logger.StopProgress() + return nil, err + } + + logger.StopProgress() + logger.Info("✓ Transaction profiled successfully") + + outputPath := profileFlags.Output + if outputPath == "" { + outputPath = fmt.Sprintf("%s%s%s", profileFilePrefix, txID.String()[:txIDDisplayLength], profileFileSuffix) + } + + if err := writePprofBinary(profile, outputPath, state.ReaderWriter()); err != nil { + return nil, fmt.Errorf("failed to write profile: %w", err) + } + + return &profilingResult{ + txID: txID, + tx: tx, + result: result, + networkName: networkName, + blockHeight: result.BlockHeight, + profileFile: outputPath, + computationUsed: computationUsed, + }, nil +} + +func (r *profilingResult) JSON() any { + return map[string]any{ + "transactionId": r.txID.String(), + "network": r.networkName, + "block_height": r.blockHeight, + "status": r.result.Status.String(), + "events": len(r.result.Events), + "profileFile": r.profileFile, + "computationUsed": r.computationUsed, + } +} + +func (r *profilingResult) String() string { + var b strings.Builder + + b.WriteString("Transaction Profiling Report\n") + b.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + b.WriteString(fmt.Sprintf("Transaction ID: %s\n", r.txID.String())) + b.WriteString(fmt.Sprintf("Network: %s\n", r.networkName)) + b.WriteString(fmt.Sprintf("Block Height: %d\n", r.blockHeight)) + b.WriteString(fmt.Sprintf("Status: %s\n", r.result.Status.String())) + b.WriteString(fmt.Sprintf("Events emitted: %d\n", len(r.result.Events))) + b.WriteString(fmt.Sprintf("Computation: %d\n\n", r.computationUsed)) + + b.WriteString(fmt.Sprintf("Profile saved: %s\n\n", r.profileFile)) + b.WriteString("Analyze with:\n") + b.WriteString(fmt.Sprintf(" go tool pprof -http=:8080 %s\n", r.profileFile)) + + return b.String() +} + +func (r *profilingResult) Oneliner() string { + return fmt.Sprintf("Transaction %s profiled successfully", r.txID.String()[:txIDDisplayLength]) +} + +func profileTransactionWithFVM( + state *flowkit.State, + network *config.Network, + block *flowsdk.Block, + priorUserTxs []*flowsdk.Transaction, + priorSystemTxs []*flowsdk.Transaction, + targetTx *flowsdk.Transaction, + isSystemTx bool, + logger output.Logger, +) (*runtime.ComputationProfile, uint64, error) { + chainID, err := util.GetChainIDFromHost(network.Host) + if err != nil { + return nil, 0, fmt.Errorf("failed to get chain ID from host %s: %w", network.Host, err) + } + + blockHeight := block.Height + forkHeight := blockHeight - 1 + if blockHeight < minProfileableBlockHeight { + return nil, 0, fmt.Errorf("cannot profile transactions in genesis or block 1 (no prior state to fork from)") + } + + nopLogger := zerolog.Nop() + baseStore, err := sqlite.New(sqlite.InMemory) + if err != nil { + return nil, 0, fmt.Errorf("failed to create storage: %w", err) + } + + store, err := remote.New(baseStore, &nopLogger, + remote.WithForkHost(network.Host), + remote.WithForkHeight(forkHeight), + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to create forked storage at height %d: %w", forkHeight, err) + } + + ctx := context.Background() + baseLedger, err := store.LedgerByHeight(ctx, forkHeight) + if err != nil { + return nil, 0, fmt.Errorf("failed to get ledger at height %d: %w", forkHeight, err) + } + + execState := fvmState.NewExecutionState(baseLedger, fvmState.DefaultParameters()) + + computationProfile := runtime.NewComputationProfile() + executionEffortWeights := environment.MainnetExecutionEffortWeights + computationProfile.WithComputationWeights(executionEffortWeights) + + runtimeConfig := runtime.Config{ + ComputationProfile: computationProfile, + } + customRuntimePool := reusableRuntime.NewCustomReusableCadenceRuntimePool( + 1, + chainID.Chain(), + runtimeConfig, + func(cfg runtime.Config) runtime.Runtime { + return runtime.NewRuntime(cfg) + }, + ) + + vm := fvm.NewVirtualMachine() + + blockHeader := &flowgo.Header{ + HeaderBody: flowgo.HeaderBody{ + ChainID: chainID, + ParentID: flowgo.Identifier(block.ParentID), + Height: block.Height, + Timestamp: uint64(block.Timestamp.UnixMilli()), + }, + PayloadHash: flowgo.Identifier(block.ID), + } + + baseFvmOptions := []fvm.Option{ + fvm.WithLogger(nopLogger), + fvm.WithBlockHeader(blockHeader), + fvm.WithContractDeploymentRestricted(false), + fvm.WithComputationLimit(flowgo.DefaultMaxTransactionGasLimit), + fvm.WithReusableCadenceRuntimePool(customRuntimePool), + } + + userCtx := fvm.NewContext( + chainID.Chain(), + append(baseFvmOptions, + fvm.WithTransactionFeesEnabled(true), + fvm.WithAuthorizationChecksEnabled(true), + fvm.WithSequenceNumberCheckAndIncrementEnabled(true), + )..., + ) + + systemCtx := fvm.NewContext( + chainID.Chain(), + append(baseFvmOptions, + fvm.WithTransactionFeesEnabled(false), + fvm.WithAuthorizationChecksEnabled(false), + fvm.WithSequenceNumberCheckAndIncrementEnabled(false), + )..., + ) + + // Execute prior transactions to recreate state + txIndex := 0 + if len(priorUserTxs) > 0 { + if err := executeTransactions(vm, userCtx, execState, priorUserTxs, txIndex, logger); err != nil { + return nil, 0, fmt.Errorf("failed to execute prior user transactions: %w", err) + } + txIndex += len(priorUserTxs) + } + + if len(priorSystemTxs) > 0 { + if err := executeTransactions(vm, systemCtx, execState, priorSystemTxs, txIndex, logger); err != nil { + return nil, 0, fmt.Errorf("failed to execute prior system transactions: %w", err) + } + } + + computationProfile.Reset() + + targetFlowTx := convert.SDKTransactionToFlow(*targetTx) + + targetCtx := userCtx + if isSystemTx { + targetCtx = systemCtx + } + + blockDB := fvmStorage.NewBlockDatabase(execState, 0, nil) + txn, err := blockDB.NewTransaction(0, fvmState.DefaultParameters()) + if err != nil { + return nil, 0, fmt.Errorf("failed to create transaction context: %w", err) + } + + targetTxIndex := uint32(len(priorUserTxs) + len(priorSystemTxs)) + txProc := fvm.Transaction(targetFlowTx, targetTxIndex) + _, output, err := vm.Run(targetCtx, txProc, txn) + if err != nil { + return nil, 0, fmt.Errorf("failed to execute target transaction: %w", err) + } + + if output.Err != nil { + logger.Info(fmt.Sprintf("⚠️ Transaction failed during execution: %s", output.Err.Error())) + } + + return computationProfile, output.ComputationUsed, nil +} + +// findTransactionIndex returns the index of a transaction in a slice, or -1 if not found +func findTransactionIndex(txs []*flowsdk.Transaction, txID flowsdk.Identifier) int { + for i, tx := range txs { + if tx.ID() == txID { + return i + } + } + return -1 +} + +// isSystemTransaction returns true if the transaction is a system transaction +func isSystemTransaction(tx *flowsdk.Transaction) bool { + return tx.Payer == flowsdk.EmptyAddress +} + +// separateTransactionsByType separates transactions into user and system transactions +func separateTransactionsByType(txs []*flowsdk.Transaction) (user, system []*flowsdk.Transaction) { + user = make([]*flowsdk.Transaction, 0, len(txs)) + system = make([]*flowsdk.Transaction, 0, len(txs)) + + for _, tx := range txs { + if isSystemTransaction(tx) { + system = append(system, tx) + } else { + user = append(user, tx) + } + } + return user, system +} + +// executeTransactions executes a list of transactions and updates the execution state +func executeTransactions( + vm *fvm.VirtualMachine, + ctx fvm.Context, + execState *fvmState.ExecutionState, + txs []*flowsdk.Transaction, + startIndex int, + logger output.Logger, +) error { + for i, tx := range txs { + flowTx := convert.SDKTransactionToFlow(*tx) + + blockDB := fvmStorage.NewBlockDatabase(execState, 0, nil) + txn, err := blockDB.NewTransaction(0, fvmState.DefaultParameters()) + if err != nil { + return fmt.Errorf("failed to create transaction context for tx %d: %w", startIndex+i, err) + } + + txProc := fvm.Transaction(flowTx, uint32(startIndex+i)) + executionSnapshot, _, err := vm.Run(ctx, txProc, txn) + if err != nil { + return fmt.Errorf("failed to execute transaction %d (%s): %w", startIndex+i, tx.ID().String()[:txIDDisplayLength], err) + } + + if err := execState.Merge(executionSnapshot); err != nil { + return fmt.Errorf("failed to merge execution snapshot for tx %d: %w", startIndex+i, err) + } + } + + return nil +} + +// writePprofBinary writes a computation profile to a pprof binary file +func writePprofBinary(profile *runtime.ComputationProfile, outputPath string, rw flowkit.ReaderWriter) error { + if profile == nil { + return fmt.Errorf("no profiling data available: profile is nil") + } + + exporter := runtime.NewPProfExporter(profile) + pprofData, err := exporter.Export() + if err != nil { + return fmt.Errorf("failed to export pprof data: %w", err) + } + + if pprofData == nil { + return fmt.Errorf("pprof data is nil after export") + } + + var buf bytes.Buffer + if err := pprofData.Write(&buf); err != nil { + return fmt.Errorf("failed to write pprof data: %w", err) + } + + if err := rw.WriteFile(outputPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to create output file %s: %w", outputPath, err) + } + + return nil +} diff --git a/internal/transactions/profile_test.go b/internal/transactions/profile_test.go new file mode 100644 index 000000000..de99fc02d --- /dev/null +++ b/internal/transactions/profile_test.go @@ -0,0 +1,581 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package transactions + +import ( + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/onflow/flow-emulator/convert" + "github.com/onflow/flow-emulator/emulator" + "github.com/onflow/flow-emulator/server" + flow "github.com/onflow/flow-go-sdk" + "github.com/onflow/flow-go-sdk/crypto" + "github.com/onflow/flow-go-sdk/templates" + flowgo "github.com/onflow/flow-go/model/flow" + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/accounts" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/gateway" + "github.com/onflow/flowkit/v2/output" + "github.com/onflow/flowkit/v2/tests" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" +) + +const ( + emulatorStartupWait = 2 * time.Second + emulatorStableWait = 500 * time.Millisecond + profileTestTimeout = 15 * time.Second + initialBlockCount = 3 + transactionGasLimit = 1000 +) + +func Test_Profile_Validation(t *testing.T) { + t.Parallel() + + srv, state, _ := util.TestMocks(t) + + t.Run("Fail no network specified", func(t *testing.T) { + t.Parallel() + result, err := profile([]string{"0x01"}, command.GlobalFlags{}, util.NoLogger, srv.Mock, state) + assert.EqualError(t, err, "network must be specified with --network flag") + assert.Nil(t, result) + }) + + t.Run("Fail network not found", func(t *testing.T) { + t.Parallel() + result, err := profile([]string{"0x01"}, command.GlobalFlags{Network: "invalid-network"}, util.NoLogger, srv.Mock, state) + assert.EqualError(t, err, "network \"invalid-network\" not found in flow.json") + assert.Nil(t, result) + }) + + t.Run("Fail transaction not found", func(t *testing.T) { + t.Parallel() + srv.GetTransactionByID.Return(nil, nil, assert.AnError) + result, err := profile([]string{"0x01"}, command.GlobalFlags{Network: "testnet"}, util.NoLogger, srv.Mock, state) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get transaction") + assert.Nil(t, result) + }) + + t.Run("Fail transaction not sealed", func(t *testing.T) { + t.Parallel() + tx := tests.NewTransaction() + result := tests.NewTransactionResult(nil) + result.Status = flow.TransactionStatusPending + srv.GetTransactionByID.Return(tx, result, nil) + + res, err := profile([]string{"0x01"}, command.GlobalFlags{Network: "testnet"}, util.NoLogger, srv.Mock, state) + assert.EqualError(t, err, "transaction is not sealed (status: PENDING)") + assert.Nil(t, res) + }) +} + +func Test_ProfilingResult(t *testing.T) { + t.Parallel() + + txID := flow.HexToID("0123456789abcdef") + tx := tests.NewTransaction() + txResult := tests.NewTransactionResult(nil) + txResult.Status = flow.TransactionStatusSealed + + t.Run("Result with profile file", func(t *testing.T) { + t.Parallel() + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + networkName: "testnet", + blockHeight: 123, + profileFile: "test-profile.pb.gz", + } + + output := result.String() + assert.Contains(t, output, txID.String()) + assert.Contains(t, output, "testnet") + assert.Contains(t, output, "Profile saved: test-profile.pb.gz") + assert.Contains(t, output, "go tool pprof") + + jsonOutput := result.JSON() + jsonMap, ok := jsonOutput.(map[string]any) + require.True(t, ok) + assert.Equal(t, "testnet", jsonMap["network"]) + assert.Equal(t, uint64(123), jsonMap["block_height"]) + assert.Equal(t, "test-profile.pb.gz", jsonMap["profileFile"]) + }) + + t.Run("Oneliner format", func(t *testing.T) { + t.Parallel() + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + networkName: "testnet", + blockHeight: 123, + } + + oneliner := result.Oneliner() + assert.Contains(t, oneliner, txID.String()[:8]) + assert.Contains(t, oneliner, "profiled successfully") + }) +} + +func Test_Profile_Integration_LocalEmulator(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + t.Run("Profile user transaction", func(t *testing.T) { + t.Parallel() + + port := getFreePort(t) + emulatorHost := fmt.Sprintf("127.0.0.1:%d", port) + emulatorServer, testTxID, testBlockHeight := startEmulatorWithTestTransaction(t, emulatorHost, port) + defer emulatorServer.Stop() + + time.Sleep(emulatorStableWait) + + runProfileTest(t, emulatorHost, testTxID, testBlockHeight) + }) + + t.Run("Profile failed transaction", func(t *testing.T) { + t.Parallel() + + port := getFreePort(t) + emulatorHost := fmt.Sprintf("127.0.0.1:%d", port) + emulatorServer, failedTxID, testBlockHeight := startEmulatorWithFailedTransaction(t, emulatorHost, port) + defer emulatorServer.Stop() + + time.Sleep(emulatorStableWait) + + runProfileTest(t, emulatorHost, failedTxID, testBlockHeight) + }) + + t.Run("Profile transaction with multiple prior transactions", func(t *testing.T) { + t.Parallel() + + port := getFreePort(t) + emulatorHost := fmt.Sprintf("127.0.0.1:%d", port) + emulatorServer, targetTxID, testBlockHeight := startEmulatorWithMultipleTransactions(t, emulatorHost, port, 5) + defer emulatorServer.Stop() + + time.Sleep(emulatorStableWait) + + runProfileTest(t, emulatorHost, targetTxID, testBlockHeight) + }) + + t.Run("Profile system transaction", func(t *testing.T) { + t.Parallel() + + port := getFreePort(t) + emulatorHost := fmt.Sprintf("127.0.0.1:%d", port) + + // Get scheduled execute callback transaction ID + executeCallbackID, blockHeight := setupScheduledTransaction(t, emulatorHost, port) + + // Profile the scheduled execute callback transaction + runProfileTest(t, emulatorHost, executeCallbackID, blockHeight) + + t.Logf("✅ Successfully profiled scheduled execute callback transaction!") + }) +} + +func getFreePort(t *testing.T) int { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + require.True(t, ok, "expected TCP address") + return tcpAddr.Port +} + +func runProfileTest(t *testing.T, emulatorHost string, testTxID flow.Identifier, testBlockHeight uint64) { + rw, _ := tests.ReaderWriter() + + state, err := flowkit.Init(rw) + require.NoError(t, err) + + emulatorAccount, err := accounts.NewEmulatorAccount(rw, crypto.ECDSA_P256, crypto.SHA3_256, "") + require.NoError(t, err) + state.Accounts().AddOrUpdate(emulatorAccount) + + network := config.Network{Name: "emulator", Host: emulatorHost} + state.Networks().AddOrUpdate(network) + + gw, err := gateway.NewGrpcGateway(network) + require.NoError(t, err) + + logger := output.NewStdoutLogger(output.InfoLog) + services := flowkit.NewFlowkit(state, network, gw, logger) + + result, err := profile( + []string{testTxID.String()}, + command.GlobalFlags{Network: "emulator"}, + logger, + services, + state, + ) + require.NoError(t, err) + require.NotNil(t, result) + + profilingResult, ok := result.(*profilingResult) + require.True(t, ok) + + // Note: System transaction IDs from GetSystemTransactionsForBlock may differ from + // IDs returned by GetTransaction due to how the emulator handles system txs. + // The important thing is that profiling succeeded. + assert.Equal(t, "emulator", profilingResult.networkName) + assert.Equal(t, testBlockHeight, profilingResult.blockHeight) + assert.NotNil(t, profilingResult.tx) + assert.NotNil(t, profilingResult.result) + assert.NotEmpty(t, profilingResult.profileFile) + t.Logf("Expected TX ID: %s", testTxID) + t.Logf("Profiled TX ID: %s", profilingResult.tx.ID()) + t.Logf("Result TX ID: %s", profilingResult.txID) + + jsonOutput := profilingResult.JSON() + require.NotNil(t, jsonOutput) + jsonMap, ok := jsonOutput.(map[string]any) + require.True(t, ok) + assert.Equal(t, "emulator", jsonMap["network"]) + assert.Equal(t, testBlockHeight, jsonMap["block_height"]) +} + +func createEmulatorServer(t *testing.T, port int) *server.EmulatorServer { + zlog := zerolog.New(zerolog.ConsoleWriter{Out: io.Discard}) + + restPort := getFreePort(t) + adminPort := getFreePort(t) + debuggerPort := getFreePort(t) + + serverConf := &server.Config{ + GRPCPort: port, + RESTPort: restPort, + AdminPort: adminPort, + DebuggerPort: debuggerPort, + Host: "127.0.0.1", + ComputationReportingEnabled: true, + StorageLimitEnabled: false, + WithContracts: true, + ScheduledTransactionsEnabled: true, + ChainID: "flow-emulator", + } + + return server.NewEmulatorServer(&zlog, serverConf) +} + +func startServer(t *testing.T, emulatorServer *server.EmulatorServer, host string, port int) { + t.Helper() + + go func() { + emulatorServer.Start() + }() + + // Wait for gRPC server to be ready + maxWait := 5 * time.Second + start := time.Now() + connected := false + for time.Since(start) < maxWait { + conn, err := grpc.NewClient( + fmt.Sprintf("%s:%d", host, port), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err == nil { + conn.Close() + connected = true + t.Logf("✅ gRPC server ready on %s:%d", host, port) + break + } + time.Sleep(50 * time.Millisecond) + } + if !connected { + t.Logf("⚠️ gRPC server did not become ready after %v", maxWait) + } +} + +func createInitialBlocks(t *testing.T, blockchain emulator.Emulator) { + for i := 0; i < initialBlockCount; i++ { + _, _, err := blockchain.ExecuteAndCommitBlock() + require.NoError(t, err) + } +} + +func buildTransaction(t *testing.T, script string, blockchain emulator.Emulator) *flow.Transaction { + latestBlock, err := blockchain.GetLatestBlock() + require.NoError(t, err) + + serviceKey := blockchain.ServiceKey() + + return flow.NewTransaction(). + SetScript([]byte(script)). + SetComputeLimit(transactionGasLimit). + SetProposalKey( + serviceKey.Address, + serviceKey.Index, + serviceKey.SequenceNumber, + ). + SetReferenceBlockID(convert.FlowIdentifierToSDK(latestBlock.ID())). + SetPayer(serviceKey.Address). + AddAuthorizer(serviceKey.Address) +} + +func submitAndCommitTransaction(t *testing.T, tx *flow.Transaction, blockchain emulator.Emulator) { + err := blockchain.AddTransaction(*convert.SDKTransactionToFlow(*tx)) + require.NoError(t, err) + + _, _, err = blockchain.ExecuteAndCommitBlock() + require.NoError(t, err) +} + +func startEmulatorWithTestTransaction(t *testing.T, host string, port int) (*server.EmulatorServer, flow.Identifier, uint64) { + emulatorServer := createEmulatorServer(t, port) + blockchain := emulatorServer.Emulator() + + createInitialBlocks(t, blockchain) + + testTx := buildTransaction(t, ` + transaction { + prepare(signer: &Account) { + log("Test transaction") + } + execute { + var i = 0 + while i < 10 { + i = i + 1 + } + } + } + `, blockchain) + + submitAndCommitTransaction(t, testTx, blockchain) + + latestBlock, err := blockchain.GetLatestBlock() + require.NoError(t, err) + + startServer(t, emulatorServer, "127.0.0.1", port) + return emulatorServer, testTx.ID(), latestBlock.Height +} + +func startEmulatorWithFailedTransaction(t *testing.T, host string, port int) (*server.EmulatorServer, flow.Identifier, uint64) { + emulatorServer := createEmulatorServer(t, port) + blockchain := emulatorServer.Emulator() + + createInitialBlocks(t, blockchain) + + failTx := buildTransaction(t, ` + transaction { + prepare(signer: &Account) { + log("About to fail") + } + execute { + panic("Intentional failure for testing") + } + } + `, blockchain) + + submitAndCommitTransaction(t, failTx, blockchain) + + block, err := blockchain.GetLatestBlock() + require.NoError(t, err) + + startServer(t, emulatorServer, "127.0.0.1", port) + return emulatorServer, failTx.ID(), block.Height +} + +func startEmulatorWithMultipleTransactions(t *testing.T, host string, port int, count int) (*server.EmulatorServer, flow.Identifier, uint64) { + emulatorServer := createEmulatorServer(t, port) + blockchain := emulatorServer.Emulator() + + createInitialBlocks(t, blockchain) + + var lastTxID flow.Identifier + serviceKey := blockchain.ServiceKey() + + for i := 0; i < count; i++ { + tx := buildTransaction(t, fmt.Sprintf(` + transaction { + prepare(signer: &Account) { + log("Transaction %d") + } + execute { + var sum = 0 + var i = 0 + while i < 10 { + sum = sum + i + i = i + 1 + } + } + } + `, i), blockchain) + + submitAndCommitTransaction(t, tx, blockchain) + + lastTxID = tx.ID() + serviceKey.SequenceNumber++ + } + + block, err := blockchain.GetLatestBlock() + require.NoError(t, err) + + startServer(t, emulatorServer, "127.0.0.1", port) + return emulatorServer, lastTxID, block.Height +} + +// setupScheduledTransaction follows the pattern from flow-emulator's TestScheduledTransaction_QueryByID +// It creates an emulator, schedules a transaction, waits for execution, gets the system tx ID, then starts gRPC server +func setupScheduledTransaction(t *testing.T, host string, port int) (flow.Identifier, uint64) { + t.Helper() + + // Create emulator server (but don't start gRPC yet) + emulatorServer := createEmulatorServer(t, port) + b := emulatorServer.Emulator() + + serviceAddress := b.ServiceKey().Address + serviceHex := serviceAddress.Hex() + + // Deploy handler contract (copied from emulator reference test) + handlerContract := fmt.Sprintf(` + import FlowTransactionScheduler from 0x%s + access(all) contract ScheduledHandler { + access(contract) var count: Int + access(all) view fun getCount(): Int { return self.count } + access(all) resource Handler: FlowTransactionScheduler.TransactionHandler { + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + ScheduledHandler.count = ScheduledHandler.count + 1 + } + } + access(all) fun createHandler(): @Handler { return <- create Handler() } + init() { self.count = 0 } + } + `, serviceHex) + + latestBlock, err := b.GetLatestBlock() + require.NoError(t, err) + tx := templates.AddAccountContract(serviceAddress, templates.Contract{Name: "ScheduledHandler", Source: handlerContract}) + tx.SetComputeLimit(flowgo.DefaultMaxTransactionGasLimit). + SetReferenceBlockID(flow.Identifier(latestBlock.ID())). + SetProposalKey(serviceAddress, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(serviceAddress) + signer, err := b.ServiceKey().Signer() + require.NoError(t, err) + require.NoError(t, tx.SignEnvelope(serviceAddress, b.ServiceKey().Index, signer)) + require.NoError(t, b.SendTransaction(convert.SDKTransactionToFlow(*tx))) + _, _, err = b.ExecuteAndCommitBlock() + require.NoError(t, err) + t.Log("✅ Handler contract deployed") + + // Schedule transaction (copied from emulator reference test) + scheduleTx := fmt.Sprintf(` + import FlowTransactionScheduler from 0x%s + import ScheduledHandler from 0x%s + import FungibleToken from 0xee82856bf20e2aa6 + import FlowToken from 0x0ae53cb6e3f42a79 + transaction { + prepare(acct: auth(Storage, Capabilities, FungibleToken.Withdraw) &Account) { + let handler <- ScheduledHandler.createHandler() + acct.storage.save(<-handler, to: /storage/counterHandler) + let cap = acct.capabilities.storage.issue(/storage/counterHandler) + let admin = acct.storage.borrow<&FlowToken.Administrator>(from: /storage/flowTokenAdmin)! + let minter <- admin.createNewMinter(allowedAmount: 10.0) + let minted <- minter.mintTokens(amount: 1.0) + let receiver = acct.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)! + receiver.deposit(from: <-minted) + destroy minter + let estimate = FlowTransactionScheduler.estimate( + data: nil, + timestamp: getCurrentBlock().timestamp + 3.0, + priority: FlowTransactionScheduler.Priority.High, + executionEffort: UInt64(5000) + ) + let feeAmount: UFix64 = estimate.flowFee ?? 0.001 + let vaultRef = acct.storage.borrow(from: /storage/flowTokenVault)! + let fees <- (vaultRef.withdraw(amount: feeAmount) as! @FlowToken.Vault) + destroy <- FlowTransactionScheduler.schedule( + handlerCap: cap, data: nil, + timestamp: getCurrentBlock().timestamp + 3.0, + priority: FlowTransactionScheduler.Priority.High, + executionEffort: UInt64(5000), fees: <-fees + ) + } + } + `, serviceHex, serviceHex) + + latestBlock, err = b.GetLatestBlock() + require.NoError(t, err) + tx = flow.NewTransaction().SetScript([]byte(scheduleTx)). + SetComputeLimit(flowgo.DefaultMaxTransactionGasLimit). + SetProposalKey(serviceAddress, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(serviceAddress).AddAuthorizer(serviceAddress). + SetReferenceBlockID(flow.Identifier(latestBlock.ID())) + signer, err = b.ServiceKey().Signer() + require.NoError(t, err) + require.NoError(t, tx.SignEnvelope(serviceAddress, b.ServiceKey().Index, signer)) + require.NoError(t, b.SendTransaction(convert.SDKTransactionToFlow(*tx))) + _, results, err := b.ExecuteAndCommitBlock() + require.NoError(t, err) + for _, r := range results { + if r.Error != nil { + t.Fatalf("schedule tx failed: %v", r.Error) + } + } + t.Log("✅ Schedule transaction succeeded") + + // Advance time and commit blocks to trigger scheduled execution (from emulator reference) + t.Log("⏳ Waiting for scheduled transaction to execute...") + time.Sleep(3500 * time.Millisecond) + _, _, err = b.ExecuteAndCommitBlock() + require.NoError(t, err) + time.Sleep(3500 * time.Millisecond) + block, _, err := b.ExecuteAndCommitBlock() + require.NoError(t, err) + + // Get system transaction IDs using GetSystemTransactionsForBlock (from emulator reference) + // Need to type-assert to *emulator.Blockchain to access this method + blockchain, ok := b.(*emulator.Blockchain) + require.True(t, ok, "emulator should be *emulator.Blockchain") + systemTxIDs, err := blockchain.GetSystemTransactionsForBlock(flowgo.Identifier(block.ID())) + require.NoError(t, err) + t.Logf("Found %d system transactions", len(systemTxIDs)) + for i, id := range systemTxIDs { + t.Logf(" [%d] %s", i, id) + } + + require.GreaterOrEqual(t, len(systemTxIDs), 3, "should have ProcessCallbacks + ExecuteCallback + SystemChunk") + + // ExecuteCallback is at index 1 (per emulator reference) + scheduledTxID := systemTxIDs[1] + t.Logf("🎯 Scheduled ExecuteCallback transaction: %s", scheduledTxID) + + // NOW start gRPC server so profile command can query it + startServer(t, emulatorServer, host, port) + time.Sleep(emulatorStableWait) + + return convert.FlowIdentifierToSDK(scheduledTxID), block.Height +} diff --git a/internal/transactions/transactions.go b/internal/transactions/transactions.go index 725540cfe..65ff15b9e 100644 --- a/internal/transactions/transactions.go +++ b/internal/transactions/transactions.go @@ -53,6 +53,7 @@ func init() { sendSignedCommand.AddToParent(Cmd) getSystemCommand.AddToParent(Cmd) decodeCommand.AddToParent(Cmd) + profileCommand.AddToParent(Cmd) } type transactionResult struct { diff --git a/internal/util/util.go b/internal/util/util.go index 99b70e684..1ba59215d 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -277,7 +277,7 @@ func NetworkToChainID(network string) (flow.ChainID, error) { // GetChainIDFromHost queries the given host directly to get its chain ID. // It will retry transient failures with exponential backoff using the emulator's gRPC retry interceptor. func GetChainIDFromHost(host string) (flowGo.ChainID, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() conn, err := grpcOpts.NewClient(