Skip to content

Commit 98eaabd

Browse files
committed
Merge branch 'master' into feature/MOBILE-685
Resolve Trezor EVM signer conflicts for USB support: personal_sign (verify wallet) now signs on-device over USB via trezor-kit 1.1.1 (signEthereumMessage), reusing the recovery-id/address check; eth_sign and typed data stay unsupported. Wire EvmSignerFactory to ITrezorClient and route EVM signer construction through it; bump trezor-kit to 1.1.1.
2 parents 42aee51 + 062fbac commit 98eaabd

86 files changed

Lines changed: 4700 additions & 909 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ plugins {
1515
id 'com.google.gms.google-services'
1616
id 'org.jetbrains.kotlin.plugin.serialization'
1717
id 'androidx.navigation.safeargs.kotlin'
18+
id 'io.github.takahirom.roborazzi' version '1.65.0'
1819
id 'androidx.baselineprofile'
1920
}
2021

@@ -241,8 +242,11 @@ android {
241242

242243
testOptions {
243244
unitTests.returnDefaultValues = true
245+
unitTests.includeAndroidResources = true
244246
unitTests.all {
245247
useJUnitPlatform()
248+
maxHeapSize = "2g"
249+
forkEvery = 80
246250
}
247251
}
248252

@@ -515,6 +519,18 @@ dependencies {
515519
testRuntimeOnly libs.spek.runner.junit5
516520
testRuntimeOnly libs.kotlin.reflect
517521

522+
// Roborazzi — JVM screenshot rendering of @Preview composables (no emulator)
523+
testImplementation platform(libs.compose.bom)
524+
testImplementation libs.roborazzi
525+
testImplementation libs.roborazzi.compose
526+
testImplementation libs.roborazzi.preview.scanner.support
527+
testImplementation libs.composable.preview.scanner.android
528+
testImplementation libs.robolectric
529+
testImplementation libs.compose.ui.test.junit4
530+
// Robolectric runners are JUnit4; the module runs tests on the JUnit Platform,
531+
// so the vintage engine is required to discover and run them.
532+
testRuntimeOnly 'org.junit.vintage:junit-vintage-engine'
533+
518534
//Android Tor
519535
implementation libs.tor.android
520536

@@ -568,6 +584,19 @@ tasks.register('cleanMacOsMetadata') {
568584
}
569585
preBuild.dependsOn cleanMacOsMetadata
570586

587+
// Roborazzi preview screenshots are a local design-review tool, not a CI gate.
588+
// Robolectric renders differently across OSes (fonts/colors), so running them on
589+
// CI (Linux) would be flaky against images recorded elsewhere. Keep them out of the
590+
// normal unit-test run (including CI's :app:testDebugUnitTest) unless explicitly
591+
// opted in with -Pscreenshots (used by the record/verify commands).
592+
tasks.withType(Test).configureEach {
593+
if (!project.hasProperty('screenshots')) {
594+
filter {
595+
excludeTestsMatching 'cash.p.terminal.screenshots.*'
596+
}
597+
}
598+
}
599+
571600
// Single entry point. Runs one generation journey on the DISPOSABLE
572601
// cash.p.terminal.baseline sandbox (onboarding → create wallet → tabs → search →
573602
// cold restart with wallet → second wallet + switch) and copies the resulting

app/src/main/java/cash/p/terminal/core/MarketKitExtensions.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,14 +269,13 @@ val BlockchainType.isEvm: Boolean
269269
-> false
270270
}
271271

272-
val BlockchainType.isUtxoBased: Boolean
272+
val BlockchainType.isBtcLike: Boolean
273273
get() = when (this) {
274274
BlockchainType.Bitcoin,
275275
BlockchainType.BitcoinCash,
276276
BlockchainType.Dash,
277277
BlockchainType.ECash,
278278
BlockchainType.Litecoin,
279-
BlockchainType.Zcash,
280279
BlockchainType.Dogecoin,
281280
BlockchainType.PirateCash,
282281
BlockchainType.Cosanta
@@ -297,10 +296,14 @@ val BlockchainType.isUtxoBased: Boolean
297296
BlockchainType.Ton,
298297
BlockchainType.Tron,
299298
is BlockchainType.Unsupported,
299+
BlockchainType.Zcash,
300300
BlockchainType.Monero
301301
-> false
302302
}
303303

304+
val BlockchainType.isUtxoBased: Boolean
305+
get() = isBtcLike || this == BlockchainType.Zcash
306+
304307

305308
fun BlockchainType.supports(accountType: AccountType): Boolean {
306309
return when (accountType) {

app/src/main/java/cash/p/terminal/core/adapters/zcash/ZcashAdapter.kt

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ class ZcashAdapter(
120120
private val backgroundManager: BackgroundManager,
121121
private val singleUseAddressManager: ZcashSingleUseAddressManager,
122122
private val dispatcherProvider: DispatcherProvider,
123+
private val restartBaseDelayMs: Long = 15_000,
124+
private val restartMaxDelayMs: Long = 120_000,
123125
) : IAdapter, IBalanceAdapter, IReceiveAdapter, ITransactionsAdapter, ISendZcashAdapter,
124126
OneTimeReceiveAdapter {
125127
private var accountBirthday = 0L
@@ -161,6 +163,8 @@ class ZcashAdapter(
161163

162164
private var startJob: Job? = null
163165
private var statusJob: Job? = null
166+
private var restartJob: Job? = null
167+
private var restartAttempt = 0
164168
private var subscriberScope: CoroutineScope? = null
165169
override val isMainNet: Boolean = true
166170
private val scope = CoroutineScope(Dispatchers.IO)
@@ -309,9 +313,13 @@ class ZcashAdapter(
309313
scope.launch {
310314
backgroundManager.stateFlow.collect { state ->
311315
when (state) {
312-
BackgroundManagerState.EnterForeground -> start()
316+
BackgroundManagerState.EnterForeground -> {
317+
// Cancel a pending self-heal restart so it doesn't race with this one.
318+
resetRestart()
319+
start()
320+
}
313321
BackgroundManagerState.EnterBackground -> {
314-
if (pollingSessionCount.get() == 0 && !backgroundKeepAliveManager.isKeepAlive(BlockchainType.Zcash)) {
322+
if (!hasActiveBackgroundSession()) {
315323
pauseSynchronizer()
316324
} else {
317325
Timber.tag("TxPoller").d("ZcashAdapter staying alive")
@@ -435,6 +443,10 @@ class ZcashAdapter(
435443
}
436444

437445
override fun start() {
446+
// Corruption recovery owns the whole lifecycle: it closes, erases, and recreates the
447+
// synchronizer itself, so every other restart trigger (foreground, polling, self-heal)
448+
// must stay out of the way while it is in flight.
449+
if (recovering.get()) return
438450
importUfvkError?.let {
439451
syncState = AdapterState.NotSynced(it)
440452
return
@@ -878,6 +890,36 @@ class ZcashAdapter(
878890

879891
private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) = Unit
880892

893+
// ZEC is intentionally kept running in the background during an active polling session or
894+
// realtime keep-alive (see EnterBackground above), so self-heal must be allowed in those
895+
// cases too, not just while the app is in the foreground.
896+
private fun hasActiveBackgroundSession(): Boolean =
897+
pollingSessionCount.get() > 0 || backgroundKeepAliveManager.isKeepAlive(BlockchainType.Zcash)
898+
899+
private fun scheduleRestart() {
900+
if (recovering.get()) return
901+
if (!backgroundManager.inForeground && !hasActiveBackgroundSession()) return
902+
if (restartJob?.isActive == true) return
903+
val delayMs = zcashRestartDelayFor(restartAttempt, restartBaseDelayMs, restartMaxDelayMs)
904+
restartAttempt++
905+
restartJob = scope.launch {
906+
delay(delayMs)
907+
// No syncState re-check here: resetRestart() already cancels this job the moment
908+
// SYNCING/SYNCED is observed, so reaching this point means the restart is still due.
909+
// (syncState itself is unreliable at this point - subscribe()'s eager resubscription
910+
// to the progress/processorInfo flows can transiently flip it back to Syncing.)
911+
if (backgroundManager.inForeground || hasActiveBackgroundSession()) {
912+
start()
913+
}
914+
}
915+
}
916+
917+
private fun resetRestart() {
918+
restartAttempt = 0
919+
restartJob?.cancel()
920+
restartJob = null
921+
}
922+
881923
private fun onStatus(status: Synchronizer.Status) {
882924
syncState = when (status) {
883925
Synchronizer.Status.STOPPED -> AdapterState.NotSynced(Exception("stopped"))
@@ -886,6 +928,14 @@ class ZcashAdapter(
886928
Synchronizer.Status.SYNCED -> AdapterState.Synced
887929
else -> syncState
888930
}
931+
// Self-heal on terminal STOPPED; reset backoff once syncing resumes. DISCONNECTED and
932+
// PREPARING are left to the SDK's own reconnect loop.
933+
when (status) {
934+
Synchronizer.Status.STOPPED -> scheduleRestart()
935+
Synchronizer.Status.SYNCING,
936+
Synchronizer.Status.SYNCED -> resetRestart()
937+
else -> {}
938+
}
889939
}
890940

891941
private fun startOneTimeAddressBalanceCheck() {
@@ -1056,6 +1106,9 @@ internal fun WalletBalance.toBalanceData(decimalCount: Int) = BalanceData(
10561106
pending = pending.convertZatoshiToZec(decimalCount)
10571107
)
10581108

1109+
internal fun zcashRestartDelayFor(attempt: Int, baseMs: Long, maxMs: Long): Long =
1110+
(baseMs shl attempt.coerceAtMost(3)).coerceAtMost(maxMs)
1111+
10591112
object ZcashAddressValidator {
10601113
fun validate(address: String): Boolean {
10611114
return isValidZcashAddress(address)

app/src/main/java/cash/p/terminal/core/managers/AdapterManager.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import cash.p.terminal.wallet.Wallet
1313
import cash.p.terminal.wallet.entities.BalanceData
1414
import cash.p.terminal.wallet.entities.TokenType
1515
import cash.p.terminal.wallet.litecoinMwebAccountIds
16+
import io.horizontalsystems.core.DispatcherProvider
1617
import io.horizontalsystems.core.entities.BlockchainType
1718
import io.reactivex.BackpressureStrategy
1819
import io.reactivex.Flowable
1920
import io.reactivex.subjects.PublishSubject
2021
import kotlinx.coroutines.CoroutineScope
21-
import kotlinx.coroutines.Dispatchers
2222
import kotlinx.coroutines.SupervisorJob
2323
import kotlinx.coroutines.Job
2424
import kotlinx.coroutines.channels.BufferOverflow
@@ -57,11 +57,12 @@ class AdapterManager(
5757
private val moneroKitManager: MoneroKitManager,
5858
private val stellarKitManager: StellarKitManager,
5959
private val pendingBalanceCalculator: PendingBalanceCalculator,
60-
private val fallbackAddressProvider: FallbackAddressProvider
60+
private val fallbackAddressProvider: FallbackAddressProvider,
61+
dispatcherProvider: DispatcherProvider
6162
) : IAdapterManager, HandlerThread("A") {
6263

6364
private val mutex = Mutex()
64-
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
65+
private val coroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob())
6566

6667
private val adaptersReadySubject = PublishSubject.create<Map<Wallet, IAdapter>>()
6768
private val adaptersMap = ConcurrentHashMap<Wallet, IAdapter>()

app/src/main/java/cash/p/terminal/core/managers/EvmKitManager.kt

Lines changed: 5 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,15 @@ import cash.p.terminal.core.onPollingStarted
77
import cash.p.terminal.core.onPollingStopped
88
import cash.p.terminal.core.UnsupportedAccountException
99
import cash.p.terminal.core.providers.AppConfigProvider
10-
import cash.p.terminal.tangem.common.CustomXPubKeyAddressParser
11-
import cash.p.terminal.tangem.domain.model.AddressBytesWithPublicKey
12-
import cash.p.terminal.tangem.signer.HardwareWalletEvmSigner
13-
import cash.p.terminal.trezorkit.client.ITrezorClient
1410
import cash.p.terminal.trezor.signer.TrezorEvmSigner
1511
import cash.p.terminal.wallet.Account
1612
import cash.p.terminal.wallet.AccountOrigin
17-
import cash.p.terminal.wallet.AccountType
18-
import cash.p.terminal.wallet.IHardwarePublicKeyStorage
19-
import cash.p.terminal.wallet.entities.HardwarePublicKey
2013
import io.horizontalsystems.core.BackgroundManager
2114
import io.horizontalsystems.core.BackgroundManagerState
2215
import io.horizontalsystems.core.entities.BlockchainType
2316
import io.horizontalsystems.erc20kit.core.Erc20Kit
2417
import io.horizontalsystems.ethereumkit.core.EthereumKit
2518
import io.horizontalsystems.ethereumkit.core.signer.Signer
26-
import io.horizontalsystems.ethereumkit.models.Address
2719
import io.horizontalsystems.ethereumkit.models.Chain
2820
import io.horizontalsystems.ethereumkit.models.FullTransaction
2921
import io.horizontalsystems.ethereumkit.models.GasPrice
@@ -62,10 +54,8 @@ class EvmKitManager(
6254
private val syncSourceManager: EvmSyncSourceManager,
6355
private val backgroundKeepAliveManager: BackgroundKeepAliveManager
6456
) {
65-
private val hardwarePublicKeyStorage: IHardwarePublicKeyStorage
66-
by inject(IHardwarePublicKeyStorage::class.java)
67-
private val trezorClient: ITrezorClient
68-
by inject(ITrezorClient::class.java)
57+
private val evmSignerFactory: EvmSignerFactory
58+
by inject(EvmSignerFactory::class.java)
6959

7060
private val lifecycleMutex = Mutex()
7161
private val pollingSessionCount = AtomicInteger(0)
@@ -118,9 +108,7 @@ class EvmKitManager(
118108
}
119109

120110
if (this.evmKitWrapper == null) {
121-
val accountType = account.type
122111
evmKitWrapper = createKitInstance(
123-
accountType = accountType,
124112
account = account,
125113
blockchainType = blockchainType
126114
)
@@ -133,55 +121,14 @@ class EvmKitManager(
133121
}
134122

135123
private fun createKitInstance(
136-
accountType: AccountType,
137124
account: Account,
138125
blockchainType: BlockchainType
139126
): EvmKitWrapper {
140127
val syncSource = syncSourceManager.getSyncSource(blockchainType)
141128

142-
val address: Address
143-
var signer: Signer? = null
144-
145-
when (accountType) {
146-
is AccountType.Mnemonic -> {
147-
val seed: ByteArray = accountType.seed
148-
address = Signer.address(seed, chain)
149-
signer = Signer.getInstance(seed, chain)
150-
}
151-
152-
is AccountType.EvmPrivateKey -> {
153-
address = Signer.address(accountType.key)
154-
signer = Signer.getInstance(accountType.key, chain)
155-
}
156-
157-
is AccountType.HardwareCard -> {
158-
val (publicKey, addressWithPublicKey) = resolveHardwareAddress(account.id, blockchainType)
159-
address = Address(addressWithPublicKey.addressBytes)
160-
signer = HardwareWalletEvmSigner(
161-
address = address,
162-
publicKey = publicKey,
163-
chain = chain,
164-
expectedPublicKeyBytes = addressWithPublicKey.publicKey
165-
)
166-
}
167-
168-
is AccountType.TrezorDevice -> {
169-
val (publicKey, addressWithPublicKey) = resolveHardwareAddress(account.id, blockchainType)
170-
address = Address(addressWithPublicKey.addressBytes)
171-
signer = TrezorEvmSigner(
172-
address = address,
173-
chain = chain,
174-
derivationPath = publicKey.derivationPath,
175-
trezorClient = trezorClient
176-
)
177-
}
178-
179-
is AccountType.EvmAddress -> {
180-
address = Address(accountType.address)
181-
}
182-
183-
else -> throw UnsupportedAccountException()
184-
}
129+
val address = runBlocking { evmSignerFactory.resolveAddress(account, blockchainType, chain) }
130+
?: throw UnsupportedAccountException()
131+
val signer = runBlocking { evmSignerFactory.createSigner(account, blockchainType, chain) }
185132

186133
val evmKit = EthereumKit.getInstance(
187134
application = App.instance,
@@ -247,19 +194,6 @@ class EvmKitManager(
247194
)
248195
}
249196

250-
private fun resolveHardwareAddress(
251-
accountId: String,
252-
blockchainType: BlockchainType
253-
): Pair<HardwarePublicKey, AddressBytesWithPublicKey> {
254-
val publicKey = runBlocking {
255-
requireNotNull(
256-
hardwarePublicKeyStorage.getKeyByBlockchain(accountId, blockchainType)
257-
)
258-
}
259-
val addressWithPublicKey = CustomXPubKeyAddressParser.parse(publicKey.key.value)
260-
return Pair(publicKey, addressWithPublicKey)
261-
}
262-
263197
suspend fun unlink(account: Account) = lifecycleMutex.withLock {
264198
if (account == currentAccount) {
265199
useCount.decrementAndGet()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package cash.p.terminal.core.managers
2+
3+
import cash.p.terminal.tangem.signer.HardwareWalletEvmSigner
4+
import cash.p.terminal.trezor.signer.TrezorEvmSigner
5+
import io.horizontalsystems.ethereumkit.core.signer.Signer
6+
7+
/**
8+
* Single dispatch point for EVM message signing. The base [Signer] exposes only non-suspend,
9+
* final `signByteArray*`/`signTypedData` methods signed with a mock key for hardware wallets
10+
* (see [HardwareWalletEvmSigner]/[TrezorEvmSigner]), so hardware signers expose their own
11+
* suspend counterparts that must be routed here instead of calling the base methods directly.
12+
*/
13+
object EvmMessageSigning {
14+
15+
suspend fun signPersonalMessage(signer: Signer, message: ByteArray): ByteArray = when (signer) {
16+
is HardwareWalletEvmSigner -> signer.signPersonalMessage(message)
17+
is TrezorEvmSigner -> signer.signPersonalMessage(message)
18+
else -> signer.signByteArray(message)
19+
}
20+
21+
suspend fun signLegacyHash(signer: Signer, hash: ByteArray): ByteArray = when (signer) {
22+
is HardwareWalletEvmSigner -> signer.signLegacyHash(hash)
23+
is TrezorEvmSigner -> signer.signLegacyHash(hash)
24+
else -> signer.signByteArrayLegacy(hash)
25+
}
26+
27+
suspend fun signTypedData(signer: Signer, rawJson: String): ByteArray = when (signer) {
28+
is HardwareWalletEvmSigner -> signer.signTypedDataMessage(rawJson)
29+
is TrezorEvmSigner -> signer.signTypedDataMessage(rawJson)
30+
else -> signer.signTypedData(rawJson)
31+
}
32+
}

0 commit comments

Comments
 (0)