From 9a492afe90a9c41805db397d7373a06ba08e87eb Mon Sep 17 00:00:00 2001 From: Eduard Maximovich <130352737+eduardmaximovich@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:15:25 +0400 Subject: [PATCH] ETH-946 - save send fee payer (#2194) * ETH-946 - save send fee payer - fixed fee payer token auto selection - clarified some points * ETH-946 - a bit cleaned up presenter, added check for compensating token, fixed "fromLamports" * ETH-946 - removed unnecessary bigdecimal scaling * ETH-946 - renamed power value to base because it's a base value, not a power --- .../wallet/feerelayer/model/FeeRelayerFee.kt | 33 +++- .../home/ui/container/MainContainerModule.kt | 4 +- .../wallet/infrastructure/StorageModule.kt | 8 +- .../p2p/wallet/send/SendFeeRelayerManager.kt | 134 +++++++------ .../java/org/p2p/wallet/send/SendModule.kt | 10 + .../send/interactor/FeePayersRepository.kt | 6 +- .../wallet/send/interactor/SendInteractor.kt | 25 ++- .../usecase/GetFeesInPayingTokenUseCase.kt | 28 +-- .../p2p/wallet/send/model/SendSolanaFee.kt | 46 +++-- .../FeePayerTokenValidityRepository.kt | 28 +++ .../p2p/wallet/send/repository/SendStorage.kt | 30 +++ .../send/repository/SendStorageContract.kt | 9 + .../p2p/wallet/send/ui/NewSendPresenter.kt | 185 ++++++++++-------- .../org/p2p/core/utils/AmountExtensions.kt | 17 +- .../TokenServiceAmountsRemoteConverter.kt | 7 + 15 files changed, 385 insertions(+), 185 deletions(-) create mode 100644 app/src/main/java/org/p2p/wallet/send/repository/FeePayerTokenValidityRepository.kt create mode 100644 app/src/main/java/org/p2p/wallet/send/repository/SendStorage.kt create mode 100644 app/src/main/java/org/p2p/wallet/send/repository/SendStorageContract.kt diff --git a/app/src/main/java/org/p2p/wallet/feerelayer/model/FeeRelayerFee.kt b/app/src/main/java/org/p2p/wallet/feerelayer/model/FeeRelayerFee.kt index b7034975ea..93287745dc 100644 --- a/app/src/main/java/org/p2p/wallet/feerelayer/model/FeeRelayerFee.kt +++ b/app/src/main/java/org/p2p/wallet/feerelayer/model/FeeRelayerFee.kt @@ -6,27 +6,40 @@ import kotlinx.parcelize.Parcelize import org.p2p.solanaj.core.FeeAmount @Parcelize -data class FeeRelayerFee constructor( +data class FeeRelayerFee( val transactionFeeInSol: BigInteger, val accountCreationFeeInSol: BigInteger, - val transactionFeeInSpl: BigInteger, - val accountCreationFeeInSpl: BigInteger, + val transactionFeeInFeePayerToken: BigInteger, + val accountCreationFeeInFeePayerToken: BigInteger, + + val transactionFeeInSourceToken: BigInteger, + val accountCreateFeeInSourceToken: BigInteger, val expectedFee: FeeAmount ) : Parcelable { - constructor(feeInSol: FeeAmount, feeInSpl: FeeAmount, expectedFee: FeeAmount) : this( - transactionFeeInSol = feeInSol.transactionFee, - accountCreationFeeInSol = feeInSol.accountCreationFee, - transactionFeeInSpl = feeInSpl.transactionFee, - accountCreationFeeInSpl = feeInSpl.accountCreationFee, + constructor( + feesInSol: FeeAmount, + feesInFeePayerToken: FeeAmount, + feesInSourceToken: FeeAmount, + expectedFee: FeeAmount + ) : this( + transactionFeeInSol = feesInSol.transactionFee, + accountCreationFeeInSol = feesInSol.accountCreationFee, + transactionFeeInFeePayerToken = feesInFeePayerToken.transactionFee, + accountCreationFeeInFeePayerToken = feesInFeePayerToken.accountCreationFee, + transactionFeeInSourceToken = feesInSourceToken.transactionFee, + accountCreateFeeInSourceToken = feesInSourceToken.accountCreationFee, expectedFee = expectedFee ) val totalInSol: BigInteger get() = transactionFeeInSol + accountCreationFeeInSol - val totalInSpl: BigInteger - get() = transactionFeeInSpl + accountCreationFeeInSpl + val totalInFeePayerToken: BigInteger + get() = transactionFeeInFeePayerToken + accountCreationFeeInFeePayerToken + + val totalInSourceToken: BigInteger + get() = transactionFeeInSourceToken + accountCreateFeeInSourceToken } diff --git a/app/src/main/java/org/p2p/wallet/home/ui/container/MainContainerModule.kt b/app/src/main/java/org/p2p/wallet/home/ui/container/MainContainerModule.kt index 3fb92abebe..394d4bfebf 100644 --- a/app/src/main/java/org/p2p/wallet/home/ui/container/MainContainerModule.kt +++ b/app/src/main/java/org/p2p/wallet/home/ui/container/MainContainerModule.kt @@ -42,7 +42,9 @@ object MainContainerModule : InjectionModule { tokenKeyProvider = get(), dispatchers = get(), sendServiceRepository = get(), - feePayersRepository = get() + feePayersRepository = get(), + feePayerTokenValidityRepository = get(), + sendStorage = get(), ) } factoryOf(::SearchInteractor) diff --git a/app/src/main/java/org/p2p/wallet/infrastructure/StorageModule.kt b/app/src/main/java/org/p2p/wallet/infrastructure/StorageModule.kt index 82c49f6697..0eebf8eb21 100644 --- a/app/src/main/java/org/p2p/wallet/infrastructure/StorageModule.kt +++ b/app/src/main/java/org/p2p/wallet/infrastructure/StorageModule.kt @@ -39,7 +39,7 @@ private const val PREFS_SWAP = "swap_prefs" private const val PREFS_STRIGA = "striga_prefs" object StorageModule { - const val PREFS_PNL = "pnl_prefs" + const val PREFS_SEND = "send_prefs" private fun Scope.androidPreferences(prefsName: String): SharedPreferences { return with(androidContext()) { @@ -70,7 +70,7 @@ object StorageModule { SecureStorageContract::class factory { AccountStorage(get(named(PREFS_ACCOUNT))) } bind AccountStorageContract::class - single { JupiterSwapStorage(androidPreferences(PREFS_SWAP),) } bind + single { JupiterSwapStorage(androidPreferences(PREFS_SWAP)) } bind JupiterSwapStorageContract::class factory { StrigaStorage(get(named(PREFS_STRIGA))) } bind StrigaStorageContract::class @@ -94,8 +94,8 @@ object StorageModule { single(named(PREFS_STRIGA)) { androidEncryptedPreferences(PREFS_STRIGA) } - single(named(PREFS_PNL)) { - androidPreferences(PREFS_PNL) + single(named(PREFS_SEND)) { + androidPreferences(PREFS_SEND) } } diff --git a/app/src/main/java/org/p2p/wallet/send/SendFeeRelayerManager.kt b/app/src/main/java/org/p2p/wallet/send/SendFeeRelayerManager.kt index cb4765be41..bc9995e9cf 100644 --- a/app/src/main/java/org/p2p/wallet/send/SendFeeRelayerManager.kt +++ b/app/src/main/java/org/p2p/wallet/send/SendFeeRelayerManager.kt @@ -10,8 +10,6 @@ import org.p2p.core.utils.Constants import org.p2p.core.utils.formatTokenWithSymbol import org.p2p.core.utils.fromLamports import org.p2p.core.utils.isZero -import org.p2p.core.utils.orZero -import org.p2p.core.utils.scaleLong import org.p2p.core.utils.toLamports import org.p2p.core.utils.toUsd import org.p2p.solanaj.core.FeeAmount @@ -84,7 +82,7 @@ class SendFeeRelayerManager( private val alternativeTokensMap: HashMap> = HashMap() suspend fun initialize( - initialToken: Token.Active, + feePayerToken: Token.Active, solToken: Token.Active, recipientAddress: SearchResult ) { @@ -94,7 +92,7 @@ class SendFeeRelayerManager( onFeeLoading?.invoke(FeeLoadingState.Instant(isLoading = true)) try { - initializeWithToken(initialToken) + initializeWithToken(feePayerToken) initializeCompleted = true } catch (e: Throwable) { @@ -106,12 +104,12 @@ class SendFeeRelayerManager( } } - private suspend fun initializeWithToken(initialToken: Token.Active) { - Timber.tag(TAG).i("initialize for SendFeeRelayerManager with token ${initialToken.mintAddress}") + private suspend fun initializeWithToken(feePayerToken: Token.Active) { + Timber.tag(TAG).i("initialize for SendFeeRelayerManager with token ${feePayerToken.mintAddress}") minRentExemption = sendInteractor.getMinRelayRentExemption() feeLimitInfo = sendInteractor.getFreeTransactionsInfo() currentSolanaEpoch = solanaRepository.getEpochInfo(useCache = true).epoch - sendInteractor.initialize(initialToken) + sendInteractor.initialize(feePayerToken) } fun getMinRentExemption(): BigInteger = minRentExemption @@ -171,7 +169,7 @@ class SendFeeRelayerManager( try { onFeeLoading?.invoke(FeeLoadingState(isLoading = true, isDelayed = useCache)) if (!initializeCompleted) { - initializeWithToken(sourceToken) + initializeWithToken(feePayer) initializeCompleted = true } @@ -189,7 +187,7 @@ class SendFeeRelayerManager( feeLimitInfo = feeLimitInfo, tokenExtensions = tokenExtensions ) - sendInteractor.setFeePayerToken(sourceToken) + sendInteractor.setFeePayerToken(feePayer) } is FeeCalculationState.PoolsNotFound -> { val solanaFee = buildSolanaFee( @@ -233,20 +231,8 @@ class SendFeeRelayerManager( } } - suspend fun buildDebugInfo(solanaFee: SendSolanaFee?): String { - val relayAccount = sendInteractor.getUserRelayAccount() - val relayInfo = sendInteractor.getRelayInfo() + fun buildDebugInfo(solanaFee: SendSolanaFee?): String { return buildString { - append("Relay account is created: ${relayAccount.isCreated}, balance: ${relayAccount.balance} (A)") - appendLine() - append("Min relay account balance required: ${relayInfo.minimumRelayAccountRent} (B)") - appendLine() - if (relayAccount.balance != null) { - val diff = relayAccount.balance - relayInfo.minimumRelayAccountRent - append("Remainder (A - B): $diff (R)") - appendLine() - } - if (solanaFee == null) { append("Expected total fee in SOL: 0 (E)") appendLine() @@ -255,26 +241,42 @@ class SendFeeRelayerManager( append("Expected total fee in Token: 0 (T)") } else { val accountBalances = solanaFee.feeRelayerFee.expectedFee.accountCreationFee - val expectedFee = if (!relayAccount.isCreated) { - accountBalances + relayInfo.minimumRelayAccountRent - } else { - accountBalances - } - append("Expected total fee in SOL: $expectedFee (E)") + append("Expected total fee in SOL: $accountBalances (E)") appendLine() val neededTopUpAmount = solanaFee.feeRelayerFee.totalInSol append("Needed top up amount (E - R): $neededTopUpAmount (S)") - appendLine() val feePayerToken = solanaFee.feePayerToken - val expectedFeeInSpl = solanaFee.feeRelayerFee.totalInSpl.orZero() + + val feePayerTokenValue = solanaFee + .feeRelayerFee + .totalInFeePayerToken .fromLamports(feePayerToken.decimals) - .scaleLong() - append("Expected total fee in Token: $expectedFeeInSpl ${feePayerToken.tokenSymbol} (T)") + .formatTokenWithSymbol( + token = feePayerToken, + exactDecimals = true, + keepInitialDecimals = true + ) + + val sourceToken = solanaFee.sourceToken + val sourceTokenValue = solanaFee + .feeRelayerFee + .totalInSourceToken + .fromLamports(sourceToken.decimals) + .formatTokenWithSymbol( + token = sourceToken, + exactDecimals = true, + keepInitialDecimals = true + ) + + append("Expected total fee in Fee Payer Token: $feePayerTokenValue (T)") + appendLine() + append("Expected total fee in Source Token: $sourceTokenValue (T)") appendLine() + append("[Token2022] Transfer Fee: ${solanaFee.token2022TransferFee}") } } @@ -297,57 +299,67 @@ class SendFeeRelayerManager( Timber.i("Requesting minRentExemption for spl_token_program: $minRentExemption") - var transactionFee = BigInteger.ZERO + var transactionFeeInSol = BigInteger.ZERO // owner's signature - transactionFee += lamportsPerSignature + transactionFeeInSol += lamportsPerSignature // feePayer's signature if (!feePayerToken.isSOL) { Timber.i("Fee payer is not sol, adding $lamportsPerSignature for fee") - transactionFee += lamportsPerSignature + transactionFeeInSol += lamportsPerSignature } val shouldCreateAccount = checkAccountCreationIsRequired(sourceToken, result.address) Timber.i("Should create account = $shouldCreateAccount") - val accountCreationFee = if (shouldCreateAccount) minRentExemption else BigInteger.ZERO + val accountCreationFeeInSol = if (shouldCreateAccount) minRentExemption else BigInteger.ZERO - val expectedFee = FeeAmount( - transactionFee = transactionFee, - accountCreationFee = accountCreationFee, + val expectedFeesInSol = FeeAmount( + transactionFee = transactionFeeInSol, + accountCreationFee = accountCreationFeeInSol, ) - val fees = feeRelayerTopUpInteractor.calculateNeededTopUpAmount(expectedFee) + // todo: since we use send service it covers all network fees, no necessity to use fee relayer + // keep it here for a while, just in case + // val feesInSol = feeRelayerTopUpInteractor.calculateNeededTopUpAmount(expectedFeesInSol) + + val feesInSol = expectedFeesInSol.copy(transactionFee = BigInteger.ZERO) - if (fees.totalFeeLamports.isZero()) { + if (feesInSol.totalFeeLamports.isZero()) { Timber.i("Total fees are zero!") return FeeCalculationState.NoFees } - val poolsStateFees = getFeesInPayingTokenUseCase.execute( - feePayerToken = feePayerToken, - transactionFeeInSol = fees.transactionFee, - accountCreationFeeInSol = fees.accountCreationFee + val feesInFeePayerToken = getFeesInPayingTokenUseCase.execute( + targetToken = feePayerToken, + transactionFeeInSol = feesInSol.transactionFee, + accountCreationFeeInSol = feesInSol.accountCreationFee ) - if (poolsStateFees != null) { - FeeCalculationState.Success( - fee = FeeRelayerFee( - feeInSol = fees, - feeInSpl = poolsStateFees, - expectedFee = expectedFee - ) - ) - } else { - FeeCalculationState.PoolsNotFound( - feeInSol = FeeRelayerFee( - feeInSol = fees, - feeInSpl = FeeAmount(fees.transactionFee, fees.accountCreationFee), - expectedFee = expectedFee - ) - ) + val feesInSourceToken = getFeesInPayingTokenUseCase.execute( + targetToken = sourceToken, + transactionFeeInSol = feesInSol.transactionFee, + accountCreationFeeInSol = feesInSol.accountCreationFee + ) + + // it is incorrect to return fees in sol if there is some error happened + // because we would add apples to oranges when choosing fee payer token + require(feesInFeePayerToken != null && feesInSourceToken != null) { + buildString { + append("Cannot calculate transaction fees in source (${sourceToken.tokenSymbol})") + append("and fee payer token ${feePayerToken.tokenSymbol}") + } } + + FeeCalculationState.Success( + fee = FeeRelayerFee( + feesInSol = feesInSol, + feesInFeePayerToken = feesInFeePayerToken, + feesInSourceToken = feesInSourceToken, + expectedFee = expectedFeesInSol + ) + ) } catch (e: CancellationException) { Timber.i("Fee calculation cancelled") return FeeCalculationState.Cancelled diff --git a/app/src/main/java/org/p2p/wallet/send/SendModule.kt b/app/src/main/java/org/p2p/wallet/send/SendModule.kt index 8179dbaa0c..12079c4d01 100644 --- a/app/src/main/java/org/p2p/wallet/send/SendModule.kt +++ b/app/src/main/java/org/p2p/wallet/send/SendModule.kt @@ -13,6 +13,7 @@ import org.p2p.core.token.Token import org.p2p.wallet.feerelayer.interactor.FeeRelayerViaLinkInteractor import org.p2p.wallet.home.ui.new.NewSelectTokenContract import org.p2p.wallet.home.ui.new.NewSelectTokenPresenter +import org.p2p.wallet.infrastructure.StorageModule import org.p2p.wallet.infrastructure.network.provider.SendModeProvider import org.p2p.wallet.infrastructure.sendvialink.UserSendLinksDatabaseRepository import org.p2p.wallet.infrastructure.sendvialink.UserSendLinksLocalRepository @@ -22,11 +23,14 @@ import org.p2p.wallet.send.interactor.usecase.CalculateToken2022TransferFeeUseCa import org.p2p.wallet.send.interactor.usecase.GetFeesInPayingTokenUseCase import org.p2p.wallet.send.interactor.usecase.GetTokenExtensionsUseCase import org.p2p.wallet.send.model.SearchResult +import org.p2p.wallet.send.repository.FeePayerTokenValidityRepository import org.p2p.wallet.send.repository.RecipientsDatabaseRepository import org.p2p.wallet.send.repository.RecipientsLocalRepository import org.p2p.wallet.send.repository.SendServiceInMemoryRepository import org.p2p.wallet.send.repository.SendServiceRemoteRepository import org.p2p.wallet.send.repository.SendServiceRepository +import org.p2p.wallet.send.repository.SendStorage +import org.p2p.wallet.send.repository.SendStorageContract import org.p2p.wallet.send.ui.NewSendContract import org.p2p.wallet.send.ui.NewSendPresenter import org.p2p.wallet.send.ui.SendOpenedFrom @@ -116,6 +120,12 @@ object SendModule : InjectionModule { factoryOf(::FeeRelayerViaLinkInteractor) factoryOf(::SendViaLinkInteractor) factoryOf(::UserSendLinksDatabaseRepository) bind UserSendLinksLocalRepository::class + factory { + SendStorage( + prefs = get(named(StorageModule.PREFS_SEND)) + ) + } bind SendStorageContract::class + singleOf(::FeePayerTokenValidityRepository) } private fun Module.initSendService() { diff --git a/app/src/main/java/org/p2p/wallet/send/interactor/FeePayersRepository.kt b/app/src/main/java/org/p2p/wallet/send/interactor/FeePayersRepository.kt index 3c9d757f88..45b998c1bb 100644 --- a/app/src/main/java/org/p2p/wallet/send/interactor/FeePayersRepository.kt +++ b/app/src/main/java/org/p2p/wallet/send/interactor/FeePayersRepository.kt @@ -38,7 +38,7 @@ class FeePayersRepository( val feePayerTokensMints = sendServiceRepository.getCompensationTokens() val tokenToExcludeSymbol = feePayerToExclude.tokenSymbol - val fees: Map = getFeesInPayingTokenUseCase.execute( + val feesInGivenTokens: Map = getFeesInPayingTokenUseCase.execute( findFeesIn = feePayerTokensMints, transactionFeeInSol = transactionFeeInSol, accountCreationFeeInSol = accountCreationFeeInSol @@ -61,9 +61,9 @@ class FeePayersRepository( } // assuming that all other tokens are SPL - val feesInSpl = fees[token.mintAddress.toBase58Instance()] + val feesInSpl = feesInGivenTokens[token.mintAddress.toBase58Instance()] if (feesInSpl == null) { - Timber.i("Fee in SPL not found for ${token.tokenSymbol} in ${fees.keys}") + Timber.i("Fee in SPL not found for ${token.tokenSymbol} in ${feesInGivenTokens.keys}") return@filter false } val isTokenCoversTheFee = token.totalInLamports >= feesInSpl diff --git a/app/src/main/java/org/p2p/wallet/send/interactor/SendInteractor.kt b/app/src/main/java/org/p2p/wallet/send/interactor/SendInteractor.kt index 28632922da..bb1438d38f 100644 --- a/app/src/main/java/org/p2p/wallet/send/interactor/SendInteractor.kt +++ b/app/src/main/java/org/p2p/wallet/send/interactor/SendInteractor.kt @@ -34,7 +34,9 @@ import org.p2p.wallet.send.model.SendTransactionFailed import org.p2p.wallet.send.model.send_service.SendFeePayerMode import org.p2p.wallet.send.model.send_service.SendRentPayerMode import org.p2p.wallet.send.model.send_service.SendTransferMode +import org.p2p.wallet.send.repository.FeePayerTokenValidityRepository import org.p2p.wallet.send.repository.SendServiceRepository +import org.p2p.wallet.send.repository.SendStorageContract import org.p2p.wallet.swap.interactor.orca.OrcaInfoInteractor import org.p2p.wallet.utils.toPublicKey @@ -51,6 +53,8 @@ class SendInteractor( private val dispatchers: CoroutineDispatchers, private val sendServiceRepository: SendServiceRepository, private val feePayersRepository: FeePayersRepository, + private val feePayerTokenValidityRepository: FeePayerTokenValidityRepository, + private val sendStorage: SendStorageContract, ) { /* @@ -62,8 +66,8 @@ class SendInteractor( /* * Initialize fee payer token * */ - suspend fun initialize(token: Token.Active) { - feePayerToken = token + suspend fun initialize(feePayer: Token.Active) { + feePayerToken = feePayer feeRelayerInteractor.load() orcaInfoInteractor.load() } @@ -462,4 +466,21 @@ class SendInteractor( accountCreationFeeInSol = accountCreationFeeInSOL ) } + + fun restoreSavedFeePayerToken(): Base58String? { + return sendStorage.restore() + } + + fun removeSavedFeePayerToken() { + sendStorage.remove() + } + + suspend fun saveFeePayerToken(token: Token.Active) { + if (checkTokenIsValidFeePayer(token)) { + sendStorage.save(token.mintAddressB58) + } + } + + suspend fun checkTokenIsValidFeePayer(token: Token.Active): Boolean = + feePayerTokenValidityRepository.checkIsValid(token) } diff --git a/app/src/main/java/org/p2p/wallet/send/interactor/usecase/GetFeesInPayingTokenUseCase.kt b/app/src/main/java/org/p2p/wallet/send/interactor/usecase/GetFeesInPayingTokenUseCase.kt index 8a8aa3b05f..1e7721656e 100644 --- a/app/src/main/java/org/p2p/wallet/send/interactor/usecase/GetFeesInPayingTokenUseCase.kt +++ b/app/src/main/java/org/p2p/wallet/send/interactor/usecase/GetFeesInPayingTokenUseCase.kt @@ -8,11 +8,9 @@ import org.p2p.core.token.Token import org.p2p.core.utils.Constants import org.p2p.solanaj.core.FeeAmount import org.p2p.token.service.converter.TokenServiceAmountsConverter -import org.p2p.wallet.send.repository.SendServiceRepository class GetFeesInPayingTokenUseCase( private val amountsConverter: TokenServiceAmountsConverter, - private val sendServiceRepository: SendServiceRepository ) { suspend fun execute( @@ -39,22 +37,30 @@ class GetFeesInPayingTokenUseCase( emptyMap() } + /** + * This function calculates how many target tokens we need in SOL equivalent to pay for the transaction + */ suspend fun execute( - feePayerToken: Token.Active, + targetToken: Token.Active, transactionFeeInSol: BigInteger, accountCreationFeeInSol: BigInteger ): FeeAmount? { - val transactionFeeSpl = amountsConverter.convertAmount( + if (targetToken.isSOL) { + return FeeAmount(transactionFeeInSol, accountCreationFeeInSol) + } + + val transactionFeeInTargetToken = amountsConverter.convertAmount( amountFrom = Constants.WRAPPED_SOL_MINT.toBase58Instance() to transactionFeeInSol, - mintsToConvertTo = listOf(feePayerToken.mintAddress.toBase58Instance()) - )[feePayerToken.mintAddress.toBase58Instance()] - val accountCreationFee = amountsConverter.convertAmount( + mintsToConvertTo = listOf(targetToken.mintAddress.toBase58Instance()) + )[targetToken.mintAddress.toBase58Instance()] + + val accountCreationFeeInTargetToken = amountsConverter.convertAmount( amountFrom = Constants.WRAPPED_SOL_MINT.toBase58Instance() to accountCreationFeeInSol, - mintsToConvertTo = listOf(feePayerToken.mintAddress.toBase58Instance()) - )[feePayerToken.mintAddress.toBase58Instance()] + mintsToConvertTo = listOf(targetToken.mintAddress.toBase58Instance()) + )[targetToken.mintAddress.toBase58Instance()] - return if (transactionFeeSpl != null && accountCreationFee != null) { - FeeAmount(transactionFeeSpl, accountCreationFee) + return if (transactionFeeInTargetToken != null && accountCreationFeeInTargetToken != null) { + FeeAmount(transactionFeeInTargetToken, accountCreationFeeInTargetToken) } else { null } diff --git a/app/src/main/java/org/p2p/wallet/send/model/SendSolanaFee.kt b/app/src/main/java/org/p2p/wallet/send/model/SendSolanaFee.kt index c9e9744e06..877cb51635 100644 --- a/app/src/main/java/org/p2p/wallet/send/model/SendSolanaFee.kt +++ b/app/src/main/java/org/p2p/wallet/send/model/SendSolanaFee.kt @@ -14,7 +14,6 @@ import org.p2p.core.utils.isLessThan import org.p2p.core.utils.isMoreThan import org.p2p.core.utils.isZeroOrLess import org.p2p.core.utils.orZero -import org.p2p.core.utils.scaleLong import org.p2p.core.utils.toLamports import org.p2p.core.utils.toUsd import org.p2p.wallet.feerelayer.model.FeePayerSelectionStrategy @@ -32,7 +31,7 @@ data class SendSolanaFee constructor( val feePayerToken: Token.Active, val feeRelayerFee: FeeRelayerFee, val token2022TransferFee: BigInteger, - private val sourceToken: Token.Active, + val sourceToken: Token.Active, private val solToken: Token.Active?, private val alternativeFeePayerTokens: List, private val supportedFeePayerTokens: List? = null @@ -92,16 +91,27 @@ data class SendSolanaFee constructor( } @IgnoredOnParcel - val accountCreationFeeDecimals: BigDecimal = - (if (feePayerToken.isSOL) feeRelayerFee.accountCreationFeeInSol else feeRelayerFee.accountCreationFeeInSpl) - .fromLamports(feePayerToken.decimals) - .scaleLong() + val accountCreationFeeDecimals: BigDecimal + get() { + val amount = if (feePayerToken.isSOL) { + feeRelayerFee.accountCreationFeeInSol + } else { + feeRelayerFee.accountCreationFeeInFeePayerToken + } + return amount.fromLamports(feePayerToken.decimals) + } @IgnoredOnParcel - val transactionDecimals: BigDecimal = - (if (feePayerToken.isSOL) feeRelayerFee.transactionFeeInSol else feeRelayerFee.transactionFeeInSpl) - .fromLamports(feePayerToken.decimals) - .scaleLong() + val transactionDecimals: BigDecimal + get() { + val amount = if (feePayerToken.isSOL) { + feeRelayerFee.transactionFeeInSol + } else { + feeRelayerFee.transactionFeeInFeePayerToken + } + + return amount.fromLamports(feePayerToken.decimals) + } @IgnoredOnParcel private val feePayerTotalLamports: BigInteger @@ -131,10 +141,10 @@ data class SendSolanaFee constructor( } // assuming that source token and fee payer are same sourceTokenSymbol == feePayerSymbol -> - sourceTokenTotal >= inputAmount + feeRelayerFee.totalInSpl + sourceTokenTotal >= inputAmount + feeRelayerFee.totalInSourceToken // assuming that source token and fee payer are different else -> - feePayerToken.totalInLamports >= feeRelayerFee.totalInSpl + feePayerToken.totalInLamports >= feeRelayerFee.totalInFeePayerToken } private fun isEnoughSol( @@ -153,9 +163,10 @@ data class SendSolanaFee constructor( sourceTokenTotal: BigInteger, inputAmount: BigInteger ): FeePayerState { + val feePayerTokenCanCoverExpenses = feePayerToken.totalInLamports >= feeRelayerFee.totalInFeePayerToken val isSourceSol = sourceTokenSymbol == SOL_SYMBOL val isAllowedToCorrectAmount = strategy == CORRECT_AMOUNT - val totalNeeded = feeRelayerFee.totalInSpl + inputAmount + val totalNeeded = feeRelayerFee.totalInSourceToken + inputAmount val isEnoughSolBalance = isEnoughSolBalance() val shouldTryReduceAmount = isAllowedToCorrectAmount && !isSourceSol && !isEnoughSolBalance val hasAlternativeFeePayerTokens = alternativeFeePayerTokens.isNotEmpty() @@ -177,9 +188,18 @@ data class SendSolanaFee constructor( appendLine("alternativeFeePayerTokens = ${alternativeFeePayerTokens.map(Token.Active::tokenSymbol)}") appendLine("isValidToSwitchOnSource = $isValidToSwitchOnSource") appendLine("shouldSwitchToSpl = $shouldSwitchToSpl") + appendLine("feePayerToken = ${feePayerToken.tokenSymbol}") + appendLine("feePayerTokenCanCoverExpenses = $feePayerTokenCanCoverExpenses") } ) return when { + feePayerTokenCanCoverExpenses -> { + if (feePayerToken.isSOL) { + SwitchToSol + } else { + SwitchToSpl(feePayerToken) + } + } shouldSwitchToSpl -> { SwitchToSpl(sourceToken) } diff --git a/app/src/main/java/org/p2p/wallet/send/repository/FeePayerTokenValidityRepository.kt b/app/src/main/java/org/p2p/wallet/send/repository/FeePayerTokenValidityRepository.kt new file mode 100644 index 0000000000..d343603f80 --- /dev/null +++ b/app/src/main/java/org/p2p/wallet/send/repository/FeePayerTokenValidityRepository.kt @@ -0,0 +1,28 @@ +package org.p2p.wallet.send.repository + +import kotlinx.coroutines.withContext +import org.p2p.core.crypto.Base58String +import org.p2p.core.dispatchers.CoroutineDispatchers +import org.p2p.core.token.Token + +/** + * Check if a token can be used as fee payer using send-service compensation tokens + */ +class FeePayerTokenValidityRepository( + private val dispatchers: CoroutineDispatchers, + private val sendServiceRepository: SendServiceRepository, +) { + + private val allowedTokenMintsCache = mutableSetOf() + + suspend fun checkIsValid(token: Token.Active): Boolean = + checkIsValid(token.mintAddressB58) + + private suspend fun checkIsValid(tokenMint: Base58String): Boolean = withContext(dispatchers.io) { + if (allowedTokenMintsCache.isEmpty()) { + allowedTokenMintsCache += sendServiceRepository.getCompensationTokens() + } + + tokenMint in allowedTokenMintsCache + } +} diff --git a/app/src/main/java/org/p2p/wallet/send/repository/SendStorage.kt b/app/src/main/java/org/p2p/wallet/send/repository/SendStorage.kt new file mode 100644 index 0000000000..be3a521b7e --- /dev/null +++ b/app/src/main/java/org/p2p/wallet/send/repository/SendStorage.kt @@ -0,0 +1,30 @@ +package org.p2p.wallet.send.repository + +import androidx.core.content.edit +import android.content.SharedPreferences +import org.p2p.core.crypto.Base58String +import org.p2p.core.crypto.toBase58Instance + +class SendStorage( + private val prefs: SharedPreferences +) : SendStorageContract { + private companion object { + const val KEY_TOKEN_MINT = "TOKEN_MINT" + } + + override fun restore(): Base58String? { + return prefs.getString(KEY_TOKEN_MINT, null)?.toBase58Instance() + } + + override fun save(tokenMint: Base58String) { + prefs.edit { + putString(KEY_TOKEN_MINT, tokenMint.base58Value) + } + } + + override fun remove() { + prefs.edit { + remove(KEY_TOKEN_MINT) + } + } +} diff --git a/app/src/main/java/org/p2p/wallet/send/repository/SendStorageContract.kt b/app/src/main/java/org/p2p/wallet/send/repository/SendStorageContract.kt new file mode 100644 index 0000000000..952d7cda30 --- /dev/null +++ b/app/src/main/java/org/p2p/wallet/send/repository/SendStorageContract.kt @@ -0,0 +1,9 @@ +package org.p2p.wallet.send.repository + +import org.p2p.core.crypto.Base58String + +interface SendStorageContract { + fun restore(): Base58String? + fun save(tokenMint: Base58String) + fun remove() +} diff --git a/app/src/main/java/org/p2p/wallet/send/ui/NewSendPresenter.kt b/app/src/main/java/org/p2p/wallet/send/ui/NewSendPresenter.kt index f3b9aa8dd3..4d7757b2d3 100644 --- a/app/src/main/java/org/p2p/wallet/send/ui/NewSendPresenter.kt +++ b/app/src/main/java/org/p2p/wallet/send/ui/NewSendPresenter.kt @@ -72,7 +72,7 @@ private const val TAG = "NewSendPresenter" class NewSendPresenter( private val recipientAddress: SearchResult, - private val openedFrom: SendOpenedFrom, + openedFrom: SendOpenedFrom, private val userInteractor: UserInteractor, private val userTokensInteractor: UserTokensInteractor, private val sendInteractor: SendInteractor, @@ -87,7 +87,7 @@ class NewSendPresenter( private val historyInteractor: HistoryInteractor, private val tokenServiceCoordinator: TokenServiceCoordinator, private val sendFeeRelayerManager: SendFeeRelayerManager, - private val maximumAmountCalculator: SendMaximumAmountCalculator + private val maximumAmountCalculator: SendMaximumAmountCalculator, ) : BasePresenter(), NewSendContract.Presenter { private val flow: NewSendAnalytics.AnalyticsSendFlow = if (openedFrom == SendOpenedFrom.SELL_FLOW) { @@ -108,6 +108,10 @@ class NewSendPresenter( lessThenMinString = resources.getString(R.string.common_less_than_minimum), ) + /** + * Token which is selected by user before the screen is opened + * Used for detecting whether we need to show token selection or not + */ private var selectedToken: Token.Active? = null private var initialAmount: BigDecimal? = null @@ -153,71 +157,101 @@ class NewSendPresenter( } } - if (token != null) { - restoreSelectedToken(view, requireToken()) - } else { - setupInitialToken(view) + launch { + if (token != null) { + restoreSelectedToken(view, requireToken()) + } else { + setupInitialToken(view) + } } } - private fun restoreSelectedToken(view: NewSendContract.View, token: Token.Active) { - launch { - view.showToken(token) - calculationMode.updateToken(token) - checkTokenRatesAndSetSwitchAmountState(token) + private suspend fun restoreSelectedToken(view: NewSendContract.View, token: Token.Active) { + view.showToken(token) + calculationMode.updateToken(token) + checkTokenRatesAndSetSwitchAmountState(token) - val userTokens = getNonZeroUserTokensOrSol() - val isTokenChangeEnabled = userTokens.size > 1 && selectedToken == null - view.setTokenContainerEnabled(isEnabled = isTokenChangeEnabled) + val userTokens = getNonZeroUserTokensOrSol() + val isTokenChangeEnabled = userTokens.size > 1 && selectedToken == null + view.setTokenContainerEnabled(isEnabled = isTokenChangeEnabled) - val currentState = sendFeeRelayerManager.getState() - handleFeeRelayerStateUpdate(currentState, view) + val currentState = sendFeeRelayerManager.getState() + handleFeeRelayerStateUpdate(currentState, view) - maximumAmountCalculator.getMaxAvailableAmountToSend( - token = token, - recipient = recipientAddress.address.toBase58Instance() - ) - // set max available amount - ?.also { calculationMode.setMaxAmounts(it) } - } + maximumAmountCalculator.getMaxAvailableAmountToSend( + token = token, + recipient = recipientAddress.address.toBase58Instance() + ) + // set max available amount + ?.also { calculationMode.setMaxAmounts(it) } } - private fun setupInitialToken(view: NewSendContract.View) { - launch { - // We should find SOL anyway because SOL is needed for Selection Mechanism - // todo: check this logic as user definitely may have empty account, we should not error by this reason - val userNonZeroTokens = getNonZeroUserTokensOrSol() - if (userNonZeroTokens.isEmpty()) { - Timber.tag(TAG).e(SendFatalError("User non-zero tokens can't be empty!")) - // we cannot proceed if user tokens are not loaded - view.showUiKitSnackBar(resources.getString(R.string.error_general_message)) - return@launch - } + private suspend fun setupInitialToken(view: NewSendContract.View) { + // We should find SOL anyway because SOL is needed for Selection Mechanism + // todo: check this logic as user definitely may have empty account, we should not error by this reason + val userNonZeroTokens = getNonZeroUserTokensOrSol() + if (userNonZeroTokens.isEmpty()) { + Timber.tag(TAG).e(SendFatalError("User non-zero tokens can't be empty!")) + // we cannot proceed if user tokens are not loaded + view.showUiKitSnackBar(resources.getString(R.string.error_general_message)) + return + } - val isTokenChangeEnabled = userNonZeroTokens.size > 1 && selectedToken == null - view.setTokenContainerEnabled(isEnabled = isTokenChangeEnabled) + val isTokenChangeEnabled = userNonZeroTokens.size > 1 && selectedToken == null + view.setTokenContainerEnabled(isEnabled = isTokenChangeEnabled) + + val initialToken = if (selectedToken != null) selectedToken!! else userNonZeroTokens.first() + token = initialToken + + // paths where this fee payer token is settling down: + // : sendFeeRelayerManager.initialize + // -> sendFeeRelayerManager.initializeWithToken + // -> sendInteractor.initialize + // : sendFeeRelayerManager.executeSmartSelection + // -> if(!initializeCompleted) + // -> sendFeeRelayerManager.initializeWithToken + // -> sendInteractor.initialize + // so send interactor is the single source of truth for the fee payer token + val feePayerToken = requireDefaultFeePayerToken(userNonZeroTokens) + + checkTokenRatesAndSetSwitchAmountState(initialToken) + + val solToken = if (initialToken.isSOL) initialToken else tokenServiceCoordinator.getUserSolToken() + if (solToken == null) { + // we cannot proceed without SOL. + view.showUiKitSnackBar(resources.getString(R.string.error_general_message)) + Timber.tag(TAG).e(SendFatalError("Couldn't find user's SOL account!")) + return + } + maximumAmountCalculator.getMaxAvailableAmountToSend( + token = initialToken, + recipient = recipientAddress.address.toBase58Instance() + ) + ?.also { calculationMode.setMaxAmounts(it) } - val initialToken = if (selectedToken != null) selectedToken!! else userNonZeroTokens.first() - token = initialToken + initializeFeeRelayer( + view = view, + sourceToken = requireToken(), + feePayerToken = feePayerToken, + solToken = solToken + ) + initialAmount?.let(::setupDefaultFields) + } - checkTokenRatesAndSetSwitchAmountState(initialToken) + private suspend fun requireDefaultFeePayerToken(userNonZeroTokens: List): Token.Active { + val restoredFeePayerTokenMint = sendInteractor.restoreSavedFeePayerToken() ?: return requireToken() - val solToken = if (initialToken.isSOL) initialToken else tokenServiceCoordinator.getUserSolToken() - if (solToken == null) { - // we cannot proceed without SOL. - view.showUiKitSnackBar(resources.getString(R.string.error_general_message)) - Timber.tag(TAG).e(SendFatalError("Couldn't find user's SOL account!")) - return@launch - } - maximumAmountCalculator.getMaxAvailableAmountToSend( - token = initialToken, - recipient = recipientAddress.address.toBase58Instance() - ) - ?.also { calculationMode.setMaxAmounts(it) } + val restoredToken = userNonZeroTokens.firstOrNull { + it.mintAddressB58 == restoredFeePayerTokenMint + } - initializeFeeRelayer(view, initialToken, solToken) - initialAmount?.let(::setupDefaultFields) + if (restoredToken == null || !sendInteractor.checkTokenIsValidFeePayer(restoredToken)) { + sendInteractor.removeSavedFeePayerToken() + return requireToken() } + + Timber.d("Using user defined fee payer token ${restoredToken.tokenSymbol}") + return restoredToken } /** @@ -307,26 +341,29 @@ class NewSendPresenter( private suspend fun initializeFeeRelayer( view: NewSendContract.View, - initialToken: Token.Active, + sourceToken: Token.Active, + feePayerToken: Token.Active, solToken: Token.Active ) { - val initialFeeLabel = if (initialToken.isToken2022) { - resources.getString(R.string.send_fees) - } else { + val initialFeeLabel = if (sourceToken.isToken2022) { resources.getString(R.string.send_fees_token2022_format) + } else { + resources.getString(R.string.send_fees) } view.setFeeLabel(initialFeeLabel) view.setBottomButtonText(TextContainer.Res(R.string.send_calculating_fees)) - sendFeeRelayerManager.initialize(initialToken, solToken, recipientAddress) + sendFeeRelayerManager.initialize(feePayerToken, solToken, recipientAddress) executeSmartSelection( token = requireToken(), - feePayerToken = requireToken(), strategy = SELECT_FEE_PAYER, useCache = false ) - updateButton(sourceToken = initialToken, feeRelayerState = sendFeeRelayerManager.getState()) + updateButton( + sourceToken = sourceToken, + feeRelayerState = sendFeeRelayerManager.getState() + ) } override fun onTokenClicked() { @@ -352,11 +389,10 @@ class NewSendPresenter( updateButton(requireToken(), sendFeeRelayerManager.getState()) /* - * Calculating if we can pay with current token instead of already selected fee payer token - * */ + * Calculating if we can pay with current token instead of already selected fee payer token + */ executeSmartSelection( token = requireToken(), - feePayerToken = requireToken(), strategy = CORRECT_AMOUNT, useCache = false ) @@ -389,7 +425,6 @@ class NewSendPresenter( * */ executeSmartSelection( token = requireToken(), - feePayerToken = requireToken(), strategy = SELECT_FEE_PAYER ) } @@ -405,20 +440,21 @@ class NewSendPresenter( /* * Calculating if we can pay with current token instead of already selected fee payer token - * */ + */ executeSmartSelection( token = requireToken(), - feePayerToken = requireToken(), strategy = SELECT_FEE_PAYER ) } override fun updateFeePayerToken(feePayerToken: Token.Active) { try { + launch { + sendInteractor.saveFeePayerToken(feePayerToken) + } sendInteractor.setFeePayerToken(feePayerToken) executeSmartSelection( token = requireToken(), - feePayerToken = feePayerToken, strategy = NO_ACTION ) } catch (e: Throwable) { @@ -455,7 +491,6 @@ class NewSendPresenter( // already selected fee payer token executeSmartSelection( token = requireToken(), - feePayerToken = requireToken(), strategy = CORRECT_AMOUNT ) } @@ -537,7 +572,6 @@ class NewSendPresenter( ) view?.showProgressDialog(internalTransactionId, progressDetails) -// val result = sendInteractor.sendTransaction(address.toPublicKey(), token, lamports) val result = sendInteractor.sendTransactionV2(address.toPublicKey(), token, lamports) userInteractor.addRecipient(recipientAddress, Date(transactionDate.dateMilli())) @@ -578,7 +612,6 @@ class NewSendPresenter( * */ private fun executeSmartSelection( token: Token.Active, - feePayerToken: Token.Active?, strategy: FeePayerSelectionStrategy, useCache: Boolean = true ) { @@ -586,7 +619,7 @@ class NewSendPresenter( feePayerJob = launch { sendFeeRelayerManager.executeSmartSelection( sourceToken = token, - feePayerToken = feePayerToken, + feePayerToken = sendInteractor.getFeePayerToken(), strategy = strategy, tokenAmount = calculationMode.getCurrentAmount(), useCache = useCache @@ -651,13 +684,11 @@ class NewSendPresenter( } private fun buildDebugInfo(solanaFee: SendSolanaFee?) { - launch { - val debugInfo = sendFeeRelayerManager.buildDebugInfo(solanaFee) - .plus("\n") - .plus(calculationMode.getDebugInfo()) + val debugInfo = sendFeeRelayerManager.buildDebugInfo(solanaFee) + .plus("\n") + .plus(calculationMode.getDebugInfo()) - view?.showDebugInfo(debugInfo) - } + view?.showDebugInfo(debugInfo) } private fun isInternetConnectionEnabled(): Boolean = diff --git a/core/src/main/java/org/p2p/core/utils/AmountExtensions.kt b/core/src/main/java/org/p2p/core/utils/AmountExtensions.kt index edcb5154d0..209f0a4340 100644 --- a/core/src/main/java/org/p2p/core/utils/AmountExtensions.kt +++ b/core/src/main/java/org/p2p/core/utils/AmountExtensions.kt @@ -6,7 +6,7 @@ import java.math.RoundingMode import org.p2p.core.token.Token import org.p2p.core.utils.Constants.FIAT_FRACTION_LENGTH -private val POWER_VALUE = BigDecimal.TEN +private val BASE_TEN = BigDecimal.TEN const val SOL_DECIMALS = 9 const val MOONPAY_DECIMAL = 2 const val STRIGA_FIAT_DECIMALS = 2 @@ -26,7 +26,7 @@ fun String?.toBigIntegerOrZero(): BigInteger { return this?.toBigIntegerOrNull() ?: BigInteger.ZERO } -fun Int.toPowerValue(): BigDecimal = POWER_VALUE.pow(this) +fun Int.toPowerValue(): BigDecimal = BASE_TEN.pow(this) fun BigDecimal.scaleShortOrFirstNotZero(): BigDecimal { return if (isZero()) { @@ -57,7 +57,7 @@ fun BigDecimal.scaleLong(decimals: Int = SCALE_VALUE_LONG): BigDecimal = // do not use BigDecimal(double) sometimes it makes the amount less // example: pass 0.030, get 0.029 fun BigInteger.fromLamports(decimals: Int): BigDecimal = - (this.toBigDecimal().divide(POWER_VALUE.pow(decimals), 18, RoundingMode.HALF_DOWN)) + (this.toBigDecimal().divide(BASE_TEN.pow(decimals), 18, RoundingMode.HALF_DOWN)) .stripTrailingZeros() // removing zeros, case: 0.02000 -> 0.02 .scaleLong(decimals) @@ -100,6 +100,17 @@ fun BigDecimal.formatTokenWithSymbol( return "$formattedAmount $tokenSymbol" } +fun BigDecimal.formatTokenWithSymbol( + token: Token.Active, + exactDecimals: Boolean = false, + keepInitialDecimals: Boolean = false, +): String = formatTokenWithSymbol( + token.tokenSymbol, + token.decimals, + exactDecimals, + keepInitialDecimals +) + // case: 10000.000000007900 -> 100 000.00 fun BigDecimal.formatTokenForMoonpay(): String = formatWithDecimals(MOONPAY_DECIMAL) diff --git a/token-service/src/main/java/org/p2p/token/service/converter/TokenServiceAmountsRemoteConverter.kt b/token-service/src/main/java/org/p2p/token/service/converter/TokenServiceAmountsRemoteConverter.kt index 9d2670241e..5e731dd8d5 100644 --- a/token-service/src/main/java/org/p2p/token/service/converter/TokenServiceAmountsRemoteConverter.kt +++ b/token-service/src/main/java/org/p2p/token/service/converter/TokenServiceAmountsRemoteConverter.kt @@ -2,6 +2,7 @@ package org.p2p.token.service.converter import java.math.BigInteger import org.p2p.core.crypto.Base58String +import org.p2p.core.utils.isZero import org.p2p.token.service.api.TokenServiceDataSource import org.p2p.token.service.api.request.TokenAmountsBodyRequest import org.p2p.token.service.api.request.TokenAmountsRequest @@ -15,6 +16,12 @@ internal class TokenServiceAmountsRemoteConverter( amountFrom: Pair, mintsToConvertTo: List ): Map { + + // do not request if amount is zero, output will obviously be zero + if (amountFrom.second.isZero()) { + return mintsToConvertTo.associateWith { BigInteger.ZERO } + } + val request = TokenAmountsBodyRequest( vsTokenMint = amountFrom.first.base58Value, amountLamports = amountFrom.second.toString(),