Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package to.bitkit.ui.screens.wallets.receive

import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import to.bitkit.test.annotations.ComposeUi
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.viewmodels.previewAmountInputViewModel
import kotlin.test.assertEquals
import kotlin.test.assertFalse

@ComposeUi
class EditInvoiceContentTest {

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun hardwareInvoiceContinuesOnchainWithoutTags() {
var updatedAmount: ULong? = null
var regularContinueCalled = false

composeTestRule.setContent {
AppThemeSurface {
EditInvoiceContent(
amountInputViewModel = previewAmountInputViewModel(sats = 12_345),
noteText = "Hardware payment",
isSoftKeyboardVisible = false,
onchainOnly = true,
keyboardVisible = false,
tags = persistentListOf("Hardware"),
onBack = {},
onContinueKeyboard = {},
onClickBalance = {},
onContinueGeneral = { regularContinueCalled = true },
onContinueOnchain = { updatedAmount = it },
onClickAddTag = {},
onTextChanged = {},
onClickTag = {},
)
}
}

composeTestRule.onNodeWithTag("TagsAdd").assertDoesNotExist()
composeTestRule.onNodeWithTag("Tag-Hardware").assertDoesNotExist()
composeTestRule.onNodeWithTag("ShowQrReceive").performClick()

assertEquals(12_345uL, updatedAmount)
assertFalse(regularContinueCalled)
}
}
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.TrezorScriptType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
Expand Down Expand Up @@ -41,6 +42,14 @@ data class HwWalletReceivedTx(
val walletId: String,
)

/** The next unused external address for a paired hardware-wallet account. */
@Immutable
data class HwReceiveAddress(
val address: String,
val path: String,
val addressType: HwFundingAddressType,
)

sealed interface HwFundingAccount {
val vendor: HwWalletVendor
val xpub: String
Expand Down Expand Up @@ -99,6 +108,14 @@ enum class HwFundingAddressType(
val accountType: AccountType
get() = addressType.toAccountType()

val trezorScriptType: TrezorScriptType
get() = when (this) {
LEGACY -> TrezorScriptType.SPEND_ADDRESS
NESTED_SEGWIT -> TrezorScriptType.SPEND_P2SH_WITNESS
NATIVE_SEGWIT -> TrezorScriptType.SPEND_WITNESS
TAPROOT -> TrezorScriptType.SPEND_TAPROOT
}

companion object {
val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE }
}
Expand Down
110 changes: 105 additions & 5 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
Expand Down Expand Up @@ -47,6 +48,7 @@ import to.bitkit.models.HwFundingAddressType
import to.bitkit.models.HwFundingBroadcastResult
import to.bitkit.models.HwFundingSignedTx
import to.bitkit.models.HwFundingTransaction
import to.bitkit.models.HwReceiveAddress
import to.bitkit.models.HwWallet
import to.bitkit.models.HwWalletReceivedTx
import to.bitkit.models.KnownDevice
Expand Down Expand Up @@ -330,7 +332,7 @@ class HwWalletRepo @Inject constructor(
}
val balanceSats = _watcherData.value
.values
.filter { it.addressType == addressType.settingsKey && it.walletId == walletId }
.filter { it.addressType == addressType && it.walletId == walletId }
.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }
HwFundingAccount.Trezor(
xpub = xpub,
Expand All @@ -340,6 +342,82 @@ class HwWalletRepo @Inject constructor(
}
}

/** Resolves the next unused external address from watcher state, falling back to an account scan. */
suspend fun getReceiveAddress(
walletId: String,
addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT,
): Result<HwReceiveAddress> = withContext(ioDispatcher) {
runSuspendCatching {
watcherReceiveAddress(walletId, addressType)?.let { return@runSuspendCatching it }
val account = getFundingAccount(walletId, addressType).getOrThrow()
val accountInfo = trezorRepo.getAccountInfo(
extendedKey = account.xpub,
network = Env.network.toCoreNetwork(),
scriptType = account.accountType,
).getOrThrow()
val unused = requireNotNull(accountInfo.account.addresses.unused.firstOrNull()) {
"No unused external address returned for hardware wallet '$walletId'"
}
val scannedAddress = HwReceiveAddress(
address = unused.address,
path = unused.path,
addressType = addressType,
)
watcherReceiveAddress(walletId, addressType) ?: scannedAddress
}
}

fun observeReceiveAddress(
walletId: String,
addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT,
): Flow<HwReceiveAddress?> = _watcherData
.map { watcherData -> watcherData.receiveAddress(walletId, addressType) }
.distinctUntilChanged()

private fun watcherReceiveAddress(
walletId: String,
addressType: HwFundingAddressType,
): HwReceiveAddress? = _watcherData.value.receiveAddress(walletId, addressType)

/** Displays the exact address currently shown by Bitkit on the device and rejects a mismatch. */
suspend fun verifyReceiveAddress(
walletId: String,
receiveAddress: HwReceiveAddress,
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
suspend fun readOnDevice() = trezorRepo.getAddress(
path = receiveAddress.path,
showOnTrezor = true,
scriptType = receiveAddress.addressType.trezorScriptType,
coin = Env.network.toTrezorCoinType(),
).getOrThrow()

ensureConnected(walletId).getOrThrow()
val firstAttempt = runSuspendCatching { readOnDevice() }
val firstError = firstAttempt.exceptionOrNull()
val response = if (firstError == null) {
firstAttempt.getOrThrow()
} else {
if (!firstError.isTrezorSessionFailure()) throw firstError
disconnectStaleSession(walletId).getOrThrow()
ensureConnected(walletId).getOrThrow()
runSuspendCatching { readOnDevice() }
.onFailure {
if (it.isTrezorSessionFailure()) {
disconnectStaleSession(walletId).getOrThrow()
}
}
.getOrThrow()
}
if (response.address != receiveAddress.address) {
throw HwReceiveAddressMismatchError(
"Address verification failed: Trezor returned '${response.address}' for " +
"'${receiveAddress.path}', expected '${receiveAddress.address}'"
)
}
}
}

/** Composes the exact on-chain funding payment before prompting for the Trezor signature. */
suspend fun composeFundingTransaction(
walletId: String,
Expand Down Expand Up @@ -585,7 +663,7 @@ class HwWalletRepo @Inject constructor(
val ids = devices.map { it.id }.toSet()
val walletWatchers = watcherData.values.filter { it.walletId == walletId }
val fundingBalanceSats = walletWatchers
.filter { it.addressType == HwFundingAddressType.DEFAULT.settingsKey }
.filter { it.addressType == HwFundingAddressType.DEFAULT }
.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }
HwWallet(
id = walletId,
Expand Down Expand Up @@ -649,11 +727,13 @@ class HwWalletRepo @Inject constructor(
val transactionDetails = event.transactionDetails
.filter { it.walletId == walletId }
.toImmutableList()
val addressType = watcherId.toFundingAddressType() ?: return@withLock emptyList()
val watcher = HwWatcherData(
walletId = walletId,
addressType = watcherId.toAddressTypeKey(),
addressType = addressType,
balanceSats = event.balance.total,
activities = activities,
receiveAddress = event.toReceiveAddress(addressType),
)
_watcherData.update { it + (watcherId to watcher) }
val previousIds = persistedActivityIds.getOrPut(watcherId) {
Expand Down Expand Up @@ -686,7 +766,6 @@ class HwWalletRepo @Inject constructor(
val persistedWatcher = watcher.copy(activities = immutablePersistedActivities)
val updatedWatcherData = _watcherData.value + (watcherId to persistedWatcher)
_watcherData.update { updatedWatcherData }

persistedActivityIds[watcherId] = persistedActivities.map { it.scopedId() }.toSet()
buildReceivedTxs(previousIds, persistedActivities, updatedWatcherData)
}
Expand Down Expand Up @@ -915,6 +994,9 @@ class HwWalletRepo @Inject constructor(
private fun String.toWalletId(): String = substringBefore(WATCHER_ID_SEPARATOR)

private fun String.toAddressTypeKey(): String = substringAfter(WATCHER_ID_SEPARATOR)

private fun String.toFundingAddressType(): HwFundingAddressType? =
HwFundingAddressType.entries.firstOrNull { it.settingsKey == toAddressTypeKey() }
}

private data class WatcherSettings(
Expand Down Expand Up @@ -957,6 +1039,8 @@ class HwPassphraseRequiredError : AppError("Passphrase needed to reopen this wal
/** The entered passphrase opened a different wallet than the one being signed from. */
class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet")

class HwReceiveAddressMismatchError(message: String) : AppError(message)

/**
* A removal asked to keep the wallet's backup data, but its tags could not be read. Raised before
* anything is deleted, so the wallet is untouched and the removal can be retried or repeated without
Expand All @@ -966,9 +1050,25 @@ class HwBackupDataUnreadableError(cause: Throwable) : AppError("Could not read t

private data class HwWatcherData(
val walletId: String,
val addressType: String,
val addressType: HwFundingAddressType,
val balanceSats: ULong,
val activities: ImmutableList<Activity>,
val receiveAddress: HwReceiveAddress,
)

private fun Map<String, HwWatcherData>.receiveAddress(
walletId: String,
addressType: HwFundingAddressType,
): HwReceiveAddress? = values.firstOrNull {
it.walletId == walletId && it.addressType == addressType
}?.receiveAddress

private fun WatcherEvent.TransactionsChanged.toReceiveAddress(
addressType: HwFundingAddressType,
) = HwReceiveAddress(
address = nextUnusedExternalAddress.address,
path = nextUnusedExternalAddress.path,
addressType = addressType,
)

private data class HwSnapshot(
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ fun ContentView(
ReceiveSheet(
appViewModel = appViewModel,
startRoute = sheet.route,
hardwareWalletId = sheet.hardwareWalletId,
walletState = walletState,
isOffline = connectivityState != ConnectivityState.CONNECTED,
navigateToExternalConnection = {
Expand Down Expand Up @@ -666,7 +667,9 @@ fun ContentView(
onSendClick = {
appViewModel.showSheet(Sheet.Send(hardwareWalletId = currentHardwareWalletId))
},
onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) },
onReceiveClick = {
appViewModel.showSheet(Sheet.Receive(hardwareWalletId = currentHardwareWalletId))
},
onScanClick = { appViewModel.showScannerSheet() },
)
}
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/to/bitkit/ui/components/SheetHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ sealed interface Sheet {
val route: SendRoute = SendRoute.Recipient,
val hardwareWalletId: String? = null,
) : Sheet
data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet
data class Receive(
val route: ReceiveRoute = ReceiveRoute.QR,
val hardwareWalletId: String? = null,
) : Sheet
data object PaymentRequests : Sheet
data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet
data object ChangePin : Sheet
Expand Down
Loading
Loading