Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-22 17:56:36 +04:00
parent 71c220eaec
commit 7680435313
10 changed files with 478 additions and 67 deletions

View file

@ -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(

View file

@ -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

View file

@ -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,
)
}
}
}
}

View file

@ -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()
}
}

View file

@ -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].

View file

@ -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) {

View file

@ -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<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.
*

View file

@ -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,

View file

@ -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])
//

View file

@ -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 ^