From 7a4261bb39824227562ba7285ac7cefca4ede4e2 Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Thu, 14 Sep 2023 11:40:26 -0400 Subject: [PATCH] Fix simulate requests, support ATC simulation, and enable cucumber simulate tests --- client/v2/algod/simulateTransaction.go | 3 +- client/v2/common/common.go | 9 +- test/applications_integration_test.go | 47 ++++++--- test/integration.tags | 1 + test/steps_test.go | 87 ++++++++++++++++ transaction/atomicTransactionComposer.go | 124 ++++++++++++++++++----- transaction/transactionSigner.go | 23 +++++ 7 files changed, 247 insertions(+), 47 deletions(-) diff --git a/client/v2/algod/simulateTransaction.go b/client/v2/algod/simulateTransaction.go index ef5fdae6..cec0944f 100644 --- a/client/v2/algod/simulateTransaction.go +++ b/client/v2/algod/simulateTransaction.go @@ -5,6 +5,7 @@ import ( "github.com/algorand/go-algorand-sdk/v2/client/v2/common" "github.com/algorand/go-algorand-sdk/v2/client/v2/common/models" + "github.com/algorand/go-algorand-sdk/v2/encoding/msgpack" ) // SimulateTransactionParams contains all of the query parameters for url serialization. @@ -28,6 +29,6 @@ type SimulateTransaction struct { // Do performs the HTTP request func (s *SimulateTransaction) Do(ctx context.Context, headers ...*common.Header) (response models.SimulateResponse, err error) { - err = s.c.post(ctx, &response, "/v2/transactions/simulate", s.p, headers, s.request) + err = s.c.post(ctx, &response, "/v2/transactions/simulate", s.p, headers, msgpack.Encode(&s.request)) return } diff --git a/client/v2/common/common.go b/client/v2/common/common.go index 538fc63a..6b9b3f5c 100644 --- a/client/v2/common/common.go +++ b/client/v2/common/common.go @@ -16,10 +16,11 @@ import ( // rawRequestPaths is a set of paths where the body should not be urlencoded var rawRequestPaths = map[string]bool{ - "/v2/transactions": true, - "/v2/teal/compile": true, - "/v2/teal/disassemble": true, - "/v2/teal/dryrun": true, + "/v2/transactions": true, + "/v2/teal/compile": true, + "/v2/teal/disassemble": true, + "/v2/teal/dryrun": true, + "/v2/transactions/simulate": true, } // Header is a struct for custom headers. diff --git a/test/applications_integration_test.go b/test/applications_integration_test.go index df33926b..7a6ed78a 100644 --- a/test/applications_integration_test.go +++ b/test/applications_integration_test.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/algorand/go-algorand-sdk/v2/transaction" "reflect" "regexp" "sort" @@ -17,6 +16,8 @@ import ( "strings" "time" + "github.com/algorand/go-algorand-sdk/v2/transaction" + "github.com/cucumber/godog" "github.com/algorand/go-algorand-sdk/v2/abi" @@ -34,7 +35,7 @@ var tx types.Transaction var transientAccount crypto.Account var applicationId uint64 var applicationIds []uint64 -var txComposerResult transaction.ExecuteResult +var txComposerMethodResults []transaction.ABIMethodResult func anAlgodVClientConnectedToPortWithToken(v int, host string, port int, token string) error { var err error @@ -485,16 +486,29 @@ func iCloneTheComposer() error { } func iExecuteTheCurrentTransactionGroupWithTheComposer() error { - var err error - txComposerResult, err = txComposer.Execute(algodV2client, context.Background(), 10) - return err + txComposerResult, err := txComposer.Execute(algodV2client, context.Background(), 10) + if err != nil { + return err + } + txComposerMethodResults = txComposerResult.MethodResults + return nil +} + +func iSimulateTheCurrentTransactionGroupWithTheComposer() error { + result, err := txComposer.Simulate(algodV2client, context.Background(), models.SimulateRequest{}) + if err != nil { + return err + } + simulateResponse = result.SimulateResponse + txComposerMethodResults = result.MethodResults + return nil } func theAppShouldHaveReturned(commaSeparatedB64Results string) error { b64ExpectedResults := strings.Split(commaSeparatedB64Results, ",") - if len(b64ExpectedResults) != len(txComposerResult.MethodResults) { - return fmt.Errorf("length of expected results doesn't match actual: %d != %d", len(b64ExpectedResults), len(txComposerResult.MethodResults)) + if len(b64ExpectedResults) != len(txComposerMethodResults) { + return fmt.Errorf("length of expected results doesn't match actual: %d != %d", len(b64ExpectedResults), len(txComposerMethodResults)) } for i, b64ExpectedResult := range b64ExpectedResults { @@ -503,7 +517,7 @@ func theAppShouldHaveReturned(commaSeparatedB64Results string) error { return err } - actualResult := txComposerResult.MethodResults[i] + actualResult := txComposerMethodResults[i] method := actualResult.Method if actualResult.DecodeError != nil { @@ -542,12 +556,12 @@ func theAppShouldHaveReturned(commaSeparatedB64Results string) error { func theAppShouldHaveReturnedABITypes(colonSeparatedExpectedTypeStrings string) error { expectedTypeStrings := strings.Split(colonSeparatedExpectedTypeStrings, ":") - if len(expectedTypeStrings) != len(txComposerResult.MethodResults) { - return fmt.Errorf("length of expected results doesn't match actual: %d != %d", len(expectedTypeStrings), len(txComposerResult.MethodResults)) + if len(expectedTypeStrings) != len(txComposerMethodResults) { + return fmt.Errorf("length of expected results doesn't match actual: %d != %d", len(expectedTypeStrings), len(txComposerMethodResults)) } for i, expectedTypeString := range expectedTypeStrings { - actualResult := txComposerResult.MethodResults[i] + actualResult := txComposerMethodResults[i] if actualResult.DecodeError != nil { return actualResult.DecodeError @@ -584,7 +598,7 @@ func theAppShouldHaveReturnedABITypes(colonSeparatedExpectedTypeStrings string) func checkAtomicResultAgainstValue(resultIndex int, path, expectedValue string) error { keys := strings.Split(path, ".") - info := txComposerResult.MethodResults[resultIndex].TransactionInfo + info := txComposerMethodResults[resultIndex].TransactionInfo jsonBytes := sdkJson.Encode(&info) @@ -677,7 +691,7 @@ func checkInnerTxnGroupIDs(colonSeparatedPathsString string) error { var current models.PendingTransactionResponse for pathIndex, innerTxnIndex := range path { if pathIndex == 0 { - current = models.PendingTransactionResponse(txComposerResult.MethodResults[innerTxnIndex].TransactionInfo) + current = models.PendingTransactionResponse(txComposerMethodResults[innerTxnIndex].TransactionInfo) } else { current = current.InnerTxns[innerTxnIndex] } @@ -704,7 +718,7 @@ func checkSpinResult(resultIndex int, method, r string) error { return fmt.Errorf("Incorrect method name, expected 'spin()', got '%s'", method) } - result := txComposerResult.MethodResults[resultIndex] + result := txComposerMethodResults[resultIndex] decodedResult := result.ReturnValue.([]interface{}) spin := decodedResult[0].([]interface{}) @@ -731,7 +745,7 @@ func sha512_256AsUint64(preimage []byte) uint64 { } func checkRandomIntResult(resultIndex, input int) error { - result := txComposerResult.MethodResults[resultIndex] + result := txComposerMethodResults[resultIndex] decodedResult := result.ReturnValue.([]interface{}) randInt := decodedResult[0].(uint64) @@ -752,7 +766,7 @@ func checkRandomIntResult(resultIndex, input int) error { } func checkRandomElementResult(resultIndex int, input string) error { - result := txComposerResult.MethodResults[resultIndex] + result := txComposerMethodResults[resultIndex] decodedResult := result.ReturnValue.([]interface{}) randElt := decodedResult[0].(byte) @@ -932,6 +946,7 @@ func ApplicationsContext(s *godog.Suite) { s.Step(`^I add the current transaction with signer to the composer\.$`, iAddTheCurrentTransactionWithSignerToTheComposer) s.Step(`^I clone the composer\.$`, iCloneTheComposer) s.Step(`^I execute the current transaction group with the composer\.$`, iExecuteTheCurrentTransactionGroupWithTheComposer) + s.Step(`^I simulate the current transaction group with the composer$`, iSimulateTheCurrentTransactionGroupWithTheComposer) s.Step(`^The app should have returned "([^"]*)"\.$`, theAppShouldHaveReturned) s.Step(`^The app should have returned ABI types "([^"]*)"\.$`, theAppShouldHaveReturnedABITypes) diff --git a/test/integration.tags b/test/integration.tags index f8b49f9f..d73c5c74 100644 --- a/test/integration.tags +++ b/test/integration.tags @@ -13,3 +13,4 @@ @rekey_v1 @send @send.keyregtxn +@simulate diff --git a/test/steps_test.go b/test/steps_test.go index 2badecf2..85779912 100644 --- a/test/steps_test.go +++ b/test/steps_test.go @@ -104,6 +104,7 @@ var sourceMap logic.SourceMap var srcMapping map[string]interface{} var seeminglyProgram []byte var sanityCheckError error +var simulateResponse modelsV2.SimulateResponse var assetTestFixture struct { Creator string @@ -335,6 +336,7 @@ func FeatureContext(s *godog.Suite) { s.Step(`^the base64 encoded signed transactions should equal "([^"]*)"$`, theBaseEncodedSignedTransactionsShouldEqual) s.Step(`^I build a payment transaction with sender "([^"]*)", receiver "([^"]*)", amount (\d+), close remainder to "([^"]*)"$`, iBuildAPaymentTransactionWithSenderReceiverAmountCloseRemainderTo) s.Step(`^I create a transaction with signer with the current transaction\.$`, iCreateATransactionWithSignerWithTheCurrentTransaction) + s.Step(`^I create a transaction with an empty signer with the current transaction\.$`, iCreateATransactionWithAnEmptySignerWithTheCurrentTransaction) s.Step(`^I append the current transaction with signer to the method arguments array\.$`, iAppendTheCurrentTransactionWithSignerToTheMethodArgumentsArray) s.Step(`^a dryrun response file "([^"]*)" and a transaction at index "([^"]*)"$`, aDryrunResponseFileAndATransactionAtIndex) s.Step(`^calling app trace produces "([^"]*)"$`, callingAppTraceProduces) @@ -354,6 +356,10 @@ func FeatureContext(s *godog.Suite) { s.Step(`^I start heuristic sanity check over the bytes$`, heuristicCheckOverBytes) s.Step(`^if the heuristic sanity check throws an error, the error contains "([^"]*)"$`, checkErrorIfMatching) s.Step(`^disassembly of "([^"]*)" matches "([^"]*)"$`, disassemblyMatches) + s.Step(`^I simulate the transaction$`, iSimulateTheTransaction) + s.Step(`^the simulation should succeed without any failure message$`, theSimulationShouldSucceedWithoutAnyFailureMessage) + s.Step(`^I prepare the transaction without signatures for simulation$`, iPrepareTheTransactionWithoutSignaturesForSimulation) + s.Step(`^the simulation should report a failure at group "([^"]*)", path "([^"]*)" with message "([^"]*)"$`, theSimulationShouldReportAFailureAtGroupPathWithMessage) s.BeforeScenario(func(interface{}) { stxObj = types.SignedTxn{} @@ -2371,6 +2377,14 @@ func iCreateATransactionWithSignerWithTheCurrentTransaction() error { return nil } +func iCreateATransactionWithAnEmptySignerWithTheCurrentTransaction() error { + accountTxAndSigner = transaction.TransactionWithSigner{ + Signer: transaction.EmptyTransactionSigner{}, + Txn: txn, + } + return nil +} + func iAppendTheCurrentTransactionWithSignerToTheMethodArgumentsArray() error { methodArgs = append(methodArgs, accountTxAndSigner) return nil @@ -2581,3 +2595,76 @@ func disassemblyMatches(bytecodeFilename, sourceFilename string) error { } return nil } + +func iSimulateTheTransaction() error { + var signedTxn types.SignedTxn + if err := msgpack.Decode(stx, &signedTxn); err != nil { + return err + } + + resp, err := algodV2client.SimulateTransaction(modelsV2.SimulateRequest{ + TxnGroups: []modelsV2.SimulateRequestTransactionGroup{ + { + Txns: []types.SignedTxn{signedTxn}, + }, + }, + }).Do(context.Background()) + if err != nil { + return err + } + + simulateResponse = resp + return nil +} + +func theSimulationShouldSucceedWithoutAnyFailureMessage() error { + for i, groupResult := range simulateResponse.TxnGroups { + if groupResult.FailureMessage != "" { + return fmt.Errorf("Simulation group %d failed with message: %s", i, groupResult.FailureMessage) + } + } + return nil +} + +func iPrepareTheTransactionWithoutSignaturesForSimulation() error { + stx = msgpack.Encode(&types.SignedTxn{Txn: txn}) + return nil +} + +func theSimulationShouldReportAFailureAtGroupPathWithMessage(txnGroupIndex, failAt, expectedFailureMsg string) error { + // Parse transaction group number + groupIndex, err := strconv.Atoi(txnGroupIndex) + if err != nil { + return err + } + + // Parse the path ("0,0") into a list of numbers ([0, 0]) + path := strings.Split(failAt, ",") + expectedPath := make([]uint64, len(path)) + for i, pathStr := range path { + pathNumber, err := strconv.ParseUint(pathStr, 10, 64) + if err != nil { + return err + } + expectedPath[i] = pathNumber + } + + actualFailureMsg := simulateResponse.TxnGroups[groupIndex].FailureMessage + if expectedFailureMsg == "" && actualFailureMsg != "" { + return fmt.Errorf("Expected no failure message, but got: '%s'", actualFailureMsg) + } else if expectedFailureMsg != "" && !strings.Contains(actualFailureMsg, expectedFailureMsg) { + return fmt.Errorf("Expected failure message '%s', but got: '%s'", expectedFailureMsg, actualFailureMsg) + } + + actualPath := simulateResponse.TxnGroups[groupIndex].FailedAt + if len(expectedPath) != len(actualPath) { + return fmt.Errorf("Expected failure path %v, but got: %v", expectedPath, actualPath) + } + for i := range expectedPath { + if expectedPath[i] != actualPath[i] { + return fmt.Errorf("Expected failure path %v, but got: %v", expectedPath, actualPath) + } + } + + return nil +} diff --git a/transaction/atomicTransactionComposer.go b/transaction/atomicTransactionComposer.go index 8565d33b..826ac52c 100644 --- a/transaction/atomicTransactionComposer.go +++ b/transaction/atomicTransactionComposer.go @@ -10,6 +10,7 @@ import ( "github.com/algorand/go-algorand-sdk/v2/client/v2/algod" "github.com/algorand/go-algorand-sdk/v2/client/v2/common/models" "github.com/algorand/go-algorand-sdk/v2/crypto" + "github.com/algorand/go-algorand-sdk/v2/encoding/msgpack" "github.com/algorand/go-algorand-sdk/v2/types" ) @@ -102,6 +103,16 @@ type AddMethodCallParams struct { BoxReferences []types.AppBoxReference } +// SimulateResult contains the results of calling the Simulate method on an +// AtomicTransactionComposer object. +type SimulateResult struct { + // The result of the transaction group simulation + SimulateResponse models.SimulateResponse + // For each ABI method call in the executed group (created by the AddMethodCall method), this + // slice contains information about the method call's return value + MethodResults []ABIMethodResult +} + // ExecuteResult contains the results of successfully calling the Execute method on an // AtomicTransactionComposer object. type ExecuteResult struct { @@ -573,6 +584,64 @@ func (atc *AtomicTransactionComposer) Submit(client *algod.Client, ctx context.C return atc.getTxIDs(), nil } +// Simulate simulates the transaction group in the network. +// +// The composer's status must be SUBMITTED or lower before calling this method. Simulation will not +// advance the status of the composer beyond SIGNED. +// +// The `request` argument can be used to customize the characteristics of the simulation. +// +// Returns a models.SimulateResponse and an ABIResult for each method call in this group. +func (atc *AtomicTransactionComposer) Simulate(client *algod.Client, ctx context.Context, request models.SimulateRequest) (SimulateResult, error) { + if atc.status > SUBMITTED { + return SimulateResult{}, errors.New("status must be SUBMITTED or lower in order to call Simulate()") + } + + stxs, err := atc.GatherSignatures() + if err != nil { + return SimulateResult{}, err + } + + txnObjects := make([]types.SignedTxn, len(stxs)) + for i, stx := range stxs { + var txnObject types.SignedTxn + err = msgpack.Decode(stx, &txnObject) + if err != nil { + return SimulateResult{}, err + } + txnObjects[i] = txnObject + } + + request.TxnGroups = []models.SimulateRequestTransactionGroup{ + {Txns: txnObjects}, + } + + simulateResponse, err := client.SimulateTransaction(request).Do(ctx) + if err != nil { + return SimulateResult{}, err + } + + result := SimulateResult{ + SimulateResponse: simulateResponse, + } + + for i, txContext := range atc.txContexts { + // Verify method call is available. This may not be the case if the App Call Tx wasn't created + // by AddMethodCall(). + if !txContext.isMethodCallTx() { + continue + } + + methodResult := ABIMethodResult{TxID: txContext.txID(), Method: *txContext.method} + txnInfo := models.PendingTransactionInfoResponse(simulateResponse.TxnGroups[0].TxnResults[i].TxnResult) + + methodResult = prepareMethodResult(methodResult, txnInfo) + result.MethodResults = append(result.MethodResults, methodResult) + } + + return result, nil +} + // Execute sends the transaction group to the network and waits until it's committed to a block. An // error will be thrown if submission or execution fails. // @@ -642,39 +711,42 @@ func (atc *AtomicTransactionComposer) Execute(client *algod.Client, ctx context. result.TransactionInfo = methodCallInfo } - if txContext.method.Returns.IsVoid() { - result.RawReturnValue = []byte{} - executeResponse.MethodResults = append(executeResponse.MethodResults, result) - continue - } + result = prepareMethodResult(result, result.TransactionInfo) + executeResponse.MethodResults = append(executeResponse.MethodResults, result) + } - if len(result.TransactionInfo.Logs) == 0 { - result.DecodeError = errors.New("method call did not log a return value") - executeResponse.MethodResults = append(executeResponse.MethodResults, result) - continue - } + return executeResponse, nil +} - lastLog := result.TransactionInfo.Logs[len(result.TransactionInfo.Logs)-1] - if !bytes.HasPrefix(lastLog, abiReturnHash) { - result.DecodeError = errors.New("method call did not log a return value") - executeResponse.MethodResults = append(executeResponse.MethodResults, result) - continue - } +func prepareMethodResult(result ABIMethodResult, transactionInfo models.PendingTransactionInfoResponse) ABIMethodResult { + result.TransactionInfo = transactionInfo - result.RawReturnValue = lastLog[len(abiReturnHash):] + if result.Method.Returns.IsVoid() { + result.RawReturnValue = []byte{} + return result + } - abiType, err := txContext.method.Returns.GetTypeObject() - if err != nil { - result.DecodeError = err - executeResponse.MethodResults = append(executeResponse.MethodResults, result) - break - } + if len(result.TransactionInfo.Logs) == 0 { + result.DecodeError = errors.New("method call did not log a return value") + return result + } - result.ReturnValue, result.DecodeError = abiType.Decode(result.RawReturnValue) - executeResponse.MethodResults = append(executeResponse.MethodResults, result) + lastLog := result.TransactionInfo.Logs[len(result.TransactionInfo.Logs)-1] + if !bytes.HasPrefix(lastLog, abiReturnHash) { + result.DecodeError = errors.New("method call did not log a return value") + return result } - return executeResponse, nil + result.RawReturnValue = lastLog[len(abiReturnHash):] + + abiType, err := result.Method.Returns.GetTypeObject() + if err != nil { + result.DecodeError = err + return result + } + + result.ReturnValue, result.DecodeError = abiType.Decode(result.RawReturnValue) + return result } // marshallAbiUint64 converts any value used to represent an ABI "uint64" into diff --git a/transaction/transactionSigner.go b/transaction/transactionSigner.go index 16fbea1a..63a66b6f 100644 --- a/transaction/transactionSigner.go +++ b/transaction/transactionSigner.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/algorand/go-algorand-sdk/v2/crypto" + "github.com/algorand/go-algorand-sdk/v2/encoding/msgpack" "github.com/algorand/go-algorand-sdk/v2/types" ) @@ -149,3 +150,25 @@ func (txSigner MultiSigAccountTransactionSigner) Equals(other TransactionSigner) } return false } + +// EmptyTransactionSigner is a TransactionSigner that produces signed transaction objects without +// signatures. This is useful for simulating transactions, but it won't work for actual submission. +type EmptyTransactionSigner struct{} + +// SignTransactions returns SignedTxn bytes but does not sign them. +func (txSigner EmptyTransactionSigner) SignTransactions(txGroup []types.Transaction, indexesToSign []int) ([][]byte, error) { + stxs := make([][]byte, len(indexesToSign)) + for i, pos := range indexesToSign { + stx := types.SignedTxn{ + Txn: txGroup[pos], + } + stxs[i] = msgpack.Encode(stx) + } + return stxs, nil +} + +// Equals returns true if the other TransactionSigner equals this one. +func (txSigner EmptyTransactionSigner) Equals(other TransactionSigner) bool { + _, ok := other.(EmptyTransactionSigner) + return ok +}