diff --git a/server/services/common.go b/server/services/common.go index 027e8f11..18b8356e 100644 --- a/server/services/common.go +++ b/server/services/common.go @@ -1,3 +1,63 @@ package services +import ( + "encoding/json" + "math/big" + "strings" +) + type objectsMap map[string]interface{} + +func toObjectsMap(value any) (objectsMap, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + var result objectsMap + err = json.Unmarshal(data, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func fromObjectsMap(obj objectsMap, value any) error { + data, err := json.Marshal(obj) + if err != nil { + return err + } + + err = json.Unmarshal(data, value) + if err != nil { + return err + } + + return nil +} + +func isZeroAmount(amount string) bool { + if amount == "" { + return true + } + + value, ok := big.NewInt(0).SetString(amount, 10) + if ok { + return value.Sign() == 0 + } + + return false +} + +func getMagnitudeOfAmount(amount string) string { + return strings.Trim(amount, "-") +} + +func multiplyUint64(a uint64, b uint64) *big.Int { + return big.NewInt(0).Mul(big.NewInt(0).SetUint64(a), big.NewInt(0).SetUint64(b)) +} + +func addBigInt(a *big.Int, b *big.Int) *big.Int { + return big.NewInt(0).Add(a, b) +} diff --git a/server/services/common_test.go b/server/services/common_test.go new file mode 100644 index 00000000..9c17b39d --- /dev/null +++ b/server/services/common_test.go @@ -0,0 +1,57 @@ +package services + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +type dummy struct { + A string `json:"a"` + B string `json:"b"` + C uint64 `json:"c"` +} + +func Test_ToObjectsMapAndFromObjectsMap(t *testing.T) { + t.Parallel() + + dummyOriginal := &dummy{ + A: "a", + B: "b", + C: 42, + } + + dummyMap, err := toObjectsMap(dummyOriginal) + require.Nil(t, err) + + dummyConverted := &dummy{} + err = fromObjectsMap(dummyMap, dummyConverted) + require.Nil(t, err) + + require.Equal(t, dummyOriginal, dummyConverted) +} + +func Test_IsZeroAmount(t *testing.T) { + require.True(t, isZeroAmount("")) + require.True(t, isZeroAmount("0")) + require.True(t, isZeroAmount("-0")) + require.True(t, isZeroAmount("00")) + require.False(t, isZeroAmount("1")) + require.False(t, isZeroAmount("-1")) +} + +func Test_GetMagnitudeOfAmount(t *testing.T) { + require.Equal(t, "100", getMagnitudeOfAmount("100")) + require.Equal(t, "100", getMagnitudeOfAmount("-100")) +} + +func Test_MultiplyUint64(t *testing.T) { + require.Equal(t, "340282366920938463426481119284349108225", multiplyUint64(math.MaxUint64, math.MaxUint64).String()) + require.Equal(t, "1", multiplyUint64(1, 1).String()) +} + +func Test_AddBigInt(t *testing.T) { + require.Equal(t, "12", addBigInt(big.NewInt(7), big.NewInt(5)).String()) +} diff --git a/server/services/constructionMetadata.go b/server/services/constructionMetadata.go new file mode 100644 index 00000000..efc41b96 --- /dev/null +++ b/server/services/constructionMetadata.go @@ -0,0 +1,90 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/ElrondNetwork/elrond-proxy-go/data" +) + +type constructionMetadata struct { + Sender string `json:"sender"` + Receiver string `json:"receiver"` + Nonce uint64 `json:"nonce"` + Amount string `json:"amount"` + CurrencySymbol string `json:"currencySymbol"` + GasLimit uint64 `json:"gasLimit"` + GasPrice uint64 `json:"gasPrice"` + Data []byte `json:"data"` + ChainID string `json:"chainID"` + Version int `json:"version"` +} + +func newConstructionMetadata(obj objectsMap) (*constructionMetadata, error) { + result := &constructionMetadata{} + err := fromObjectsMap(obj, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (metadata *constructionMetadata) toTransactionJson() ([]byte, error) { + tx, err := metadata.toTransaction() + if err != nil { + return nil, err + } + + txJson, err := json.Marshal(tx) + if err != nil { + return nil, err + } + + return txJson, nil +} + +func (metadata *constructionMetadata) toTransaction() (*data.Transaction, error) { + err := metadata.validate() + if err != nil { + return nil, err + } + + tx := &data.Transaction{ + Sender: metadata.Sender, + Receiver: metadata.Receiver, + Nonce: metadata.Nonce, + Value: metadata.Amount, + GasLimit: metadata.GasLimit, + GasPrice: metadata.GasPrice, + Data: metadata.Data, + ChainID: metadata.ChainID, + Version: uint32(metadata.Version), + } + + return tx, nil +} + +func (metadata *constructionMetadata) validate() error { + if len(metadata.Sender) == 0 { + return errors.New("missing metadata: 'sender'") + } + if len(metadata.Receiver) == 0 { + return errors.New("missing metadata: 'receiver'") + } + if metadata.GasLimit == 0 { + return errors.New("missing metadata: 'gasLimit'") + } + if metadata.GasPrice == 0 { + return errors.New("missing metadata: 'gasPrice'") + } + if metadata.Version != 1 { + return fmt.Errorf("bad metadata: unexpected 'version' %v", metadata.Version) + } + if len(metadata.ChainID) == 0 { + return errors.New("missing metadata: 'chainID'") + } + + return nil +} diff --git a/server/services/constructionMetadata_test.go b/server/services/constructionMetadata_test.go new file mode 100644 index 00000000..ad6efabf --- /dev/null +++ b/server/services/constructionMetadata_test.go @@ -0,0 +1,82 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConstructionMetadata_ToTransactionJson(t *testing.T) { + t.Parallel() + + options := &constructionMetadata{ + Sender: "alice", + Receiver: "bob", + Nonce: 42, + Amount: "1234", + CurrencySymbol: "XeGLD", + GasLimit: 80000, + GasPrice: 1000000000, + Data: []byte("hello"), + ChainID: "T", + Version: 1, + } + + expectedJson := `{"nonce":42,"value":"1234","receiver":"bob","sender":"alice","gasPrice":1000000000,"gasLimit":80000,"data":"aGVsbG8=","chainID":"T","version":1}` + actualJson, err := options.toTransactionJson() + require.Nil(t, err) + require.Equal(t, expectedJson, string(actualJson)) +} + +func TestConstructionMetadata_Validate(t *testing.T) { + t.Parallel() + + require.ErrorContains(t, (&constructionMetadata{}).validate(), "missing metadata: 'sender'") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + }).validate(), "missing metadata: 'receiver'") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + }).validate(), "missing metadata: 'gasLimit'") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + GasLimit: 50000, + }).validate(), "missing metadata: 'gasPrice'") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + GasLimit: 50000, + GasPrice: 1000000000, + }).validate(), "bad metadata: unexpected 'version' 0") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + GasLimit: 50000, + GasPrice: 1000000000, + Version: 42, + }).validate(), "bad metadata: unexpected 'version' 42") + + require.ErrorContains(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + GasLimit: 50000, + GasPrice: 1000000000, + Version: 1, + }).validate(), "missing metadata: 'chainID'") + + require.Nil(t, (&constructionMetadata{ + Sender: "alice", + Receiver: "bob", + GasLimit: 50000, + GasPrice: 1000000000, + Version: 1, + ChainID: "T", + }).validate()) +} diff --git a/server/services/constructionOptions.go b/server/services/constructionOptions.go new file mode 100644 index 00000000..3cdc7140 --- /dev/null +++ b/server/services/constructionOptions.go @@ -0,0 +1,61 @@ +package services + +import ( + "errors" +) + +type constructionOptions struct { + Sender string `json:"sender"` + Receiver string `json:"receiver"` + Amount string `json:"amount"` + CurrencySymbol string `json:"currencySymbol"` + GasLimit uint64 `json:"gasLimit"` + GasPrice uint64 `json:"gasPrice"` + Data []byte `json:"data"` +} + +func newConstructionOptions(obj objectsMap) (*constructionOptions, error) { + result := &constructionOptions{} + err := fromObjectsMap(obj, result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (options *constructionOptions) coalesceGasLimit(estimatedGasLimit uint64) uint64 { + if options.GasLimit == 0 { + return estimatedGasLimit + } + + return options.GasLimit +} + +func (options *constructionOptions) coalesceGasPrice(minGasPrice uint64) uint64 { + if options.GasPrice == 0 { + return minGasPrice + } + + return options.GasPrice +} + +func (options *constructionOptions) validate(nativeCurrencySymbol string) error { + if len(options.Sender) == 0 { + return errors.New("missing option: 'sender'") + } + if len(options.Receiver) == 0 { + return errors.New("missing option: 'receiver'") + } + if isZeroAmount(options.Amount) { + return errors.New("missing option: 'amount'") + } + if len(options.CurrencySymbol) == 0 { + return errors.New("missing option: 'currencySymbol'") + } + if len(options.Data) > 0 && options.CurrencySymbol != nativeCurrencySymbol { + return errors.New("for custom currencies, option 'data' must be empty") + } + + return nil +} diff --git a/server/services/constructionOptions_test.go b/server/services/constructionOptions_test.go new file mode 100644 index 00000000..15cc269f --- /dev/null +++ b/server/services/constructionOptions_test.go @@ -0,0 +1,67 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConstructionOptions_CoalesceGasLimit(t *testing.T) { + t.Parallel() + + options := &constructionOptions{ + GasLimit: 80000, + } + require.Equal(t, uint64(80000), options.coalesceGasLimit(50000)) + + options = &constructionOptions{} + require.Equal(t, uint64(50000), options.coalesceGasLimit(50000)) +} + +func TestConstructionOptions_CoalesceGasPrice(t *testing.T) { + t.Parallel() + + options := &constructionOptions{ + GasPrice: 1000000001, + } + require.Equal(t, uint64(1000000001), options.coalesceGasPrice(1000000000)) + + options = &constructionOptions{} + require.Equal(t, uint64(1000000000), options.coalesceGasLimit(1000000000)) +} + +func TestConstructionOptions_Validate(t *testing.T) { + t.Parallel() + + require.ErrorContains(t, (&constructionOptions{}).validate("XeGLD"), "missing option: 'sender'") + + require.ErrorContains(t, (&constructionOptions{ + Sender: "alice", + }).validate("XeGLD"), "missing option: 'receiver'") + + require.ErrorContains(t, (&constructionOptions{ + Sender: "alice", + Receiver: "bob", + }).validate("XeGLD"), "missing option: 'amount'") + + require.ErrorContains(t, (&constructionOptions{ + Sender: "alice", + Receiver: "bob", + Amount: "1234", + }).validate("XeGLD"), "missing option: 'currencySymbol'") + + require.ErrorContains(t, (&constructionOptions{ + Sender: "alice", + Receiver: "bob", + Amount: "1234", + CurrencySymbol: "FOO", + Data: []byte("hello"), + }).validate("XeGLD"), "for custom currencies, option 'data' must be empty") + + require.Nil(t, (&constructionOptions{ + Sender: "alice", + Receiver: "bob", + Amount: "1234", + CurrencySymbol: "XeGLD", + }).validate("XeGLD")) +} diff --git a/server/services/constructionPreprocessMetadata.go b/server/services/constructionPreprocessMetadata.go new file mode 100644 index 00000000..613b11f9 --- /dev/null +++ b/server/services/constructionPreprocessMetadata.go @@ -0,0 +1,21 @@ +package services + +type constructionPreprocessMetadata struct { + Sender string `json:"sender"` + Receiver string `json:"receiver"` + Amount string `json:"amount"` + CurrencySymbol string `json:"currencySymbol"` + GasLimit uint64 `json:"gasLimit"` + GasPrice uint64 `json:"gasPrice"` + Data []byte `json:"data"` +} + +func newConstructionPreprocessMetadata(obj objectsMap) (*constructionPreprocessMetadata, error) { + result := &constructionPreprocessMetadata{} + err := fromObjectsMap(obj, result) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/server/services/constructionService.go b/server/services/constructionService.go index bf382f5d..49eb6f8a 100644 --- a/server/services/constructionService.go +++ b/server/services/constructionService.go @@ -5,8 +5,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" - "strings" "github.com/ElrondNetwork/elrond-proxy-go/data" "github.com/coinbase/rosetta-sdk-go/server" @@ -35,116 +33,83 @@ func (service *constructionService) ConstructionPreprocess( _ context.Context, request *types.ConstructionPreprocessRequest, ) (*types.ConstructionPreprocessResponse, *types.Error) { - if err := service.checkOperationsAndMeta(request.Operations, request.Metadata); err != nil { - return nil, err - } - - options, errOptions := service.prepareConstructionOptions(request.Operations, request.Metadata) - if errOptions != nil { - return nil, service.errFactory.newErrWithOriginal(ErrConstructionCheck, errOptions) - } - - if len(request.MaxFee) > 0 { - maxFee := request.MaxFee[0] - if !service.extension.isNativeCurrency(maxFee.Currency) { - return nil, service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("invalid currency")) - } + log.Debug("constructionService.ConstructionPreprocess()", "metadata", request.Metadata) - options["maxFee"] = maxFee.Value - } + noOperationProvided := len(request.Operations) == 0 + lessThanTwoOperationsProvided := len(request.Operations) < 2 - if request.SuggestedFeeMultiplier != nil { - options["feeMultiplier"] = *request.SuggestedFeeMultiplier - } - - if request.Metadata["gasLimit"] != nil { - options["gasLimit"] = request.Metadata["gasLimit"] - } - if request.Metadata["gasPrice"] != nil { - options["gasPrice"] = request.Metadata["gasPrice"] - } - if request.Metadata["data"] != nil { - options["data"] = request.Metadata["data"] + requestMetadata, err := newConstructionPreprocessMetadata(request.Metadata) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - return &types.ConstructionPreprocessResponse{ - Options: options, - }, nil -} + responseOptions := &constructionOptions{} -func (service *constructionService) checkOperationsAndMeta(ops []*types.Operation, meta map[string]interface{}) *types.Error { - if len(ops) == 0 { - return service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("invalid number of operations")) + if len(requestMetadata.Sender) > 0 { + responseOptions.Sender = requestMetadata.Sender + } else { + // Fallback: get "sender" from the first operation + if noOperationProvided { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, errors.New("cannot prepare sender")) + } + responseOptions.Sender = request.Operations[0].Account.Address } - for _, op := range ops { - if !checkOperationsType(op) { - return service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("unsupported operation type")) - } - if op.Amount.Currency.Symbol != service.extension.getNativeCurrency().Symbol { - return service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("unsupported currency symbol")) + if len(requestMetadata.Receiver) > 0 { + responseOptions.Receiver = requestMetadata.Receiver + } else { + // Fallback: get "receiver" from the second operation + if lessThanTwoOperationsProvided { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, errors.New("cannot prepare receiver")) } + responseOptions.Receiver = request.Operations[1].Account.Address } - if meta["gasLimit"] != nil { - if !checkValueIsOk(meta["gasLimit"]) { - return service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("invalid metadata gas limit")) + if len(requestMetadata.Amount) > 0 { + responseOptions.Amount = requestMetadata.Amount + } else { + // Fallback: get "amount" from the first operation + if noOperationProvided { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, errors.New("cannot prepare amount")) } + responseOptions.Amount = getMagnitudeOfAmount(request.Operations[0].Amount.Value) } - if meta["gasPrice"] != nil { - if !checkValueIsOk(meta["gasPrice"]) { - return service.errFactory.newErrWithOriginal(ErrConstructionCheck, errors.New("invalid metadata gas price")) + + if len(requestMetadata.CurrencySymbol) > 0 { + responseOptions.CurrencySymbol = requestMetadata.CurrencySymbol + } else { + // Fallback: get "currencySymbol" from the first operation + if noOperationProvided { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, errors.New("cannot prepare currency")) } + responseOptions.CurrencySymbol = request.Operations[0].Amount.Currency.Symbol } - return nil -} - -func checkValueIsOk(value interface{}) bool { - switch value.(type) { - case uint64, float64, int: - return true - default: - return false + if requestMetadata.GasLimit > 0 { + responseOptions.GasLimit = requestMetadata.GasLimit } -} - -func checkOperationsType(op *types.Operation) bool { - for _, supOp := range SupportedOperationTypes { - if supOp == op.Type { - return true - } + if requestMetadata.GasPrice > 0 { + responseOptions.GasPrice = requestMetadata.GasPrice + } + if len(requestMetadata.Data) > 0 { + responseOptions.Data = requestMetadata.Data } - return false -} - -func (service *constructionService) prepareConstructionOptions(operations []*types.Operation, metadata objectsMap) (objectsMap, error) { - options := make(objectsMap) - options["type"] = operations[0].Type - options["sender"] = operations[0].Account.Address - - if metadata["receiver"] != nil { - options["receiver"] = metadata["receiver"] - } else { - if len(operations) > 1 { - options["receiver"] = operations[1].Account.Address - } else { - return nil, errors.New("cannot prepare transaction receiver") - } + err = responseOptions.validate( + service.extension.getNativeCurrencySymbol(), + ) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - if metadata["value"] != nil { - options["value"] = metadata["value"] - } else { - if len(operations) > 1 { - options["value"] = operations[1].Amount.Value - } else { - options["value"] = strings.Trim(operations[0].Amount.Value, "-") - } + optionsAsObjectsMap, err := toObjectsMap(responseOptions) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - return options, nil + return &types.ConstructionPreprocessResponse{ + Options: optionsAsObjectsMap, + }, nil } // ConstructionMetadata gets any information required to construct a transaction for a specific network (e.g. the account nonce) @@ -152,75 +117,62 @@ func (service *constructionService) ConstructionMetadata( _ context.Context, request *types.ConstructionMetadataRequest, ) (*types.ConstructionMetadataResponse, *types.Error) { + log.Debug("constructionService.ConstructionMetadata()", "options", request.Options) + if service.provider.IsOffline() { return nil, service.errFactory.newErr(ErrOfflineMode) } - txType, ok := request.Options["type"].(string) - if !ok { - return nil, service.errFactory.newErrWithOriginal(ErrInvalidInputParam, errors.New("invalid operation type")) + requestOptions, err := newConstructionOptions(request.Options) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - metadata, err := service.computeMetadata(request.Options) + account, err := service.provider.GetAccount(requestOptions.Sender) if err != nil { - return nil, err + return nil, service.errFactory.newErrWithOriginal(ErrUnableToGetAccount, err) } - networkConfig := service.provider.GetNetworkConfig() - suggestedFee, gasPrice, gasLimit, err := service.computeSuggestedFeeAndGas(txType, request.Options, networkConfig) + computedData := service.computeData(requestOptions) + + fee, gasLimit, gasPrice, errTyped := service.computeFeeComponents(requestOptions, computedData) if err != nil { - return nil, err + return nil, errTyped } - metadata["gasLimit"] = gasLimit - metadata["gasPrice"] = gasPrice + metadata := &constructionMetadata{ + Nonce: account.Account.Nonce, + Sender: requestOptions.Sender, + Receiver: requestOptions.Receiver, + Amount: requestOptions.Amount, + CurrencySymbol: requestOptions.CurrencySymbol, + GasLimit: gasLimit, + GasPrice: gasPrice, + Data: computedData, + ChainID: service.provider.GetNetworkConfig().NetworkID, + Version: transactionVersion, + } + + metadataAsObjectsMap, err := toObjectsMap(metadata) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } return &types.ConstructionMetadataResponse{ - Metadata: metadata, + Metadata: metadataAsObjectsMap, SuggestedFee: []*types.Amount{ - service.extension.valueToNativeAmount(suggestedFee.String()), + service.extension.valueToNativeAmount(fee.String()), }, }, nil } -func (service *constructionService) computeMetadata(options objectsMap) (objectsMap, *types.Error) { - metadata := make(objectsMap) - if dataField, ok := options["data"]; ok { - // convert string to byte array - metadata["data"] = []byte(fmt.Sprintf("%v", dataField)) - } - - var ok bool - if metadata["sender"], ok = options["sender"]; !ok { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, errors.New("sender address missing")) - } - if metadata["receiver"], ok = options["receiver"]; !ok { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, errors.New("receiver address missing")) - } - if metadata["value"], ok = options["value"]; !ok { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, errors.New("value missing")) - } - - metadata["chainID"] = service.provider.GetNetworkConfig().NetworkID - metadata["version"] = transactionVersion - - senderAddressI, ok := options["sender"] - if !ok { - return nil, service.errFactory.newErrWithOriginal(ErrInvalidInputParam, errors.New("cannot find sender address")) - } - senderAddress, ok := senderAddressI.(string) - if !ok { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, errors.New("sender address is invalid")) +func (service *constructionService) computeData(options *constructionOptions) []byte { + if service.extension.isNativeCurrencySymbol(options.CurrencySymbol) { + return options.Data } - account, err := service.provider.GetAccount(senderAddress) - if err != nil { - return nil, service.errFactory.newErrWithOriginal(ErrUnableToGetAccount, err) - } - - metadata["nonce"] = account.Account.Nonce - - return metadata, nil + // TODO: Handle in a future PR + return make([]byte, 0) } // ConstructionPayloads returns an unsigned transaction blob and a collection of payloads that must be signed @@ -228,27 +180,23 @@ func (service *constructionService) ConstructionPayloads( _ context.Context, request *types.ConstructionPayloadsRequest, ) (*types.ConstructionPayloadsResponse, *types.Error) { - if err := service.checkOperationsAndMeta(request.Operations, request.Metadata); err != nil { - return nil, err - } + log.Debug("constructionService.ConstructionPayloads()", "metadata", request.Metadata) - tx, err := createTransaction(request) + metadata, err := newConstructionMetadata(request.Metadata) if err != nil { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, err) + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - txJson, err := json.Marshal(tx) + txJson, err := metadata.toTransactionJson() if err != nil { - return nil, service.errFactory.newErrWithOriginal(ErrMalformedValue, err) + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - signer := request.Operations[0].Account.Address - return &types.ConstructionPayloadsResponse{ UnsignedTransaction: string(txJson), Payloads: []*types.SigningPayload{ { - AccountIdentifier: addressToAccountIdentifier(signer), + AccountIdentifier: addressToAccountIdentifier(metadata.Sender), SignatureType: types.Ed25519, Bytes: txJson, }, @@ -301,22 +249,6 @@ func (service *constructionService) createOperationsFromPreparedTx(tx *data.Tran return operations } -func createTransaction(request *types.ConstructionPayloadsRequest) (*data.Transaction, error) { - tx := &data.Transaction{} - - requestMetadataBytes, err := json.Marshal(request.Metadata) - if err != nil { - return nil, err - } - - err = json.Unmarshal(requestMetadataBytes, tx) - if err != nil { - return nil, err - } - - return tx, nil -} - func getTxFromRequest(txString string) (*data.Transaction, error) { txBytes := []byte(txString) @@ -400,6 +332,8 @@ func (service *constructionService) ConstructionSubmit( _ context.Context, request *types.ConstructionSubmitRequest, ) (*types.TransactionIdentifierResponse, *types.Error) { + log.Debug("constructionService.ConstructionSubmit()", "transaction", request.SignedTransaction) + if service.provider.IsOffline() { return nil, service.errFactory.newErr(ErrOfflineMode) } diff --git a/server/services/constructionServiceFee.go b/server/services/constructionServiceFee.go index e7265cda..3c794fee 100644 --- a/server/services/constructionServiceFee.go +++ b/server/services/constructionServiceFee.go @@ -1,124 +1,47 @@ package services import ( - "fmt" "math/big" - "github.com/ElrondNetwork/rosetta/server/resources" "github.com/coinbase/rosetta-sdk-go/types" ) -func (service *constructionService) computeSuggestedFeeAndGas(txType string, options objectsMap, networkConfig *resources.NetworkConfig) (*big.Int, uint64, uint64, *types.Error) { - var gasLimit, gasPrice uint64 +func (service *constructionService) computeFeeComponents(options *constructionOptions, computedData []byte) (*big.Int, uint64, uint64, *types.Error) { + networkConfig := service.provider.GetNetworkConfig() + minGasPrice := networkConfig.MinGasPrice + // TODO: Handle in a future PR + gasPriceModifier := float64(0.01) - if gasLimitI, ok := options["gasLimit"]; ok { - gasLimit = getUint64Value(gasLimitI) - - err := service.checkProvidedGasLimit(gasLimit, txType, options, networkConfig) - if err != nil { - return nil, 0, 0, err - } - - } else { - // if gas limit is not provided, we estimate it - estimatedGasLimit, err := service.estimateGasLimit(txType, networkConfig, options) - if err != nil { - return nil, 0, 0, err - } - - gasLimit = estimatedGasLimit + isForNativeCurrency := service.extension.isNativeCurrencySymbol(options.CurrencySymbol) + isForCustomCurrency := !isForNativeCurrency + if isForCustomCurrency { + // TODO: Handle in a future PR + return nil, 0, 0, service.errFactory.newErr(ErrNotImplemented) } - if gasPriceI, ok := options["gasPrice"]; ok { - gasPrice = getUint64Value(gasPriceI) + movementGasLimit := networkConfig.MinGasLimit + networkConfig.GasPerDataByte*uint64(len(computedData)) + // TODO: Handle in a future PR + executionGasLimit := uint64(0) + estimatedGasLimit := movementGasLimit + executionGasLimit - if gasPrice < networkConfig.MinGasPrice { - return nil, 0, 0, service.errFactory.newErr(ErrGasPriceTooLow) - } + gasLimit := options.coalesceGasLimit(estimatedGasLimit) + gasPrice := options.coalesceGasPrice(minGasPrice) - } else { - // if gas price is not provided, we set it to minGasPrice - gasPrice = networkConfig.MinGasPrice + if gasLimit < estimatedGasLimit { + return nil, 0, 0, service.errFactory.newErr(ErrInsufficientGasLimit) } - - suggestedFee := big.NewInt(0).Mul( - big.NewInt(0).SetUint64(gasPrice), - big.NewInt(0).SetUint64(gasLimit), - ) - - suggestedFee, gasPrice = service.adjustTxFeeWithFeeMultiplier(suggestedFee, gasPrice, options, networkConfig.MinGasPrice) - - return suggestedFee, gasPrice, gasLimit, nil -} - -func (service *constructionService) adjustTxFeeWithFeeMultiplier( - txFee *big.Int, gasPrice uint64, options objectsMap, minGasPrice uint64, -) (*big.Int, uint64) { - feeMultiplierI, ok := options["feeMultiplier"] - if !ok { - return txFee, gasPrice - } - - feeMultiplier, ok := feeMultiplierI.(float64) - if !ok { - return txFee, gasPrice - } - - feeMultiplierBig := big.NewFloat(feeMultiplier) - bigVal, ok := big.NewFloat(0).SetString(txFee.String()) - if !ok { - return txFee, gasPrice - } - - bigVal.Mul(bigVal, feeMultiplierBig) - - result := new(big.Int) - bigVal.Int(result) - - gasPrice = uint64(feeMultiplier * float64(gasPrice)) if gasPrice < minGasPrice { - gasPrice = minGasPrice - } - - return result, gasPrice -} - -func (service *constructionService) estimateGasLimit(operationType string, networkConfig *resources.NetworkConfig, options objectsMap) (uint64, *types.Error) { - gasForDataField := uint64(0) - if dataFieldI, ok := options["data"]; ok { - dataField := fmt.Sprintf("%v", dataFieldI) - gasForDataField = networkConfig.GasPerDataByte * uint64(len(dataField)) + return nil, 0, 0, service.errFactory.newErr(ErrGasPriceTooLow) } - switch operationType { - case opTransfer: - return networkConfig.MinGasLimit + gasForDataField, nil - default: - // we do not support this yet other operation types, but we might support it in the future - return 0, service.errFactory.newErr(ErrNotImplemented) - } + fee := computeFee(movementGasLimit, executionGasLimit, gasPrice, gasPriceModifier) + return fee, gasLimit, gasPrice, nil } -func (service *constructionService) checkProvidedGasLimit(providedGasLimit uint64, txType string, options objectsMap, networkConfig *resources.NetworkConfig) *types.Error { - estimatedGasLimit, err := service.estimateGasLimit(txType, networkConfig, options) - if err != nil { - return err - } - - if providedGasLimit < estimatedGasLimit { - return service.errFactory.newErr(ErrInsufficientGasLimit) - } - - return nil -} - -func getUint64Value(obj interface{}) uint64 { - if value, ok := obj.(uint64); ok { - return value - } - if value, ok := obj.(float64); ok { - return uint64(value) - } - - return 0 +func computeFee(movementGasLimit uint64, executionGasLimit uint64, gasPrice uint64, gasPriceModifier float64) *big.Int { + movementFee := multiplyUint64(movementGasLimit, gasPrice) + executionGasPrice := uint64(float64(gasPrice) * gasPriceModifier) + executionFee := multiplyUint64(executionGasLimit, executionGasPrice) + computedFee := addBigInt(movementFee, executionFee) + return computedFee } diff --git a/server/services/constructionServiceFee_test.go b/server/services/constructionServiceFee_test.go index 5c002ac2..6281f76e 100644 --- a/server/services/constructionServiceFee_test.go +++ b/server/services/constructionServiceFee_test.go @@ -4,169 +4,101 @@ import ( "math/big" "testing" - "github.com/ElrondNetwork/rosetta/server/resources" "github.com/ElrondNetwork/rosetta/testscommon" "github.com/stretchr/testify/require" ) -func TestEstimateGasLimit(t *testing.T) { +func TestConstructionService_ComputeFeeComponents(t *testing.T) { t.Parallel() networkProvider := testscommon.NewNetworkProviderMock() service := NewConstructionService(networkProvider).(*constructionService) - minGasLimit := uint64(1000) - gasPerDataByte := uint64(100) - networkConfig := &resources.NetworkConfig{ - GasPerDataByte: gasPerDataByte, - MinGasLimit: minGasLimit, - } - - dataField := "transaction-data" - options := objectsMap{ - "data": dataField, - } - - expectedGasLimit := minGasLimit + uint64(len(dataField))*gasPerDataByte - - gasLimit, err := service.estimateGasLimit(opTransfer, networkConfig, options) - require.Nil(t, err) - require.Equal(t, expectedGasLimit, gasLimit) - - gasLimit, err = service.estimateGasLimit(opTransfer, networkConfig, nil) - require.Nil(t, err) - require.Equal(t, minGasLimit, gasLimit) - - // Unsupported operation type (you cannot estimate gasLimit for e.g. a reward operation) - gasLimit, err = service.estimateGasLimit(opReward, networkConfig, nil) - require.Equal(t, ErrNotImplemented, errCode(err.Code)) - require.Equal(t, uint64(0), gasLimit) -} - -func TestProvidedGasLimit(t *testing.T) { - t.Parallel() - - networkProvider := testscommon.NewNetworkProviderMock() - service := NewConstructionService(networkProvider).(*constructionService) - - minGasLimit := uint64(1000) - gasPerDataByte := uint64(100) - networkConfig := &resources.NetworkConfig{ - GasPerDataByte: gasPerDataByte, - MinGasLimit: minGasLimit, - } - - dataField := "transaction-data" - options := objectsMap{ - "data": dataField, - } - - err := service.checkProvidedGasLimit(uint64(900), opTransfer, options, networkConfig) - require.Equal(t, ErrInsufficientGasLimit, errCode(err.Code)) - - err = service.checkProvidedGasLimit(uint64(900), opReward, options, networkConfig) - require.Equal(t, ErrNotImplemented, errCode(err.Code)) - - err = service.checkProvidedGasLimit(uint64(9000), opTransfer, options, networkConfig) - require.Nil(t, err) -} - -func TestAdjustTxFeeWithFeeMultiplier(t *testing.T) { - t.Parallel() - - networkProvider := testscommon.NewNetworkProviderMock() - service := NewConstructionService(networkProvider).(*constructionService) - - options := objectsMap{ - "feeMultiplier": 1.1, - } - - expectedGasPrice := uint64(1100) - expectedFee := "1100" - suggestedFee := big.NewInt(1000) - - suggestedFeeResult, gasPriceResult := service.adjustTxFeeWithFeeMultiplier(suggestedFee, 1000, options, 1000) - require.Equal(t, expectedFee, suggestedFeeResult.String()) - require.Equal(t, expectedGasPrice, gasPriceResult) - - expectedGasPrice = uint64(1000) - expectedFee = "1000" - suggestedFeeResult, gasPriceResult = service.adjustTxFeeWithFeeMultiplier(suggestedFee, 1000, make(objectsMap), 1000) - require.Equal(t, expectedFee, suggestedFeeResult.String()) - require.Equal(t, expectedGasPrice, gasPriceResult) + t.Run("native transfer", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 50000, + GasPrice: 1000000000, + CurrencySymbol: "XeGLD", + }, []byte{}) + + require.Nil(t, err) + require.Equal(t, "50000000000000", fee.String()) + require.Equal(t, uint64(50000), gasLimit) + require.Equal(t, uint64(1000000000), gasPrice) + }) + + t.Run("native transfer, with computed data", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 53000, + GasPrice: 1000000000, + CurrencySymbol: "XeGLD", + }, []byte{0xaa, 0xbb}) + + require.Nil(t, err) + require.Equal(t, "53000000000000", fee.String()) + require.Equal(t, uint64(53000), gasLimit) + require.Equal(t, uint64(1000000000), gasPrice) + }) + + t.Run("native transfer, with insufficient gas limit", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 40000, + GasPrice: 1000000000, + CurrencySymbol: "XeGLD", + }, []byte{}) + + require.Equal(t, int32(ErrInsufficientGasLimit), err.Code) + require.Nil(t, fee) + require.Equal(t, uint64(0), gasLimit) + require.Equal(t, uint64(0), gasPrice) + }) + + t.Run("native transfer, with more gas limit than necessary", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 70000, + GasPrice: 1000000000, + CurrencySymbol: "XeGLD", + }, []byte{}) + + require.Nil(t, err) + require.Equal(t, "50000000000000", fee.String()) + require.Equal(t, uint64(70000), gasLimit) + require.Equal(t, uint64(1000000000), gasPrice) + }) + + t.Run("native transfer, with gas price too low", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 50000, + GasPrice: 500000000, + CurrencySymbol: "XeGLD", + }, []byte{}) + + require.Equal(t, int32(ErrGasPriceTooLow), err.Code) + require.Nil(t, fee) + require.Equal(t, uint64(0), gasLimit) + require.Equal(t, uint64(0), gasPrice) + }) + + t.Run("native transfer, with gas price higher than necessary", func(t *testing.T) { + fee, gasLimit, gasPrice, err := service.computeFeeComponents(&constructionOptions{ + GasLimit: 50000, + GasPrice: 2000000000, + CurrencySymbol: "XeGLD", + }, []byte{}) + + require.Nil(t, err) + require.Equal(t, "100000000000000", fee.String()) + require.Equal(t, uint64(50000), gasLimit) + require.Equal(t, uint64(2000000000), gasPrice) + }) } -func TestComputeSuggestedFeeAndGas(t *testing.T) { +func TestComputeFee(t *testing.T) { t.Parallel() - networkProvider := testscommon.NewNetworkProviderMock() - service := NewConstructionService(networkProvider).(*constructionService) - - minGasLimit := uint64(1000) - minGasPrice := uint64(10) - gasPerDataByte := uint64(100) - networkConfig := &resources.NetworkConfig{ - GasPerDataByte: gasPerDataByte, - MinGasLimit: minGasLimit, - MinGasPrice: minGasPrice, - } - - providedGasPrice := uint64(10) - options := objectsMap{ - "gasPrice": providedGasPrice, - } - - suggestedFee, gasPrice, gasLimit, err := service.computeSuggestedFeeAndGas(opTransfer, options, networkConfig) - require.Nil(t, err) - require.Equal(t, minGasLimit, gasLimit) - require.Equal(t, big.NewInt(10000), suggestedFee) - require.Equal(t, providedGasPrice, gasPrice) - - // err provided gas price is too low - options["gasPrice"] = 1 - _, _, _, err = service.computeSuggestedFeeAndGas(opTransfer, options, networkConfig) - require.Equal(t, ErrGasPriceTooLow, errCode(err.Code)) - - // err provided gas limit is too low - options["gasPrice"] = minGasPrice - options["gasLimit"] = 1 - _, _, _, err = service.computeSuggestedFeeAndGas(opTransfer, options, networkConfig) - require.Equal(t, ErrInsufficientGasLimit, errCode(err.Code)) - - delete(options, "gasLimit") - options["gasPrice"] = minGasPrice - _, _, _, err = service.computeSuggestedFeeAndGas(opReward, options, networkConfig) - require.Equal(t, ErrNotImplemented, errCode(err.Code)) - - //check with fee multiplier - delete(options, "gasPrice") - delete(options, "gasLimit") - options["feeMultiplier"] = 1.1 - expectedSuggestedFee := big.NewInt(11000) - expectedGasPrice := uint64(11) - suggestedFee, gasPrice, gasLimit, err = service.computeSuggestedFeeAndGas(opTransfer, options, networkConfig) - require.Nil(t, err) - require.Equal(t, minGasLimit, gasLimit) - require.Equal(t, expectedSuggestedFee, suggestedFee) - require.Equal(t, expectedGasPrice, gasPrice) -} - -func TestAdjustTxFeeWithFeeMultiplier_FeeMultiplierLessThanOne(t *testing.T) { - t.Parallel() - - networkProvider := testscommon.NewNetworkProviderMock() - service := NewConstructionService(networkProvider).(*constructionService) - - options := objectsMap{ - "feeMultiplier": 0.5, - } - - expectedFee := "500" - suggestedFee := big.NewInt(1000) - gasPrice := uint64(1000) - minGasPrice := uint64(900) - - suggestedFeeResult, gasPriceResult := service.adjustTxFeeWithFeeMultiplier(suggestedFee, gasPrice, options, minGasPrice) - require.Equal(t, expectedFee, suggestedFeeResult.String()) - require.Equal(t, minGasPrice, gasPriceResult) + require.Equal(t, big.NewInt(50000000000000), computeFee(50000, 0, 1000000000, 0.01)) + require.Equal(t, big.NewInt(50000000000000), computeFee(50000, 0, 1000000000, 0.02)) + require.Equal(t, big.NewInt(100000000000000), computeFee(50000, 0, 2000000000, 0.01)) + require.Equal(t, big.NewInt(70000000000000), computeFee(70000, 0, 1000000000, 0.01)) + require.Equal(t, big.NewInt(60000000000000), computeFee(50000, 1000000, 1000000000, 0.01)) } diff --git a/server/services/constructionService_test.go b/server/services/constructionService_test.go index efdb9fdf..b248ff19 100644 --- a/server/services/constructionService_test.go +++ b/server/services/constructionService_test.go @@ -12,58 +12,198 @@ import ( ) func TestConstructionService_ConstructionPreprocess(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() extension := newNetworkProviderExtension(networkProvider) service := NewConstructionService(networkProvider) - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("-1234"), - }, - { - OperationIdentifier: indexToOperationIdentifier(1), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressBob), - Amount: extension.valueToNativeAmount("1234"), - }, - } + t.Run("with minimal (empty) 'metadata', 'options' being inferred from 'operations'", func(t *testing.T) { + t.Parallel() - feeMultiplier := 1.1 + operations := []*types.Operation{ + { + OperationIdentifier: indexToOperationIdentifier(0), + Type: opTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressAlice), + Amount: extension.valueToNativeAmount("-1234"), + }, + { + OperationIdentifier: indexToOperationIdentifier(1), + Type: opTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressBob), + Amount: extension.valueToNativeAmount("1234"), + }, + } - response, err := service.ConstructionPreprocess(context.Background(), - &types.ConstructionPreprocessRequest{ - Operations: operations, - MaxFee: []*types.Amount{ - extension.valueToNativeAmount("123"), + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Operations: operations, + Metadata: objectsMap{}, }, - SuggestedFeeMultiplier: &feeMultiplier, - Metadata: objectsMap{ - "gasPrice": 1000000000, - "gasLimit": 50000, - "data": "hello", + ) + + expectedOptions := &constructionOptions{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Amount: "1234", + CurrencySymbol: "XeGLD", + } + + actualOptions := &constructionOptions{} + _ = fromObjectsMap(response.Options, actualOptions) + + require.Nil(t, err) + require.Equal(t, expectedOptions, actualOptions) + }) + + t.Run("with one operation, with metadata having: 'receiver'", func(t *testing.T) { + t.Parallel() + + operations := []*types.Operation{ + { + OperationIdentifier: indexToOperationIdentifier(0), + Type: opTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressAlice), + Amount: extension.valueToNativeAmount("-1234"), }, - }, - ) - require.Nil(t, err) - require.Equal(t, map[string]interface{}{ - "receiver": testscommon.TestAddressBob, - "sender": testscommon.TestAddressAlice, - "gasPrice": 1000000000, - "gasLimit": 50000, - "feeMultiplier": feeMultiplier, - "data": "hello", - "value": "1234", - "maxFee": "123", - "type": opTransfer, - }, response.Options) + } + + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Operations: operations, + Metadata: objectsMap{ + "receiver": testscommon.TestAddressBob, + }, + }, + ) + + expectedOptions := &constructionOptions{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Amount: "1234", + CurrencySymbol: "XeGLD", + } + + actualOptions := &constructionOptions{} + _ = fromObjectsMap(response.Options, actualOptions) + + require.Nil(t, err) + require.Equal(t, expectedOptions, actualOptions) + }) + + t.Run("with one operation, and metadata having: 'receiver', 'amount'", func(t *testing.T) { + t.Parallel() + + operations := []*types.Operation{ + { + OperationIdentifier: indexToOperationIdentifier(0), + Type: opTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressAlice), + Amount: extension.valueToNativeAmount("ignored"), + }, + } + + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Operations: operations, + Metadata: objectsMap{ + "receiver": testscommon.TestAddressBob, + "amount": "1234", + }, + }, + ) + + expectedOptions := &constructionOptions{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Amount: "1234", + CurrencySymbol: "XeGLD", + } + + actualOptions := &constructionOptions{} + _ = fromObjectsMap(response.Options, actualOptions) + + require.Nil(t, err) + require.Equal(t, expectedOptions, actualOptions) + }) + + t.Run("with one operation, and missing metadata: 'receiver'", func(t *testing.T) { + t.Parallel() + + operations := []*types.Operation{ + { + OperationIdentifier: indexToOperationIdentifier(0), + Type: opTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressAlice), + Amount: extension.valueToNativeAmount("1234"), + }, + } + + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Operations: operations, + Metadata: objectsMap{}, + }, + ) + + require.Equal(t, int32(ErrConstruction), err.Code) + require.Nil(t, response) + }) + + t.Run("with maximal 'metadata', without 'operations'", func(t *testing.T) { + t.Parallel() + + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Metadata: objectsMap{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + "gasLimit": 70000, + "gasPrice": 1000000000, + "data": []byte("hello"), + }, + }, + ) + + expectedOptions := &constructionOptions{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Amount: "1234", + CurrencySymbol: "XeGLD", + GasLimit: 70000, + GasPrice: 1000000000, + Data: []byte("hello"), + } + + actualOptions := &constructionOptions{} + _ = fromObjectsMap(response.Options, actualOptions) + + require.Nil(t, err) + require.Equal(t, expectedOptions, actualOptions) + }) + + t.Run("with incomplete 'metadata', without 'operations'", func(t *testing.T) { + t.Parallel() + + response, err := service.ConstructionPreprocess(context.Background(), + &types.ConstructionPreprocessRequest{ + Metadata: objectsMap{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), err.Code) + require.Nil(t, response) + }) } func TestConstructionService_ConstructionMetadata(t *testing.T) { networkProvider := testscommon.NewNetworkProviderMock() - networkProvider.MockNetworkConfig.NetworkID = "T" networkProvider.MockAccountsByAddress[testscommon.TestAddressAlice] = &resources.Account{ Address: testscommon.TestAddressAlice, Nonce: 42, @@ -71,105 +211,128 @@ func TestConstructionService_ConstructionMetadata(t *testing.T) { service := NewConstructionService(networkProvider) - options := map[string]interface{}{ - "receiver": testscommon.TestAddressBob, - "sender": testscommon.TestAddressAlice, - "gasPrice": uint64(1000000000), - "gasLimit": uint64(57500), - "feeMultiplier": 1.1, - "data": "hello", - "value": "1234", - "maxFee": "1", - "type": opTransfer, - } - response, err := service.ConstructionMetadata(context.Background(), - &types.ConstructionMetadataRequest{ - Options: options, - }, - ) + t.Run("with explicitly providing gas limit and price", func(t *testing.T) { + t.Parallel() - require.Nil(t, err) - require.Equal(t, "63250000000000", response.SuggestedFee[0].Value) - - expectedMetadata := map[string]interface{}{ - "receiver": testscommon.TestAddressBob, - "sender": testscommon.TestAddressAlice, - "chainID": "T", - "version": 1, - "data": []byte("hello"), - "value": "1234", - "nonce": uint64(42), - "gasPrice": uint64(1100000000), - "gasLimit": uint64(57500), - } + response, err := service.ConstructionMetadata(context.Background(), + &types.ConstructionMetadataRequest{ + Options: objectsMap{ + "receiver": testscommon.TestAddressBob, + "sender": testscommon.TestAddressAlice, + "amount": "1234", + "currencySymbol": "XeGLD", + "gasLimit": 70000, + "gasPrice": 1000000000, + "data": []byte("hello"), + }, + }, + ) + + expectedMetadata := &constructionMetadata{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Nonce: 42, + Amount: "1234", + CurrencySymbol: "XeGLD", + GasLimit: 70000, + GasPrice: 1000000000, + Data: []byte("hello"), + ChainID: "T", + Version: 1, + } + + actualMetadata := &constructionMetadata{} + _ = fromObjectsMap(response.Metadata, actualMetadata) - require.Equal(t, expectedMetadata, response.Metadata) + require.Nil(t, err) + // We are suggesting the fee by considering the refund + require.Equal(t, "57500000000000", response.SuggestedFee[0].Value) + require.Equal(t, expectedMetadata, actualMetadata) + }) + + t.Run("without providing gas limit and price", func(t *testing.T) { + t.Parallel() + + response, err := service.ConstructionMetadata(context.Background(), + &types.ConstructionMetadataRequest{ + Options: objectsMap{ + "receiver": testscommon.TestAddressBob, + "sender": testscommon.TestAddressAlice, + "amount": "1234", + "currencySymbol": "XeGLD", + "data": []byte("hello"), + }, + }, + ) + + expectedMetadata := &constructionMetadata{ + Sender: testscommon.TestAddressAlice, + Receiver: testscommon.TestAddressBob, + Nonce: 42, + Amount: "1234", + CurrencySymbol: "XeGLD", + GasLimit: 57500, + GasPrice: 1000000000, + Data: []byte("hello"), + ChainID: "T", + Version: 1, + } + + actualMetadata := &constructionMetadata{} + _ = fromObjectsMap(response.Metadata, actualMetadata) + + require.Nil(t, err) + require.Equal(t, "57500000000000", response.SuggestedFee[0].Value) + require.Equal(t, expectedMetadata, actualMetadata) + }) } func TestConstructionService_ConstructionPayloads(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() - networkProvider.MockNetworkConfig.NetworkID = "T" networkProvider.MockAccountsByAddress[testscommon.TestAddressAlice] = &resources.Account{ Address: testscommon.TestAddressAlice, Nonce: 42, } - extension := newNetworkProviderExtension(networkProvider) service := NewConstructionService(networkProvider) - metadata := map[string]interface{}{ - "receiver": testscommon.TestAddressBob, - "sender": testscommon.TestAddressAlice, - "chainID": "T", - "version": 1, - "data": []byte("hello"), - "value": "1234", - "nonce": uint64(42), - "gasPrice": uint64(1100000000), - "gasLimit": uint64(57500), - } - - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("-1234"), - }, - { - OperationIdentifier: indexToOperationIdentifier(1), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressBob), - Amount: extension.valueToNativeAmount("1234"), - }, - } - response, err := service.ConstructionPayloads(context.Background(), &types.ConstructionPayloadsRequest{ - Operations: operations, - Metadata: metadata, + Metadata: objectsMap{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "nonce": 42, + "amount": "1234", + "currencySymbol": "XeGLD", + "gasLimit": 57500, + "gasPrice": 1000000000, + "data": []byte("hello"), + "chainID": "T", + "version": 1, + }, }, ) - unsignedTx := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1100000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` - unsignedTxBytes := []byte(unsignedTx) + expectedTxJson := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1000000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` require.Nil(t, err) - firstPayload := response.Payloads[0] - require.Equal(t, unsignedTx, response.UnsignedTransaction) - require.Equal(t, unsignedTxBytes, firstPayload.Bytes) - require.Equal(t, testscommon.TestAddressAlice, firstPayload.AccountIdentifier.Address) - require.Equal(t, types.Ed25519, firstPayload.SignatureType) + require.Len(t, response.Payloads, 1) + require.Equal(t, expectedTxJson, response.UnsignedTransaction) + require.Equal(t, []byte(expectedTxJson), response.Payloads[0].Bytes) + require.Equal(t, testscommon.TestAddressAlice, response.Payloads[0].AccountIdentifier.Address) + require.Equal(t, types.Ed25519, response.Payloads[0].SignatureType) } func TestConstructionService_ConstructionParse(t *testing.T) { - networkProvider := testscommon.NewNetworkProviderMock() - networkProvider.MockNetworkConfig.NetworkID = "T" + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() extension := newNetworkProviderExtension(networkProvider) service := NewConstructionService(networkProvider) - unsignedTx := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1100000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` + notSignedTx := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1100000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` operations := []*types.Operation{ { @@ -189,7 +352,7 @@ func TestConstructionService_ConstructionParse(t *testing.T) { response, err := service.ConstructionParse(context.Background(), &types.ConstructionParseRequest{ Signed: false, - Transaction: unsignedTx, + Transaction: notSignedTx, }, ) require.Nil(t, err) @@ -198,16 +361,16 @@ func TestConstructionService_ConstructionParse(t *testing.T) { } func TestConstructionService_ConstructionCombine(t *testing.T) { - networkProvider := testscommon.NewNetworkProviderMock() - networkProvider.MockNetworkConfig.NetworkID = "T" + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() service := NewConstructionService(networkProvider) - unsignedTx := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1100000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` + notSignedTx := `{"nonce":42,"value":"1234","receiver":"erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx","sender":"erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th","gasPrice":1100000000,"gasLimit":57500,"data":"aGVsbG8=","chainID":"T","version":1}` response, err := service.ConstructionCombine(context.Background(), &types.ConstructionCombineRequest{ - UnsignedTransaction: unsignedTx, + UnsignedTransaction: notSignedTx, Signatures: []*types.Signature{ { Bytes: []byte{0xaa, 0xbb}, @@ -222,6 +385,8 @@ func TestConstructionService_ConstructionCombine(t *testing.T) { } func TestConstructionService_ConstructionDerive(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() service := NewConstructionService(networkProvider) @@ -239,6 +404,8 @@ func TestConstructionService_ConstructionDerive(t *testing.T) { } func TestConstructionService_ConstructionHash(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() networkProvider.MockComputedTransactionHash = "aaaa" service := NewConstructionService(networkProvider) @@ -255,6 +422,8 @@ func TestConstructionService_ConstructionHash(t *testing.T) { } func TestConstructionService_ConstructionSubmit(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() var calledWithTransaction *data.Transaction @@ -279,6 +448,8 @@ func TestConstructionService_ConstructionSubmit(t *testing.T) { } func TestConstructionService_CreateOperationsFromPreparedTx(t *testing.T) { + t.Parallel() + networkProvider := testscommon.NewNetworkProviderMock() extension := newNetworkProviderExtension(networkProvider) service := NewConstructionService(networkProvider).(*constructionService) @@ -307,105 +478,3 @@ func TestConstructionService_CreateOperationsFromPreparedTx(t *testing.T) { operations := service.createOperationsFromPreparedTx(preparedTx) require.Equal(t, expectedOperations, operations) } - -func TestConstructionService_PrepareConstructionOptions(t *testing.T) { - t.Parallel() - - networkProvider := testscommon.NewNetworkProviderMock() - extension := newNetworkProviderExtension(networkProvider) - service := NewConstructionService(networkProvider).(*constructionService) - - t.Run("two operations, no metadata", func(t *testing.T) { - t.Parallel() - - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("-12345"), - }, - { - OperationIdentifier: indexToOperationIdentifier(1), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressBob), - Amount: extension.valueToNativeAmount("12345"), - }, - } - - metadata := make(objectsMap) - - options, err := service.prepareConstructionOptions(operations, metadata) - require.Nil(t, err) - require.Equal(t, opTransfer, options["type"]) - require.Equal(t, testscommon.TestAddressAlice, options["sender"]) - require.Equal(t, testscommon.TestAddressBob, options["receiver"]) - require.Equal(t, "12345", options["value"]) - }) - - t.Run("one operation, with metadata having: receiver", func(t *testing.T) { - t.Parallel() - - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("-12345"), - }, - } - - metadata := make(objectsMap) - metadata["receiver"] = testscommon.TestAddressBob - - options, err := service.prepareConstructionOptions(operations, metadata) - require.Nil(t, err) - require.Equal(t, opTransfer, options["type"]) - require.Equal(t, testscommon.TestAddressAlice, options["sender"]) - require.Equal(t, testscommon.TestAddressBob, options["receiver"]) - require.Equal(t, "12345", options["value"]) - }) - - t.Run("one operation, with metadata having: receiver, value", func(t *testing.T) { - t.Parallel() - - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("ignored"), - }, - } - - metadata := make(objectsMap) - metadata["receiver"] = testscommon.TestAddressBob - metadata["value"] = "12345" - - options, err := service.prepareConstructionOptions(operations, metadata) - require.Nil(t, err) - require.Equal(t, opTransfer, options["type"]) - require.Equal(t, testscommon.TestAddressAlice, options["sender"]) - require.Equal(t, testscommon.TestAddressBob, options["receiver"]) - require.Equal(t, "12345", options["value"]) - }) - - t.Run("one operation, with missing metadata: receiver", func(t *testing.T) { - t.Parallel() - - operations := []*types.Operation{ - { - OperationIdentifier: indexToOperationIdentifier(0), - Type: opTransfer, - Account: addressToAccountIdentifier(testscommon.TestAddressAlice), - Amount: extension.valueToNativeAmount("ignored"), - }, - } - - metadata := make(objectsMap) - - options, err := service.prepareConstructionOptions(operations, metadata) - require.ErrorContains(t, err, "cannot prepare transaction receiver") - require.Nil(t, options) - }) -} diff --git a/server/services/errors.go b/server/services/errors.go index 20d3e139..bdd06a7d 100644 --- a/server/services/errors.go +++ b/server/services/errors.go @@ -18,7 +18,7 @@ const ( ErrMalformedValue ErrUnableToGetNodeStatus ErrMustQueryByIndexOrByHash - ErrConstructionCheck + ErrConstruction ErrUnableToGetNetworkConfig ErrUnsupportedCurveType ErrInsufficientGasLimit @@ -97,8 +97,8 @@ func createErrPrototypes() ([]errPrototype, map[errCode]errPrototype) { retriable: false, }, { - code: ErrConstructionCheck, - message: "operation construction check error", + code: ErrConstruction, + message: "construction error", retriable: false, }, { diff --git a/server/services/networkProviderExtension.go b/server/services/networkProviderExtension.go index df758e5a..0d2341ce 100644 --- a/server/services/networkProviderExtension.go +++ b/server/services/networkProviderExtension.go @@ -40,11 +40,19 @@ func (extension *networkProviderExtension) getNativeCurrency() *types.Currency { } } +func (extension *networkProviderExtension) getNativeCurrencySymbol() string { + return extension.provider.GetNativeCurrency().Symbol +} + func (extension *networkProviderExtension) isNativeCurrency(currency *types.Currency) bool { nativeCurrency := extension.provider.GetNativeCurrency() return currency.Symbol == nativeCurrency.Symbol && currency.Decimals == nativeCurrency.Decimals } +func (extension *networkProviderExtension) isNativeCurrencySymbol(symbol string) bool { + return symbol == extension.getNativeCurrencySymbol() +} + func (extension *networkProviderExtension) getGenesisBlockIdentifier() *types.BlockIdentifier { summary := extension.provider.GetGenesisBlockSummary() return blockSummaryToIdentifier(summary) diff --git a/version/constants.go b/version/constants.go index 58fd8fdb..aed8bb06 100644 --- a/version/constants.go +++ b/version/constants.go @@ -5,9 +5,9 @@ const ( RosettaVersion = "v1.4.12" // RosettaMiddlewareVersion is the version of this package (application) - RosettaMiddlewareVersion = "v0.2.8" + RosettaMiddlewareVersion = "v0.3.0" // NodeVersion is the canonical version of the node runtime // TODO: We should fetch this from node/status. - NodeVersion = "v1.3.43-rosetta1" + NodeVersion = "v1.3.44-rosetta1" )