diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 667b78653d..57ab3eb8e5 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -69,6 +69,20 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideSignAndBroadcastPsbtUseCase( + cardSdkConfigRepository: CardSdkConfigRepository, + walletManagersFacade: WalletManagersFacade, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): SignAndBroadcastPsbtUseCase { + return SignAndBroadcastPsbtUseCase( + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + getHotTransactionSigner = tangemHotWalletSignerFactory::create, + ) + } + @Provides @Singleton fun provideAssociateAssetUseCase( diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 3a0617f596..cba4635b01 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -465,6 +465,17 @@ internal class DefaultWalletManagersFacade @Inject constructor( return getEnabledDynamicAddressesManagerOrNull(userWalletId, network) != null } + override suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal? = + withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + ?: return@withContext null + + when (val result = walletManager.getPsbtFee(psbtBase64)) { + is Result.Success -> result.data.toBigDecimal() + is Result.Failure -> null + } + } + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt new file mode 100644 index 0000000000..7f8d93b979 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt @@ -0,0 +1,80 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Signs and broadcasts a Bitcoin swap transaction supplied by a DEX provider as a Base64 PSBT. + * + * Unlike a normal send (where we build the transaction ourselves), the provider returns an + * almost-complete transaction encoded as a PSBT in `txData`. This use case: + * 1. derives which inputs belong to the wallet ([WalletManager.deriveSignInputs]), + * 2. signs them with the card/hot signer ([WalletManager.signPsbt]), + * 3. finalizes and broadcasts the transaction ([WalletManager.broadcastPsbt]), + * + * returning the resulting transaction hash. Errors from any step are mapped to [SendTransactionError] + * via [SendTransactionUseCase.handleError]. + */ +class SignAndBroadcastPsbtUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, + private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner, +) { + + suspend operator fun invoke( + psbtBase64: String, + userWallet: UserWallet, + network: Network, + ): Either { + walletManagersFacade.update( + userWalletId = userWallet.walletId, + network = network, + extraTokens = emptySet(), + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: return SendTransactionError.UnknownError().left() + + val signInputs = when (val result = walletManager.deriveSignInputs(psbtBase64)) { + is Result.Success -> result.data + is Result.Failure -> return SendTransactionUseCase.handleError(result).left() + } + + val signer = createSigner(userWallet) + + val signedPsbt = when (val result = walletManager.signPsbt(psbtBase64, signInputs, signer)) { + is Result.Success -> result.data + is Result.Failure -> return SendTransactionUseCase.handleError(result).left() + } + + return when (val result = walletManager.broadcastPsbt(signedPsbt)) { + is Result.Success -> result.data.right() + is Result.Failure -> SendTransactionUseCase.handleError(result).left() + } + } + + private fun createSigner(userWallet: UserWallet): TransactionSigner { + return when (userWallet) { + is UserWallet.Hot -> getHotTransactionSigner(userWallet) + is UserWallet.Cold -> { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, + ) + } + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt new file mode 100644 index 0000000000..aaf09a36da --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt @@ -0,0 +1,116 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Tests for [SignAndBroadcastPsbtUseCase] — the orchestration that derives the wallet's inputs from a + * provider PSBT, signs them, and broadcasts the finalized transaction (Bitcoin swap flow). + */ +internal class SignAndBroadcastPsbtUseCaseTest { + + private val walletManagersFacade: WalletManagersFacade = mockk() + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true) + private val signer: TransactionSigner = mockk(relaxed = true) + private val walletManager: WalletManager = mockk() + + private val useCase = SignAndBroadcastPsbtUseCase( + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + getHotTransactionSigner = { signer }, + ) + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val network: Network = mockk(relaxed = true) + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val psbt = "psbt-base64" + private val signInputs = listOf(SignInput(address = "addr", index = 0, sighashTypes = listOf(1))) + + @BeforeEach + fun setup() { + // The use case refreshes the wallet manager (fresh UTXO set) before signing the PSBT. + coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns mockk(relaxed = true) + } + + @Test + fun `GIVEN derive sign broadcast succeed WHEN invoke THEN returns tx hash`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager + every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs) + coEvery { walletManager.signPsbt(psbt, signInputs, signer) } returns Result.Success("signed-psbt") + coEvery { walletManager.broadcastPsbt("signed-psbt") } returns Result.Success("tx-hash") + + // Act + val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network) + + // Assert + assertThat(actual).isEqualTo("tx-hash".right()) + coVerify(exactly = 1) { walletManagersFacade.update(userWalletId, network, emptySet()) } + coVerify(exactly = 1) { walletManager.broadcastPsbt("signed-psbt") } + } + + @Test + fun `GIVEN deriveSignInputs fails WHEN invoke THEN returns error and does not sign`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager + every { + walletManager.deriveSignInputs(psbt) + } returns Result.Failure(BlockchainSdkError.CustomError("no inputs")) + + // Act + val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network) + + // Assert + assertThat(actual.isLeft()).isTrue() + coVerify(exactly = 0) { walletManager.signPsbt(any(), any(), any()) } + } + + @Test + fun `GIVEN signPsbt fails WHEN invoke THEN returns error and does not broadcast`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager + every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs) + coEvery { + walletManager.signPsbt(psbt, signInputs, signer) + } returns Result.Failure(BlockchainSdkError.CustomError("sign fail")) + + // Act + val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network) + + // Assert + assertThat(actual.isLeft()).isTrue() + coVerify(exactly = 0) { walletManager.broadcastPsbt(any()) } + } + + @Test + fun `GIVEN wallet manager missing WHEN invoke THEN returns error`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns null + + // Act + val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network) + + // Assert + assertThat(actual.isLeft()).isTrue() + } +} \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index b1f17fb70a..aa21b96b61 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -237,6 +237,15 @@ interface WalletManagersFacade { derivationPath: String?, ): BigDecimal + /** + * Computes the on-chain miner fee embedded in a Bitcoin swap [psbtBase64], in satoshi. + * + * Swap providers return a "naked" PSBT whose fee is implied by `sum(inputs) - sum(outputs)` + * rather than reported separately. Returns `null` if the wallet manager is unavailable, the + * network is not a PSBT-capable Bitcoin chain, or the fee cannot be derived from the PSBT. + */ + suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal? + /** * Get requirements for asset(currency) * @return null if there's no requirement, otherwise [AssetRequirementsCondition]. diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 0b10ee5654..9464e5f5b1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -13,7 +13,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat @@ -63,6 +62,8 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.lib.crypto.BlockchainUtils.isBitcoin +import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger @@ -81,6 +82,7 @@ internal class SwapInteractorImpl @Inject constructor( private val allowPermissionsHandler: AllowPermissionsHandler, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, private val sendTransactionUseCase: SendTransactionUseCase, + private val signAndBroadcastPsbtUseCase: SignAndBroadcastPsbtUseCase, private val createTransactionUseCase: CreateTransactionUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @@ -685,28 +687,59 @@ internal class SwapInteractorImpl @Inject constructor( isTangemPayWithdrawal = isTangemPayWithdrawal, ) } - ResolvedFlow.DexLike -> { - val networkId = fromSwapCurrencyStatus.currency.network.rawId - if (isSolana(networkId)) { - onSwapSolanaDex( - provider = swapProvider, - swapData = requireNotNull(swapData), - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amountToSwap = amountToSwap, - ) - } else { - if (fee == null) return SwapTransactionState.Error.UnknownError - onSwapDex( - provider = swapProvider, - swapData = requireNotNull(swapData), - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - swapFee = fee, - amountToSwap = amountToSwap, - integratedApproval = integratedApproval, - ) - } + ResolvedFlow.DexLike -> onSwapDexLike( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + swapProvider = swapProvider, + swapData = requireNotNull(swapData), + amountToSwap = amountToSwap, + fee = fee, + integratedApproval = integratedApproval, + ) + } + } + + /** + * Dispatches a DEX-like swap by from-network: Solana (compiled tx) and Bitcoin (PSBT) get their + * own signing paths; everything else goes through the EVM [onSwapDex] (which requires a [fee]). + */ + @Suppress("LongParameterList") + private suspend fun onSwapDexLike( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + swapProvider: SwapProvider, + swapData: SwapDataModel, + amountToSwap: String, + fee: SwapFee?, + integratedApproval: IntegratedApprovalData?, + ): SwapTransactionState { + val networkId = fromSwapCurrencyStatus.currency.network.rawId + return when { + isSolana(networkId) -> onSwapSolanaDex( + provider = swapProvider, + swapData = swapData, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amountToSwap = amountToSwap, + ) + isBitcoin(networkId) -> onSwapBitcoinPsbt( + provider = swapProvider, + swapData = swapData, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amountToSwap = amountToSwap, + ) + else -> { + if (fee == null) return SwapTransactionState.Error.UnknownError + onSwapDex( + provider = swapProvider, + swapData = swapData, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + swapFee = fee, + amountToSwap = amountToSwap, + integratedApproval = integratedApproval, + ) } } } @@ -1042,6 +1075,44 @@ internal class SwapInteractorImpl @Inject constructor( ) } + /** + * Bitcoin DEX swap: the provider returns an almost-complete transaction as a Base64 PSBT in + * `txData`. We derive our inputs, sign and broadcast it ourselves (see [SignAndBroadcastPsbtUseCase]), + * then reuse the shared DEX success path. No fee handling: the fee is already embedded in the PSBT. + */ + private suspend fun onSwapBitcoinPsbt( + provider: SwapProvider, + swapData: SwapDataModel, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + amountToSwap: String, + ): SwapTransactionState { + val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX + val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } + val psbtBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + + val result = signAndBroadcastPsbtUseCase( + psbtBase64 = psbtBase64, + userWallet = fromSwapCurrencyStatus.userWallet, + network = fromSwapCurrencyStatus.currency.network, + ) + return result.fold( + ifRight = { txHash -> + finalizeDexSwapSuccess( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + swapData = swapData, + amount = amount, + txHash = txHash, + payInAddress = swapData.transaction.txTo, + ) + }, + ifLeft = { SwapTransactionState.Error.TransactionError(it) }, + ) + } + private suspend fun handleSwapResult( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -2283,10 +2354,6 @@ internal class SwapInteractorImpl @Inject constructor( } } - private fun isSolana(networkId: String): Boolean { - return networkId == Blockchain.Solana.toNetworkId() - } - private fun getPayoutAddress(txData: TransactionData.Uncompiled): String { val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData return if (ethereumCallData is EthereumYieldSupplySendCallData) { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 171921234c..0dd14bd531 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -10,6 +10,7 @@ import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException @@ -32,6 +33,7 @@ import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUs import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES +import com.tangem.lib.crypto.BlockchainUtils.isBitcoin import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @@ -55,7 +57,7 @@ import java.math.BigInteger * * @see DexFeeResult for the returned shape. */ -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") class DexSwapFeeCalculator( private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, @@ -79,56 +81,117 @@ class DexSwapFeeCalculator( ?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - if (isSolana(networkRawId)) { - val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) - val formattedHash = getFormattedHash(transactionBytes) - - // TODO Update after new firmware [REDACTED_JIRA] - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && - fromSwapCurrencyStatus.userWallet is UserWallet.Cold - ) { - raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError) - } - - val solanaFee = getFeeDataForSolanaDexSwap( + when { + isBitcoin(networkRawId) -> calculateBitcoinFee( fromSwapCurrencyStatus = fromSwapCurrencyStatus, - transactionBytes = transactionBytes, - ) - DexFeeResult( - transactionFee = TransactionFeeResult.Loaded(solanaFee), + transaction = transaction, + nativeCoinDecimals = nativeCoinDecimals, otherNativeFee = otherNativeFee, - gas = null, ) - } else { - val rawFeeResult = getFeeDataForDexSwap( + isSolana(networkRawId) -> calculateSolanaFee( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + transaction = transaction, + otherNativeFee = otherNativeFee, + ) + else -> calculateEvmFee( fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, selectedToken = selectedToken, permissionState = permissionState, - ).bind() - // Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex. - // The original cast `(fee as TransactionFeeResult.Loaded)` only holds when - // selectedToken == null; we defensively support LoadedExtended too so the calculator - // also handles the gasless-token DEX branch (currently unreachable from production - // callers, kept for symmetry with the CEX calculator). - val patched: TransactionFeeResult = when (rawFeeResult) { - is TransactionFeeResult.Loaded -> - TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee)) - is TransactionFeeResult.LoadedExtended -> - TransactionFeeResult.LoadedExtended( - rawFeeResult.fee.copy( - transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee), - ), - ) - } - DexFeeResult( - transactionFee = patched, otherNativeFee = otherNativeFee, - gas = transaction.gas, ) } } + /** + * Bitcoin swaps arrive as a ready-made PSBT whose miner fee is implied by + * sum(inputs) - sum(outputs); a single provider-fixed tier with no gas bump. + */ + private suspend fun Raise.calculateBitcoinFee( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transaction: ExpressTransactionModel.DEX, + nativeCoinDecimals: Int, + otherNativeFee: BigDecimal, + ): DexFeeResult { + val network = fromSwapCurrencyStatus.currency.network + val feeSatoshi = walletManagersFacade.getPsbtFee( + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = network, + psbtBase64 = transaction.txData, + ) ?: raise(GetFeeError.UnknownError) + val feeAmount = Amount( + currencySymbol = network.currencySymbol, + value = feeSatoshi.movePointLeft(nativeCoinDecimals), + decimals = nativeCoinDecimals, + ) + return DexFeeResult( + transactionFee = TransactionFeeResult.Loaded(TransactionFee.Single(normal = Fee.Common(feeAmount))), + otherNativeFee = otherNativeFee, + gas = null, + ) + } + + private suspend fun Raise.calculateSolanaFee( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transaction: ExpressTransactionModel.DEX, + otherNativeFee: BigDecimal, + ): DexFeeResult { + val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) + val formattedHash = getFormattedHash(transactionBytes) + + // TODO Update after new firmware [REDACTED_JIRA] + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && + fromSwapCurrencyStatus.userWallet is UserWallet.Cold + ) { + raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError) + } + + val solanaFee = getFeeDataForSolanaDexSwap( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + transactionBytes = transactionBytes, + ) + return DexFeeResult( + transactionFee = TransactionFeeResult.Loaded(solanaFee), + otherNativeFee = otherNativeFee, + gas = null, + ) + } + + private suspend fun Raise.calculateEvmFee( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transaction: ExpressTransactionModel.DEX, + selectedToken: CryptoCurrencyStatus?, + permissionState: PermissionDataState, + otherNativeFee: BigDecimal, + ): DexFeeResult { + val rawFeeResult = getFeeDataForDexSwap( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + transaction = transaction, + selectedToken = selectedToken, + permissionState = permissionState, + ).bind() + // Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex. + // The original cast `(fee as TransactionFeeResult.Loaded)` only holds when + // selectedToken == null; we defensively support LoadedExtended too so the calculator + // also handles the gasless-token DEX branch (currently unreachable from production + // callers, kept for symmetry with the CEX calculator). + val patched: TransactionFeeResult = when (rawFeeResult) { + is TransactionFeeResult.Loaded -> + TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee)) + is TransactionFeeResult.LoadedExtended -> + TransactionFeeResult.LoadedExtended( + rawFeeResult.fee.copy( + transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee), + ), + ) + } + return DexFeeResult( + transactionFee = patched, + otherNativeFee = otherNativeFee, + gas = transaction.gas, + ) + } + /** * Yield-mode DEX fee path: routes the swap through the user's yield module proxy. * diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index 94d013973d..0d9b2d6065 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -62,6 +62,7 @@ internal open class SwapInteractorImplTestBase { protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true) private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true) protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + protected val signAndBroadcastPsbtUseCase: SignAndBroadcastPsbtUseCase = mockk(relaxed = true) protected val createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true) protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true) protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true) @@ -99,6 +100,7 @@ internal open class SwapInteractorImplTestBase { allowPermissionsHandler = allowPermissionsHandler, cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, sendTransactionUseCase = sendTransactionUseCase, + signAndBroadcastPsbtUseCase = signAndBroadcastPsbtUseCase, createTransactionUseCase = createTransactionUseCase, createTransferTransactionUseCase = createTransferTransactionUseCase, createTransactionExtrasUseCase = createTransactionExtrasUseCase, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 9b07cca2b8..39bc1e1c00 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -48,6 +48,7 @@ internal class DexSwapFeeCalculatorTest { private val ethNetwork = Blockchain.Ethereum.toNetworkId() private val solanaNetwork = Blockchain.Solana.toNetworkId() + private val bitcoinNetwork = Blockchain.Bitcoin.toNetworkId() private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true) @@ -584,6 +585,54 @@ internal class DexSwapFeeCalculatorTest { } } + // ------------------------------------------------------------------------- + // Bitcoin PSBT DEX path + // ------------------------------------------------------------------------- + + @Test + fun `Bitcoin DEX reads the PSBT fee from the wallet manager and skips the gas patch`() = runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = bitcoinNetwork, isCoin = true) + val transaction = buildDex(txData = "cHNidP8B-base64-psbt", gas = null) + + // 1_329 satoshi embedded in the PSBT → 0.00001329 BTC (8 decimals). + coEvery { + walletManagersFacade.getPsbtFee(any(), any(), psbtBase64 = "cHNidP8B-base64-psbt") + } returns BigDecimal("1329") + + val result = sut.calculate(fromStatus, transaction) + + // getFee/getEthSpecificFee must NOT be used for Bitcoin — the fee comes from the PSBT. + coVerify(exactly = 0) { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } + coVerify(exactly = 0) { + getEthSpecificFeeUseCase.invoke(userWallet = any(), cryptoCurrency = any(), gasLimit = any()) + } + assertThat(result.isRight()).isTrue() + result.onRight { dexFeeResult -> + val fee = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee + val btcFee = (fee as TransactionFee.Single).normal as Fee.Common + assertThat(btcFee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00001329")) + assertThat(btcFee.amount.decimals).isEqualTo(8) + assertThat(dexFeeResult.gas).isNull() + } + } + + @Test + fun `Bitcoin DEX returns Left UnknownError when the PSBT fee cannot be derived`() = runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = bitcoinNetwork, isCoin = true) + val transaction = buildDex(txData = "cHNidP8B-base64-psbt", gas = null) + + coEvery { walletManagersFacade.getPsbtFee(any(), any(), any()) } returns null + + val result = sut.calculate(fromStatus, transaction) + + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isEqualTo(GetFeeError.UnknownError) + } + } + // ------------------------------------------------------------------------- // Integrated-approve simulated estimation override ([REDACTED_TASK_KEY]) // diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9f936c4349..6aaa3d0243 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1578" +tangemBlockchainSdk = "releases-6.0-1580" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^