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
|
||||
@Singleton
|
||||
fun provideAssociateAssetUseCase(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
): 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].
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue