Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 12:32:13 +04:00
parent fa55c16871
commit 85b00c4c65
18 changed files with 269 additions and 16 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.tap.common.libs.blockchainsdk package com.tangem.tap.common.libs.blockchainsdk
import androidx.annotation.VisibleForTesting
import com.tangem.Message import com.tangem.Message
import com.tangem.TangemSdk import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.common.TransactionSigner
@ -7,22 +8,70 @@ import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm
import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.core.analytics.store.LastSignedWalletFormStore
import com.tangem.data.card.TransactionSignerFactory import com.tangem.data.card.TransactionSignerFactory
import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.update
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.tap.domain.TangemSigner import com.tangem.tap.domain.TangemSigner
import com.tangem.tap.domain.TangemSignerResponse
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
internal class DefaultTransactionSignerFactory( internal class DefaultTransactionSignerFactory(
private val lastSignedWalletFormStore: LastSignedWalletFormStore, private val lastSignedWalletFormStore: LastSignedWalletFormStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val coroutineScope: AppCoroutineScope,
) : TransactionSignerFactory { ) : TransactionSignerFactory {
override fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner { override fun createTransactionSigner(
cardId: String?,
sdk: TangemSdk,
twinKey: TwinKey?,
userWalletId: UserWalletId,
): TransactionSigner {
return TangemSigner( return TangemSigner(
cardId = cardId, cardId = cardId,
tangemSdk = sdk, tangemSdk = sdk,
initialMessage = Message(), initialMessage = Message(),
twinKey = twinKey, twinKey = twinKey,
) { signResponse -> ) { signResponse ->
onSignerResponse(userWalletId, signResponse)
}
}
@VisibleForTesting
internal fun onSignerResponse(userWalletId: UserWalletId, signResponse: TangemSignerResponse) {
lastSignedWalletFormStore.update( lastSignedWalletFormStore.update(
if (signResponse.isRing) WalletForm.Ring else WalletForm.Card, if (signResponse.isRing) WalletForm.Ring else WalletForm.Card,
) )
coroutineScope.launch {
userWalletsListRepository.update(userWalletId) { userWallet ->
userWallet.updateSignedHashes(signResponse)
} }
} }
} }
private fun UserWallet.updateSignedHashes(signResponse: TangemSignerResponse): UserWallet {
if (this !is UserWallet.Cold) return this
return copy(
scanResponse = scanResponse.copy(
card = scanResponse.card.copy(
wallets = scanResponse.card.wallets.map { wallet ->
if (wallet.publicKey.contentEquals(signResponse.signedWalletPublicKey)) {
wallet.copy(
// Keep previously known counters if the signer response does not provide them,
// otherwise we would regress the UI counters to null.
totalSignedHashes = signResponse.totalSignedHashes ?: wallet.totalSignedHashes,
remainingSignatures = signResponse.remainingSignatures ?: wallet.remainingSignatures,
)
} else {
wallet
}
},
),
),
)
}
}

View file

@ -51,6 +51,7 @@ internal object WalletConnectDomainModule {
cardSdkConfigRepository.getCommonSigner( cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse),
userWalletId = wallet.walletId,
) )
} }
} }

View file

@ -2,7 +2,9 @@ package com.tangem.tap.di.libs.blockchainsdk
import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.core.analytics.store.LastSignedWalletFormStore
import com.tangem.data.card.TransactionSignerFactory import com.tangem.data.card.TransactionSignerFactory
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -20,7 +22,13 @@ internal class TransactionSignerFactoryModule {
@Singleton @Singleton
fun provideTransactionSignerFactory( fun provideTransactionSignerFactory(
lastSignedWalletFormStore: LastSignedWalletFormStore, lastSignedWalletFormStore: LastSignedWalletFormStore,
userWalletsListRepository: UserWalletsListRepository,
appCoroutineScope: AppCoroutineScope,
): TransactionSignerFactory { ): TransactionSignerFactory {
return DefaultTransactionSignerFactory(lastSignedWalletFormStore) return DefaultTransactionSignerFactory(
lastSignedWalletFormStore = lastSignedWalletFormStore,
userWalletsListRepository = userWalletsListRepository,
coroutineScope = appCoroutineScope,
)
} }
} }

View file

@ -40,6 +40,7 @@ class TangemSigner(
totalSignedHashes = result.data.totalSignedHashes, totalSignedHashes = result.data.totalSignedHashes,
remainingSignatures = result.data.remainingSignatures, remainingSignatures = result.data.remainingSignatures,
isRing = result.data.batchId?.let(::isRing) == true, isRing = result.data.batchId?.let(::isRing) == true,
signedWalletPublicKey = publicKey.seedKey,
), ),
) )
if (continuation.isActive) { if (continuation.isActive) {
@ -86,6 +87,7 @@ class TangemSigner(
totalSignedHashes = result.data.totalSignedHashes, totalSignedHashes = result.data.totalSignedHashes,
remainingSignatures = result.data.remainingSignatures, remainingSignatures = result.data.remainingSignatures,
isRing = result.data.batchId?.let(::isRing) == true, isRing = result.data.batchId?.let(::isRing) == true,
signedWalletPublicKey = publicKey.seedKey,
), ),
) )
if (continuation.isActive) { if (continuation.isActive) {
@ -102,8 +104,10 @@ class TangemSigner(
} }
} }
@Suppress("ArrayInDataClass")
data class TangemSignerResponse( data class TangemSignerResponse(
val totalSignedHashes: Int?, val totalSignedHashes: Int?,
val remainingSignatures: Int?, val remainingSignatures: Int?,
val isRing: Boolean, val isRing: Boolean,
val signedWalletPublicKey: ByteArray,
) )

View file

@ -0,0 +1,163 @@
package com.tangem.tap.common.libs.blockchainsdk
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm
import com.tangem.core.analytics.store.LastSignedWalletFormStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.tap.domain.TangemSignerResponse
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultTransactionSignerFactoryTest {
private val lastSignedWalletFormStore = mockk<LastSignedWalletFormStore>(relaxed = true)
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val factory = DefaultTransactionSignerFactory(
lastSignedWalletFormStore = lastSignedWalletFormStore,
userWalletsListRepository = userWalletsListRepository,
coroutineScope = TestAppCoroutineScope(),
)
private val baseWallet = MockUserWalletFactory.create()
/** Wallet that will be the target of the signing operation. */
private val walletA = baseWallet.scanResponse.card.wallets.first().copy(
publicKey = PUBLIC_KEY_A,
totalSignedHashes = 0,
remainingSignatures = 100,
)
/** Another wallet that must stay untouched after signing with [walletA]'s key. */
private val walletB = baseWallet.scanResponse.card.wallets.first().copy(
publicKey = PUBLIC_KEY_B,
totalSignedHashes = 7,
remainingSignatures = 50,
)
private val userWallet = baseWallet.copy(
scanResponse = baseWallet.scanResponse.copy(
card = baseWallet.scanResponse.card.copy(wallets = listOf(walletA, walletB)),
),
)
private val savedWalletSlot = slot<UserWallet>()
@BeforeEach
fun setup() {
clearMocks(lastSignedWalletFormStore, userWalletsListRepository)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
coEvery { userWalletsListRepository.saveWithoutLock(capture(savedWalletSlot), any()) } answers {
savedWalletSlot.captured.right()
}
}
@Test
fun `updates signed hashes only for the wallet matching the signed public key`() {
factory.onSignerResponse(
userWalletId = userWallet.walletId,
signResponse = signerResponse(
signedWalletPublicKey = PUBLIC_KEY_A,
totalSignedHashes = 5,
remainingSignatures = 95,
),
)
val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
val savedA = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }
val savedB = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) }
assertThat(savedA.totalSignedHashes).isEqualTo(5)
assertThat(savedA.remainingSignatures).isEqualTo(95)
// The non-signed wallet must keep its original values.
assertThat(savedB.totalSignedHashes).isEqualTo(7)
assertThat(savedB.remainingSignatures).isEqualTo(50)
}
@Test
fun `keeps previously known counters when the signer response has null values`() {
factory.onSignerResponse(
userWalletId = userWallet.walletId,
signResponse = signerResponse(
signedWalletPublicKey = PUBLIC_KEY_A,
totalSignedHashes = null,
remainingSignatures = null,
),
)
val savedA = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }
// Null response values must not overwrite the known counters.
assertThat(savedA.totalSignedHashes).isEqualTo(0)
assertThat(savedA.remainingSignatures).isEqualTo(100)
}
@Test
fun `leaves all wallets untouched when no public key matches`() {
factory.onSignerResponse(
userWalletId = userWallet.walletId,
signResponse = signerResponse(
signedWalletPublicKey = UNKNOWN_PUBLIC_KEY,
totalSignedHashes = 5,
remainingSignatures = 95,
),
)
val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }.totalSignedHashes).isEqualTo(0)
assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) }.totalSignedHashes).isEqualTo(7)
}
@Test
fun `updates last signed wallet form with Card for a non-ring response`() {
factory.onSignerResponse(
userWalletId = userWallet.walletId,
signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = false),
)
verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Card) }
}
@Test
fun `updates last signed wallet form with Ring for a ring response`() {
factory.onSignerResponse(
userWalletId = userWallet.walletId,
signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = true),
)
verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Ring) }
}
private fun signerResponse(
signedWalletPublicKey: ByteArray,
totalSignedHashes: Int? = 1,
remainingSignatures: Int? = 1,
isRing: Boolean = false,
) = TangemSignerResponse(
totalSignedHashes = totalSignedHashes,
remainingSignatures = remainingSignatures,
isRing = isRing,
signedWalletPublicKey = signedWalletPublicKey,
)
private companion object {
val PUBLIC_KEY_A = byteArrayOf(1, 2, 3)
val PUBLIC_KEY_B = byteArrayOf(4, 5, 6)
val UNKNOWN_PUBLIC_KEY = byteArrayOf(9, 9, 9)
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWalletId
/** /**
* Implementation of repository for managing of CardSDK config * Implementation of repository for managing of CardSDK config
@ -61,8 +62,13 @@ internal class DefaultCardSdkConfigRepository(
} }
} }
override fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner { override fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner {
return transactionSignerFactory.createTransactionSigner(cardId = cardId, sdk = sdk, twinKey = twinKey) return transactionSignerFactory.createTransactionSigner(
cardId = cardId,
sdk = sdk,
twinKey = twinKey,
userWalletId = userWalletId,
)
} }
override fun isLinkedTerminal() = sdk.config.linkedTerminal override fun isLinkedTerminal() = sdk.config.linkedTerminal

View file

@ -3,11 +3,17 @@ package com.tangem.data.card
import com.tangem.TangemSdk import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.models.wallet.UserWalletId
/** /**
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
interface TransactionSignerFactory { interface TransactionSignerFactory {
fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner fun createTransactionSigner(
cardId: String?,
sdk: TangemSdk,
twinKey: TwinKey?,
userWalletId: UserWalletId,
): TransactionSigner
} }

View file

@ -4,6 +4,7 @@ import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWalletId
/** /**
* Repository for managing with CardSDK config * Repository for managing with CardSDK config
@ -28,8 +29,13 @@ interface CardSdkConfigRepository {
/** Update the card ID display format according to the [productType] of the scanned card */ /** Update the card ID display format according to the [productType] of the scanned card */
fun updateCardIdDisplayFormat(productType: ProductType) fun updateCardIdDisplayFormat(productType: ProductType)
/** Get common signer by [cardId] */ /**
fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner * Get common signer by [cardId].
*
* @param userWalletId ID of the user wallet being signed. Used to persist the updated number of signed hashes
* back into the wallet after a successful signing operation.
*/
fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner
/** Check if linked terminal is enabled */ /** Check if linked terminal is enabled */
fun isLinkedTerminal(): Boolean? fun isLinkedTerminal(): Boolean?

View file

@ -65,14 +65,15 @@ class AssociateAssetUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner { private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) { return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet) is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner() is UserWallet.Cold -> getColdSigner(userWallet)
} }
} }
private fun getColdSigner(): TransactionSigner { private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner( return cardSdkConfigRepository.getCommonSigner(
cardId = null, cardId = null,
twinKey = null, // use null here because no assets support for Twin cards twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
) )
} }

View file

@ -62,14 +62,15 @@ class OpenTrustlineUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner { private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) { return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet) is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner() is UserWallet.Cold -> getColdSigner(userWallet)
} }
} }
private fun getColdSigner(): TransactionSigner { private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner( return cardSdkConfigRepository.getCommonSigner(
cardId = null, cardId = null,
twinKey = null, // use null here because no assets support for Twin cards twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
) )
} }
} }

View file

@ -70,6 +70,7 @@ class PrepareAndSignUseCase(
val signer = cardSdkConfigRepository.getCommonSigner( val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
return signer return signer
} }

View file

@ -69,6 +69,7 @@ class PrepareForSendUseCase(
val signer = cardSdkConfigRepository.getCommonSigner( val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
return signer return signer
} }

View file

@ -58,14 +58,15 @@ class RetryIncompleteTransactionUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner { private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) { return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet) is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner() is UserWallet.Cold -> getColdSigner(userWallet)
} }
} }
private fun getColdSigner(): TransactionSigner { private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner( return cardSdkConfigRepository.getCommonSigner(
cardId = null, cardId = null,
twinKey = null, // use null here because no assets support for Twin cards twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
) )
} }
} }

View file

@ -36,6 +36,7 @@ class SendLargeSolanaTransactionUseCase(
val signer = cardSdkConfigRepository.getCommonSigner( val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
val walletManager = walletManagersFacade val walletManager = walletManagersFacade

View file

@ -62,6 +62,7 @@ class SendTransactionUseCase(
val coldSigner = cardSdkConfigRepository.getCommonSigner( val coldSigner = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
coldSigner coldSigner

View file

@ -43,6 +43,7 @@ class SignCloreMessageUseCase(
cardSdkConfigRepository.getCommonSigner( cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = null, twinKey = null,
userWalletId = userWallet.walletId,
) )
} }
is UserWallet.Hot -> getHotWalletSigner(userWallet) is UserWallet.Hot -> getHotWalletSigner(userWallet)

View file

@ -44,6 +44,7 @@ class SignUseCase(
return cardSdkConfigRepository.getCommonSigner( return cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
} }
} }

View file

@ -235,6 +235,7 @@ class CreateAndSendGaslessTransactionUseCase(
cardSdkConfigRepository.getCommonSigner( cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp }, cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
) )
} }
is UserWallet.Hot -> getHotWalletSigner(userWallet) is UserWallet.Hot -> getHotWalletSigner(userWallet)