From 60316873fea20def7f7b5eb85760b7f5bee9016b Mon Sep 17 00:00:00 2001 From: miiu Date: Tue, 21 Jul 2026 12:58:43 +0300 Subject: [PATCH 1/6] extra unit test --- .github/workflows/check_with_mesh_cli.yml | 2 +- .../check_with_mesh_cli_on_localnet.yml | 2 +- .../check_with_mesh_cli_on_mainnet.yml | 2 +- .github/workflows/code-coverage.yml | 6 +- .github/workflows/golangci-lint.yml | 10 +- .github/workflows/pr-build.yml | 4 +- .github/workflows/regularly_check_devnet.yml | 2 +- .github/workflows/regularly_check_mainnet.yml | 2 +- .github/workflows/regularly_check_testnet.yml | 2 +- go.mod | 8 +- go.sum | 8 +- server/provider/currenciesProvider.go | 2 +- server/services/accountService.go | 5 + server/services/constructionService.go | 65 ++++++ server/services/constructionService_test.go | 198 ++++++++++++++++++ server/services/offline_services.go | 5 + 16 files changed, 298 insertions(+), 25 deletions(-) diff --git a/.github/workflows/check_with_mesh_cli.yml b/.github/workflows/check_with_mesh_cli.yml index 33e4631e..1eb221c9 100644 --- a/.github/workflows/check_with_mesh_cli.yml +++ b/.github/workflows/check_with_mesh_cli.yml @@ -15,7 +15,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/.github/workflows/check_with_mesh_cli_on_localnet.yml b/.github/workflows/check_with_mesh_cli_on_localnet.yml index 27604903..4489a533 100644 --- a/.github/workflows/check_with_mesh_cli_on_localnet.yml +++ b/.github/workflows/check_with_mesh_cli_on_localnet.yml @@ -15,7 +15,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/.github/workflows/check_with_mesh_cli_on_mainnet.yml b/.github/workflows/check_with_mesh_cli_on_mainnet.yml index 380338c7..c841f092 100644 --- a/.github/workflows/check_with_mesh_cli_on_mainnet.yml +++ b/.github/workflows/check_with_mesh_cli_on_mainnet.yml @@ -15,7 +15,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index bfa5353f..1ac7db07 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -15,10 +15,10 @@ jobs: runs-on: ${{ matrix.runs-on }} name: Build steps: - - name: Set up Go 1.23 - uses: actions/setup-go@v5 + - name: Set up Go 1.26 + uses: actions/setup-go@v6 with: - go-version: 1.23.6 + go-version: 1.26.2 id: go - name: Check out code into the Go module directory diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index e8ca4ad5..eb21eb76 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -11,14 +11,14 @@ jobs: name: golangci linter runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: 1.23.6 - - uses: actions/checkout@v4 + go-version: 1.26.2 + - uses: actions/checkout@v6 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.64.5 + version: v2.12.2 # Optional: golangci-lint command line arguments. args: --timeout 10m0s --max-issues-per-linter 0 --max-same-issues 0 --print-issued-lines diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index af39fe93..88b89b4f 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go 1.x - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: 1.23.6 + go-version: 1.26.2 id: go - name: Check out code into the Go module directory diff --git a/.github/workflows/regularly_check_devnet.yml b/.github/workflows/regularly_check_devnet.yml index ed24edd2..feaca88d 100644 --- a/.github/workflows/regularly_check_devnet.yml +++ b/.github/workflows/regularly_check_devnet.yml @@ -17,7 +17,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/.github/workflows/regularly_check_mainnet.yml b/.github/workflows/regularly_check_mainnet.yml index 1dc5b54a..77e36b50 100644 --- a/.github/workflows/regularly_check_mainnet.yml +++ b/.github/workflows/regularly_check_mainnet.yml @@ -17,7 +17,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/.github/workflows/regularly_check_testnet.yml b/.github/workflows/regularly_check_testnet.yml index 5f9f126b..368ad1e8 100644 --- a/.github/workflows/regularly_check_testnet.yml +++ b/.github/workflows/regularly_check_testnet.yml @@ -15,7 +15,7 @@ jobs: with: python-version: 3.11 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: | diff --git a/go.mod b/go.mod index a40460d8..5f2382e5 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,17 @@ module github.com/multiversx/mx-chain-rosetta -go 1.23.6 +go 1.26 require ( - github.com/coinbase/rosetta-sdk-go v0.8.3 - github.com/coinbase/rosetta-sdk-go/types v1.0.0 + github.com/coinbase/rosetta-sdk-go v0.12.0 + github.com/coinbase/rosetta-sdk-go/types v1.7.2 github.com/gin-gonic/gin v1.10.0 github.com/multiversx/mx-chain-core-go v1.4.2-0.20260219122727-014ae9f9311f github.com/multiversx/mx-chain-go v1.11.1 github.com/multiversx/mx-chain-logger-go v1.1.0 github.com/multiversx/mx-chain-proxy-go v1.4.0 github.com/multiversx/mx-chain-storage-go v1.1.0 + github.com/multiversx/mx-chain-vm-common-go v1.6.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli v1.22.16 ) @@ -45,7 +46,6 @@ require ( github.com/multiversx/mx-chain-communication-go v1.3.0 // indirect github.com/multiversx/mx-chain-crypto-go v1.3.0 // indirect github.com/multiversx/mx-chain-es-indexer-go v1.9.3-0.20260112102658-97d6a0ceb5f6 // indirect - github.com/multiversx/mx-chain-vm-common-go v1.6.0 // indirect github.com/pelletier/go-toml v1.9.3 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 118c0055..4ae0921c 100644 --- a/go.sum +++ b/go.sum @@ -28,10 +28,10 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/coinbase/rosetta-sdk-go v0.8.3 h1:IYqd+Ser5NVh0s7p8p2Ir82iCvi75E1l0NH2H4NEr0Y= -github.com/coinbase/rosetta-sdk-go v0.8.3/go.mod h1:ChOHc+BNq7zqJDDkui0DA124GOvlAiRbdgAc1U9GMDQ= -github.com/coinbase/rosetta-sdk-go/types v1.0.0 h1:jpVIwLcPoOeCR6o1tU+Xv7r5bMONNbHU7MuEHboiFuA= -github.com/coinbase/rosetta-sdk-go/types v1.0.0/go.mod h1:eq7W2TMRH22GTW0N0beDnN931DW0/WOI1R2sdHNHG4c= +github.com/coinbase/rosetta-sdk-go v0.12.0 h1:x3T5ScBw0J5fmu9Nkx4QNI+s8NqvSYupb39RIOm5HjQ= +github.com/coinbase/rosetta-sdk-go v0.12.0/go.mod h1:rdzdWYdC3AKsR7IDXKFu7HW7sa9q+czdYGxw44vNRSQ= +github.com/coinbase/rosetta-sdk-go/types v1.7.2 h1:iRt/R+Esn4GNyd2cijjKPiIine/nAlUaCleaAdj8d+A= +github.com/coinbase/rosetta-sdk-go/types v1.7.2/go.mod h1:bN5JAIEG/dGZNnYzsfizeHhzFOf9s5rkJoaNM/7L5QI= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/server/provider/currenciesProvider.go b/server/provider/currenciesProvider.go index 96f483a4..d9d2f246 100644 --- a/server/provider/currenciesProvider.go +++ b/server/provider/currenciesProvider.go @@ -49,7 +49,7 @@ func (provider *currenciesProvider) GetCustomCurrencies() []resources.Currency { return provider.customCurrencies } -// GetCustomCurrencyBySymbol gets a custom currency (ESDT) by symbol (identifier) +// GetCustomCurrenciesSymbols gets a custom currency (ESDT) by symbol (identifier) func (provider *currenciesProvider) GetCustomCurrenciesSymbols() []string { return provider.customCurrenciesSymbols } diff --git a/server/services/accountService.go b/server/services/accountService.go index 0c79ed84..d7be27f3 100644 --- a/server/services/accountService.go +++ b/server/services/accountService.go @@ -109,3 +109,8 @@ func (service *accountService) getNativeSymbol() string { func (service *accountService) AccountCoins(_ context.Context, _ *types.AccountCoinsRequest) (*types.AccountCoinsResponse, *types.Error) { return nil, service.errFactory.newErr(ErrNotImplemented) } + +// AllAccountBalances implements the /account/all-balances. +func (service *accountService) AllAccountBalances(_ context.Context, _ *types.AllAccountBalancesRequest) (*types.AllAccountBalancesResponse, *types.Error) { + return nil, service.errFactory.newErr(ErrNotImplemented) +} diff --git a/server/services/constructionService.go b/server/services/constructionService.go index 81efa333..95d1762b 100644 --- a/server/services/constructionService.go +++ b/server/services/constructionService.go @@ -414,3 +414,68 @@ func (service *constructionService) ConstructionSubmit( }, }, nil } + +func (service *constructionService) ConstructionPreprocessOperations( + _ context.Context, + request *types.ConstructionPreprocessOperationsRequest, +) (*types.ConstructionPreprocessOperationsResponse, *types.Error) { + log.Debug("constructionService.ConstructionPreprocessOperations()", "construct_op", request.ConstructOp, "from_address", request.FromAddress) + + requestOptions, err := newConstructionOptions(request.Options) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } + if request.FromAddress != nil && len(*request.FromAddress) > 0 && len(requestOptions.Sender) == 0 { + requestOptions.Sender = *request.FromAddress + } + + nativeCurrencySymbol := service.extension.getNativeCurrencySymbol() + isNative := service.extension.isNativeCurrencySymbol(requestOptions.CurrencySymbol) + if !isNative && !service.provider.HasCustomCurrency(requestOptions.CurrencySymbol) { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, fmt.Errorf("unsupported currency: %s", requestOptions.CurrencySymbol)) + } + + if request.ConstructOp == "transfer" { + err = requestOptions.validate(nativeCurrencySymbol) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } + } else { + return nil, service.errFactory.newErrWithOriginal(ErrNotImplemented, fmt.Errorf("unsupported construct_op: %s", request.ConstructOp)) + } + + tx := &data.Transaction{ + Sender: requestOptions.Sender, + Receiver: requestOptions.Receiver, + Value: requestOptions.Amount, + Data: requestOptions.Data, + } + + if !isNative { + tx.Value = amountZero + tx.Data = service.computeDataForCustomCurrencyTransfer(requestOptions.CurrencySymbol, requestOptions.Amount) + } + + operations, err := service.createOperationsFromPreparedTx(tx) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } + + fee, gasLimit, gasPrice, errTyped := service.computeFeeComponents(requestOptions, tx.Data) + if errTyped != nil { + return nil, errTyped + } + + requestOptions.GasLimit = gasLimit + requestOptions.GasPrice = gasPrice + + metadataBytes, err := json.Marshal(requestOptions) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } + return &types.ConstructionPreprocessOperationsResponse{ + Operations: operations, + MaxFee: service.extension.valueToNativeAmount(fee.String()), + Metadata: new(string(metadataBytes)), + }, nil +} diff --git a/server/services/constructionService_test.go b/server/services/constructionService_test.go index 167dac11..6b0d2dd0 100644 --- a/server/services/constructionService_test.go +++ b/server/services/constructionService_test.go @@ -643,6 +643,204 @@ func TestConstructionService_ConstructionSubmit(t *testing.T) { require.Equal(t, uint64(42), calledWithTransaction.Nonce) } +func TestConstructionService_ConstructionPreprocessOperations(t *testing.T) { + t.Parallel() + + networkProvider := testscommon.NewNetworkProviderMock() + networkProvider.MockCustomCurrencies = []resources.Currency{ + {Symbol: "TEST-abcdef", Decimals: 6}, + } + extension := newNetworkProviderExtension(networkProvider) + service := NewConstructionService(networkProvider) + + t.Run("native transfer", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + }, + }, + ) + + require.Nil(t, errTyped) + + expectedOperations := []*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"), + }, + } + + require.Equal(t, expectedOperations, response.Operations) + require.Equal(t, "50000000000000", response.MaxFee.Value) + require.NotNil(t, response.Metadata) + }) + + t.Run("custom transfer", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "TEST-abcdef", + }, + }, + ) + + require.Nil(t, errTyped) + + expectedOperations := []*types.Operation{ + { + OperationIdentifier: indexToOperationIdentifier(0), + Type: opCustomTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressAlice), + Amount: extension.valueToCustomAmount("-1234", "TEST-abcdef"), + }, + { + OperationIdentifier: indexToOperationIdentifier(1), + Type: opCustomTransfer, + Account: addressToAccountIdentifier(testscommon.TestAddressBob), + Amount: extension.valueToCustomAmount("1234", "TEST-abcdef"), + }, + } + + require.Equal(t, expectedOperations, response.Operations) + require.Equal(t, "112000000000000", response.MaxFee.Value) + require.NotNil(t, response.Metadata) + }) + + t.Run("native transfer with data", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + "data": []byte("hello"), + }, + }, + ) + + require.Nil(t, errTyped) + + expectedOperations := []*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"), + }, + } + + require.Equal(t, expectedOperations, response.Operations) + require.Equal(t, "57500000000000", response.MaxFee.Value) + require.NotNil(t, response.Metadata) + }) + + t.Run("from_address fallback", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + FromAddress: new(testscommon.TestAddressAlice), + Options: map[string]interface{}{ + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + }, + }, + ) + + require.Nil(t, errTyped) + require.Equal(t, testscommon.TestAddressAlice, response.Operations[0].Account.Address) + }) + + t.Run("unsupported construct_op", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "stake", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + }, + }, + ) + + require.Equal(t, int32(ErrNotImplemented), errTyped.Code) + require.Nil(t, response) + }) + + t.Run("unsupported currency", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "UNKNOWN-abc", + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), errTyped.Code) + require.Nil(t, response) + }) + + t.Run("missing required fields", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "currencySymbol": "XeGLD", + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), errTyped.Code) + require.Nil(t, response) + }) +} + func TestConstructionService_CreateOperationsFromPreparedTx(t *testing.T) { t.Parallel() diff --git a/server/services/offline_services.go b/server/services/offline_services.go index 70924d17..178be716 100644 --- a/server/services/offline_services.go +++ b/server/services/offline_services.go @@ -81,3 +81,8 @@ func (service *offlineService) NetworkList( ) (*types.NetworkListResponse, *types.Error) { return nil, service.errFactory.newErr(ErrOfflineMode) } + +// AllAccountBalances is not implemented yet +func (service *offlineService) AllAccountBalances(_ context.Context, _ *types.AllAccountBalancesRequest) (*types.AllAccountBalancesResponse, *types.Error) { + return nil, service.errFactory.newErr(ErrOfflineMode) +} From cf2a6dbe050c9e7ce238353884e7881329e9e1be Mon Sep 17 00:00:00 2001 From: miiu Date: Tue, 21 Jul 2026 13:04:50 +0300 Subject: [PATCH 2/6] fix --- .github/workflows/golangci-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index eb21eb76..c1f979c3 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,7 +21,7 @@ jobs: version: v2.12.2 # Optional: golangci-lint command line arguments. - args: --timeout 10m0s --max-issues-per-linter 0 --max-same-issues 0 --print-issued-lines + args: --timeout 10m0s --max-issues-per-linter 0 --max-same-issues 0 # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true From a16ace6bbfa51d0647592acdd716f7315e16ec1e Mon Sep 17 00:00:00 2001 From: miiu Date: Thu, 20 Aug 2026 15:34:51 +0300 Subject: [PATCH 3/6] fixes --- server/provider/currenciesProvider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/provider/currenciesProvider.go b/server/provider/currenciesProvider.go index d9d2f246..a62f8b25 100644 --- a/server/provider/currenciesProvider.go +++ b/server/provider/currenciesProvider.go @@ -49,7 +49,7 @@ func (provider *currenciesProvider) GetCustomCurrencies() []resources.Currency { return provider.customCurrencies } -// GetCustomCurrenciesSymbols gets a custom currency (ESDT) by symbol (identifier) +// GetCustomCurrenciesSymbols returns the enabled custom currency (ESDT) symbols. func (provider *currenciesProvider) GetCustomCurrenciesSymbols() []string { return provider.customCurrenciesSymbols } From 033ad9cf9b1c25676eb9e960c05209ae53cc53fe Mon Sep 17 00:00:00 2001 From: miiu Date: Mon, 24 Aug 2026 10:14:25 +0300 Subject: [PATCH 4/6] fixes after review --- server/services/constructionService.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/services/constructionService.go b/server/services/constructionService.go index 95d1762b..a00d6970 100644 --- a/server/services/constructionService.go +++ b/server/services/constructionService.go @@ -415,6 +415,10 @@ func (service *constructionService) ConstructionSubmit( }, nil } +// ConstructionPreprocessOperations will validate the requested operations and options, +// prepare the corresponding transaction (handling native or custom currency transfers) +// and return the resulting operations along with the maximum fee and metadata required +// for the subsequent payload construction func (service *constructionService) ConstructionPreprocessOperations( _ context.Context, request *types.ConstructionPreprocessOperationsRequest, @@ -429,12 +433,12 @@ func (service *constructionService) ConstructionPreprocessOperations( requestOptions.Sender = *request.FromAddress } - nativeCurrencySymbol := service.extension.getNativeCurrencySymbol() isNative := service.extension.isNativeCurrencySymbol(requestOptions.CurrencySymbol) if !isNative && !service.provider.HasCustomCurrency(requestOptions.CurrencySymbol) { return nil, service.errFactory.newErrWithOriginal(ErrConstruction, fmt.Errorf("unsupported currency: %s", requestOptions.CurrencySymbol)) } + nativeCurrencySymbol := service.extension.getNativeCurrencySymbol() if request.ConstructOp == "transfer" { err = requestOptions.validate(nativeCurrencySymbol) if err != nil { From bef379e2f52cdec1eda61d1ecc8d90a0426de572 Mon Sep 17 00:00:00 2001 From: miiu Date: Mon, 24 Aug 2026 10:52:53 +0300 Subject: [PATCH 5/6] fixes after second review --- .github/workflows/code-coverage.yml | 2 +- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/pr-build.yml | 2 +- server/services/constructionOptions.go | 14 ++++ server/services/constructionService.go | 15 +++- server/services/constructionServiceFee.go | 12 +++ server/services/constructionService_test.go | 90 +++++++++++++++++++++ 7 files changed, 132 insertions(+), 5 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 1ac7db07..8dfa4838 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go 1.26 uses: actions/setup-go@v6 with: - go-version: 1.26.2 + go-version: 1.26.6 id: go - name: Check out code into the Go module directory diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index c1f979c3..daef4304 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/setup-go@v6 with: - go-version: 1.26.2 + go-version: 1.26.6 - uses: actions/checkout@v6 - name: golangci-lint uses: golangci/golangci-lint-action@v7 diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 88b89b4f..c91ff7a3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v6 with: - go-version: 1.26.2 + go-version: 1.26.6 id: go - name: Check out code into the Go module directory diff --git a/server/services/constructionOptions.go b/server/services/constructionOptions.go index 3cdc7140..1601d8c9 100644 --- a/server/services/constructionOptions.go +++ b/server/services/constructionOptions.go @@ -2,6 +2,7 @@ package services import ( "errors" + "math/big" ) type constructionOptions struct { @@ -40,6 +41,19 @@ func (options *constructionOptions) coalesceGasPrice(minGasPrice uint64) uint64 return options.GasPrice } +func validateTransferAmount(amount string) error { + bigAmount, ok := big.NewInt(0).SetString(amount, 10) + if !ok { + return errors.New("option 'amount' is not a valid integer") + } + + if bigAmount.Sign() <= 0 { + return errors.New("option 'amount' must be a positive integer") + } + + return nil +} + func (options *constructionOptions) validate(nativeCurrencySymbol string) error { if len(options.Sender) == 0 { return errors.New("missing option: 'sender'") diff --git a/server/services/constructionService.go b/server/services/constructionService.go index a00d6970..f944fcf2 100644 --- a/server/services/constructionService.go +++ b/server/services/constructionService.go @@ -444,6 +444,10 @@ func (service *constructionService) ConstructionPreprocessOperations( if err != nil { return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } + err = validateTransferAmount(requestOptions.Amount) + if err != nil { + return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) + } } else { return nil, service.errFactory.newErrWithOriginal(ErrNotImplemented, fmt.Errorf("unsupported construct_op: %s", request.ConstructOp)) } @@ -458,6 +462,11 @@ func (service *constructionService) ConstructionPreprocessOperations( if !isNative { tx.Value = amountZero tx.Data = service.computeDataForCustomCurrencyTransfer(requestOptions.CurrencySymbol, requestOptions.Amount) + } else if isCustomCurrencyTransfer(string(tx.Data)) { + return nil, service.errFactory.newErrWithOriginal( + ErrConstruction, + errors.New("for native currency transfers, option 'data' must not encode a custom currency (ESDT) transfer"), + ) } operations, err := service.createOperationsFromPreparedTx(tx) @@ -465,7 +474,7 @@ func (service *constructionService) ConstructionPreprocessOperations( return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } - fee, gasLimit, gasPrice, errTyped := service.computeFeeComponents(requestOptions, tx.Data) + _, gasLimit, gasPrice, errTyped := service.computeFeeComponents(requestOptions, tx.Data) if errTyped != nil { return nil, errTyped } @@ -473,13 +482,15 @@ func (service *constructionService) ConstructionPreprocessOperations( requestOptions.GasLimit = gasLimit requestOptions.GasPrice = gasPrice + maxFee := service.computeMaxFee(requestOptions, gasLimit, gasPrice) + metadataBytes, err := json.Marshal(requestOptions) if err != nil { return nil, service.errFactory.newErrWithOriginal(ErrConstruction, err) } return &types.ConstructionPreprocessOperationsResponse{ Operations: operations, - MaxFee: service.extension.valueToNativeAmount(fee.String()), + MaxFee: service.extension.valueToNativeAmount(maxFee.String()), Metadata: new(string(metadataBytes)), }, nil } diff --git a/server/services/constructionServiceFee.go b/server/services/constructionServiceFee.go index edcc76ec..09287ed6 100644 --- a/server/services/constructionServiceFee.go +++ b/server/services/constructionServiceFee.go @@ -36,6 +36,18 @@ func (service *constructionService) computeFeeComponents(options *constructionOp return fee, gasLimit, gasPrice, nil } +func (service *constructionService) computeMaxFee(options *constructionOptions, gasLimit uint64, gasPrice uint64) *big.Int { + networkConfig := service.provider.GetNetworkConfig() + + executionGasLimit := uint64(0) + if !service.extension.isNativeCurrencySymbol(options.CurrencySymbol) { + executionGasLimit = networkConfig.GasLimitCustomTransfer + } + movementGasLimit := gasLimit - executionGasLimit + + return computeFee(movementGasLimit, executionGasLimit, gasPrice, networkConfig.GasPriceModifier) +} + func computeFee(movementGasLimit uint64, executionGasLimit uint64, gasPrice uint64, gasPriceModifier float64) *big.Int { movementFee := multiplyUint64(movementGasLimit, gasPrice) executionGasPrice := uint64(float64(gasPrice) * gasPriceModifier) diff --git a/server/services/constructionService_test.go b/server/services/constructionService_test.go index 6b0d2dd0..4693e14d 100644 --- a/server/services/constructionService_test.go +++ b/server/services/constructionService_test.go @@ -2,6 +2,8 @@ package services import ( "context" + "encoding/json" + "math/big" "testing" "github.com/coinbase/rosetta-sdk-go/types" @@ -765,6 +767,94 @@ func TestConstructionService_ConstructionPreprocessOperations(t *testing.T) { require.NotNil(t, response.Metadata) }) + t.Run("native transfer with custom currency shaped data", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + "data": []byte("ESDTTransfer@544553542d616263646566@2a"), + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), errTyped.Code) + require.Nil(t, response) + }) + + t.Run("native transfer with malformed amount", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "not-a-number", + "currencySymbol": "XeGLD", + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), errTyped.Code) + require.Nil(t, response) + }) + + t.Run("native transfer with explicit gas limit", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "XeGLD", + "gasLimit": 1000000, + }, + }, + ) + + require.Nil(t, errTyped) + + var metadata struct { + GasLimit uint64 `json:"gasLimit"` + GasPrice uint64 `json:"gasPrice"` + } + err := json.Unmarshal([]byte(*response.Metadata), &metadata) + require.Nil(t, err) + require.Equal(t, uint64(1000000), metadata.GasLimit) + + expectedMaxFee := big.NewInt(1000000 * 1000000000) + require.Equal(t, expectedMaxFee.String(), response.MaxFee.Value) + }) + + t.Run("custom transfer with negative amount", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "-1234", + "currencySymbol": "TEST-abcdef", + }, + }, + ) + + require.Equal(t, int32(ErrConstruction), errTyped.Code) + require.Nil(t, response) + }) + t.Run("from_address fallback", func(t *testing.T) { t.Parallel() From 82db28dcd029e211bc664c541eedc54ba25e737e Mon Sep 17 00:00:00 2001 From: miiu Date: Tue, 25 Aug 2026 10:32:26 +0300 Subject: [PATCH 6/6] fixes --- server/services/constructionService.go | 2 +- server/services/constructionServiceFee.go | 16 ++++++----- server/services/constructionService_test.go | 32 ++++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/server/services/constructionService.go b/server/services/constructionService.go index f944fcf2..15cc943f 100644 --- a/server/services/constructionService.go +++ b/server/services/constructionService.go @@ -482,7 +482,7 @@ func (service *constructionService) ConstructionPreprocessOperations( requestOptions.GasLimit = gasLimit requestOptions.GasPrice = gasPrice - maxFee := service.computeMaxFee(requestOptions, gasLimit, gasPrice) + maxFee := service.computeMaxFee(tx.Data, gasLimit, gasPrice) metadataBytes, err := json.Marshal(requestOptions) if err != nil { diff --git a/server/services/constructionServiceFee.go b/server/services/constructionServiceFee.go index 09287ed6..90f6079e 100644 --- a/server/services/constructionServiceFee.go +++ b/server/services/constructionServiceFee.go @@ -36,16 +36,18 @@ func (service *constructionService) computeFeeComponents(options *constructionOp return fee, gasLimit, gasPrice, nil } -func (service *constructionService) computeMaxFee(options *constructionOptions, gasLimit uint64, gasPrice uint64) *big.Int { +// computeMaxFee returns the maximum fee chargeable for a transaction built with the given options, +// taking into account the user-provided gas limit, so it stays consistent with the metadata returned +// to the client. Following the protocol's gas categorization (see SplitTxGasInCategoriesInEpoch), +// movement gas is MinGasLimit + GasPerDataByte * len(data), charged at the base gas price, while all +// remaining gas is processing gas, charged with the gas price modifier. +func (service *constructionService) computeMaxFee(computedData []byte, gasLimit uint64, gasPrice uint64) *big.Int { networkConfig := service.provider.GetNetworkConfig() - executionGasLimit := uint64(0) - if !service.extension.isNativeCurrencySymbol(options.CurrencySymbol) { - executionGasLimit = networkConfig.GasLimitCustomTransfer - } - movementGasLimit := gasLimit - executionGasLimit + movementGasLimit := networkConfig.MinGasLimit + networkConfig.GasPerDataByte*uint64(len(computedData)) + processingGasLimit := gasLimit - movementGasLimit - return computeFee(movementGasLimit, executionGasLimit, gasPrice, networkConfig.GasPriceModifier) + return computeFee(movementGasLimit, processingGasLimit, gasPrice, networkConfig.GasPriceModifier) } func computeFee(movementGasLimit uint64, executionGasLimit uint64, gasPrice uint64, gasPriceModifier float64) *big.Int { diff --git a/server/services/constructionService_test.go b/server/services/constructionService_test.go index 4693e14d..053bf7d2 100644 --- a/server/services/constructionService_test.go +++ b/server/services/constructionService_test.go @@ -832,7 +832,37 @@ func TestConstructionService_ConstructionPreprocessOperations(t *testing.T) { require.Nil(t, err) require.Equal(t, uint64(1000000), metadata.GasLimit) - expectedMaxFee := big.NewInt(1000000 * 1000000000) + expectedMaxFee := big.NewInt(50000*1000000000 + 950000*10000000) + require.Equal(t, expectedMaxFee.String(), response.MaxFee.Value) + }) + + t.Run("custom transfer with explicit gas limit", func(t *testing.T) { + t.Parallel() + + response, errTyped := service.ConstructionPreprocessOperations(context.Background(), + &types.ConstructionPreprocessOperationsRequest{ + ConstructOp: "transfer", + Options: map[string]interface{}{ + "sender": testscommon.TestAddressAlice, + "receiver": testscommon.TestAddressBob, + "amount": "1234", + "currencySymbol": "TEST-abcdef", + "gasLimit": 1000000, + }, + }, + ) + + require.Nil(t, errTyped) + + var metadata struct { + GasLimit uint64 `json:"gasLimit"` + GasPrice uint64 `json:"gasPrice"` + } + err := json.Unmarshal([]byte(*response.Metadata), &metadata) + require.Nil(t, err) + require.Equal(t, uint64(1000000), metadata.GasLimit) + + expectedMaxFee := big.NewInt(110000*1000000000 + 890000*10000000) require.Equal(t, expectedMaxFee.String(), response.MaxFee.Value) })