From 92d361ce127b119f5f5ec2ae7020e218abd02bd6 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 19 Nov 2024 23:30:34 -0800 Subject: [PATCH] feat: use CrowdNode API for fees (#1324) * feat: use CrowdNode API for fees * tests: update MainViewModelTest --- .../crowdnode/api/CrowdNodeApi.kt | 23 ++++++ .../crowdnode/api/CrowdNodeWebApi.kt | 25 ++++++ .../crowdnode/model/CrowdNodeFeeInfo.kt | 49 ++++++++++++ .../crowdnode/ui/CrowdNodeViewModel.kt | 11 ++- .../crowdnode/utils/CrowdNodeConfig.kt | 3 + .../wallet/ui/main/MainViewModel.kt | 11 ++- .../util/viewModels/MainViewModelTest.kt | 76 ++++++++++++++++--- 7 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/model/CrowdNodeFeeInfo.kt diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt index d2990d0475..7de7decda8 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeApi.kt @@ -54,6 +54,7 @@ import retrofit2.HttpException import java.io.IOException import java.net.UnknownHostException import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.pow import kotlin.time.Duration @@ -77,6 +78,7 @@ interface CrowdNodeApi { suspend fun deposit(amount: Coin, emptyWallet: Boolean, checkBalanceConditions: Boolean): Boolean suspend fun withdraw(amount: Coin): Boolean suspend fun getWithdrawalLimit(period: WithdrawalLimitPeriod): Coin + suspend fun getFee(): Double fun hasAnyDeposits(): Boolean fun refreshBalance(retries: Int = 0, afterWithdrawal: Boolean = false) fun trackLinkingAccount(address: Address) @@ -342,6 +344,11 @@ class CrowdNodeApiAggregator @Inject constructor( ) } + override suspend fun getFee(): Double { + refreshFees() + return config.get(CrowdNodeConfig.FEE_PERCENTAGE) ?: FeeInfo.DEFAULT_FEE + } + override fun hasAnyDeposits(): Boolean { val accountAddress = this.accountAddress requireNotNull(accountAddress) { "Account address is null, make sure to sign up" } @@ -854,6 +861,22 @@ class CrowdNodeApiAggregator @Inject constructor( } } + /** + * obtains the current CrowdNode fee on masternode rewards once per 24 hours + */ + private suspend fun refreshFees() { + val lastFeeRequest = config.get(CrowdNodeConfig.LAST_FEE_REQUEST) + if (lastFeeRequest == null || (lastFeeRequest + TimeUnit.DAYS.toMillis(1)) < System.currentTimeMillis()) { + val feeInfo = webApi.getFees(accountAddress) + log.info("crowdnode feeInfo: {}", feeInfo) + val fee = feeInfo.getNormal()?.fee + fee?.let { + config.set(CrowdNodeConfig.FEE_PERCENTAGE, fee) + config.set(CrowdNodeConfig.LAST_FEE_REQUEST, System.currentTimeMillis()) + } + } + } + private fun notifyIfNeeded(message: String, tag: String) { if (showNotificationOnResult) { notificationService.showNotification( diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeWebApi.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeWebApi.kt index 4f39052c80..99b5884760 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeWebApi.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/api/CrowdNodeWebApi.kt @@ -66,6 +66,11 @@ interface CrowdNodeEndpoint { @Path("address") address: String ): Response> + @GET("odata/apifundings/GetFeeJson(address='{address}')") + suspend fun getFees( + @Path("address") address: String + ): Response + @GET("odata/apiaddresses/IsApiAddressInUse(address='{address}')") suspend fun isAddressInUse( @Path("address") address: String @@ -269,6 +274,26 @@ open class CrowdNodeWebApi @Inject constructor( } } + open suspend fun getFees(address: Address?): FeeInfo { + return try { + val response = endpoint.getFees(address?.toBase58() ?: "") + + return if (response.isSuccessful) { + response.body()!! + } else { + FeeInfo.default + } + } catch (ex: Exception) { + log.error("Error in getFees: $ex") + + if (ex !is IOException) { + analyticsService.logError(ex) + } + + FeeInfo.default + } + } + open suspend fun isApiAddressInUse(address: Address): Pair { return try { val result = endpoint.isAddressInUse(address.toBase58()) diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/model/CrowdNodeFeeInfo.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/model/CrowdNodeFeeInfo.kt new file mode 100644 index 0000000000..a10356e57e --- /dev/null +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/model/CrowdNodeFeeInfo.kt @@ -0,0 +1,49 @@ +package org.dash.wallet.integrations.crowdnode.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +data class FeeLadder( + val name: String, + val type: String, + val amount: Double, + val fee: Double +) + +/* + [ + { + "Key":"FeeLadder", + "Value":"[ + { + \"name\":\"Up to 10 Dash and above\", + \"type\":\"Normal\", + \"amount\":10.0,\"fee\":35.0 + }, + { + \"name\":\"Trustless up to 100 Dash and above\", + \"type\":\"Trustless\", + \"amount\":100.0, + \"fee\":20.0 + } + ]" + } + ] + */ +@Parcelize +data class FeeInfo( + @SerializedName("FeeLadder") val feeLadder: @RawValue List +) : Parcelable { + companion object { + const val DEFAULT_FEE = 35.0 + const val DEFAULT_AMOUNT = 100.0 + const val KEY_FEELADDER = "FeeLadder" + const val TYPE_NORMAL = "Normal" + const val TYPE_TRUSTLESS = "Trustless" + val default = FeeInfo(listOf(FeeLadder("", TYPE_NORMAL, DEFAULT_AMOUNT, DEFAULT_FEE))) + } + + fun getNormal() = feeLadder.find { it.type == FeeInfo.TYPE_NORMAL } +} diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt index d15dc3c9e6..98edda9bef 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt @@ -43,6 +43,7 @@ import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService import org.dash.wallet.common.ui.BalanceUIState import org.dash.wallet.integrations.crowdnode.api.CrowdNodeApi +import org.dash.wallet.integrations.crowdnode.model.FeeInfo import org.dash.wallet.integrations.crowdnode.model.MessageStatusException import org.dash.wallet.integrations.crowdnode.model.OnlineAccountStatus import org.dash.wallet.integrations.crowdnode.model.SignUpStatus @@ -118,6 +119,7 @@ class CrowdNodeViewModel @Inject constructor( val crowdNodeBalance: LiveData get() = _crowdNodeBalance + private var crowdNodeFee: Double = FeeInfo.DEFAULT_FEE val dashFormat: MonetaryFormat get() = globalConfig.format.noCode() @@ -164,6 +166,12 @@ class CrowdNodeViewModel @Inject constructor( } } .launchIn(viewModelScope) + + config.observe(CrowdNodeConfig.FEE_PERCENTAGE) + .onEach { + crowdNodeFee = it ?: FeeInfo.DEFAULT_FEE + } + .launchIn(viewModelScope) } fun backupPassphrase() { @@ -413,6 +421,7 @@ class CrowdNodeViewModel @Inject constructor( } fun getCrowdNodeAPY(): Double { - return 0.85 * getMasternodeAPY() + val withoutFees = (100.0 - crowdNodeFee) / 100 + return withoutFees * getMasternodeAPY() } } diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/utils/CrowdNodeConfig.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/utils/CrowdNodeConfig.kt index 8cb8cabc5e..3fc26f809e 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/utils/CrowdNodeConfig.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/utils/CrowdNodeConfig.kt @@ -19,6 +19,7 @@ package org.dash.wallet.integrations.crowdnode.utils import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -47,5 +48,7 @@ open class CrowdNodeConfig @Inject constructor( val WITHDRAWAL_LIMIT_PER_HOUR = longPreferencesKey("withdrawal_limit_per_hour") val WITHDRAWAL_LIMIT_PER_DAY = longPreferencesKey("withdrawal_limit_per_day") val LAST_WITHDRAWAL_BLOCK = intPreferencesKey("last_withdrawal_block") + val FEE_PERCENTAGE = doublePreferencesKey("fee_double") + val LAST_FEE_REQUEST = longPreferencesKey("last_fee_request") } } diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index ef2bf79609..b3a4973e69 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -101,6 +101,7 @@ import org.dash.wallet.common.transactions.TransactionUtils.isEntirelySelf import org.dash.wallet.common.transactions.TransactionWrapper import org.dash.wallet.common.transactions.TransactionWrapperComparator import org.dash.wallet.common.util.toBigDecimal +import org.dash.wallet.integrations.crowdnode.api.CrowdNodeApi import org.dash.wallet.integrations.crowdnode.transactions.FullCrowdNodeSignUpTxSetFactory import org.slf4j.LoggerFactory import kotlin.math.abs @@ -134,7 +135,8 @@ class MainViewModel @Inject constructor( dashPayConfig: DashPayConfig, dashPayContactRequestDao: DashPayContactRequestDao, private val coinJoinConfig: CoinJoinConfig, - private val coinJoinService: CoinJoinService + private val coinJoinService: CoinJoinService, + private val crowdNodeApi: CrowdNodeApi ) : BaseContactsViewModel(blockchainIdentityDataDao, dashPayProfileDao, dashPayContactRequestDao) { companion object { private const val THROTTLE_DURATION = 500L @@ -406,7 +408,9 @@ class MainViewModel @Inject constructor( fun getLastStakingAPY() { viewModelScope.launch(Dispatchers.IO) { - _stakingAPY.postValue(0.85 * blockchainStateProvider.getLastMasternodeAPY()) + val withoutFees = (100.0 - crowdNodeApi.getFee()) / 100 + log.info("fees: without $withoutFees") + _stakingAPY.postValue(withoutFees * blockchainStateProvider.getLastMasternodeAPY()) } } @@ -538,7 +542,8 @@ class MainViewModel @Inject constructor( if (state.isSynced()) { viewModelScope.launch(Dispatchers.IO) { - _stakingAPY.postValue(0.85 * blockchainStateProvider.getMasternodeAPY()) + val withoutFees = (100.0 - crowdNodeApi.getFee()) / 100 + _stakingAPY.postValue(withoutFees * blockchainStateProvider.getMasternodeAPY()) } } diff --git a/wallet/test/de/schildbach/wallet/util/viewModels/MainViewModelTest.kt b/wallet/test/de/schildbach/wallet/util/viewModels/MainViewModelTest.kt index ae9a8d3b4e..2fdcb59e70 100644 --- a/wallet/test/de/schildbach/wallet/util/viewModels/MainViewModelTest.kt +++ b/wallet/test/de/schildbach/wallet/util/viewModels/MainViewModelTest.kt @@ -18,6 +18,7 @@ package de.schildbach.wallet.util.viewModels import android.os.Looper +import android.telephony.TelephonyManager import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.work.WorkManager @@ -32,10 +33,16 @@ import de.schildbach.wallet.database.dao.InvitationsDao import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig import de.schildbach.wallet.transactions.TxFilterType import androidx.datastore.preferences.core.Preferences +import de.schildbach.wallet.data.CoinJoinConfig +import de.schildbach.wallet.database.dao.DashPayContactRequestDao import de.schildbach.wallet.database.dao.UserAlertDao import de.schildbach.wallet.database.entity.BlockchainIdentityBaseData import de.schildbach.wallet.database.entity.BlockchainIdentityConfig import de.schildbach.wallet.database.entity.BlockchainIdentityData +import de.schildbach.wallet.security.BiometricHelper +import de.schildbach.wallet.service.CoinJoinService +import de.schildbach.wallet.service.platform.PlatformService +import de.schildbach.wallet.service.platform.PlatformSyncService import de.schildbach.wallet.ui.main.MainViewModel import io.mockk.* import junit.framework.TestCase.assertEquals @@ -60,6 +67,7 @@ import org.dash.wallet.common.services.ExchangeRatesProvider import org.dash.wallet.common.services.RateRetrievalState import org.dash.wallet.common.services.TransactionMetadataProvider import org.dash.wallet.common.services.analytics.AnalyticsService +import org.dash.wallet.integrations.crowdnode.api.CrowdNodeApi import org.junit.Before import org.junit.Ignore import org.junit.Rule @@ -88,6 +96,8 @@ class MainViewModelTest { every { applicationContext } returns mockk() every { mainLooper } returns Looper.getMainLooper() } + private val platformService = mockk() + private val platformSyncService = mockk() private val mockIdentityData = BlockchainIdentityBaseData(-1, BlockchainIdentityData.CreationState.NONE, null, null, null, false,null, false) private val blockchainIdentityConfigMock = mockk { coEvery { loadBase() } returns mockIdentityData @@ -103,7 +113,7 @@ class MainViewModelTest { every { dashPayProfileDao() } returns dashPayProfileDaoMock every { dashPayContactRequestDao() } returns mockk() every { invitationsDao() } returns invitationsDaoMock - every { userAlertDao() } returns mockk() + every { userAlertDao() } returns userAgentDaoMock every { transactionMetadataDocumentDao() } returns mockk() every { transactionMetadataCacheDao() } returns mockk() } @@ -127,7 +137,7 @@ class MainViewModelTest { private val transactionMetadataMock = mockk { every { observePresentableMetadata() } returns MutableStateFlow(mapOf()) } - + private val dashPayContactRequestDao = mockk() private val mockDashPayConfig = mockk { every { observe(any()) } returns MutableStateFlow(0L) coEvery { areNotificationsDisabled() } returns false @@ -144,6 +154,12 @@ class MainViewModelTest { private val platformRepo = mockk() + private val biometricHelper = mockk() + private val telephonyManager = mockk() + private val coinJoinConfig = mockk() + private val coinJoinService = mockk() + private val crowdNodeApi = mockk() + @get:Rule var rule: TestRule = InstantTaskExecutorRule() @@ -200,16 +216,35 @@ class MainViewModelTest { every { savedStateMock.set(any(), any()) } just runs } - @Test + @Test(timeout = 1000) fun observeBlockchainState_replaying_notSynced() { every { blockchainStateMock.observeState() } returns MutableStateFlow(BlockchainState(replaying = true)) val viewModel = spyk( MainViewModel( - analyticsService, configMock, uiConfigMock, - exchangeRatesMock, walletDataMock, walletApp, platformRepo, - mockk(), mockk(), blockchainIdentityConfigMock, savedStateMock, transactionMetadataMock, - blockchainStateMock, mockk(), mockk(), mockk(), userAgentDaoMock, mockk(), mockDashPayConfig, mockk(), mockk() + analyticsService, + configMock, + uiConfigMock, + exchangeRatesMock, + walletDataMock, + walletApp, + platformRepo, + platformService, + platformSyncService, + blockchainIdentityConfigMock, + savedStateMock, + transactionMetadataMock, + blockchainStateMock, + biometricHelper, + telephonyManager, + invitationsDaoMock, + userAgentDaoMock, + dashPayProfileDaoMock, + mockDashPayConfig, + dashPayContactRequestDao, + coinJoinConfig, + coinJoinService, + crowdNodeApi ) ) @@ -226,10 +261,29 @@ class MainViewModelTest { every { blockchainStateMock.observeState() } returns MutableStateFlow(state) val viewModel = spyk( MainViewModel( - analyticsService, configMock, uiConfigMock, - exchangeRatesMock, walletDataMock, walletApp, platformRepo, - mockk(), mockk(), blockchainIdentityConfigMock, savedStateMock, transactionMetadataMock, - blockchainStateMock, mockk(), mockk(), mockk(), mockk(), mockk(), mockDashPayConfig, mockk(), mockk() + analyticsService, + configMock, + uiConfigMock, + exchangeRatesMock, + walletDataMock, + walletApp, + platformRepo, + platformService, + platformSyncService, + blockchainIdentityConfigMock, + savedStateMock, + transactionMetadataMock, + blockchainStateMock, + biometricHelper, + telephonyManager, + invitationsDaoMock, + userAgentDaoMock, + dashPayProfileDaoMock, + mockDashPayConfig, + dashPayContactRequestDao, + coinJoinConfig, + coinJoinService, + crowdNodeApi ) )