diff --git a/cmd/goal/application.go b/cmd/goal/application.go index da142e3e08..c92bab2b91 100644 --- a/cmd/goal/application.go +++ b/cmd/goal/application.go @@ -17,6 +17,8 @@ package main import ( + "bytes" + "crypto/sha512" "encoding/base32" "encoding/base64" "encoding/binary" @@ -28,9 +30,11 @@ import ( "github.com/spf13/cobra" "github.com/algorand/go-algorand/crypto" + "github.com/algorand/go-algorand/data/abi" "github.com/algorand/go-algorand/data/basics" "github.com/algorand/go-algorand/data/transactions" "github.com/algorand/go-algorand/data/transactions/logic" + "github.com/algorand/go-algorand/libgoal" "github.com/algorand/go-algorand/protocol" ) @@ -41,6 +45,9 @@ var ( approvalProgFile string clearProgFile string + method string + methodArgs []string + approvalProgRawFile string clearProgRawFile string @@ -79,9 +86,10 @@ func init() { appCmd.AddCommand(clearAppCmd) appCmd.AddCommand(readStateAppCmd) appCmd.AddCommand(infoAppCmd) + appCmd.AddCommand(methodAppCmd) appCmd.PersistentFlags().StringVarP(&walletName, "wallet", "w", "", "Set the wallet to be used for the selected operation") - appCmd.PersistentFlags().StringSliceVar(&appArgs, "app-arg", nil, "Args to encode for application transactions (all will be encoded to a byte slice). For ints, use the form 'int:1234'. For raw bytes, use the form 'b64:A=='. For printable strings, use the form 'str:hello'. For addresses, use the form 'addr:XYZ...'.") + appCmd.PersistentFlags().StringArrayVar(&appArgs, "app-arg", nil, "Args to encode for application transactions (all will be encoded to a byte slice). For ints, use the form 'int:1234'. For raw bytes, use the form 'b64:A=='. For printable strings, use the form 'str:hello'. For addresses, use the form 'addr:XYZ...'.") appCmd.PersistentFlags().StringSliceVar(&foreignApps, "foreign-app", nil, "Indexes of other apps whose global state is read in this transaction") appCmd.PersistentFlags().StringSliceVar(&foreignAssets, "foreign-asset", nil, "Indexes of assets whose parameters are read in this transaction") appCmd.PersistentFlags().StringSliceVar(&appStrAccounts, "app-account", nil, "Accounts that may be accessed from application logic") @@ -108,6 +116,10 @@ func init() { deleteAppCmd.Flags().StringVarP(&account, "from", "f", "", "Account to send delete transaction from") readStateAppCmd.Flags().StringVarP(&account, "from", "f", "", "Account to fetch state from") updateAppCmd.Flags().StringVarP(&account, "from", "f", "", "Account to send update transaction from") + methodAppCmd.Flags().StringVarP(&account, "from", "f", "", "Account to call method from") + + methodAppCmd.Flags().StringVar(&method, "method", "", "Method to be called") + methodAppCmd.Flags().StringArrayVar(&methodArgs, "arg", nil, "Args to pass in for calling a method") // Can't use PersistentFlags on the root because for some reason marking // a root command as required with MarkPersistentFlagRequired isn't @@ -120,6 +132,7 @@ func init() { readStateAppCmd.Flags().Uint64Var(&appIdx, "app-id", 0, "Application ID") updateAppCmd.Flags().Uint64Var(&appIdx, "app-id", 0, "Application ID") infoAppCmd.Flags().Uint64Var(&appIdx, "app-id", 0, "Application ID") + methodAppCmd.Flags().Uint64Var(&appIdx, "app-id", 0, "Application ID") // Add common transaction flags to all txn-generating app commands addTxnFlags(createAppCmd) @@ -129,6 +142,7 @@ func init() { addTxnFlags(optInAppCmd) addTxnFlags(closeOutAppCmd) addTxnFlags(clearAppCmd) + addTxnFlags(methodAppCmd) readStateAppCmd.Flags().BoolVar(&fetchLocal, "local", false, "Fetch account-specific state for this application. `--from` address is required when using this flag") readStateAppCmd.Flags().BoolVar(&fetchGlobal, "global", false, "Fetch global state for this application.") @@ -161,6 +175,13 @@ func init() { readStateAppCmd.MarkFlagRequired("app-id") infoAppCmd.MarkFlagRequired("app-id") + + methodAppCmd.MarkFlagRequired("method") // nolint:errcheck // follow previous required flag format + methodAppCmd.MarkFlagRequired("app-id") // nolint:errcheck + methodAppCmd.MarkFlagRequired("from") // nolint:errcheck + methodAppCmd.Flags().MarkHidden("app-arg") // nolint:errcheck + methodAppCmd.Flags().MarkHidden("app-input") // nolint:errcheck + methodAppCmd.Flags().MarkHidden("i") // nolint:errcheck } type appCallArg struct { @@ -229,6 +250,23 @@ func parseAppArg(arg appCallArg) (rawValue []byte, parseErr error) { return } rawValue = data + case "abi": + typeAndValue := strings.SplitN(arg.Value, ":", 2) + if len(typeAndValue) != 2 { + parseErr = fmt.Errorf("Could not decode abi string (%s): should split abi-type and abi-value with colon", arg.Value) + return + } + abiType, err := abi.TypeOf(typeAndValue[0]) + if err != nil { + parseErr = fmt.Errorf("Could not decode abi type string (%s): %v", typeAndValue[0], err) + return + } + value, err := abiType.UnmarshalFromJSON([]byte(typeAndValue[1])) + if err != nil { + parseErr = fmt.Errorf("Could not decode abi value string (%s):%v ", typeAndValue[1], err) + return + } + return abiType.Encode(value) default: parseErr = fmt.Errorf("Unknown encoding: %s", arg.Encoding) } @@ -266,6 +304,20 @@ func processAppInputFile() (args [][]byte, accounts []string, foreignApps []uint return parseAppInputs(inputs) } +// filterEmptyStrings filters out empty string parsed in by StringArrayVar +// this function is added to support abi argument parsing +// since parsing of `appArg` diverted from `StringSliceVar` to `StringArrayVar` +func filterEmptyStrings(strSlice []string) []string { + var newStrSlice []string + + for _, str := range strSlice { + if len(str) > 0 { + newStrSlice = append(newStrSlice, str) + } + } + return newStrSlice +} + func getAppInputs() (args [][]byte, accounts []string, foreignApps []uint64, foreignAssets []uint64) { if (appArgs != nil || appStrAccounts != nil || foreignApps != nil) && appInputFilename != "" { reportErrorf("Cannot specify both command-line arguments/accounts and JSON input filename") @@ -275,7 +327,11 @@ func getAppInputs() (args [][]byte, accounts []string, foreignApps []uint64, for } var encodedArgs []appCallArg - for _, arg := range appArgs { + + // we need to filter out empty strings from appArgs first, caused by change to `StringArrayVar` + newAppArgs := filterEmptyStrings(appArgs) + + for _, arg := range newAppArgs { encodingValue := strings.SplitN(arg, ":", 2) if len(encodingValue) != 2 { reportErrorf("all arguments should be of the form 'encoding:value'") @@ -327,6 +383,12 @@ func mustParseOnCompletion(ocString string) (oc transactions.OnCompletion) { } } +func getDataDirAndClient() (dataDir string, client libgoal.Client) { + dataDir = ensureSingleDataDir() + client = ensureFullClient(dataDir) + return +} + func mustParseProgArgs() (approval []byte, clear []byte) { // Ensure we don't have ambiguous or all empty args if (approvalProgFile == "") == (approvalProgRawFile == "") { @@ -357,9 +419,7 @@ var createAppCmd = &cobra.Command{ Long: `Issue a transaction that creates an application`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Construct schemas from args localSchema := basics.StateSchema{ @@ -451,8 +511,7 @@ var updateAppCmd = &cobra.Command{ Long: `Issue a transaction that updates an application's ApprovalProgram and ClearStateProgram`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters approvalProg, clearProg := mustParseProgArgs() @@ -523,8 +582,7 @@ var optInAppCmd = &cobra.Command{ Long: `Opt an account in to an application, allocating local state in your account`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters appArgs, appAccounts, foreignApps, foreignAssets := getAppInputs() @@ -594,8 +652,7 @@ var closeOutAppCmd = &cobra.Command{ Long: `Close an account out of an application, removing local state from your account. The application must still exist. If it doesn't, use 'goal app clear'.`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters appArgs, appAccounts, foreignApps, foreignAssets := getAppInputs() @@ -665,8 +722,7 @@ var clearAppCmd = &cobra.Command{ Long: `Remove any local state from your account associated with an application. The application does not need to exist anymore.`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters appArgs, appAccounts, foreignApps, foreignAssets := getAppInputs() @@ -736,8 +792,7 @@ var callAppCmd = &cobra.Command{ Long: `Call an application, invoking application-specific functionality`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters appArgs, appAccounts, foreignApps, foreignAssets := getAppInputs() @@ -807,8 +862,7 @@ var deleteAppCmd = &cobra.Command{ Long: `Delete an application, removing the global state and other application parameters from the creator's account`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + dataDir, client := getDataDirAndClient() // Parse transaction parameters appArgs, appAccounts, foreignApps, foreignAssets := getAppInputs() @@ -879,8 +933,7 @@ var readStateAppCmd = &cobra.Command{ Long: `Read global or local (account-specific) state for an application`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + _, client := getDataDirAndClient() // Ensure exactly one of --local or --global is specified if fetchLocal == fetchGlobal { @@ -961,8 +1014,7 @@ var infoAppCmd = &cobra.Command{ Long: `Look up application information stored on the network, such as program hash.`, Args: validateNoPosArgsFn, Run: func(cmd *cobra.Command, _ []string) { - dataDir := ensureSingleDataDir() - client := ensureFullClient(dataDir) + _, client := getDataDirAndClient() meta, err := client.ApplicationInformation(appIdx) if err != nil { @@ -995,3 +1047,140 @@ var infoAppCmd = &cobra.Command{ } }, } + +var methodAppCmd = &cobra.Command{ + Use: "method", + Short: "Invoke a method", + Long: `Invoke a method in an App (stateful contract) with an application call transaction`, + Args: validateNoPosArgsFn, + Run: func(cmd *cobra.Command, args []string) { + dataDir, client := getDataDirAndClient() + + // Parse transaction parameters + appArgsParsed, appAccounts, foreignApps, foreignAssets := getAppInputs() + if len(appArgsParsed) > 0 { + reportErrorf("in goal app method: --arg and --app-arg are mutually exclusive, do not use --app-arg") + } + + onCompletion := mustParseOnCompletion(createOnCompletion) + + if appIdx == 0 { + reportErrorf("app id == 0, goal app create not supported in goal app method") + } + + var approvalProg, clearProg []byte + if onCompletion == transactions.UpdateApplicationOC { + approvalProg, clearProg = mustParseProgArgs() + } + + var applicationArgs [][]byte + + // insert the method selector hash + hash := sha512.Sum512_256([]byte(method)) + applicationArgs = append(applicationArgs, hash[0:4]) + + // parse down the ABI type from method signature + argTupleTypeStr, retTypeStr, err := abi.ParseMethodSignature(method) + if err != nil { + reportErrorf("cannot parse method signature: %v", err) + } + err = abi.ParseArgJSONtoByteSlice(argTupleTypeStr, methodArgs, &applicationArgs) + if err != nil { + reportErrorf("cannot parse arguments to ABI encoding: %v", err) + } + + tx, err := client.MakeUnsignedApplicationCallTx( + appIdx, applicationArgs, appAccounts, foreignApps, foreignAssets, + onCompletion, approvalProg, clearProg, basics.StateSchema{}, basics.StateSchema{}, 0) + + if err != nil { + reportErrorf("Cannot create application txn: %v", err) + } + + // Fill in note and lease + tx.Note = parseNoteField(cmd) + tx.Lease = parseLease(cmd) + + // Fill in rounds, fee, etc. + fv, lv, err := client.ComputeValidityRounds(firstValid, lastValid, numValidRounds) + if err != nil { + reportErrorf("Cannot determine last valid round: %s", err) + } + + tx, err = client.FillUnsignedTxTemplate(account, fv, lv, fee, tx) + if err != nil { + reportErrorf("Cannot construct transaction: %s", err) + } + explicitFee := cmd.Flags().Changed("fee") + if explicitFee { + tx.Fee = basics.MicroAlgos{Raw: fee} + } + + // Broadcast + wh, pw := ensureWalletHandleMaybePassword(dataDir, walletName, true) + signedTxn, err := client.SignTransactionWithWallet(wh, pw, tx) + if err != nil { + reportErrorf(errorSigningTX, err) + } + + txid, err := client.BroadcastTransaction(signedTxn) + if err != nil { + reportErrorf(errorBroadcastingTX, err) + } + + // Report tx details to user + reportInfof("Issued transaction from account %s, txid %s (fee %d)", tx.Sender, txid, tx.Fee.Raw) + + if !noWaitAfterSend { + _, err := waitForCommit(client, txid, lv) + if err != nil { + reportErrorf(err.Error()) + } + + resp, err := client.PendingTransactionInformationV2(txid) + if err != nil { + reportErrorf(err.Error()) + } + + if retTypeStr == "void" { + return + } + + // specify the return hash prefix + hashRet := sha512.Sum512_256([]byte("return")) + hashRetPrefix := hashRet[:4] + + var abiEncodedRet []byte + foundRet := false + if resp.Logs != nil { + for i := len(*resp.Logs) - 1; i >= 0; i-- { + retLog := (*resp.Logs)[i] + if bytes.HasPrefix(retLog, hashRetPrefix) { + abiEncodedRet = retLog[4:] + foundRet = true + break + } + } + } + + if !foundRet { + reportErrorf("cannot find return log for abi type %s", retTypeStr) + } + + retType, err := abi.TypeOf(retTypeStr) + if err != nil { + reportErrorf("cannot cast %s to abi type: %v", retTypeStr, err) + } + decoded, err := retType.Decode(abiEncodedRet) + if err != nil { + reportErrorf("cannot decode return value %v: %v", abiEncodedRet, err) + } + + decodedJSON, err := retType.MarshalToJSON(decoded) + if err != nil { + reportErrorf("cannot marshal returned bytes %v to JSON: %v", decoded, err) + } + fmt.Printf("method %s output: %s", method, string(decodedJSON)) + } + }, +} diff --git a/data/abi/abi_encode.go b/data/abi/abi_encode.go index fc4790c139..fa5dbd57c8 100644 --- a/data/abi/abi_encode.go +++ b/data/abi/abi_encode.go @@ -21,6 +21,7 @@ import ( "fmt" "math/big" "reflect" + "strings" ) // typeCastToTuple cast an array-like ABI type into an ABI tuple type. @@ -58,7 +59,7 @@ func (t Type) typeCastToTuple(tupLen ...int) (Type, error) { return Type{}, fmt.Errorf("type cannot support conversion to tuple") } - tuple, err := makeTupleType(childT) + tuple, err := MakeTupleType(childT) if err != nil { return Type{}, err } @@ -476,3 +477,73 @@ func decodeTuple(encoded []byte, childT []Type) ([]interface{}, error) { } return values, nil } + +// ParseArgJSONtoByteSlice convert input method arguments to ABI encoded bytes +// it converts funcArgTypes into a tuple type and apply changes over input argument string (in JSON format) +// if there are greater or equal to 15 inputs, then we compact the tailing inputs into one tuple +func ParseArgJSONtoByteSlice(funcArgTypes string, jsonArgs []string, applicationArgs *[][]byte) error { + abiTupleT, err := TypeOf(funcArgTypes) + if err != nil { + return err + } + if len(abiTupleT.childTypes) != len(jsonArgs) { + return fmt.Errorf("input argument number %d != method argument number %d", len(jsonArgs), len(abiTupleT.childTypes)) + } + + // change the input args to be 1 - 14 + 15 (compacting everything together) + if len(jsonArgs) > 14 { + compactedType, err := MakeTupleType(abiTupleT.childTypes[14:]) + if err != nil { + return err + } + abiTupleT.childTypes = abiTupleT.childTypes[:14] + abiTupleT.childTypes = append(abiTupleT.childTypes, compactedType) + abiTupleT.staticLength = 15 + + remainingJSON := "[" + strings.Join(jsonArgs[14:], ",") + "]" + jsonArgs = jsonArgs[:14] + jsonArgs = append(jsonArgs, remainingJSON) + } + + // parse JSON value to ABI encoded bytes + for i := 0; i < len(jsonArgs); i++ { + interfaceVal, err := abiTupleT.childTypes[i].UnmarshalFromJSON([]byte(jsonArgs[i])) + if err != nil { + return err + } + abiEncoded, err := abiTupleT.childTypes[i].Encode(interfaceVal) + if err != nil { + return err + } + *applicationArgs = append(*applicationArgs, abiEncoded) + } + return nil +} + +// ParseMethodSignature parses a method of format `method(...argTypes...)retType` +// into `(...argTypes)` and `retType` +func ParseMethodSignature(methodSig string) (string, string, error) { + var stack []int + + for index, chr := range methodSig { + if chr == '(' { + stack = append(stack, index) + } else if chr == ')' { + if len(stack) == 0 { + break + } + leftParenIndex := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if len(stack) == 0 { + returnType := methodSig[index+1:] + if _, err := TypeOf(returnType); err != nil { + if returnType != "void" { + return "", "", fmt.Errorf("cannot infer return type: %s", returnType) + } + } + return methodSig[leftParenIndex : index+1], methodSig[index+1:], nil + } + } + } + return "", "", fmt.Errorf("unpaired parentheses: %s", methodSig) +} diff --git a/data/abi/abi_encode_test.go b/data/abi/abi_encode_test.go index fc7fbd8411..c585564c61 100644 --- a/data/abi/abi_encode_test.go +++ b/data/abi/abi_encode_test.go @@ -462,14 +462,13 @@ func TestDecodeValid(t *testing.T) { t.Run("static uint array decode", func(t *testing.T) { staticUintArrT, err := TypeOf("uint64[8]") require.NoError(t, err, "make static uint array type failure") - inputUint := []interface{}{1, 2, 3, 4, 5, 6, 7, 8} expected := []interface{}{ uint64(1), uint64(2), uint64(3), uint64(4), uint64(5), uint64(6), uint64(7), uint64(8), } - arrayEncoded, err := staticUintArrT.Encode(inputUint) + arrayEncoded, err := staticUintArrT.Encode(expected) require.NoError(t, err, "uint64 static array encode should not return error") actual, err := staticUintArrT.Decode(arrayEncoded) require.NoError(t, err, "uint64 static array decode should not return error") @@ -835,6 +834,11 @@ func categorySelfRoundTripTest(t *testing.T, category []testUnit) { actual, err := abiType.Decode(encodedValue) require.NoError(t, err, "failure to decode value") require.Equal(t, testObj.value, actual, "decoded value not equal to expected") + jsonEncodedValue, err := abiType.MarshalToJSON(testObj.value) + require.NoError(t, err, "failure to encode value to JSON type") + jsonActual, err := abiType.UnmarshalFromJSON(jsonEncodedValue) + require.NoError(t, err, "failure to decode JSON value back") + require.Equal(t, testObj.value, jsonActual, "decode JSON value not equal to expected") } } @@ -856,18 +860,8 @@ func addPrimitiveRandomValues(t *testing.T, pool *map[BaseType][]testUnit) { randVal, err := rand.Int(rand.Reader, max) require.NoError(t, err, "generate random uint, should be no error") - var narrowest interface{} - if bitSize <= 64 && bitSize > 32 { - narrowest = randVal.Uint64() - } else if bitSize <= 32 && bitSize > 16 { - narrowest = uint32(randVal.Uint64()) - } else if bitSize == 16 { - narrowest = uint16(randVal.Uint64()) - } else if bitSize == 8 { - narrowest = uint8(randVal.Uint64()) - } else { - narrowest = randVal - } + narrowest, err := castBigIntToNearestPrimitive(randVal, uint16(bitSize)) + require.NoError(t, err, "cast random uint to nearest primitive failure") (*pool)[Uint][uintIndex] = testUnit{serializedType: uintTstr, value: narrowest} uintIndex++ @@ -877,18 +871,8 @@ func addPrimitiveRandomValues(t *testing.T, pool *map[BaseType][]testUnit) { randVal, err := rand.Int(rand.Reader, max) require.NoError(t, err, "generate random ufixed, should be no error") - var narrowest interface{} - if bitSize <= 64 && bitSize > 32 { - narrowest = randVal.Uint64() - } else if bitSize <= 32 && bitSize > 16 { - narrowest = uint32(randVal.Uint64()) - } else if bitSize == 16 { - narrowest = uint16(randVal.Uint64()) - } else if bitSize == 8 { - narrowest = uint8(randVal.Uint64()) - } else { - narrowest = randVal - } + narrowest, err := castBigIntToNearestPrimitive(randVal, uint16(bitSize)) + require.NoError(t, err, "cast random uint to nearest primitive failure") ufixedT, err := makeUfixedType(bitSize, precision) require.NoError(t, err, "make ufixed type failure") @@ -999,7 +983,7 @@ func addTupleRandomValues(t *testing.T, slotRange BaseType, pool *map[BaseType][ require.NoError(t, err, "deserialize type failure for tuple elements") elemTypes[index] = abiT } - tupleT, err := makeTupleType(elemTypes) + tupleT, err := MakeTupleType(elemTypes) require.NoError(t, err, "make tuple type failure") (*pool)[Tuple] = append((*pool)[Tuple], testUnit{ serializedType: tupleT.String(), diff --git a/data/abi/abi_json.go b/data/abi/abi_json.go new file mode 100644 index 0000000000..482419e6b9 --- /dev/null +++ b/data/abi/abi_json.go @@ -0,0 +1,254 @@ +// Copyright (C) 2019-2021 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package abi + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/algorand/go-algorand/data/basics" + "math/big" +) + +func castBigIntToNearestPrimitive(num *big.Int, bitSize uint16) (interface{}, error) { + if num.BitLen() > int(bitSize) { + return nil, fmt.Errorf("cast big int to nearest primitive failure: %v >= 2^%d", num, bitSize) + } else if num.Sign() < 0 { + return nil, fmt.Errorf("cannot cast big int to near primitive: %v < 0", num) + } + + switch bitSize / 8 { + case 1: + return uint8(num.Uint64()), nil + case 2: + return uint16(num.Uint64()), nil + case 3, 4: + return uint32(num.Uint64()), nil + case 5, 6, 7, 8: + return num.Uint64(), nil + default: + return num, nil + } +} + +// MarshalToJSON convert golang value to JSON format from ABI type +func (t Type) MarshalToJSON(value interface{}) ([]byte, error) { + switch t.abiTypeID { + case Uint: + bytesUint, err := encodeInt(value, t.bitSize) + if err != nil { + return nil, err + } + return new(big.Int).SetBytes(bytesUint).MarshalJSON() + case Ufixed: + denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(t.precision)), nil) + encodedUint, err := encodeInt(value, t.bitSize) + if err != nil { + return nil, err + } + return []byte(new(big.Rat).SetFrac(new(big.Int).SetBytes(encodedUint), denom).FloatString(int(t.precision))), nil + case Bool: + boolValue, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("cannot infer to bool for marshal to JSON") + } + return json.Marshal(boolValue) + case Byte: + byteValue, ok := value.(byte) + if !ok { + return nil, fmt.Errorf("cannot infer to byte for marshal to JSON") + } + return json.Marshal(byteValue) + case Address: + var addressInternal basics.Address + switch valueCasted := value.(type) { + case []byte: + copy(addressInternal[:], valueCasted[:]) + return json.Marshal(addressInternal.String()) + case [addressByteSize]byte: + addressInternal = valueCasted + return json.Marshal(addressInternal.String()) + default: + return nil, fmt.Errorf("cannot infer to byte slice/array for marshal to JSON") + } + case ArrayStatic, ArrayDynamic: + values, err := inferToSlice(value) + if err != nil { + return nil, err + } + if t.abiTypeID == ArrayStatic && int(t.staticLength) != len(values) { + return nil, fmt.Errorf("length of slice %d != type specific length %d", len(values), t.staticLength) + } + if t.childTypes[0].abiTypeID == Byte { + byteArr := make([]byte, len(values)) + for i := 0; i < len(values); i++ { + tempByte, ok := values[i].(byte) + if !ok { + return nil, fmt.Errorf("cannot infer byte element from slice") + } + byteArr[i] = tempByte + } + return json.Marshal(byteArr) + } + rawMsgSlice := make([]json.RawMessage, len(values)) + for i := 0; i < len(values); i++ { + rawMsgSlice[i], err = t.childTypes[0].MarshalToJSON(values[i]) + if err != nil { + return nil, err + } + } + return json.Marshal(rawMsgSlice) + case String: + stringVal, ok := value.(string) + if !ok { + return nil, fmt.Errorf("cannot infer to string for marshal to JSON") + } + return json.Marshal(stringVal) + case Tuple: + values, err := inferToSlice(value) + if err != nil { + return nil, err + } + if len(values) != int(t.staticLength) { + return nil, fmt.Errorf("tuple element number != value slice length") + } + rawMsgSlice := make([]json.RawMessage, len(values)) + for i := 0; i < len(values); i++ { + rawMsgSlice[i], err = t.childTypes[i].MarshalToJSON(values[i]) + if err != nil { + return nil, err + } + } + return json.Marshal(rawMsgSlice) + default: + return nil, fmt.Errorf("cannot infer ABI type for marshalling value to JSON") + } +} + +// UnmarshalFromJSON convert bytes to golang value following ABI type and encoding rules +func (t Type) UnmarshalFromJSON(jsonEncoded []byte) (interface{}, error) { + switch t.abiTypeID { + case Uint: + num := new(big.Int) + if err := num.UnmarshalJSON(jsonEncoded); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to uint: %v", string(jsonEncoded), err) + } + return castBigIntToNearestPrimitive(num, t.bitSize) + case Ufixed: + floatTemp := new(big.Rat) + if err := floatTemp.UnmarshalText(jsonEncoded); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to ufixed: %v", string(jsonEncoded), err) + } + denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(t.precision)), nil) + denomRat := new(big.Rat).SetInt(denom) + numeratorRat := new(big.Rat).Mul(denomRat, floatTemp) + if !numeratorRat.IsInt() { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to ufixed: precision out of range", string(jsonEncoded)) + } + return castBigIntToNearestPrimitive(numeratorRat.Num(), t.bitSize) + case Bool: + var elem bool + if err := json.Unmarshal(jsonEncoded, &elem); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to bool: %v", string(jsonEncoded), err) + } + return elem, nil + case Byte: + var elem byte + if err := json.Unmarshal(jsonEncoded, &elem); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded to byte: %v", err) + } + return elem, nil + case Address: + var addrStr string + if err := json.Unmarshal(jsonEncoded, &addrStr); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded to string: %v", err) + } + addr, err := basics.UnmarshalChecksumAddress(addrStr) + if err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to address: %v", string(jsonEncoded), err) + } + return addr[:], nil + case ArrayStatic, ArrayDynamic: + if t.childTypes[0].abiTypeID == Byte && bytes.HasPrefix(jsonEncoded, []byte{'"'}) { + var byteArr []byte + err := json.Unmarshal(jsonEncoded, &byteArr) + if err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to bytes: %v", string(jsonEncoded), err) + } + if t.abiTypeID == ArrayStatic && len(byteArr) != int(t.staticLength) { + return nil, fmt.Errorf("length of slice %d != type specific length %d", len(byteArr), t.staticLength) + } + outInterface := make([]interface{}, len(byteArr)) + for i := 0; i < len(byteArr); i++ { + outInterface[i] = byteArr[i] + } + return outInterface, nil + } + var elems []json.RawMessage + if err := json.Unmarshal(jsonEncoded, &elems); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to array: %v", string(jsonEncoded), err) + } + if t.abiTypeID == ArrayStatic && len(elems) != int(t.staticLength) { + return nil, fmt.Errorf("JSON array element number != ABI array elem number") + } + values := make([]interface{}, len(elems)) + for i := 0; i < len(elems); i++ { + tempValue, err := t.childTypes[0].UnmarshalFromJSON(elems[i]) + if err != nil { + return nil, err + } + values[i] = tempValue + } + return values, nil + case String: + stringEncoded := string(jsonEncoded) + if bytes.HasPrefix(jsonEncoded, []byte{'"'}) { + var stringVar string + if err := json.Unmarshal(jsonEncoded, &stringVar); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to string: %v", stringEncoded, err) + } + return stringVar, nil + } else if bytes.HasPrefix(jsonEncoded, []byte{'['}) { + var elems []byte + if err := json.Unmarshal(jsonEncoded, &elems); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to string: %v", stringEncoded, err) + } + return string(elems), nil + } else { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to string", stringEncoded) + } + case Tuple: + var elems []json.RawMessage + if err := json.Unmarshal(jsonEncoded, &elems); err != nil { + return nil, fmt.Errorf("cannot cast JSON encoded (%s) to array for tuple: %v", string(jsonEncoded), err) + } + if len(elems) != int(t.staticLength) { + return nil, fmt.Errorf("JSON array element number != ABI tuple elem number") + } + values := make([]interface{}, len(elems)) + for i := 0; i < len(elems); i++ { + tempValue, err := t.childTypes[i].UnmarshalFromJSON(elems[i]) + if err != nil { + return nil, err + } + values[i] = tempValue + } + return values, nil + default: + return nil, fmt.Errorf("cannot cast JSON encoded %s to ABI encoding stuff", string(jsonEncoded)) + } +} diff --git a/data/abi/abi_json_test.go b/data/abi/abi_json_test.go new file mode 100644 index 0000000000..d65e3c10af --- /dev/null +++ b/data/abi/abi_json_test.go @@ -0,0 +1,123 @@ +// Copyright (C) 2019-2021 Algorand, Inc. +// This file is part of go-algorand +// +// go-algorand is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// go-algorand is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with go-algorand. If not, see . + +package abi + +import ( + "testing" + + "github.com/algorand/go-algorand/test/partitiontest" + "github.com/stretchr/testify/require" +) + +func TestJSONtoInterfaceValid(t *testing.T) { + partitiontest.PartitionTest(t) + var testCases = []struct { + input string + typeStr string + expected interface{} + }{ + { + input: `[true, [0, 1, 2], 17]`, + typeStr: `(bool,byte[],uint64)`, + expected: []interface{}{ + true, + []interface{}{byte(0), byte(1), byte(2)}, + uint64(17), + }, + }, + { + input: `[true, "AAEC", 17]`, + typeStr: `(bool,byte[],uint64)`, + expected: []interface{}{ + true, + []interface{}{byte(0), byte(1), byte(2)}, + uint64(17), + }, + }, + { + input: `"AQEEBQEE"`, + typeStr: `byte[6]`, + expected: []interface{}{byte(1), byte(1), byte(4), byte(5), byte(1), byte(4)}, + }, + { + input: `[[0, [true, false], "utf-8"], [18446744073709551615, [false, true], "pistachio"]]`, + typeStr: `(uint64,bool[2],string)[]`, + expected: []interface{}{ + []interface{}{uint64(0), []interface{}{true, false}, "utf-8"}, + []interface{}{^uint64(0), []interface{}{false, true}, "pistachio"}, + }, + }, + { + input: `[]`, + typeStr: `(uint64,bool[2],string)[]`, + expected: []interface{}{}, + }, + { + input: "[]", + typeStr: "()", + expected: []interface{}{}, + }, + { + input: "[65, 66, 67]", + typeStr: "string", + expected: "ABC", + }, + { + input: "[]", + typeStr: "string", + expected: "", + }, + { + input: "123.456", + typeStr: "ufixed64x3", + expected: uint64(123456), + }, + { + input: `"optin"`, + typeStr: "string", + expected: "optin", + }, + { + input: `"AAEC"`, + typeStr: "byte[3]", + expected: []interface{}{byte(0), byte(1), byte(2)}, + }, + { + input: `["uwu",["AAEC",12.34]]`, + typeStr: "(string,(byte[3],ufixed64x3))", + expected: []interface{}{"uwu", []interface{}{[]interface{}{byte(0), byte(1), byte(2)}, uint64(12340)}}, + }, + { + input: `[399,"should pass",[true,false,false,true]]`, + typeStr: "(uint64,string,bool[])", + expected: []interface{}{uint64(399), "should pass", []interface{}{true, false, false, true}}, + }, + } + + for _, testCase := range testCases { + abiT, err := TypeOf(testCase.typeStr) + require.NoError(t, err, "fail to construct ABI type (%s): %v", testCase.typeStr, err) + res, err := abiT.UnmarshalFromJSON([]byte(testCase.input)) + require.NoError(t, err, "fail to unmarshal JSON to interface: (%s): %v", testCase.input, err) + require.Equal(t, testCase.expected, res, "%v not matching with expected value %v", res, testCase.expected) + resEncoded, err := abiT.Encode(res) + require.NoError(t, err, "fail to encode %v to ABI bytes: %v", res, err) + resDecoded, err := abiT.Decode(resEncoded) + require.NoError(t, err, "fail to decode ABI bytes of %v: %v", res, err) + require.Equal(t, res, resDecoded, "ABI encode-decode round trip: %v not match with expected %v", resDecoded, res) + } +} diff --git a/data/abi/abi_type.go b/data/abi/abi_type.go index 654a2fba46..eb93f9eea1 100644 --- a/data/abi/abi_type.go +++ b/data/abi/abi_type.go @@ -185,7 +185,7 @@ func TypeOf(str string) (Type, error) { } tupleTypes[i] = ti } - return makeTupleType(tupleTypes) + return MakeTupleType(tupleTypes) default: return Type{}, fmt.Errorf("cannot convert a string %s to an ABI type", str) } @@ -333,8 +333,8 @@ func makeDynamicArrayType(argumentType Type) Type { } } -// makeTupleType makes tuple ABI type by taking an array of tuple element types as argument. -func makeTupleType(argumentTypes []Type) (Type, error) { +// MakeTupleType makes tuple ABI type by taking an array of tuple element types as argument. +func MakeTupleType(argumentTypes []Type) (Type, error) { if len(argumentTypes) >= math.MaxUint16 { return Type{}, fmt.Errorf("tuple type child type number larger than maximum uint16 error") } diff --git a/go.mod b/go.mod index 0299aa610e..b9dafd4bc3 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,6 @@ require ( github.com/gopherjs/gopherwasm v1.0.1 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 - github.com/gorilla/schema v1.0.2 github.com/gorilla/websocket v1.4.2 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmoiron/sqlx v1.2.0 diff --git a/test/scripts/e2e_subs/e2e-app-abi-add.sh b/test/scripts/e2e_subs/e2e-app-abi-add.sh new file mode 100755 index 0000000000..60e1c1f3eb --- /dev/null +++ b/test/scripts/e2e_subs/e2e-app-abi-add.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +date '+app-abi-add-test start %Y%m%d_%H%M%S' + +set -e +set -x +set -o pipefail +export SHELLOPTS + +WALLET=$1 + +# Directory of this bash program +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +gcmd="goal -w ${WALLET}" + +GLOBAL_INTS=2 +ACCOUNT=$(${gcmd} account list|awk '{ print $3 }') + +printf '#pragma version 2\nint 1' > "${TEMPDIR}/simple.teal" +PROGRAM=($(${gcmd} clerk compile "${TEMPDIR}/simple.teal")) +APPID=$(${gcmd} app create --creator ${ACCOUNT} --approval-prog ${DIR}/tealprogs/app-abi-add-example.teal --clear-prog ${TEMPDIR}/simple.teal --global-byteslices 0 --global-ints ${GLOBAL_INTS} --local-byteslices 0 --local-ints 0 | grep Created | awk '{ print $6 }') + +# Should succeed to opt in +${gcmd} app optin --app-id $APPID --from $ACCOUNT + +# Call should now succeed +RES=$(${gcmd} app method --method "add(uint64,uint64)uint64" --arg 1 --arg 2 --app-id $APPID --from $ACCOUNT 2>&1 || true) +EXPECTED="method add(uint64,uint64)uint64 output: 3" +if [[ $RES != *"${EXPECTED}"* ]]; then + date '+app-abi-add-test FAIL the application creation should not fail %Y%m%d_%H%M%S' + false +fi + +# Delete application should still succeed +${gcmd} app delete --app-id $APPID --from $ACCOUNT + +# Clear should still succeed +${gcmd} app clear --app-id $APPID --from $ACCOUNT diff --git a/test/scripts/e2e_subs/e2e-app-abi-arg.sh b/test/scripts/e2e_subs/e2e-app-abi-arg.sh new file mode 100755 index 0000000000..c6f719a479 --- /dev/null +++ b/test/scripts/e2e_subs/e2e-app-abi-arg.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +date '+app-abi-arg-test start %Y%m%d_%H%M%S' + +set -e +set -x +set -o pipefail +export SHELLOPTS + +WALLET=$1 + +# Directory of this bash program +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +gcmd="goal -w ${WALLET}" + +GLOBAL_INTS=2 +ACCOUNT=$(${gcmd} account list|awk '{ print $3 }') + +printf '#pragma version 2\nint 1' > "${TEMPDIR}/simple.teal" +PROGRAM=($(${gcmd} clerk compile "${TEMPDIR}/simple.teal")) +APPID=$(${gcmd} app create --creator ${ACCOUNT} --approval-prog ${DIR}/tealprogs/app-abi-arg.teal --clear-prog ${TEMPDIR}/simple.teal --global-byteslices 0 --global-ints ${GLOBAL_INTS} --local-byteslices 0 --local-ints 0 | grep Created | awk '{ print $6 }') + +# Should succeed to opt in with string "optin" +${gcmd} app optin --app-id $APPID --from $ACCOUNT --app-arg 'abi:string:"optin"' + +# Call should now succeed +${gcmd} app call --app-id $APPID --from $ACCOUNT --app-arg 'abi:uint64:0' +${gcmd} app call --app-id $APPID --from $ACCOUNT --app-arg 'abi:byte[3]:"AAEC"' +${gcmd} app call --app-id $APPID --from $ACCOUNT --app-arg 'abi:(string,(byte[3],ufixed64x3)):["uwu",["AAEC",12.34]]' +${gcmd} app call --app-id $APPID --from $ACCOUNT --app-arg 'abi:(uint64,string,bool[]):[399,"should pass",[true,false,false,true]]' + +# Delete application should still succeed +${gcmd} app delete --app-id $APPID --from $ACCOUNT + +# Clear should still succeed +${gcmd} app clear --app-id $APPID --from $ACCOUNT diff --git a/test/scripts/e2e_subs/tealprogs/app-abi-add-example.teal b/test/scripts/e2e_subs/tealprogs/app-abi-add-example.teal new file mode 100644 index 0000000000..18d3b3e6e7 --- /dev/null +++ b/test/scripts/e2e_subs/tealprogs/app-abi-add-example.teal @@ -0,0 +1,87 @@ +#pragma version 5 +intcblock 1 0 +bytecblock 0x151f7c75 +txn ApplicationID +intc_1 // 0 +== +bnz main_l12 +txn OnCompletion +intc_0 // OptIn +== +bnz main_l11 +txn OnCompletion +pushint 5 // DeleteApplication +== +bnz main_l10 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0xfe6bdf69 // 0xfe6bdf69 +== +&& +bnz main_l9 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0xa88c26a5 // 0xa88c26a5 +== +&& +bnz main_l8 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0x535a47ba // 0x535a47ba +== +&& +bnz main_l7 +intc_1 // 0 +return +main_l7: +txna ApplicationArgs 1 +callsub sub2 +intc_0 // 1 +return +main_l8: +callsub sub1 +intc_0 // 1 +return +main_l9: +txna ApplicationArgs 1 +txna ApplicationArgs 2 +callsub sub0 +intc_0 // 1 +return +main_l10: +intc_0 // 1 +return +main_l11: +intc_0 // 1 +return +main_l12: +intc_0 // 1 +return +sub0: // add +store 1 +store 0 +bytec_0 // 0x151f7c75 +load 0 +btoi +load 1 +btoi ++ +itob +concat +log +retsub +sub1: // empty +bytec_0 // 0x151f7c75 +log +retsub +sub2: // payment +store 2 +pushbytes 0x151f7c7580 // 0x151f7c7580 +log +retsub \ No newline at end of file diff --git a/test/scripts/e2e_subs/tealprogs/app-abi-arg.teal b/test/scripts/e2e_subs/tealprogs/app-abi-arg.teal new file mode 100644 index 0000000000..900ee0e541 --- /dev/null +++ b/test/scripts/e2e_subs/tealprogs/app-abi-arg.teal @@ -0,0 +1,73 @@ +#pragma version 5 +intcblock 1 0 +txn ApplicationID +intc_1 // 0 +== +bnz main_l14 +txn OnCompletion +pushint 5 // DeleteApplication +== +bnz main_l13 +txn OnCompletion +intc_0 // OptIn +== +txna ApplicationArgs 0 +pushbytes 0x00056f7074696e // 0x00056f7074696e +== +&& +bnz main_l12 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0x0000000000000000 // 0x0000000000000000 +== +&& +bnz main_l11 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0x000102 // 0x000102 +== +&& +bnz main_l10 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0x000d00010200000000000030340003757775 // 0x000d00010200000000000030340003757775 +== +&& +bnz main_l9 +txn OnCompletion +intc_1 // NoOp +== +txna ApplicationArgs 0 +pushbytes 0x000000000000018f000c0019000b73686f756c642070617373000490 // 0x000000000000018f000c0019000b73686f756c642070617373000490 +== +&& +bnz main_l8 +intc_1 // 0 +return +main_l8: +intc_0 // 1 +return +main_l9: +intc_0 // 1 +return +main_l10: +intc_0 // 1 +return +main_l11: +intc_0 // 1 +return +main_l12: +intc_0 // 1 +return +main_l13: +intc_0 // 1 +return +main_l14: +intc_0 // 1 +return \ No newline at end of file