diff --git a/internal/transactions/profile.go b/internal/transactions/profile.go new file mode 100644 index 000000000..8133dee6a --- /dev/null +++ b/internal/transactions/profile.go @@ -0,0 +1,358 @@ +/* + * 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 ( + "context" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/onflow/flow-emulator/convert" + "github.com/onflow/flow-emulator/emulator" + "github.com/onflow/flow-emulator/server" + flowsdk "github.com/onflow/flow-go-sdk" + flowgo "github.com/onflow/flow-go/model/flow" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/output" + + "github.com/onflow/flow-cli/internal/command" +) + +type flagsProfile struct { + Network string `default:"" flag:"network" info:"Network to profile transaction from"` +} + +var profileFlags = flagsProfile{} + +var profileCommand = &command.Command{ + Cmd: &cobra.Command{ + Use: "profile ", + Short: "Profile a transaction by replaying it on a forked emulator", + Example: "flow transactions profile 07a8b433... --network 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) { + txID := flowsdk.HexToID(strings.TrimPrefix(args[0], "0x")) + + networkName := profileFlags.Network + if networkName == "" { + 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...", txID.String(), networkName)) + + tx, result, err := flow.GetTransactionByID(context.Background(), txID, true) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get transaction: %w", err) + } + + 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)) + logger.Info("Fetching block and transactions...") + + block, err := flow.GetBlock(context.Background(), flowkit.BlockQuery{Height: result.BlockHeight}) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get block: %w", err) + } + + allTxs, _, err := flow.GetTransactionsByBlockID(context.Background(), block.ID) + if err != nil { + logger.StopProgress() + return nil, fmt.Errorf("failed to get block transactions: %w", err) + } + + logger.Info(fmt.Sprintf("✓ Found %d transactions in block", len(allTxs))) + + targetIdx := findTransactionIndex(allTxs, txID) + if targetIdx == -1 { + logger.StopProgress() + return nil, fmt.Errorf("transaction not found in block") + } + + logger.Info("Creating forked emulator blockchain...") + logger.StopProgress() + + blockchain, cleanup, err := createForkedEmulator(state, network, result.BlockHeight) + if err != nil { + return nil, err + } + defer cleanup() + + logger.StartProgress("Replaying transactions with profiling...") + + txReport, err := replayTransactions(blockchain, allTxs, targetIdx, logger) + if err != nil { + logger.StopProgress() + return nil, err + } + + logger.StopProgress() + logger.Info("✓ Transaction profiled successfully") + + return &profilingResult{ + txID: txID, + tx: tx, + result: result, + txReport: txReport, + networkName: networkName, + blockHeight: result.BlockHeight, + }, nil +} + +func findTransactionIndex(txs []*flowsdk.Transaction, targetID flowsdk.Identifier) int { + for i, tx := range txs { + if tx.ID() == targetID { + return i + } + } + return -1 +} + +func createForkedEmulator( + state *flowkit.State, + network *config.Network, + blockHeight uint64, +) (emulator.Emulator, func(), error) { + serviceAccount, err := state.EmulatorServiceAccount() + if err != nil { + return nil, nil, fmt.Errorf("failed to get emulator service account: %w", err) + } + + privateKey, err := serviceAccount.Key.PrivateKey() + if err != nil { + return nil, nil, fmt.Errorf("failed to get service account private key: %w", err) + } + + chainID := detectChainID(network.Host) + forkHeight := calculateForkHeight(blockHeight) + + zlog := zerolog.Nop() + serverConf := &server.Config{ + ForkHost: network.Host, + ForkHeight: forkHeight, + ChainID: chainID, + ComputationReportingEnabled: true, + ServicePrivateKey: *privateKey, + ServiceKeySigAlgo: serviceAccount.Key.SigAlgo(), + ServiceKeyHashAlgo: serviceAccount.Key.HashAlgo(), + SkipTransactionValidation: true, + StorageLimitEnabled: false, + Host: "127.0.0.1", + GRPCPort: 3571, + RESTPort: 8889, + AdminPort: 8081, + } + + emulatorServer := server.NewEmulatorServer(&zlog, serverConf) + if emulatorServer == nil { + return nil, nil, fmt.Errorf("failed to create emulator server") + } + + go func() { + defer func() { + if r := recover(); r != nil { + // Silently recover from emulator panics + } + }() + emulatorServer.Start() + }() + + cleanup := func() { + if emulatorServer != nil { + emulatorServer.Stop() + } + } + + time.Sleep(2 * time.Second) + + blockchain := emulatorServer.Emulator() + if blockchain == nil { + cleanup() + return nil, nil, fmt.Errorf("failed to get emulator blockchain instance") + } + + return blockchain, cleanup, nil +} + +func detectChainID(host string) flowgo.ChainID { + if strings.Contains(host, "testnet") || strings.Contains(host, "devnet") { + return flowgo.Testnet + } + if strings.Contains(host, "127.0.0.1") || strings.Contains(host, "localhost") { + return flowgo.Emulator + } + return flowgo.Mainnet +} + +func calculateForkHeight(blockHeight uint64) uint64 { + if blockHeight > 1 { + return blockHeight - 1 + } + return 1 +} + +func replayTransactions( + blockchain emulator.Emulator, + txs []*flowsdk.Transaction, + targetIdx int, + logger output.Logger, +) (*emulator.ProcedureReport, error) { + var txReport *emulator.ProcedureReport + + for i := 0; i <= targetIdx; i++ { + tx := txs[i] + logger.Info(fmt.Sprintf(" [%d/%d] Replaying transaction %s...", i+1, targetIdx+1, tx.ID().String()[:8])) + + flowGoTx := convert.SDKTransactionToFlow(*tx) + if err := blockchain.AddTransaction(*flowGoTx); err != nil { + return nil, fmt.Errorf("failed to add transaction %s: %w", tx.ID(), err) + } + + _, txResults, err := blockchain.ExecuteAndCommitBlock() + if err != nil { + return nil, fmt.Errorf("failed to execute block with transaction %s: %w", tx.ID(), err) + } + + if i == targetIdx && len(txResults) > 0 { + computationReport := blockchain.ComputationReport() + if computationReport != nil { + if report, ok := computationReport.Transactions[tx.ID().String()]; ok { + txReport = &report + } + } + } + } + + return txReport, nil +} + +type profilingResult struct { + txID flowsdk.Identifier + tx *flowsdk.Transaction + result *flowsdk.TransactionResult + txReport *emulator.ProcedureReport + networkName string + blockHeight uint64 +} + +func (r *profilingResult) JSON() any { + result := map[string]any{ + "transaction_id": r.txID.String(), + "network": r.networkName, + "block_height": r.blockHeight, + "status": r.result.Status.String(), + "events_count": len(r.result.Events), + } + + if r.result.Error != nil { + result["error"] = r.result.Error.Error() + } + + if r.txReport != nil { + result["computation_metrics"] = map[string]any{ + "computation_used": r.txReport.ComputationUsed, + "intensities": r.txReport.Intensities, + "memory_estimate": r.txReport.MemoryEstimate, + } + } + + return result +} + +func (r *profilingResult) String() string { + var sb strings.Builder + + sb.WriteString("Transaction Profiling Report\n") + sb.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + sb.WriteString(fmt.Sprintf("Transaction ID: %s\n", r.txID.String())) + sb.WriteString(fmt.Sprintf("Network: %s\n", r.networkName)) + sb.WriteString(fmt.Sprintf("Block Height: %d\n", r.blockHeight)) + sb.WriteString(fmt.Sprintf("Status: %s\n", r.result.Status.String())) + + if r.result.Error != nil { + sb.WriteString(fmt.Sprintf("\nError: %s\n", r.result.Error.Error())) + } + + sb.WriteString("\nProfiling Metrics:\n") + sb.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + sb.WriteString(fmt.Sprintf("Events emitted: %d\n", len(r.result.Events))) + + if r.txReport != nil { + sb.WriteString("\nComputation Metrics:\n") + sb.WriteString(fmt.Sprintf(" Computation used: %d\n", r.txReport.ComputationUsed)) + sb.WriteString(fmt.Sprintf(" Memory estimate: %d bytes\n", r.txReport.MemoryEstimate)) + + if len(r.txReport.Intensities) > 0 { + sb.WriteString("\nIntensity Breakdown:\n") + count := 0 + for kind, value := range r.txReport.Intensities { + if count >= 10 { + break + } + sb.WriteString(fmt.Sprintf(" - %s: %d\n", kind, value)) + count++ + } + } + } else { + sb.WriteString("\nNote: Computation profiling data not available.\n") + } + + return sb.String() +} + +func (r *profilingResult) Oneliner() string { + return fmt.Sprintf("%s: %s (block %d)", r.txID.String(), r.result.Status.String(), r.blockHeight) +} + +func (r *profilingResult) ExitCode() int { + if r.result.Error != nil { + return 1 + } + return 0 +} diff --git a/internal/transactions/profile_test.go b/internal/transactions/profile_test.go new file mode 100644 index 000000000..ba50d6fe9 --- /dev/null +++ b/internal/transactions/profile_test.go @@ -0,0 +1,325 @@ +/* + * 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 ( + "io" + "testing" + "time" + + "github.com/onflow/flow-go-sdk" + "github.com/onflow/flow-go-sdk/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/onflow/flow-emulator/convert" + "github.com/onflow/flow-emulator/emulator" + "github.com/onflow/flow-emulator/server" + "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/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" +) + +func Test_Profile_Validation(t *testing.T) { + srv, state, _ := util.TestMocks(t) + + t.Run("Fail no network specified", func(t *testing.T) { + profileFlags.Network = "" + 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) { + profileFlags.Network = "invalid-network" + result, err := profile([]string{"0x01"}, command.GlobalFlags{}, util.NoLogger, srv.Mock, state) + assert.EqualError(t, err, "network \"invalid-network\" not found in flow.json") + assert.Nil(t, result) + profileFlags.Network = "" + }) + + t.Run("Fail transaction not found", func(t *testing.T) { + profileFlags.Network = "testnet" + srv.GetTransactionByID.Return(nil, nil, assert.AnError) + result, err := profile([]string{"0x01"}, command.GlobalFlags{}, util.NoLogger, srv.Mock, state) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get transaction") + assert.Nil(t, result) + profileFlags.Network = "" + }) + + t.Run("Fail transaction not sealed", func(t *testing.T) { + profileFlags.Network = "testnet" + tx := tests.NewTransaction() + result := tests.NewTransactionResult(nil) + result.Status = flow.TransactionStatusPending + srv.GetTransactionByID.Return(tx, result, nil) + + res, err := profile([]string{"0x01"}, command.GlobalFlags{}, util.NoLogger, srv.Mock, state) + assert.EqualError(t, err, "transaction is not sealed (status: PENDING)") + assert.Nil(t, res) + profileFlags.Network = "" + }) +} + +func Test_ProfilingResult(t *testing.T) { + txID := flow.HexToID("0123456789abcdef") + tx := tests.NewTransaction() + txResult := tests.NewTransactionResult(nil) + txResult.Status = flow.TransactionStatusSealed + + t.Run("Result without profiling data", func(t *testing.T) { + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + networkName: "testnet", + blockHeight: 123, + } + + output := result.String() + assert.Contains(t, output, txID.String()) + assert.Contains(t, output, "testnet") + assert.Contains(t, output, "Note: Computation profiling data not available") + }) + + t.Run("Result with profiling data", func(t *testing.T) { + txReport := &emulator.ProcedureReport{ + ComputationUsed: 42, + MemoryEstimate: 1024, + Intensities: map[string]uint64{ + "test1": 10, + "test2": 20, + }, + } + + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + txReport: txReport, + networkName: "testnet", + blockHeight: 123, + } + + output := result.String() + assert.Contains(t, output, "Computation used: 42") + assert.Contains(t, output, "Memory estimate: 1024 bytes") + + 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.NotNil(t, jsonMap["computation_metrics"]) + }) + + t.Run("Oneliner format", func(t *testing.T) { + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + networkName: "testnet", + blockHeight: 123, + } + + oneliner := result.Oneliner() + assert.Contains(t, oneliner, txID.String()) + assert.Contains(t, oneliner, "SEALED") + assert.Contains(t, oneliner, "123") + }) + + t.Run("Exit code on success", func(t *testing.T) { + result := &profilingResult{ + txID: txID, + tx: tx, + result: txResult, + networkName: "testnet", + blockHeight: 123, + } + assert.Equal(t, 0, result.ExitCode()) + }) +} + +func Test_Profile_Integration_LocalEmulator(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + emulatorHost := "127.0.0.1:3570" + t.Log("Starting local emulator...") + emulatorServer, testTxID, testBlockHeight := startEmulatorWithTestTransaction(t, emulatorHost) + defer emulatorServer.Stop() + + time.Sleep(500 * time.Millisecond) + + t.Run("Profile transaction by forking local emulator", func(t *testing.T) { + 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) + + state.Networks().AddOrUpdate(config.Network{ + Name: "emulator", + Host: emulatorHost, + }) + + gw, err := gateway.NewGrpcGateway(config.Network{ + Name: "emulator", + Host: emulatorHost, + }) + require.NoError(t, err) + + logger := output.NewStdoutLogger(output.InfoLog) + services := flowkit.NewFlowkit(state, config.Network{Name: "emulator", Host: emulatorHost}, gw, logger) + + profileFlags.Network = "emulator" + defer func() { profileFlags.Network = "" }() + + t.Logf("Attempting to profile transaction %s...", testTxID.String()) + + done := make(chan bool) + var result interface{} + var profileErr error + + go func() { + result, profileErr = profile( + []string{testTxID.String()}, + command.GlobalFlags{}, + logger, + services, + state, + ) + done <- true + }() + + select { + case <-done: + if profileErr != nil { + t.Fatalf("Profile failed: %v", profileErr) + } + + require.NotNil(t, result) + + profilingResult, ok := result.(*profilingResult) + require.True(t, ok) + + assert.Equal(t, testTxID, profilingResult.txID) + assert.Equal(t, "emulator", profilingResult.networkName) + assert.Equal(t, testBlockHeight, profilingResult.blockHeight) + assert.NotNil(t, profilingResult.tx) + assert.NotNil(t, profilingResult.result) + + t.Log("\n" + profilingResult.String()) + + assert.NotNil(t, profilingResult.txReport) + if profilingResult.txReport != nil { + t.Logf("✓ Computation used: %d", profilingResult.txReport.ComputationUsed) + t.Logf("✓ Memory estimate: %d bytes", profilingResult.txReport.MemoryEstimate) + } + + 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"]) + + t.Log("✓ Successfully profiled transaction!") + + case <-time.After(30 * time.Second): + t.Fatal("Profile command timed out") + } + }) +} + +func startEmulatorWithTestTransaction(t *testing.T, host string) (*server.EmulatorServer, flow.Identifier, uint64) { + zlog := zerolog.New(zerolog.ConsoleWriter{Out: io.Discard}) + + serverConf := &server.Config{ + GRPCPort: 3570, + Host: "127.0.0.1", + ComputationReportingEnabled: true, + StorageLimitEnabled: false, + } + + emulatorServer := server.NewEmulatorServer(&zlog, serverConf) + go emulatorServer.Start() + + time.Sleep(2 * time.Second) + + blockchain := emulatorServer.Emulator() + + testTx := flow.NewTransaction(). + SetScript([]byte(` + transaction { + prepare(signer: &Account) { + log("Test transaction") + } + execute { + var i = 0 + while i < 10 { + i = i + 1 + } + } + } + `)). + SetGasLimit(1000). + SetProposalKey( + blockchain.ServiceKey().Address, + blockchain.ServiceKey().Index, + blockchain.ServiceKey().SequenceNumber, + ). + SetPayer(blockchain.ServiceKey().Address). + AddAuthorizer(blockchain.ServiceKey().Address) + + signer, err := blockchain.ServiceKey().Signer() + require.NoError(t, err) + + err = testTx.SignEnvelope( + blockchain.ServiceKey().Address, + blockchain.ServiceKey().Index, + signer, + ) + require.NoError(t, err) + + flowGoTx := convert.SDKTransactionToFlow(*testTx) + err = blockchain.AddTransaction(*flowGoTx) + require.NoError(t, err) + + _, _, err = blockchain.ExecuteAndCommitBlock() + require.NoError(t, err) + + latestBlock, err := blockchain.GetLatestBlock() + require.NoError(t, err) + + t.Logf("Created test transaction %s at block %d", testTx.ID().String(), latestBlock.Height) + + return emulatorServer, testTx.ID(), latestBlock.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 {