diff --git a/cmd/goal/application.go b/cmd/goal/application.go index 0920793300..0c5e9e0cbf 100644 --- a/cmd/goal/application.go +++ b/cmd/goal/application.go @@ -1063,7 +1063,7 @@ var infoAppCmd = &cobra.Command{ } // populateMethodCallTxnArgs parses and loads transactions from the files indicated by the values -// slice. An error will occur if the transaction does not matched the expected type, it has a nonzero +// slice. An error will occur if the transaction does not match the expected type, it has a nonzero // group ID, or if it is signed by a normal signature or Msig signature (but not Lsig signature) func populateMethodCallTxnArgs(types []string, values []string) ([]transactions.SignedTxn, error) { loadedTxns := make([]transactions.SignedTxn, len(values)) diff --git a/cmd/goal/clerk.go b/cmd/goal/clerk.go index 2147828947..f044915fba 100644 --- a/cmd/goal/clerk.go +++ b/cmd/goal/clerk.go @@ -76,6 +76,7 @@ func init() { clerkCmd.AddCommand(compileCmd) clerkCmd.AddCommand(dryrunCmd) clerkCmd.AddCommand(dryrunRemoteCmd) + clerkCmd.AddCommand(simulateCmd) // Wallet to be used for the clerk operation clerkCmd.PersistentFlags().StringVarP(&walletName, "wallet", "w", "", "Set the wallet to be used for the selected operation") @@ -88,7 +89,7 @@ func init() { sendCmd.Flags().StringVar(&rekeyToAddress, "rekey-to", "", "Rekey account to the given spending key/address. (Future transactions from this account will need to be signed with the new key.)") sendCmd.Flags().StringVarP(&programSource, "from-program", "F", "", "Program source to use as account logic") sendCmd.Flags().StringVarP(&progByteFile, "from-program-bytes", "P", "", "Program binary to use as account logic") - sendCmd.Flags().StringSliceVar(&argB64Strings, "argb64", nil, "base64 encoded args to pass to transaction logic") + sendCmd.Flags().StringSliceVar(&argB64Strings, "argb64", nil, "Base64 encoded args to pass to transaction logic") sendCmd.Flags().StringVarP(&logicSigFile, "logic-sig", "L", "", "LogicSig to apply to transaction") sendCmd.Flags().StringVar(&msigParams, "msig-params", "", "Multisig preimage parameters - [threshold] [Address 1] [Address 2] ...\nUsed to add the necessary fields in case the account was rekeyed to a multisig account") sendCmd.MarkFlagRequired("to") @@ -108,8 +109,8 @@ func init() { signCmd.Flags().StringVarP(&signerAddress, "signer", "S", "", "Address of key to sign with, if different from transaction \"from\" address due to rekeying") signCmd.Flags().StringVarP(&programSource, "program", "p", "", "Program source to use as account logic") signCmd.Flags().StringVarP(&logicSigFile, "logic-sig", "L", "", "LogicSig to apply to transaction") - signCmd.Flags().StringSliceVar(&argB64Strings, "argb64", nil, "base64 encoded args to pass to transaction logic") - signCmd.Flags().StringVarP(&protoVersion, "proto", "P", "", "consensus protocol version id string") + signCmd.Flags().StringSliceVar(&argB64Strings, "argb64", nil, "Base64 encoded args to pass to transaction logic") + signCmd.Flags().StringVarP(&protoVersion, "proto", "P", "", "Consensus protocol version id string") signCmd.MarkFlagRequired("infile") signCmd.MarkFlagRequired("outfile") @@ -123,26 +124,29 @@ func init() { splitCmd.MarkFlagRequired("infile") splitCmd.MarkFlagRequired("outfile") - compileCmd.Flags().BoolVarP(&disassemble, "disassemble", "D", false, "disassemble a compiled program") - compileCmd.Flags().BoolVarP(&noProgramOutput, "no-out", "n", false, "don't write contract program binary") - compileCmd.Flags().BoolVarP(&writeSourceMap, "map", "m", false, "write out source map") - compileCmd.Flags().BoolVarP(&signProgram, "sign", "s", false, "sign program, output is a binary signed LogicSig record") + compileCmd.Flags().BoolVarP(&disassemble, "disassemble", "D", false, "Disassemble a compiled program") + compileCmd.Flags().BoolVarP(&noProgramOutput, "no-out", "n", false, "Don't write contract program binary") + compileCmd.Flags().BoolVarP(&writeSourceMap, "map", "m", false, "Write out source map") + compileCmd.Flags().BoolVarP(&signProgram, "sign", "s", false, "Sign program, output is a binary signed LogicSig record") compileCmd.Flags().StringVarP(&outFilename, "outfile", "o", "", "Filename to write program bytes or signed LogicSig to") compileCmd.Flags().StringVarP(&account, "account", "a", "", "Account address to sign the program (If not specified, uses default account)") - dryrunCmd.Flags().StringVarP(&txFilename, "txfile", "t", "", "transaction or transaction-group to test") - dryrunCmd.Flags().StringVarP(&protoVersion, "proto", "P", "", "consensus protocol version id string") + dryrunCmd.Flags().StringVarP(&txFilename, "txfile", "t", "", "Transaction or transaction-group to test") + dryrunCmd.Flags().StringVarP(&protoVersion, "proto", "P", "", "Consensus protocol version id string") dryrunCmd.Flags().BoolVar(&dumpForDryrun, "dryrun-dump", false, "Dump in dryrun format acceptable by dryrun REST api instead of running") dryrunCmd.Flags().Var(&dumpForDryrunFormat, "dryrun-dump-format", "Dryrun dump format: "+dumpForDryrunFormat.AllowedString()) - dryrunCmd.Flags().StringSliceVar(&dumpForDryrunAccts, "dryrun-accounts", nil, "additional accounts to include into dryrun request obj") + dryrunCmd.Flags().StringSliceVar(&dumpForDryrunAccts, "dryrun-accounts", nil, "Additional accounts to include into dryrun request obj") dryrunCmd.Flags().StringVarP(&outFilename, "outfile", "o", "", "Filename for writing dryrun state object") dryrunCmd.MarkFlagRequired("txfile") - dryrunRemoteCmd.Flags().StringVarP(&txFilename, "dryrun-state", "D", "", "dryrun request object to run") - dryrunRemoteCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "print more info") - dryrunRemoteCmd.Flags().BoolVarP(&rawOutput, "raw", "r", false, "output raw response from algod") + dryrunRemoteCmd.Flags().StringVarP(&txFilename, "dryrun-state", "D", "", "Dryrun request object to run") + dryrunRemoteCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Print more info") + dryrunRemoteCmd.Flags().BoolVarP(&rawOutput, "raw", "r", false, "Output raw response from algod") dryrunRemoteCmd.MarkFlagRequired("dryrun-state") + simulateCmd.Flags().StringVarP(&txFilename, "txfile", "t", "", "Transaction or transaction-group to test") + simulateCmd.Flags().StringVarP(&outFilename, "outfile", "o", "", "Filename for writing simulation result") + panicIfErr(simulateCmd.MarkFlagRequired("txfile")) } var clerkCmd = &cobra.Command{ @@ -1253,6 +1257,34 @@ var dryrunRemoteCmd = &cobra.Command{ }, } +var simulateCmd = &cobra.Command{ + Use: "simulate", + Short: "Simulate a transaction or transaction group with algod's simulate REST endpoint", + Long: `Simulate a transaction or transaction group with algod's simulate REST endpoint under various configurations.`, + Run: func(cmd *cobra.Command, args []string) { + data, err := readFile(txFilename) + if err != nil { + reportErrorf(fileReadError, txFilename, err) + } + + dataDir := datadir.EnsureSingleDataDir() + client := ensureFullClient(dataDir) + resp, err := client.TransactionSimulation(data) + if err != nil { + reportErrorf("simulation error: %s", err.Error()) + } + + if outFilename != "" { + err = writeFile(outFilename, protocol.EncodeJSON(&resp), 0600) + if err != nil { + reportErrorf("write file error: %s", err.Error()) + } + } else { + fmt.Println(string(protocol.EncodeJSON(&resp))) + } + }, +} + // unmarshalSlice converts string addresses to basics.Address func unmarshalSlice(accts []string) ([]basics.Address, error) { result := make([]basics.Address, 0, len(accts)) diff --git a/daemon/algod/api/client/restClient.go b/daemon/algod/api/client/restClient.go index f3f5415734..c9bdb0a833 100644 --- a/daemon/algod/api/client/restClient.go +++ b/daemon/algod/api/client/restClient.go @@ -45,10 +45,11 @@ const ( // rawRequestPaths is a set of paths where the body should not be urlencoded var rawRequestPaths = map[string]bool{ - "/v2/transactions": true, - "/v2/teal/dryrun": true, - "/v2/teal/compile": true, - "/v2/participation": true, + "/v2/transactions": true, + "/v2/teal/dryrun": true, + "/v2/teal/compile": true, + "/v2/participation": true, + "/v2/transactions/simulate": true, } // unauthorizedRequestError is generated when we receive 401 error from the server. This error includes the inner error @@ -284,7 +285,7 @@ func (client RestClient) WaitForBlock(round basics.Round) (response model.NodeSt return } -// HealthCheck does a health check on the the potentially running node, +// HealthCheck does a health check on the potentially running node, // returning an error if the API is down func (client RestClient) HealthCheck() error { return client.get(nil, "/health", nil) @@ -629,6 +630,12 @@ func (client RestClient) RawDryrun(data []byte) (response []byte, err error) { return } +// SimulateRawTransaction gets the raw transaction or raw transaction group, and returns relevant simulation results. +func (client RestClient) SimulateRawTransaction(data []byte) (response model.SimulateResponse, err error) { + err = client.submitForm(&response, "/v2/transactions/simulate", data, "POST", false /* encodeJSON */, true /* decodeJSON */, false) + return +} + // StateProofs gets a state proof that covers a given round func (client RestClient) StateProofs(round uint64) (response model.StateProofResponse, err error) { err = client.get(&response, fmt.Sprintf("/v2/stateproofs/%d", round), nil) @@ -670,7 +677,6 @@ func (client RestClient) GetParticipationKeyByID(participationID string) (respon func (client RestClient) RemoveParticipationKeyByID(participationID string) (err error) { err = client.delete(nil, fmt.Sprintf("/v2/participation/%s", participationID), nil, true) return - } /* Endpoint registered for follower nodes */ diff --git a/libgoal/libgoal.go b/libgoal/libgoal.go index 4756aa155f..5951793de3 100644 --- a/libgoal/libgoal.go +++ b/libgoal/libgoal.go @@ -504,11 +504,10 @@ func (c *Client) signAndBroadcastTransactionWithWallet(walletHandle, pw []byte, // // validRounds | lastValid | result (lastValid) // ------------------------------------------------- -// 0 | 0 | firstValid + maxTxnLife -// 0 | N | lastValid -// M | 0 | first + validRounds - 1 -// M | M | error -// +// 0 | 0 | firstValid + maxTxnLife +// 0 | N | lastValid +// M | 0 | first + validRounds - 1 +// M | M | error func (c *Client) ComputeValidityRounds(firstValid, lastValid, validRounds uint64) (first, last, latest uint64, err error) { params, err := c.cachedSuggestedParams() if err != nil { @@ -1270,6 +1269,16 @@ func (c *Client) Dryrun(data []byte) (resp model.DryrunResponse, err error) { return } +// TransactionSimulation takes raw transaction or raw transaction group, and returns relevant simulation results. +func (c *Client) TransactionSimulation(data []byte) (resp model.SimulateResponse, err error) { + algod, err := c.ensureAlgodClient() + if err != nil { + return + } + resp, err = algod.SimulateRawTransaction(data) + return +} + // TransactionProof returns a Merkle proof for a transaction in a block. func (c *Client) TransactionProof(txid string, round uint64, hashType crypto.HashType) (resp model.TransactionProofResponse, err error) { algod, err := c.ensureAlgodClient() diff --git a/test/scripts/e2e_subs/e2e-app-simulate.sh b/test/scripts/e2e_subs/e2e-app-simulate.sh new file mode 100755 index 0000000000..cae8ebeab3 --- /dev/null +++ b/test/scripts/e2e_subs/e2e-app-simulate.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +date '+app-simple-test start %Y%m%d_%H%M%S' + +set -e +set -x +set -o pipefail +set -o nounset +export SHELLOPTS + +WALLET=$1 + +# Directory of this bash program +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +gcmd="goal -w ${WALLET}" + +ACCOUNT=$(${gcmd} account list|awk '{ print $3 }') + +CONST_TRUE="true" +CONST_FALSE="false" + +############################################## +# WE FIRST TEST TRANSACTION GROUP SIMULATION # +############################################## + +${gcmd} clerk send -a 10000 -f ${ACCOUNT} -t ${ACCOUNT} -o pay1.tx +${gcmd} clerk send -a 10000 -f ${ACCOUNT} -t ${ACCOUNT} -o pay2.tx + +cat pay1.tx pay2.tx | ${gcmd} clerk group -i - -o grouped.tx + +# We first test transaction group simulation WITHOUT signatures +RES=$(${gcmd} clerk simulate -t grouped.tx) + +if [[ $(echo "$RES" | jq '."would-succeed"') != $CONST_FALSE ]]; then + date '+app-simulate-test FAIL the simulation transaction group without signatures should not succeed %Y%m%d_%H%M%S' + false +fi + +# check the simulation failing reason, first transaction has no signature +if [[ $(echo "$RES" | jq '."txn-groups"[0]."txn-results"[0]."missing-signature"') != $CONST_TRUE ]]; then + date '+app-simulate-test FAIL the simulation transaction group FAIL for first transaction has NO signature %Y%m%d_%H%M%S' + false +fi + +# check the simulation failing reason, second transaction has no signature +if [[ $(echo "$RES" | jq '."txn-groups"[0]."txn-results"[1]."missing-signature"') != $CONST_TRUE ]]; then + date '+app-simulate-test FAIL the simulation transaction group FAIL for second transaction has NO signature %Y%m%d_%H%M%S' + false +fi + +# We then test transaction group simulation WITH signatures +${gcmd} clerk split -i grouped.tx -o grouped.tx + +${gcmd} clerk sign -i grouped-0.tx -o grouped-0.stx +${gcmd} clerk sign -i grouped-1.tx -o grouped-1.stx + +cat grouped-0.stx grouped-1.stx > grouped.stx + +RES=$(${gcmd} clerk simulate -t grouped.stx | jq '."would-succeed"') + +if [[ $RES != $CONST_TRUE ]]; then + date '+app-simulate-test FAIL should pass to simulate self pay transaction group %Y%m%d_%H%M%S' + false +fi + +############################################### +# WE ALSO TEST OVERSPEND IN TRANSACTION GROUP # +############################################### + +${gcmd} clerk send -a 1000000000000 -f ${ACCOUNT} -t ${ACCOUNT} -o pay1.tx +${gcmd} clerk send -a 10000 -f ${ACCOUNT} -t ${ACCOUNT} -o pay2.tx + +cat pay1.tx pay2.tx | ${gcmd} clerk group -i - -o grouped.tx + +${gcmd} clerk split -i grouped.tx -o grouped.tx + +${gcmd} clerk sign -i grouped-0.tx -o grouped-0.stx +${gcmd} clerk sign -i grouped-1.tx -o grouped-1.stx + +cat grouped-0.stx grouped-1.stx > grouped.stx + +RES=$(${gcmd} clerk simulate -t grouped.stx) + +if [[ $(echo "$RES" | jq '."would-succeed"') != $CONST_FALSE ]]; then + data '+app-simulate-test FAIL should FAIL for overspending in simulate self pay transaction group %Y%m%d_%H%M%S' + false +fi + +OVERSPEND_INFO="overspend" + +if [[ $(echo "$RES" | jq '."txn-groups"[0]."failure-message"') != *"$OVERSPEND_INFO"* ]]; then + data '+app-simulate-test FAIL first overspending transaction in transaction group should contain message OVERSPEND %Y%m%d_%H%M%S' + false +fi + +####################################################### +# NOW WE TRY TO TEST SIMULATION WITH ABI METHOD CALLS # +####################################################### + +printf '#pragma version 2\nint 1' > "${TEMPDIR}/simple-v2.teal" + +# Real Create +RES=$(${gcmd} app method --method "create(uint64)uint64" --arg "1234" --create --approval-prog ${DIR}/tealprogs/app-abi-method-example.teal --clear-prog ${TEMPDIR}/simple-v2.teal --global-byteslices 0 --global-ints 0 --local-byteslices 1 --local-ints 0 --extra-pages 0 --from $ACCOUNT 2>&1 || true) +EXPECTED="method create(uint64)uint64 succeeded with output: 2468" +if [[ $RES != *"${EXPECTED}"* ]]; then + date '+app-simulate-test FAIL the method call to create(uint64)uint64 should not fail %Y%m%d_%H%M%S' + false +fi + +APPID=$(echo "$RES" | grep Created | awk '{ print $6 }') + +# SIMULATION! empty()void +${gcmd} app method --method "empty()void" --app-id $APPID --from $ACCOUNT 2>&1 -o empty.tx + +# SIMULATE without a signature first +RES=$(${gcmd} clerk simulate -t empty.tx) + +# confirm that without signature, the simulation should fail +if [[ $(echo "$RES" | jq '."would-succeed"') != $CONST_FALSE ]]; then + date '+app-simulate-test FAIL the simulation call to empty()void without signature should not succeed %Y%m%d_%H%M%S' + false +fi + +# check again the simulation failing reason +if [[ $(echo "$RES" | jq '."txn-groups"[0]."txn-results"[0]."missing-signature"') != $CONST_TRUE ]]; then + date '+app-simulate-test FAIL the simulation call to empty()void without signature should fail with missing-signature %Y%m%d_%H%M%S' + false +fi + +# SIMULATE with a signature +${gcmd} clerk sign -i empty.tx -o empty.stx +RES=$(${gcmd} clerk simulate -t empty.stx | jq '."would-succeed"') + +# with signature, simulation app-call should succeed +if [[ $RES != $CONST_TRUE ]]; then + date '+app-simulate-test FAIL the simulation call to empty()void should succeed %Y%m%d_%H%M%S' + false +fi