diff --git a/services/connector/commands/send_transaction_test.go b/services/connector/commands/send_transaction_test.go index 14da43d09..f13fc3932 100644 --- a/services/connector/commands/send_transaction_test.go +++ b/services/connector/commands/send_transaction_test.go @@ -2,7 +2,6 @@ package commands import ( "encoding/json" - "errors" "math/big" "testing" "time" @@ -13,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/status-im/status-go/eth-node/types" mock_client "github.com/status-im/status-go/rpc/chain/mock/client" + "github.com/status-im/status-go/services/wallet/router/fees" "github.com/status-im/status-go/services/wallet/wallettypes" "github.com/status-im/status-go/signal" ) @@ -82,9 +82,10 @@ func TestSendTransactionWithSignalTimout(t *testing.T) { WalletResponseMaxInterval = 1 * time.Millisecond mockedChainClient := mock_client.NewMockClientInterface(state.mockCtrl) + feeHistory := &fees.FeeHistory{} + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().SuggestGasPrice(state.ctx).Times(1).Return(big.NewInt(1), nil) - mockedChainClient.EXPECT().SuggestGasTipCap(state.ctx).Times(1).Return(big.NewInt(0), errors.New("EIP-1559 is not enabled")) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().PendingNonceAt(state.ctx, common.Address(accountAddress)).Times(1).Return(uint64(10), nil) @@ -127,9 +128,10 @@ func TestSendTransactionWithSignalAccepted(t *testing.T) { t.Cleanup(signal.ResetMobileSignalHandler) mockedChainClient := mock_client.NewMockClientInterface(state.mockCtrl) + feeHistory := &fees.FeeHistory{} + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().SuggestGasPrice(state.ctx).Times(1).Return(big.NewInt(1), nil) - mockedChainClient.EXPECT().SuggestGasTipCap(state.ctx).Times(1).Return(big.NewInt(0), errors.New("EIP-1559 is not enabled")) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().PendingNonceAt(state.ctx, common.Address(accountAddress)).Times(1).Return(uint64(10), nil) @@ -169,9 +171,10 @@ func TestSendTransactionWithSignalRejected(t *testing.T) { t.Cleanup(signal.ResetMobileSignalHandler) mockedChainClient := mock_client.NewMockClientInterface(state.mockCtrl) + feeHistory := &fees.FeeHistory{} + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().SuggestGasPrice(state.ctx).Times(1).Return(big.NewInt(1), nil) - mockedChainClient.EXPECT().SuggestGasTipCap(state.ctx).Times(1).Return(big.NewInt(0), errors.New("EIP-1559 is not enabled")) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().PendingNonceAt(state.ctx, common.Address(accountAddress)).Times(1).Return(uint64(10), nil) diff --git a/services/connector/connector_flows_test.go b/services/connector/connector_flows_test.go index 69341b734..2fb908336 100644 --- a/services/connector/connector_flows_test.go +++ b/services/connector/connector_flows_test.go @@ -2,7 +2,6 @@ package connector import ( "encoding/json" - "errors" "fmt" "math/big" "testing" @@ -15,6 +14,7 @@ import ( "github.com/status-im/status-go/services/connector/chainutils" "github.com/status-im/status-go/services/connector/commands" walletCommon "github.com/status-im/status-go/services/wallet/common" + "github.com/status-im/status-go/services/wallet/router/fees" "github.com/status-im/status-go/signal" ) @@ -104,9 +104,10 @@ func TestRequestAccountsSwitchChainAndSendTransactionFlow(t *testing.T) { // Send transaction mockedChainClient := mock_client.NewMockClientInterface(state.mockCtrl) + feeHistory := &fees.FeeHistory{} + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().SuggestGasPrice(state.ctx).Times(1).Return(big.NewInt(1), nil) - mockedChainClient.EXPECT().SuggestGasTipCap(state.ctx).Times(1).Return(big.NewInt(0), errors.New("EIP-1559 is not enabled")) state.rpcClient.EXPECT().EthClient(uint64(1)).Times(1).Return(mockedChainClient, nil) mockedChainClient.EXPECT().PendingNonceAt(state.ctx, common.Address(accountAddress)).Times(1).Return(uint64(10), nil) diff --git a/services/wallet/api.go b/services/wallet/api.go index 672f4fe0a..8fa0189eb 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -438,6 +438,7 @@ func (api *API) FetchTokenDetails(ctx context.Context, symbols []string) (map[st return api.s.marketManager.FetchTokenDetails(symbols) } +// @deprecated we should remove it once clients fully switched to wallet router, `GetSuggestedRoutesAsync` should be used instead func (api *API) GetSuggestedFees(ctx context.Context, chainID uint64) (*fees.SuggestedFeesGwei, error) { logutils.ZapLogger().Debug("call to GetSuggestedFees") return api.s.router.GetFeesManager().SuggestedFeesGwei(ctx, chainID) @@ -448,6 +449,7 @@ func (api *API) GetEstimatedLatestBlockNumber(ctx context.Context, chainID uint6 return api.s.blockChainState.GetEstimatedLatestBlockNumber(ctx, chainID) } +// @deprecated we should remove it once clients fully switched to wallet router, `GetSuggestedRoutesAsync` should be used instead func (api *API) GetTransactionEstimatedTime(ctx context.Context, chainID uint64, maxFeePerGas *big.Float) (fees.TransactionEstimation, error) { logutils.ZapLogger().Debug("call to getTransactionEstimatedTime") return api.s.router.GetFeesManager().TransactionEstimatedTime(ctx, chainID, gweiToWei(maxFeePerGas)), nil diff --git a/services/wallet/router/fees/estimated_time.go b/services/wallet/router/fees/estimated_time.go new file mode 100644 index 000000000..9498ae2fb --- /dev/null +++ b/services/wallet/router/fees/estimated_time.go @@ -0,0 +1,115 @@ +package fees + +import ( + "context" + "math" + "math/big" + "sort" + "strings" +) + +const inclusionThreshold = 0.95 + +type TransactionEstimation int + +const ( + Unknown TransactionEstimation = iota + LessThanOneMinute + LessThanThreeMinutes + LessThanFiveMinutes + MoreThanFiveMinutes +) + +func (f *FeeManager) TransactionEstimatedTime(ctx context.Context, chainID uint64, maxFeePerGas *big.Int) TransactionEstimation { + feeHistory, err := f.getFeeHistory(ctx, chainID, 100, "latest", nil) + if err != nil { + return Unknown + } + + return f.estimatedTime(feeHistory, maxFeePerGas) +} + +func (f *FeeManager) estimatedTime(feeHistory *FeeHistory, maxFeePerGas *big.Int) TransactionEstimation { + fees, err := f.getFeeHistorySorted(feeHistory) + if err != nil || len(fees) == 0 { + return Unknown + } + + // pEvent represents the probability of the transaction being included in a block, + // we assume this one is static over time, in reality it is not. + pEvent := 0.0 + for idx, fee := range fees { + if fee.Cmp(maxFeePerGas) == 1 || idx == len(fees)-1 { + pEvent = float64(idx) / float64(len(fees)) + break + } + } + + // Probability of next 4 blocks including the transaction (less than 1 minute) + // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 4 events and in our context P(A) == P(B) == pEvent + // The factors are calculated using the combinations formula + probability := pEvent*4 - 6*(math.Pow(pEvent, 2)) + 4*(math.Pow(pEvent, 3)) - (math.Pow(pEvent, 4)) + if probability >= inclusionThreshold { + return LessThanOneMinute + } + + // Probability of next 12 blocks including the transaction (less than 5 minutes) + // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 20 events and in our context P(A) == P(B) == pEvent + // The factors are calculated using the combinations formula + probability = pEvent*12 - + 66*(math.Pow(pEvent, 2)) + + 220*(math.Pow(pEvent, 3)) - + 495*(math.Pow(pEvent, 4)) + + 792*(math.Pow(pEvent, 5)) - + 924*(math.Pow(pEvent, 6)) + + 792*(math.Pow(pEvent, 7)) - + 495*(math.Pow(pEvent, 8)) + + 220*(math.Pow(pEvent, 9)) - + 66*(math.Pow(pEvent, 10)) + + 12*(math.Pow(pEvent, 11)) - + math.Pow(pEvent, 12) + if probability >= inclusionThreshold { + return LessThanThreeMinutes + } + + // Probability of next 20 blocks including the transaction (less than 5 minutes) + // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 20 events and in our context P(A) == P(B) == pEvent + // The factors are calculated using the combinations formula + probability = pEvent*20 - + 190*(math.Pow(pEvent, 2)) + + 1140*(math.Pow(pEvent, 3)) - + 4845*(math.Pow(pEvent, 4)) + + 15504*(math.Pow(pEvent, 5)) - + 38760*(math.Pow(pEvent, 6)) + + 77520*(math.Pow(pEvent, 7)) - + 125970*(math.Pow(pEvent, 8)) + + 167960*(math.Pow(pEvent, 9)) - + 184756*(math.Pow(pEvent, 10)) + + 167960*(math.Pow(pEvent, 11)) - + 125970*(math.Pow(pEvent, 12)) + + 77520*(math.Pow(pEvent, 13)) - + 38760*(math.Pow(pEvent, 14)) + + 15504*(math.Pow(pEvent, 15)) - + 4845*(math.Pow(pEvent, 16)) + + 1140*(math.Pow(pEvent, 17)) - + 190*(math.Pow(pEvent, 18)) + + 20*(math.Pow(pEvent, 19)) - + math.Pow(pEvent, 20) + if probability >= inclusionThreshold { + return LessThanFiveMinutes + } + + return MoreThanFiveMinutes +} + +func (f *FeeManager) getFeeHistorySorted(feeHistory *FeeHistory) ([]*big.Int, error) { + fees := []*big.Int{} + for _, fee := range feeHistory.BaseFeePerGas { + i := new(big.Int) + i.SetString(strings.Replace(fee, "0x", "", 1), 16) + fees = append(fees, i) + } + + sort.Slice(fees, func(i, j int) bool { return fees[i].Cmp(fees[j]) < 0 }) + return fees, nil +} diff --git a/services/wallet/router/fees/fees.go b/services/wallet/router/fees/fees.go index a7834b80e..5270eb3f9 100644 --- a/services/wallet/router/fees/fees.go +++ b/services/wallet/router/fees/fees.go @@ -2,16 +2,11 @@ package fees import ( "context" - "math" "math/big" - "sort" - "strings" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/consensus/misc" "github.com/ethereum/go-ethereum/params" - gaspriceoracle "github.com/status-im/status-go/contracts/gas-price-oracle" "github.com/status-im/status-go/errors" "github.com/status-im/status-go/rpc" "github.com/status-im/status-go/rpc/chain" @@ -29,6 +24,8 @@ const ( var ( ErrCustomFeeModeNotAvailableInSuggestedFees = &errors.ErrorResponse{Code: errors.ErrorCode("WRF-001"), Details: "custom fee mode is not available in suggested fees"} + ErrEIP1559IncompaibleChain = &errors.ErrorResponse{Code: errors.ErrorCode("WRF-002"), Details: "EIP-1559 is not supported on this chain"} + ErrInvalidRewardData = &errors.ErrorResponse{Code: errors.ErrorCode("WRF-003"), Details: "invalid reward data"} ) type MaxFeesLevels struct { @@ -37,13 +34,20 @@ type MaxFeesLevels struct { High *hexutil.Big `json:"high"` } +type MaxPriorityFeesSuggestedBounds struct { + Lower *big.Int + Upper *big.Int +} + type SuggestedFees struct { - GasPrice *big.Int `json:"gasPrice"` - BaseFee *big.Int `json:"baseFee"` - MaxFeesLevels *MaxFeesLevels `json:"maxFeesLevels"` - MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas"` - L1GasFee *big.Float `json:"l1GasFee,omitempty"` - EIP1559Enabled bool `json:"eip1559Enabled"` + GasPrice *big.Int + BaseFee *big.Int + CurrentBaseFee *big.Int // Current network base fee (in ETH WEI) + MaxFeesLevels *MaxFeesLevels + MaxPriorityFeePerGas *big.Int + MaxPriorityFeeSuggestedBounds *MaxPriorityFeesSuggestedBounds + L1GasFee *big.Float + EIP1559Enabled bool } // ////////////////////////////////////////////////////////////////////////////// @@ -81,58 +85,29 @@ func (s *SuggestedFees) FeeFor(mode GasFeeMode) (*big.Int, error) { return s.MaxFeesLevels.FeeFor(mode) } -const inclusionThreshold = 0.95 - -type TransactionEstimation int - -const ( - Unknown TransactionEstimation = iota - LessThanOneMinute - LessThanThreeMinutes - LessThanFiveMinutes - MoreThanFiveMinutes -) - -type FeeHistory struct { - BaseFeePerGas []string `json:"baseFeePerGas"` -} - type FeeManager struct { RPCClient rpc.ClientInterface } func (f *FeeManager) SuggestedFees(ctx context.Context, chainID uint64) (*SuggestedFees, error) { - backend, err := f.RPCClient.EthClient(chainID) + feeHistory, err := f.getFeeHistory(ctx, chainID, 300, "latest", []int{25, 50, 75}) if err != nil { - return nil, err + return f.getNonEIP1559SuggestedFees(ctx, chainID) } - gasPrice, err := backend.SuggestGasPrice(ctx) + + maxPriorityFeePerGasLowerBound, maxPriorityFeePerGas, maxPriorityFeePerGasUpperBound, baseFee, err := getEIP1559SuggestedFees(feeHistory) if err != nil { - return nil, err - } - maxPriorityFeePerGas, err := backend.SuggestGasTipCap(ctx) - if err != nil { - return &SuggestedFees{ - GasPrice: gasPrice, - BaseFee: big.NewInt(0), - MaxPriorityFeePerGas: big.NewInt(0), - MaxFeesLevels: &MaxFeesLevels{ - Low: (*hexutil.Big)(gasPrice), - Medium: (*hexutil.Big)(gasPrice), - High: (*hexutil.Big)(gasPrice), - }, - EIP1559Enabled: false, - }, nil - } - baseFee, err := f.getBaseFee(ctx, backend) - if err != nil { - return nil, err + return f.getNonEIP1559SuggestedFees(ctx, chainID) } return &SuggestedFees{ - GasPrice: gasPrice, BaseFee: baseFee, + CurrentBaseFee: baseFee, MaxPriorityFeePerGas: maxPriorityFeePerGas, + MaxPriorityFeeSuggestedBounds: &MaxPriorityFeesSuggestedBounds{ + Lower: maxPriorityFeePerGasLowerBound, + Upper: maxPriorityFeePerGasUpperBound, + }, MaxFeesLevels: &MaxFeesLevels{ Low: (*hexutil.Big)(new(big.Int).Add(baseFee, maxPriorityFeePerGas)), Medium: (*hexutil.Big)(new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), maxPriorityFeePerGas)), @@ -142,6 +117,9 @@ func (f *FeeManager) SuggestedFees(ctx context.Context, chainID uint64) (*Sugges }, nil } +// ////////////////////////////////////////////////////////////////////////////// +// TODO: remove `SuggestedFeesGwei` once mobile app fully switched to router, this function should not be exposed via api +// ////////////////////////////////////////////////////////////////////////////// func (f *FeeManager) SuggestedFeesGwei(ctx context.Context, chainID uint64) (*SuggestedFeesGwei, error) { fees, err := f.SuggestedFees(ctx, chainID) if err != nil { @@ -175,126 +153,3 @@ func (f *FeeManager) getBaseFee(ctx context.Context, client chain.ClientInterfac baseFee := misc.CalcBaseFee(config, header) return baseFee, nil } - -func (f *FeeManager) TransactionEstimatedTime(ctx context.Context, chainID uint64, maxFeePerGas *big.Int) TransactionEstimation { - fees, err := f.getFeeHistorySorted(chainID) - if err != nil { - return Unknown - } - - // pEvent represents the probability of the transaction being included in a block, - // we assume this one is static over time, in reality it is not. - pEvent := 0.0 - for idx, fee := range fees { - if fee.Cmp(maxFeePerGas) == 1 || idx == len(fees)-1 { - pEvent = float64(idx) / float64(len(fees)) - break - } - } - - // Probability of next 4 blocks including the transaction (less than 1 minute) - // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 4 events and in our context P(A) == P(B) == pEvent - // The factors are calculated using the combinations formula - probability := pEvent*4 - 6*(math.Pow(pEvent, 2)) + 4*(math.Pow(pEvent, 3)) - (math.Pow(pEvent, 4)) - if probability >= inclusionThreshold { - return LessThanOneMinute - } - - // Probability of next 12 blocks including the transaction (less than 5 minutes) - // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 20 events and in our context P(A) == P(B) == pEvent - // The factors are calculated using the combinations formula - probability = pEvent*12 - - 66*(math.Pow(pEvent, 2)) + - 220*(math.Pow(pEvent, 3)) - - 495*(math.Pow(pEvent, 4)) + - 792*(math.Pow(pEvent, 5)) - - 924*(math.Pow(pEvent, 6)) + - 792*(math.Pow(pEvent, 7)) - - 495*(math.Pow(pEvent, 8)) + - 220*(math.Pow(pEvent, 9)) - - 66*(math.Pow(pEvent, 10)) + - 12*(math.Pow(pEvent, 11)) - - math.Pow(pEvent, 12) - if probability >= inclusionThreshold { - return LessThanThreeMinutes - } - - // Probability of next 20 blocks including the transaction (less than 5 minutes) - // Generalising the formula: P(AUB) = P(A) + P(B) - P(A∩B) for 20 events and in our context P(A) == P(B) == pEvent - // The factors are calculated using the combinations formula - probability = pEvent*20 - - 190*(math.Pow(pEvent, 2)) + - 1140*(math.Pow(pEvent, 3)) - - 4845*(math.Pow(pEvent, 4)) + - 15504*(math.Pow(pEvent, 5)) - - 38760*(math.Pow(pEvent, 6)) + - 77520*(math.Pow(pEvent, 7)) - - 125970*(math.Pow(pEvent, 8)) + - 167960*(math.Pow(pEvent, 9)) - - 184756*(math.Pow(pEvent, 10)) + - 167960*(math.Pow(pEvent, 11)) - - 125970*(math.Pow(pEvent, 12)) + - 77520*(math.Pow(pEvent, 13)) - - 38760*(math.Pow(pEvent, 14)) + - 15504*(math.Pow(pEvent, 15)) - - 4845*(math.Pow(pEvent, 16)) + - 1140*(math.Pow(pEvent, 17)) - - 190*(math.Pow(pEvent, 18)) + - 20*(math.Pow(pEvent, 19)) - - math.Pow(pEvent, 20) - if probability >= inclusionThreshold { - return LessThanFiveMinutes - } - - return MoreThanFiveMinutes -} - -func (f *FeeManager) getFeeHistorySorted(chainID uint64) ([]*big.Int, error) { - var feeHistory FeeHistory - - err := f.RPCClient.Call(&feeHistory, chainID, "eth_feeHistory", 101, "latest", nil) - if err != nil { - return nil, err - } - - fees := []*big.Int{} - for _, fee := range feeHistory.BaseFeePerGas { - i := new(big.Int) - i.SetString(strings.Replace(fee, "0x", "", 1), 16) - fees = append(fees, i) - } - - sort.Slice(fees, func(i, j int) bool { return fees[i].Cmp(fees[j]) < 0 }) - return fees, nil -} - -// Returns L1 fee for placing a transaction to L1 chain, appicable only for txs made from L2. -func (f *FeeManager) GetL1Fee(ctx context.Context, chainID uint64, input []byte) (uint64, error) { - if chainID == common.EthereumMainnet || chainID == common.EthereumSepolia { - return 0, nil - } - - ethClient, err := f.RPCClient.EthClient(chainID) - if err != nil { - return 0, err - } - - contractAddress, err := gaspriceoracle.ContractAddress(chainID) - if err != nil { - return 0, err - } - - contract, err := gaspriceoracle.NewGaspriceoracleCaller(contractAddress, ethClient) - if err != nil { - return 0, err - } - - callOpt := &bind.CallOpts{} - - result, err := contract.GetL1Fee(callOpt, input) - if err != nil { - return 0, err - } - - return result.Uint64(), nil -} diff --git a/services/wallet/router/fees/fees_history.go b/services/wallet/router/fees/fees_history.go new file mode 100644 index 000000000..d9965d93f --- /dev/null +++ b/services/wallet/router/fees/fees_history.go @@ -0,0 +1,71 @@ +package fees + +import ( + "context" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gaspriceoracle "github.com/status-im/status-go/contracts/gas-price-oracle" + "github.com/status-im/status-go/services/wallet/common" +) + +type FeeHistory struct { + BaseFeePerGas []string `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` + OldestBlock string `json:"oldestBlock"` + Reward [][]string `json:"reward,omitempty"` +} + +func (fh *FeeHistory) isEIP1559Compatible() bool { + if len(fh.BaseFeePerGas) == 0 { + return false + } + + for _, fee := range fh.BaseFeePerGas { + if fee != "0x0" { + return true + } + } + + return false +} + +func (f *FeeManager) getFeeHistory(ctx context.Context, chainID uint64, blockCount uint64, newestBlock string, rewardPercentiles []int) (feeHistory *FeeHistory, err error) { + feeHistory = &FeeHistory{} + err = f.RPCClient.Call(feeHistory, chainID, "eth_feeHistory", blockCount, newestBlock, rewardPercentiles) + if err != nil { + return nil, err + } + + return feeHistory, nil +} + +// GetL1Fee returns L1 fee for placing a transaction to L1 chain, appicable only for txs made from L2. +func (f *FeeManager) GetL1Fee(ctx context.Context, chainID uint64, input []byte) (uint64, error) { + if chainID == common.EthereumMainnet || chainID == common.EthereumSepolia { + return 0, nil + } + + ethClient, err := f.RPCClient.EthClient(chainID) + if err != nil { + return 0, err + } + + contractAddress, err := gaspriceoracle.ContractAddress(chainID) + if err != nil { + return 0, err + } + + contract, err := gaspriceoracle.NewGaspriceoracleCaller(contractAddress, ethClient) + if err != nil { + return 0, err + } + + callOpt := &bind.CallOpts{} + + result, err := contract.GetL1Fee(callOpt, input) + if err != nil { + return 0, err + } + + return result.Uint64(), nil +} diff --git a/services/wallet/router/fees/fees_test.go b/services/wallet/router/fees/fees_test.go new file mode 100644 index 000000000..af99a2344 --- /dev/null +++ b/services/wallet/router/fees/fees_test.go @@ -0,0 +1,169 @@ +package fees + +import ( + "context" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + + "go.uber.org/mock/gomock" + + mock_client "github.com/status-im/status-go/rpc/chain/mock/client" + mock_rpcclient "github.com/status-im/status-go/rpc/mock/client" +) + +type testState struct { + ctx context.Context + mockCtrl *gomock.Controller + rpcClient *mock_rpcclient.MockClientInterface + feeManager *FeeManager +} + +func setupTest(t *testing.T) (state testState) { + state.ctx = context.Background() + state.mockCtrl = gomock.NewController(t) + state.rpcClient = mock_rpcclient.NewMockClientInterface(state.mockCtrl) + state.feeManager = &FeeManager{ + RPCClient: state.rpcClient, + } + return state +} + +func TestEstimatedTime(t *testing.T) { + state := setupTest(t) + // no fee history + feeHistory := &FeeHistory{} + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(100), "latest", nil).Times(1).Return(nil) + + maxFeesPerGas := big.NewInt(2e9) + estimation := state.feeManager.TransactionEstimatedTime(context.Background(), uint64(1), maxFeesPerGas) + + assert.Equal(t, Unknown, estimation) + + // there is fee history + state.rpcClient.EXPECT().Call(feeHistory, uint64(1), "eth_feeHistory", uint64(100), "latest", nil).Times(1).Return(nil). + Do(func(feeHistory, chainID, method any, args ...any) { + feeHistoryResponse := &FeeHistory{ + BaseFeePerGas: []string{ + "0x12f0e070b", + "0x13f10da8b", + "0x126c30d5e", + "0x136e4fe51", + "0x134180d5a", + "0x134e32c33", + "0x137da8d22", + }, + } + *feeHistory.(*FeeHistory) = *feeHistoryResponse + }) + + maxFeesPerGas = big.NewInt(100e9) + estimation = state.feeManager.TransactionEstimatedTime(context.Background(), uint64(1), maxFeesPerGas) + + assert.Equal(t, LessThanOneMinute, estimation) +} + +func TestSuggestedFeesForNotEIP1559CompatibleChains(t *testing.T) { + state := setupTest(t) + + chainID := uint64(1) + gasPrice := big.NewInt(1) + feeHistory := &FeeHistory{} + + state.rpcClient.EXPECT().Call(feeHistory, chainID, "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil) + mockedChainClient := mock_client.NewMockClientInterface(state.mockCtrl) + state.rpcClient.EXPECT().EthClient(chainID).Times(1).Return(mockedChainClient, nil) + mockedChainClient.EXPECT().SuggestGasPrice(state.ctx).Times(1).Return(gasPrice, nil) + + suggestedFees, err := state.feeManager.SuggestedFees(context.Background(), chainID) + assert.NoError(t, err) + assert.NotNil(t, suggestedFees) + assert.Equal(t, gasPrice, suggestedFees.GasPrice) + assert.False(t, suggestedFees.EIP1559Enabled) +} + +func TestSuggestedFeesForEIP1559CompatibleChains(t *testing.T) { + state := setupTest(t) + + chainID := uint64(1) + feeHistory := &FeeHistory{} + + state.rpcClient.EXPECT().Call(feeHistory, chainID, "eth_feeHistory", uint64(300), "latest", []int{25, 50, 75}).Times(1).Return(nil). + Do(func(feeHistory, chainID, method any, args ...any) { + feeHistoryResponse := &FeeHistory{ + BaseFeePerGas: []string{ + "0x12f0e070b", + "0x13f10da8b", + "0x126c30d5e", + "0x136e4fe51", + "0x134180d5a", + "0x134e32c33", + "0x137da8d22", + }, + GasUsedRatio: []float64{ + 0.7113286209349903, + 0.19531163333333335, + 0.7189235666666667, + 0.4639678021079083, + 0.5103012666666666, + 0.538413, + 0.16543626666666666, + }, + OldestBlock: "0x1497d4b", + Reward: [][]string{ + { + "0x2faf080", + "0x39d10680", + "0x722d7ef5", + }, + { + "0x5f5e100", + "0x3b9aca00", + "0x59682f00", + }, + { + "0x342e4a2", + "0x39d10680", + "0x77359400", + }, + { + "0x14a22237", + "0x40170350", + "0x77359400", + }, + { + "0x9134860", + "0x39d10680", + "0x618400ad", + }, + { + "0x2faf080", + "0x39d10680", + "0x77359400", + }, + { + "0x1ed69035", + "0x39d10680", + "0x41d0a8d6", + }, + }, + } + *feeHistory.(*FeeHistory) = *feeHistoryResponse + }) + + suggestedFees, err := state.feeManager.SuggestedFees(context.Background(), chainID) + assert.NoError(t, err) + assert.NotNil(t, suggestedFees) + + assert.Nil(t, suggestedFees.GasPrice) + assert.Equal(t, big.NewInt(6958609414), suggestedFees.BaseFee) + assert.Equal(t, big.NewInt(6958609414), suggestedFees.CurrentBaseFee) + assert.Equal(t, big.NewInt(7928609414), suggestedFees.MaxFeesLevels.Low.ToInt()) + assert.Equal(t, big.NewInt(14887218828), suggestedFees.MaxFeesLevels.Medium.ToInt()) + assert.Equal(t, big.NewInt(21845828242), suggestedFees.MaxFeesLevels.High.ToInt()) + assert.Equal(t, big.NewInt(970000000), suggestedFees.MaxPriorityFeePerGas) + assert.Equal(t, big.NewInt(54715554), suggestedFees.MaxPriorityFeeSuggestedBounds.Lower) + assert.Equal(t, big.NewInt(1636040877), suggestedFees.MaxPriorityFeeSuggestedBounds.Upper) + assert.True(t, suggestedFees.EIP1559Enabled) +} diff --git a/services/wallet/router/fees/suggested_priority.go b/services/wallet/router/fees/suggested_priority.go new file mode 100644 index 000000000..aeb4d0f82 --- /dev/null +++ b/services/wallet/router/fees/suggested_priority.go @@ -0,0 +1,138 @@ +package fees + +import ( + "context" + "errors" + "math/big" + "sort" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" +) + +const baseFeeIncreaseFactor = 1.33 + +func hexStringToBigInt(value string) (*big.Int, error) { + valueWitoutPrefix := strings.TrimPrefix(value, "0x") + val, success := new(big.Int).SetString(valueWitoutPrefix, 16) + if !success { + return nil, errors.New("failed to convert hex string to big.Int") + } + return val, nil +} + +func scaleBaseFeePerGas(value string) (*big.Int, error) { + val, err := hexStringToBigInt(value) + if err != nil { + return nil, err + } + valueDouble := new(big.Float).SetInt(val) + valueDouble.Mul(valueDouble, big.NewFloat(baseFeeIncreaseFactor)) + scaledValue := new(big.Int) + valueDouble.Int(scaledValue) + return scaledValue, nil +} + +func (f *FeeManager) getNonEIP1559SuggestedFees(ctx context.Context, chainID uint64) (*SuggestedFees, error) { + backend, err := f.RPCClient.EthClient(chainID) + if err != nil { + return nil, err + } + gasPrice, err := backend.SuggestGasPrice(ctx) + if err != nil { + return nil, err + } + return &SuggestedFees{ + GasPrice: gasPrice, + BaseFee: big.NewInt(0), + MaxPriorityFeePerGas: big.NewInt(0), + MaxPriorityFeeSuggestedBounds: &MaxPriorityFeesSuggestedBounds{ + Lower: big.NewInt(0), + Upper: big.NewInt(0), + }, + MaxFeesLevels: &MaxFeesLevels{ + Low: (*hexutil.Big)(gasPrice), + Medium: (*hexutil.Big)(gasPrice), + High: (*hexutil.Big)(gasPrice), + }, + EIP1559Enabled: false, + }, nil +} + +// getEIP1559SuggestedFees returns suggested fees for EIP-1559 compatible chains +// source https://github.com/brave/brave-core/blob/master/components/brave_wallet/browser/eth_gas_utils.cc +func getEIP1559SuggestedFees(feeHistory *FeeHistory) (lowPriorityFee, avgPriorityFee, highPriorityFee, suggestedBaseFee *big.Int, err error) { + if feeHistory == nil || !feeHistory.isEIP1559Compatible() { + return nil, nil, nil, nil, ErrEIP1559IncompaibleChain + } + + pendingBaseFee := feeHistory.BaseFeePerGas[len(feeHistory.BaseFeePerGas)-1] + suggestedBaseFee, err = scaleBaseFeePerGas(pendingBaseFee) + if err != nil { + return nil, nil, nil, nil, err + } + + fallbackPriorityFee := big.NewInt(2e9) // 2 Gwei + lowPriorityFee = new(big.Int).Set(fallbackPriorityFee) + avgPriorityFee = new(big.Int).Set(fallbackPriorityFee) + highPriorityFee = new(big.Int).Set(fallbackPriorityFee) + + if len(feeHistory.Reward) == 0 { + return lowPriorityFee, avgPriorityFee, highPriorityFee, suggestedBaseFee, nil + } + + priorityFees := make([][]*big.Int, 3) + for i := 0; i < 3; i++ { + currentPriorityFees := []*big.Int{} + invalidData := false + + for _, r := range feeHistory.Reward { + if len(r) != 3 { + invalidData = true + break + } + + fee, err := hexStringToBigInt(r[i]) + if err != nil { + invalidData = true + break + } + currentPriorityFees = append(currentPriorityFees, fee) + } + + if invalidData { + return nil, nil, nil, nil, ErrInvalidRewardData + } + + sort.Slice(currentPriorityFees, func(a, b int) bool { + return currentPriorityFees[a].Cmp(currentPriorityFees[b]) < 0 + }) + + percentileIndex := int(float64(len(currentPriorityFees)) * 0.4) + if i == 0 { + lowPriorityFee = currentPriorityFees[percentileIndex] + } else if i == 1 { + avgPriorityFee = currentPriorityFees[percentileIndex] + } else { + highPriorityFee = currentPriorityFees[percentileIndex] + } + + priorityFees[i] = currentPriorityFees + } + + // Adjust low priority fee if it's equal to avg + lowIndex := int(float64(len(priorityFees[0])) * 0.4) + for lowIndex > 0 && lowPriorityFee == avgPriorityFee { + lowIndex-- + lowPriorityFee = priorityFees[0][lowIndex] + } + + // Adjust high priority fee if it's equal to avg + highIndex := int(float64(len(priorityFees[2])) * 0.4) + for highIndex < len(priorityFees[2])-1 && highPriorityFee == avgPriorityFee { + highIndex++ + highPriorityFee = priorityFees[2][highIndex] + } + + return lowPriorityFee, avgPriorityFee, highPriorityFee, suggestedBaseFee, nil +} diff --git a/services/wallet/router/router_helper.go b/services/wallet/router/router_helper.go index 0194a5082..bc168922c 100644 --- a/services/wallet/router/router_helper.go +++ b/services/wallet/router/router_helper.go @@ -186,6 +186,19 @@ func (r *Router) applyCustomFields(ctx context.Context, path *routes.Path, fetch r.lastInputParamsMutex.Lock() defer r.lastInputParamsMutex.Unlock() + // set network fields + if fetchedFees.CurrentBaseFee != nil { + path.CurrentBaseFee = (*hexutil.Big)(fetchedFees.CurrentBaseFee) + } + if fetchedFees.MaxPriorityFeeSuggestedBounds != nil { + if fetchedFees.MaxPriorityFeeSuggestedBounds.Lower != nil { + path.SuggestedMinPriorityFee = (*hexutil.Big)(fetchedFees.MaxPriorityFeeSuggestedBounds.Lower) + } + if fetchedFees.MaxPriorityFeeSuggestedBounds.Upper != nil { + path.SuggestedMaxPriorityFee = (*hexutil.Big)(fetchedFees.MaxPriorityFeeSuggestedBounds.Upper) + } + } + // set appropriate nonce/s, and update later in this function if custom nonce/s are provided err := r.resolveNonceForPath(ctx, path, r.lastInputParams.AddrFrom, usedNonces) if err != nil { @@ -341,7 +354,11 @@ func (r *Router) evaluateAndUpdatePathDetails(ctx context.Context, path *routes. path.TxEstimatedTime = r.feesManager.TransactionEstimatedTime(ctx, path.FromChain.ChainID, path.TxMaxFeesPerGas.ToInt()) if path.ApprovalRequired { - path.ApprovalEstimatedTime = r.feesManager.TransactionEstimatedTime(ctx, path.FromChain.ChainID, path.ApprovalMaxFeesPerGas.ToInt()) + if path.TxMaxFeesPerGas.ToInt().Cmp(path.ApprovalMaxFeesPerGas.ToInt()) == 0 { + path.ApprovalEstimatedTime = path.TxEstimatedTime + } else { + path.ApprovalEstimatedTime = r.feesManager.TransactionEstimatedTime(ctx, path.FromChain.ChainID, path.ApprovalMaxFeesPerGas.ToInt()) + } } return diff --git a/services/wallet/router/routes/router_path.go b/services/wallet/router/routes/router_path.go index 7a2636e77..2ac001ee2 100644 --- a/services/wallet/router/routes/router_path.go +++ b/services/wallet/router/routes/router_path.go @@ -22,7 +22,10 @@ type Path struct { AmountInLocked bool // Is the amount locked AmountOut *hexutil.Big // Amount that will be received on the destination chain - SuggestedLevelsForMaxFeesPerGas *fees.MaxFeesLevels // Suggested max fees for the transaction (in ETH WEI) + SuggestedLevelsForMaxFeesPerGas *fees.MaxFeesLevels // Suggested max fees by the network (in ETH WEI) + SuggestedMinPriorityFee *hexutil.Big // Suggested min priority fee by the network (in ETH WEI) + SuggestedMaxPriorityFee *hexutil.Big // Suggested max priority fee by the network (in ETH WEI) + CurrentBaseFee *hexutil.Big // Current network base fee (in ETH WEI) TxNonce *hexutil.Uint64 // Nonce for the transaction TxMaxFeesPerGas *hexutil.Big // Max fees per gas (determined by client via GasFeeMode, in ETH WEI) @@ -117,6 +120,18 @@ func (p *Path) Copy() *Path { } } + if p.SuggestedMinPriorityFee != nil { + newPath.SuggestedMinPriorityFee = (*hexutil.Big)(big.NewInt(0).Set(p.SuggestedMinPriorityFee.ToInt())) + } + + if p.SuggestedMaxPriorityFee != nil { + newPath.SuggestedMaxPriorityFee = (*hexutil.Big)(big.NewInt(0).Set(p.SuggestedMaxPriorityFee.ToInt())) + } + + if p.CurrentBaseFee != nil { + newPath.CurrentBaseFee = (*hexutil.Big)(big.NewInt(0).Set(p.CurrentBaseFee.ToInt())) + } + if p.TxNonce != nil { txNonce := *p.TxNonce newPath.TxNonce = &txNonce