Skip to content
Draft
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 @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import to.bitkit.repositories.OnchainSendOutcome
import kotlin.test.assertIs
import org.junit.Test
import org.junit.runner.RunWith
import to.bitkit.data.keychain.Keychain
Expand Down Expand Up @@ -148,11 +150,12 @@ class TxBumpingTests {
val lowFeeRate = 1uL // 1 sat/vbyte (very low)

println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte")
val originalTxId = lightningService.send(
val outcome = lightningService.send(
address = destinationAddress,
sats = sendAmount,
satsPerVByte = lowFeeRate,
)
val originalTxId = assertIs<OnchainSendOutcome.Accepted>(outcome).txid

lightningService.sync()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import to.bitkit.repositories.OnchainSendOutcome
import kotlin.test.assertIs
import org.junit.Test
import org.junit.runner.RunWith
import org.lightningdevkit.ldknode.CoinSelectionAlgorithm
Expand Down Expand Up @@ -203,12 +205,13 @@ class UtxoSelectionTests {
val feeRate = 1uL // 1 sat/vbyte

println("Sending $sendAmount sats to $destinationAddress using specific UTXOs")
val txId = lightningService.send(
val outcome = lightningService.send(
address = destinationAddress,
sats = sendAmount,
satsPerVByte = feeRate,
utxosToSpend = utxosToSpend
)
val txId = assertIs<OnchainSendOutcome.Accepted>(outcome).txid

assertTrue(txId.isNotEmpty(), "Transaction ID should not be empty")
println("Transaction sent successfully with txid: $txId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class MnemonicInputFieldTest {
onBackspaceInEmpty = { backspaceInEmptyCount++ },
focusRequester = focusRequester,
index = 0,
isFocused = true,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package to.bitkit.ui.screens.wallets.send

import android.graphics.Bitmap
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import to.bitkit.test.annotations.ComposeUi
import to.bitkit.ui.theme.AppThemeSurface
import java.io.File
import kotlin.test.assertEquals

@ComposeUi
class SendPendingScreenTest {
companion object {
private const val SCREENSHOT_FRAME_WAIT_MS = 500L
}

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun unresolvedOnchainContentStaysPendingWhenVisibilityIsToggled() {
// Component rendering only; this fixture does not reload the durable attempt or attempt another send.
var visible by mutableStateOf(true)
var closeCount = 0
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
if (visible) {
SendPendingContent(
amount = 1_000L,
isOnchain = true,
activityId = null,
txid = "ab".repeat(32),
refusalReason = "Test backend refusal",
onClose = { closeCount++; visible = false },
onViewDetails = { error("Unresolved send has no activity") },
)
}
}
}
}
assertUnresolved()
saveScreenshot("ln112-babysit-pending-refused-component.png")
composeTestRule.onNodeWithText("Close").performClick()
composeTestRule.runOnIdle { assertEquals(1, closeCount); visible = true }
assertUnresolved()
saveScreenshot("ln112-babysit-pending-refused-visible-component.png")
}

@Test
fun exactCandidateWithLocalActivityOffersDetailsWithoutClaimingAcceptance() {
var detailsId: String? = null
val txid = "cd".repeat(32)
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
SendPendingContent(amount = 1_000L, isOnchain = true, activityId = "queued-local-activity",
txid = txid, onClose = {}, onViewDetails = { detailsId = it })
}
}
}
composeTestRule.onNodeWithText(txid, substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed()
composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist()
saveScreenshot("ln112-babysit-pending-details-component.png")
composeTestRule.onNodeWithText("Details").performClick()
composeTestRule.runOnIdle { assertEquals("queued-local-activity", detailsId) }
}
Comment thread
ovitrif marked this conversation as resolved.

private fun assertUnresolved() {
composeTestRule.onNodeWithText("Test backend refusal", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("ab".repeat(32), substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed()
composeTestRule.onNodeWithText("Bitkit will block another send", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("Details").assertIsNotEnabled()
composeTestRule.onNodeWithText("Retry").assertDoesNotExist()
composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist()
}

private fun saveScreenshot(name: String) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
composeTestRule.waitForIdle()
instrumentation.waitForIdleSync()
// Semantics may be committed before the emulator compositor presents that frame.
android.os.SystemClock.sleep(SCREENSHOT_FRAME_WAIT_MS)
val image = requireNotNull(instrumentation.uiAutomation.takeScreenshot())
File(instrumentation.targetContext.getExternalFilesDir(null), name).outputStream().use {
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
}
image.recycle()
}
}
27 changes: 21 additions & 6 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,15 @@ class Keychain @Inject constructor(

fun loadString(key: String): String? = load(key)?.decodeToString()

fun loadString(key: String, walletIndex: Int): String? =
loadIndexed(key, stringPreferencesKey("${key}_$walletIndex"))?.decodeToString()

fun load(key: String): ByteArray? = loadIndexed(key, key.indexed)

@Suppress("TooGenericExceptionCaught")
fun load(key: String): ByteArray? {
private fun loadIndexed(key: String, indexedKey: Preferences.Key<String>): ByteArray? {
try {
return snapshot[key.indexed]?.fromBase64()?.let {
return snapshot[indexedKey]?.fromBase64()?.let {
keyStore.decrypt(it)
}
} catch (c: CancellationException) {
Expand Down Expand Up @@ -131,11 +136,16 @@ class Keychain @Inject constructor(
Logger.info("Saved value for key '$key'", context = TAG)
}

suspend fun upsertString(key: String, value: String) = upsertIndexed(key, value, key.indexed)

suspend fun upsertString(key: String, value: String, walletIndex: Int) =
upsertIndexed(key, value, stringPreferencesKey("${key}_$walletIndex"))

@Suppress("TooGenericExceptionCaught")
suspend fun upsertString(key: String, value: String) {
private suspend fun upsertIndexed(key: String, value: String, indexedKey: Preferences.Key<String>) {
try {
val encryptedValue = keyStore.encrypt(value.toByteArray())
keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
keychain.edit { it[indexedKey] = encryptedValue.toBase64() }
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
Expand All @@ -144,10 +154,14 @@ class Keychain @Inject constructor(
Logger.info("Upserted value for key '$key'", context = TAG)
}

suspend fun delete(key: String) = deleteIndexed(key, key.indexed)

suspend fun delete(key: String, walletIndex: Int) = deleteIndexed(key, stringPreferencesKey("${key}_$walletIndex"))

@Suppress("TooGenericExceptionCaught")
suspend fun delete(key: String) {
private suspend fun deleteIndexed(key: String, indexedKey: Preferences.Key<String>) {
try {
keychain.edit { it.remove(key.indexed) }
keychain.edit { it.remove(indexedKey) }
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
Expand Down Expand Up @@ -236,6 +250,7 @@ class Keychain @Inject constructor(
PAYKIT_SDK_STATE,
PAYKIT_PENDING_BACKUP_RESTORE,
PAYKIT_PENDING_PAYMENT_PROOFS,
ONCHAIN_SEND_ATTEMPT,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PUBKY_SECRET_KEY,
SHARED_PUBKY_SOURCE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ data class PaykitPaymentStateBackup(
val onchainAmountSats: ULong? = null,
val onchainWalletId: String? = null,
val onchainMatchingTransactionIdsBeforeAttempt: Set<String>,
val onchainAcceptanceVerified: Boolean = false,
) {
constructor(proof: PendingPaykitPaymentProof) : this(
identity = proof.identity,
Expand All @@ -64,6 +65,7 @@ data class PaykitPaymentStateBackup(
onchainAmountSats = proof.onchainAmountSats,
onchainWalletId = proof.onchainWalletId,
onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt,
onchainAcceptanceVerified = proof.onchainAcceptanceVerified,
)

fun restored() = PendingPaykitPaymentProof(
Expand All @@ -79,6 +81,7 @@ data class PaykitPaymentStateBackup(
onchainAmountSats = onchainAmountSats,
onchainWalletId = onchainWalletId ?: WalletScope.default,
onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt,
onchainAcceptanceVerified = onchainAcceptanceVerified,
)
}
}
23 changes: 23 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,29 @@
walletId: String = WalletScope.default,
): OnchainActivity? = coreService.activity.getOnchainActivityByTxId(txid, walletId)

/** Local follow-up after the original hardware wallet independently observed this exact outgoing tx. */
suspend fun completeObservedHardwarePayment(
walletId: String,
txid: String,
address: String,
amountSats: ULong,
fee: ULong,
feeRate: ULong,
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
require(walletId != WalletScope.default)
coreService.activity.createSentOnchainActivityFromSendResult(
txid = txid, address = address, amount = amountSats, fee = fee, feeRate = feeRate,

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)
isTransfer = false, channelId = null, walletId = walletId,

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)
)
// The Core writer logs storage errors internally. Read back before completing the proof.
val activity = getOnchainActivityByTxId(txid, walletId)
check(activity?.walletId == walletId && activity.txId.equals(txid, true) &&

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "("

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)
activity.txType == PaymentType.SENT) { "Original hardware payment activity is not durable" }

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline before ")"

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Missing newline before ")"
notifyPaymentActivityChanged()
}
}

/**
* Checks if a transaction is inbound (received) by looking up the payment direction.
*/
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,37 @@ class HwWalletRepo @Inject constructor(
}
}

/** Fresh backend observation of this exact transaction in the original hardware wallet. */
suspend fun observeExactTransaction(
walletId: String,
txid: String,
originalAddress: String? = null,
originalAmountSats: ULong? = null,
): Result<Boolean> = withContext(ioDispatcher) {
runSuspendCatching {
require(walletId != WalletScope.default && txid.matches(Regex("[0-9a-fA-F]{64}")))
val account = getFundingAccount(walletId).getOrThrow()
val detail = trezorRepo.getTransactionDetail(
extendedKey = account.xpub,
txid = txid,
network = Env.network.toCoreNetwork(),
scriptType = account.accountType,
).getOrThrow()
if (!detail.txid.equals(txid, ignoreCase = true) || detail.sent == 0uL) {
return@runSuspendCatching false
}
if (originalAddress != null || originalAmountSats != null) {
val address = requireNotNull(originalAddress).also { require(it.isNotBlank()) }
val amount = requireNotNull(originalAmountSats)
val fee = requireNotNull(detail.fee)
val rate = requireNotNull(detail.feeRate).also { require(it.isFinite() && it >= 0.0) }
activityRepo.completeObservedHardwarePayment(walletId, txid, address, amount, fee, ceil(rate).toULong())
.getOrThrow()
}
true
}
}

suspend fun disconnectStaleSession(walletId: String): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
val deviceId = transportDeviceIdOrNull(walletId) ?: return@runSuspendCatching
Expand Down
Loading
Loading