Updated on 2026-08-14
This commit is contained in:
parent
71c220eaec
commit
7680435313
10 changed files with 478 additions and 67 deletions
|
|
@ -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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAssociateAssetUseCase(
|
fun provideAssociateAssetUseCase(
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
||||||
return getEnabledDynamicAddressesManagerOrNull(userWalletId, network) != null
|
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? {
|
override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? {
|
||||||
val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null
|
val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null
|
||||||
return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address
|
return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address
|
||||||
|
|
|
||||||
|
|
@ -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<SendTransactionError, String> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<UserWallet.Hot>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -237,6 +237,15 @@ interface WalletManagersFacade {
|
||||||
derivationPath: String?,
|
derivationPath: String?,
|
||||||
): BigDecimal
|
): 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)
|
* Get requirements for asset(currency)
|
||||||
* @return null if there's no requirement, otherwise [AssetRequirementsCondition].
|
* @return null if there's no requirement, otherwise [AssetRequirementsCondition].
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee
|
||||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
|
||||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||||
import com.tangem.core.ui.extensions.stringReference
|
import com.tangem.core.ui.extensions.stringReference
|
||||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
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.toStringWithRightOffset
|
||||||
import com.tangem.feature.swap.domain.models.ui.*
|
import com.tangem.feature.swap.domain.models.ui.*
|
||||||
import com.tangem.features.swap.SwapFeatureToggles
|
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.coroutines.runSuspendCatching
|
||||||
import com.tangem.utils.extensions.orZero
|
import com.tangem.utils.extensions.orZero
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
|
@ -81,6 +82,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
||||||
private val allowPermissionsHandler: AllowPermissionsHandler,
|
private val allowPermissionsHandler: AllowPermissionsHandler,
|
||||||
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher,
|
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher,
|
||||||
private val sendTransactionUseCase: SendTransactionUseCase,
|
private val sendTransactionUseCase: SendTransactionUseCase,
|
||||||
|
private val signAndBroadcastPsbtUseCase: SignAndBroadcastPsbtUseCase,
|
||||||
private val createTransactionUseCase: CreateTransactionUseCase,
|
private val createTransactionUseCase: CreateTransactionUseCase,
|
||||||
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
|
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
|
||||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
|
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
|
||||||
|
|
@ -685,28 +687,59 @@ internal class SwapInteractorImpl @Inject constructor(
|
||||||
isTangemPayWithdrawal = isTangemPayWithdrawal,
|
isTangemPayWithdrawal = isTangemPayWithdrawal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ResolvedFlow.DexLike -> {
|
ResolvedFlow.DexLike -> onSwapDexLike(
|
||||||
val networkId = fromSwapCurrencyStatus.currency.network.rawId
|
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||||
if (isSolana(networkId)) {
|
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||||
onSwapSolanaDex(
|
swapProvider = swapProvider,
|
||||||
provider = swapProvider,
|
swapData = requireNotNull(swapData),
|
||||||
swapData = requireNotNull(swapData),
|
amountToSwap = amountToSwap,
|
||||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
fee = fee,
|
||||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
integratedApproval = integratedApproval,
|
||||||
amountToSwap = amountToSwap,
|
)
|
||||||
)
|
}
|
||||||
} else {
|
}
|
||||||
if (fee == null) return SwapTransactionState.Error.UnknownError
|
|
||||||
onSwapDex(
|
/**
|
||||||
provider = swapProvider,
|
* Dispatches a DEX-like swap by from-network: Solana (compiled tx) and Bitcoin (PSBT) get their
|
||||||
swapData = requireNotNull(swapData),
|
* own signing paths; everything else goes through the EVM [onSwapDex] (which requires a [fee]).
|
||||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
*/
|
||||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
@Suppress("LongParameterList")
|
||||||
swapFee = fee,
|
private suspend fun onSwapDexLike(
|
||||||
amountToSwap = amountToSwap,
|
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||||
integratedApproval = integratedApproval,
|
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(
|
private suspend fun handleSwapResult(
|
||||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||||
toSwapCurrencyStatus: 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 {
|
private fun getPayoutAddress(txData: TransactionData.Uncompiled): String {
|
||||||
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
||||||
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.tangem.blockchain.common.Amount
|
||||||
import com.tangem.blockchain.common.Blockchain
|
import com.tangem.blockchain.common.Blockchain
|
||||||
import com.tangem.blockchain.common.TransactionData
|
import com.tangem.blockchain.common.TransactionData
|
||||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
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.common.transaction.TransactionFee
|
||||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
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.domain.ExpressTransactionModel
|
||||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
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.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||||
|
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
|
||||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
@ -55,7 +57,7 @@ import java.math.BigInteger
|
||||||
*
|
*
|
||||||
* @see DexFeeResult for the returned shape.
|
* @see DexFeeResult for the returned shape.
|
||||||
*/
|
*/
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
|
||||||
class DexSwapFeeCalculator(
|
class DexSwapFeeCalculator(
|
||||||
private val getFeeUseCase: GetFeeUseCase,
|
private val getFeeUseCase: GetFeeUseCase,
|
||||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
|
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
|
||||||
|
|
@ -79,56 +81,117 @@ class DexSwapFeeCalculator(
|
||||||
?.movePointLeft(nativeCoinDecimals)
|
?.movePointLeft(nativeCoinDecimals)
|
||||||
?: BigDecimal.ZERO
|
?: BigDecimal.ZERO
|
||||||
|
|
||||||
if (isSolana(networkRawId)) {
|
when {
|
||||||
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
|
isBitcoin(networkRawId) -> calculateBitcoinFee(
|
||||||
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,
|
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||||
transactionBytes = transactionBytes,
|
transaction = transaction,
|
||||||
)
|
nativeCoinDecimals = nativeCoinDecimals,
|
||||||
DexFeeResult(
|
|
||||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
|
||||||
otherNativeFee = otherNativeFee,
|
otherNativeFee = otherNativeFee,
|
||||||
gas = null,
|
|
||||||
)
|
)
|
||||||
} else {
|
isSolana(networkRawId) -> calculateSolanaFee(
|
||||||
val rawFeeResult = getFeeDataForDexSwap(
|
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||||
|
transaction = transaction,
|
||||||
|
otherNativeFee = otherNativeFee,
|
||||||
|
)
|
||||||
|
else -> calculateEvmFee(
|
||||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||||
transaction = transaction,
|
transaction = transaction,
|
||||||
selectedToken = selectedToken,
|
selectedToken = selectedToken,
|
||||||
permissionState = permissionState,
|
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,
|
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<GetFeeError>.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<GetFeeError>.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<GetFeeError>.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.
|
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ internal open class SwapInteractorImplTestBase {
|
||||||
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
|
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
|
||||||
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true)
|
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true)
|
||||||
protected val sendTransactionUseCase: SendTransactionUseCase = 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 createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true)
|
||||||
protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true)
|
protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true)
|
||||||
protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
|
protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
|
||||||
|
|
@ -99,6 +100,7 @@ internal open class SwapInteractorImplTestBase {
|
||||||
allowPermissionsHandler = allowPermissionsHandler,
|
allowPermissionsHandler = allowPermissionsHandler,
|
||||||
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
|
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
|
||||||
sendTransactionUseCase = sendTransactionUseCase,
|
sendTransactionUseCase = sendTransactionUseCase,
|
||||||
|
signAndBroadcastPsbtUseCase = signAndBroadcastPsbtUseCase,
|
||||||
createTransactionUseCase = createTransactionUseCase,
|
createTransactionUseCase = createTransactionUseCase,
|
||||||
createTransferTransactionUseCase = createTransferTransactionUseCase,
|
createTransferTransactionUseCase = createTransferTransactionUseCase,
|
||||||
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ internal class DexSwapFeeCalculatorTest {
|
||||||
|
|
||||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||||
private val solanaNetwork = Blockchain.Solana.toNetworkId()
|
private val solanaNetwork = Blockchain.Solana.toNetworkId()
|
||||||
|
private val bitcoinNetwork = Blockchain.Bitcoin.toNetworkId()
|
||||||
|
|
||||||
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = 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])
|
// Integrated-approve simulated estimation override ([REDACTED_TASK_KEY])
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
# https://github.com/tangem/tangem-sdk-android/
|
# https://github.com/tangem/tangem-sdk-android/
|
||||||
# https://github.com/tangem/vico
|
# 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
|
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||||
tangemCardSdk = "releases-6.0-626"
|
tangemCardSdk = "releases-6.0-626"
|
||||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue