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
Expand Up @@ -105,4 +105,22 @@ class SendAmountContentTest {

composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled()
}

@Test
fun whenFundingSourceSwitching_sourceAndContinueButtonsShouldBeDisabled() {
composeTestRule.setContent {
SendAmountContent(
nodeLifecycleState = nodeLifecycleState,
uiState = uiState.copy(
isAmountInputValid = true,
canSwitchFundingSource = true,
isSwitchingFundingSource = true,
),
amountInputViewModel = previewAmountInputViewModel(),
)
}

composeTestRule.onNodeWithTag("AssetButton-switch").assertIsNotEnabled()
composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled()
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,27 @@ fun Throwable.isTrezorFirmwareError(): Boolean =
val message = it.message.orEmpty()
"Device error (code $FIRMWARE_ERROR_CODE)" in message && "Firmware error" in message
}

fun Throwable.isTrezorSessionFailure(): Boolean =
generateSequence(this) { it.cause }.any { error ->
when (error) {
is TrezorException.TransportException,
is TrezorException.DeviceDisconnected,
is TrezorException.ConnectionException,
is TrezorException.Timeout,
is TrezorException.NotConnected,
is TrezorException.SessionException,
is TrezorException.IoException,
-> true

is TrezorException.ProtocolException -> error.errorDetails.lowercase().let { details ->
"thp decryption" in details ||
"thp encryption" in details ||
"thp ack" in details ||
"thp invalid sync" in details ||
"thp state missing" in details
}

else -> false
}
}
73 changes: 53 additions & 20 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,30 @@ class ActivityRepo @Inject constructor(
type: ActivityFilter,
txType: PaymentType?,
retry: Boolean = true,
): Result<Activity> = findActivityByPaymentId(
paymentHashOrTxId = paymentHashOrTxId,
type = type,
txType = txType,
retry = retry,
walletId = WalletScope.default,
)

suspend fun findActivityByPaymentId(
paymentHashOrTxId: String,
type: ActivityFilter,
txType: PaymentType?,
retry: Boolean,
walletId: String,
): Result<Activity> = withContext(bgDispatcher) {
runCatching {
require(paymentHashOrTxId.isNotEmpty()) { "paymentHashOrTxId is empty" }

suspend fun findActivity(): Activity? = getActivities(filter = type, txType = txType, limit = 10u)
suspend fun findActivity(): Activity? = getActivities(
walletId = walletId,
filter = type,
txType = txType,
limit = 10u,
)
.getOrNull()
?.firstOrNull { it.matchesPaymentId(paymentHashOrTxId) }

Expand All @@ -362,21 +381,25 @@ class ActivityRepo @Inject constructor(
context = TAG
)

lightningRepo.sync().onSuccess { Logger.debug("Syncing LN node SUCCESS", context = TAG) }

syncActivities().onSuccess {
Logger.debug(
"Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'",
context = TAG,
)
if (walletId == WalletScope.default) {
lightningRepo.sync().onSuccess { Logger.debug("Syncing LN node SUCCESS", context = TAG) }
syncActivities().onSuccess {
Logger.debug(
"Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'",
context = TAG,
)
activity = findActivity()
}
} else {
activity = findActivity()
}
}

checkNotNull(activity) { "Activity not found" }
}.onFailure {
Logger.error(
"findActivityByPaymentId error (paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType')",
"findActivityByPaymentId error " +
"(paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType' walletId:'$walletId')",
context = TAG,
)
}
Expand Down Expand Up @@ -438,6 +461,7 @@ class ActivityRepo @Inject constructor(
val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey
val txIdsInBoostTxIds = getTxIdsInBoostTxIds()
getActivities(
walletId = null,
filter = ActivityFilter.ALL,
sortDirection = SortDirection.DESC,
).getOrThrow()
Expand All @@ -452,6 +476,7 @@ class ActivityRepo @Inject constructor(
contactPublicKey: String,
forPaymentId: String,
syncLdkPayments: Boolean = true,
walletId: String = WalletScope.default,
): Result<Unit> = withContext(ioDispatcher) {
runCatching {
if (syncLdkPayments) {
Expand All @@ -461,7 +486,7 @@ class ActivityRepo @Inject constructor(
}

val normalizedKey = PubkyPublicKeyFormat.normalized(contactPublicKey) ?: contactPublicKey
val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments)
val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments, walletId)
if (activity == null) {
Logger.warn(
"Skipped setting contact for payment '$forPaymentId' because activity was not found",
Expand All @@ -476,7 +501,7 @@ class ActivityRepo @Inject constructor(
val updatedAt = nowTimestamp().epochSecond.toULong()
val updatedActivity = activity.withContact(normalizedKey, updatedAt)
updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow()
updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt)
updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt, walletId)
}.onFailure {
Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG)
}
Expand All @@ -485,6 +510,7 @@ class ActivityRepo @Inject constructor(
suspend fun clearContact(
forPaymentId: String,
syncLdkPayments: Boolean = true,
walletId: String = WalletScope.default,
): Result<Unit> = withContext(ioDispatcher) {
runCatching {
if (syncLdkPayments) {
Expand All @@ -493,7 +519,7 @@ class ActivityRepo @Inject constructor(
}.getOrThrow()
}

val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments)
val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments, walletId)
if (activity == null) {
Logger.warn(
"Skipped clearing contact for payment '$forPaymentId' because activity was not found",
Expand All @@ -506,7 +532,7 @@ class ActivityRepo @Inject constructor(
val updatedAt = nowTimestamp().epochSecond.toULong()
val updatedActivity = activity.withContact(null, updatedAt)
updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow()
updateReplacementContactIfNeeded(updatedActivity, null, updatedAt)
updateReplacementContactIfNeeded(updatedActivity, null, updatedAt, walletId)
}.onFailure {
Logger.error("Failed to clear contact for payment '$forPaymentId'", it, context = TAG)
}
Expand All @@ -516,10 +542,11 @@ class ActivityRepo @Inject constructor(
activity: Activity,
normalizedKey: String?,
updatedAt: ULong,
walletId: String = WalletScope.default,
) {
if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return

getActivities(filter = ActivityFilter.ONCHAIN).getOrThrow()
getActivities(walletId = walletId, filter = ActivityFilter.ONCHAIN).getOrThrow()
.filterIsInstance<Activity.Onchain>()
.filter { activity.v1.txId in it.v1.boostTxIds }
.filterNot { PubkyPublicKeyFormat.matches(it.v1.contact, normalizedKey) }
Expand All @@ -529,18 +556,24 @@ class ActivityRepo @Inject constructor(
}
}

private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? {
val activity = getActivityByPaymentId(forPaymentId)
private suspend fun findActivityForPaymentId(
forPaymentId: String,
syncLdkPayments: Boolean,
walletId: String = WalletScope.default,
): Activity? {
val activity = getActivityByPaymentId(forPaymentId, walletId)
if (activity != null) return activity
if (!syncLdkPayments) return null

syncActivities().getOrThrow()
return getActivityByPaymentId(forPaymentId)
return getActivityByPaymentId(forPaymentId, walletId)
}

private suspend fun getActivityByPaymentId(forPaymentId: String): Activity? =
coreService.activity.getActivity(forPaymentId, WalletScope.default)
?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) }
private suspend fun getActivityByPaymentId(
forPaymentId: String,
walletId: String = WalletScope.default,
): Activity? = coreService.activity.getActivity(forPaymentId, walletId)
?: getOnchainActivityByTxId(forPaymentId, walletId)?.let { Activity.Onchain(it) }

private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) {
is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt))
Expand Down
75 changes: 70 additions & 5 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import to.bitkit.data.PendingNameUpdate
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.isTrezorUserCancellation
import to.bitkit.ext.isTrezorSessionFailure
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.scopedId
import to.bitkit.ext.timestamp
Expand Down Expand Up @@ -73,7 +73,7 @@ import kotlin.time.Duration.Companion.seconds
* Built on top of [TrezorRepo], which owns the device list, connect orchestration
* and the underlying watcher transport.
*/
@Suppress("TooManyFunctions")
@Suppress("LargeClass", "TooManyFunctions")
@Singleton
class HwWalletRepo @Inject constructor(
private val trezorRepo: TrezorRepo,
Expand All @@ -89,7 +89,7 @@ class HwWalletRepo @Inject constructor(
private val WATCHER_START_RETRY_DELAY = 30.seconds
const val DEVICE_LABEL_MAX_LENGTH = 50

/** Trezor v1 (2.4.0) tracks native segwit only; multi-type HW support is follow-up work. */
/** Trezor v1 (2.4.0) tracks native SegWit accounts. */
private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey)
}

Expand Down Expand Up @@ -373,6 +373,59 @@ class HwWalletRepo @Inject constructor(
}
}

/** Estimates the exact funding fee from the public account key without opening the device. */
suspend fun estimateFundingMiningFee(
walletId: String,
address: String,
sats: ULong,
satsPerVByte: ULong,
): Result<ULong> = withContext(ioDispatcher) {
runSuspendCatching {
composeFundingOffline(
walletId = walletId,
output = ComposeOutput.Payment(address = address, amountSats = sats),
satsPerVByte = satsPerVByte,
).fee
}
}

/** Exact amount available after the coin-selection fee, computed offline from the account xpub. */
suspend fun maxSpendableFunding(
walletId: String,
address: String,
satsPerVByte: ULong,
): Result<ULong> = withContext(ioDispatcher) {
runSuspendCatching {
val success = composeFundingOffline(
walletId = walletId,
output = ComposeOutput.SendMax(address = address),
satsPerVByte = satsPerVByte,
)
success.totalSpent.safe() - success.fee.safe()
}
}

private suspend fun composeFundingOffline(
walletId: String,
output: ComposeOutput,
satsPerVByte: ULong,
): ComposeResult.Success {
val account = getFundingAccount(walletId).getOrThrow()
val composed = trezorRepo.composeTransactionOffline(
extendedKey = account.xpub,
outputs = listOf(output),
feeRates = listOf(satsPerVByte.toFloat()),
network = Env.network.toCoreNetwork(),
accountType = account.accountType,
coinSelection = CoinSelection.BRANCH_AND_BOUND,
).getOrThrow()
return composed.filterIsInstance<ComposeResult.Success>().firstOrNull()
?: throw AppError(
composed.filterIsInstance<ComposeResult.Error>().firstOrNull()?.error
?: "Failed to compose hardware wallet payment"
)
}

/** Signs a composed funding payment on the Trezor. */
suspend fun signFunding(
walletId: String,
Expand All @@ -388,7 +441,7 @@ class HwWalletRepo @Inject constructor(
psbtBase64 = funding.psbt,
network = Env.network.toTrezorCoinType(),
).getOrElse {
if (!it.isTrezorUserCancellation()) {
if (it.isTrezorSessionFailure()) {
transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) }
}
throw it
Expand Down Expand Up @@ -612,7 +665,7 @@ class HwWalletRepo @Inject constructor(
)
val snapshotCacheKey = snapshot.toCacheKey()
lastPersistedHwSnapshots[walletId]
?.takeIf { it.source == snapshotCacheKey }
?.takeIf { it.source == snapshotCacheKey && !it.hasRetainedPendingSend() }
?.let {
_watcherData.update { data ->
data + (watcherId to watcher.copy(activities = it.activities))
Expand Down Expand Up @@ -936,3 +989,15 @@ private data class PersistedHwSnapshot(
val source: HwSnapshot,
val activities: ImmutableList<Activity>,
)

private fun PersistedHwSnapshot.hasRetainedPendingSend(): Boolean {
val sourceIds = source.activities.map { it.scopedId() }.toSet()
return activities.any {
val activity = (it as? Activity.Onchain)?.v1 ?: return@any false
it.scopedId() !in sourceIds &&
activity.txType == PaymentType.SENT &&
!activity.confirmed &&
!activity.isTransfer &&
activity.doesExist
}
}
Loading
Loading