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
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3406,7 +3406,8 @@ class AppViewModel @Inject constructor(
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.wallet__error_sending_title),
description = e.message ?: context.getString(R.string.common__error_body)
description = e.message ?: context.getString(R.string.common__error_body),
testTag = "OnchainSendFailedToast",
)
hideSheet()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class PaymentFailureReasonExtTest {
assertEquals(message, Exception(" ").toSendFailureMessage(context))
assertEquals(
message,
LdkError(NodeException.DuplicatePayment("Duplicate payment.")).toSendFailureMessage(context),
LdkError(NodeException.DuplicatePayment()).toSendFailureMessage(context),
)
}

Expand All @@ -56,11 +56,11 @@ class PaymentFailureReasonExtTest {
fun `compact failure types use android ldk error classes`() {
assertEquals(
"DuplicatePayment",
LdkError(NodeException.DuplicatePayment("Duplicate payment.")).toCompactFailureType(),
LdkError(NodeException.DuplicatePayment()).toCompactFailureType(),
)
assertEquals(
"InvalidCustomTlvs",
LdkError(NodeException.InvalidCustomTlvs("Invalid custom TLVs")).toCompactFailureType(),
LdkError(NodeException.InvalidCustomTlvs()).toCompactFailureType(),
)
}

Expand Down
69 changes: 67 additions & 2 deletions app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.Node
import org.lightningdevkit.ldknode.NodeException
import org.lightningdevkit.ldknode.NodeStatus
import org.lightningdevkit.ldknode.PaymentDetails
import org.lightningdevkit.ldknode.PeerDetails
Expand Down Expand Up @@ -893,15 +894,16 @@ class LightningRepoTest : BaseUnitTest() {
}

@Test
fun `sendOnChain should cache activity meta data`() = test {
fun `accepted onchain send should create metadata and sent activity`() = test {
val mockSettingsData = SettingsData(
defaultTransactionSpeed = TransactionSpeed.Fast,
coinSelectAuto = false // Disable auto coin selection to simplify the test
)
val activityService = mock<ActivityService>()
whenever(settingsStore.data).thenReturn(flowOf(mockSettingsData))

whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit))
whenever(coreService.activity).thenReturn(mock())
whenever(coreService.activity).thenReturn(activityService)

whenever(
lightningService.send(
Expand Down Expand Up @@ -937,6 +939,69 @@ class LightningRepoTest : BaseUnitTest() {
verifyBlocking(preActivityMetadataRepo) {
addPreActivityMetadata(any())
}
verify(activityService).createSentOnchainActivityFromSendResult(
txid = "testPaymentId",
address = "test_address",
amount = 1000uL,
fee = 0uL,
feeRate = 10uL,
isTransfer = true,
channelId = "test_channel_id",
)
}

@Test
fun `unsuccessful onchain send should not create metadata or sent activity`() = test {
val errors = listOf(
NodeException.OnchainTxBroadcastRejected("Broadcast rejected"),
NodeException.OnchainTxBroadcastNotDispatched("Broadcast not dispatched"),
NodeException.OnchainTxBroadcastFailed("Broadcast failed"),
NodeException.OnchainTxBroadcastTimeout("Broadcast timed out"),
)
val activityService = mock<ActivityService>()
whenever(settingsStore.data).thenReturn(
flowOf(
SettingsData(
defaultTransactionSpeed = TransactionSpeed.Fast,
coinSelectAuto = false,
)
)
)
whenever(coreService.activity).thenReturn(activityService)
startNodeForTesting()
val spySut = spy(sut)
doReturn(Result.success(10uL)).whenever(spySut).getFeeRateForSpeed(any(), anyOrNull())

errors.forEach { error ->
whenever(
lightningService.send(
address = any(),
sats = any(),
satsPerVByte = any(),
utxosToSpend = anyOrNull(),
isMaxAmount = any(),
)
).thenAnswer { throw error }

val result = spySut.sendOnChain(
address = "test_address",
sats = 1000uL,
speed = TransactionSpeed.Fast,
)

assertEquals(error, result.exceptionOrNull())
}
verifyBlocking(preActivityMetadataRepo, never()) { addPreActivityMetadata(any()) }
verify(activityService, never()).createSentOnchainActivityFromSendResult(
txid = any(),
address = any(),
amount = any(),
fee = any(),
feeRate = any(),
isTransfer = any(),
channelId = anyOrNull(),
walletId = any(),
)
}

@Test
Expand Down
40 changes: 20 additions & 20 deletions app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `acknowledge during delivery clears the unacked failure`() = test {
val (bolt11, _) = testInvoice()
stubPayInvoiceFailure(NodeException.InvalidInvoice("bad"))
stubPayInvoiceFailure(NodeException.InvalidInvoice())
val session = QuickPaySession()
val flushed = mutableListOf<Throwable>()
val flushJob = launch { sut.unhandledFailures.collect { flushed += it } }
Expand Down Expand Up @@ -507,7 +507,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `lookup throw on duplicate still emits pending`() = test {
val (bolt11, _) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
whenever { lightningRepo.listPaymentsOrNull() }.thenAnswer { error("uniffi") }
val session = QuickPaySession()
sut.attach(session).test {
Expand Down Expand Up @@ -561,27 +561,27 @@ class QuickPayRepoTest : BaseUnitTest() {
fun `classifies wrapped and unwrapped ldk errors`() {
assertEquals(
QuickPayDispatchClass.PRE_DISPATCH_REJECTION,
classifyDispatchError(NodeException.InvalidInvoice("bad")),
classifyDispatchError(NodeException.InvalidInvoice()),
)
assertEquals(
QuickPayDispatchClass.PRE_DISPATCH_REJECTION,
classifyDispatchError(LdkError(NodeException.InvalidInvoice("bad"))),
classifyDispatchError(LdkError(NodeException.InvalidInvoice())),
)
assertEquals(
QuickPayDispatchClass.DUPLICATE_PAYMENT,
classifyDispatchError(NodeException.DuplicatePayment("dup")),
classifyDispatchError(NodeException.DuplicatePayment()),
)
assertEquals(
QuickPayDispatchClass.DUPLICATE_PAYMENT,
classifyDispatchError(LdkError(NodeException.DuplicatePayment("dup"))),
classifyDispatchError(LdkError(NodeException.DuplicatePayment())),
)
assertEquals(
QuickPayDispatchClass.AMBIGUOUS,
classifyDispatchError(NodeException.PersistenceFailed("io")),
classifyDispatchError(NodeException.PersistenceFailed()),
)
assertEquals(
QuickPayDispatchClass.AMBIGUOUS,
classifyDispatchError(LdkError(NodeException.PaymentSendingFailed("send"))),
classifyDispatchError(LdkError(NodeException.PaymentSendingFailed())),
)
}

Expand All @@ -597,7 +597,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `duplicate payment with pending ldk does not refund`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(pendingRow(hash))
val session = QuickPaySession()

Expand All @@ -613,7 +613,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `duplicate payment with succeeded ldk refunds a fresh reserve and emits already paid`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(succeededRow(hash))
val session = QuickPaySession()

Expand All @@ -633,7 +633,7 @@ class QuickPayRepoTest : BaseUnitTest() {
assertNotNull(sut.reserveBound(hash, 500u).getOrThrow())
sut.signalCompletion(paymentId = null, paymentHash = hash, success = true)
assertEquals(250L, spentCents())
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(succeededRow(hash))
val session = QuickPaySession()

Expand All @@ -649,7 +649,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `ambiguous pending emits pending and keeps spend`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send"))
stubPayInvoiceFailure(NodeException.PaymentSendingFailed())
paymentRows = listOf(pendingRow(hash))
val session = QuickPaySession()

Expand All @@ -665,7 +665,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `sync dispatch failure with failed ldk row refunds immediately`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send"))
stubPayInvoiceFailure(NodeException.PaymentSendingFailed())
paymentRows = listOf(failedRow(hash))
val session = QuickPaySession()

Expand Down Expand Up @@ -720,7 +720,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `rescan of a pending hash replays pending to a new session`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(pendingRow(hash))
val first = QuickPaySession()
val second = QuickPaySession()
Expand All @@ -741,7 +741,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `rescan pending then success settles once`() = test {
val (bolt11, hash) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(pendingRow(hash))
val first = QuickPaySession()
val second = QuickPaySession()
Expand Down Expand Up @@ -797,7 +797,7 @@ class QuickPayRepoTest : BaseUnitTest() {
val first = QuickPaySession()
sut.attach(first)
sut.detachAll()
stubPayInvoiceFailure(NodeException.InvalidInvoice("bad"))
stubPayInvoiceFailure(NodeException.InvalidInvoice())
val second = QuickPaySession()
sut.attach(second).test {
sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = testInvoice().first, amountSats = 500u))
Expand All @@ -809,7 +809,7 @@ class QuickPayRepoTest : BaseUnitTest() {
fun `hasOpen is true for a live op or recovered row`() = test {
val (bolt11, hash) = testInvoice()
assertFalse(sut.hasOpen(hash))
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
paymentRows = listOf(pendingRow(hash))
val session = QuickPaySession()
sut.attach(session)
Expand Down Expand Up @@ -887,7 +887,7 @@ class QuickPayRepoTest : BaseUnitTest() {
assertEquals(250L, spentCents())
assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size)
if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend())
Result.failure(LdkError(NodeException.InvalidInvoice("done")))
Result.failure(LdkError(NodeException.InvalidInvoice()))
}
val session = QuickPaySession()
sut.attach(session)
Expand Down Expand Up @@ -958,7 +958,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `pre-dispatch rejection refunds after dispatch`() = test {
val (bolt11, _) = testInvoice()
stubPayInvoiceFailure(NodeException.InvalidInvoice("bad"))
stubPayInvoiceFailure(NodeException.InvalidInvoice())
val session = QuickPaySession()
sut.attach(session).test {
sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u))
Expand All @@ -971,7 +971,7 @@ class QuickPayRepoTest : BaseUnitTest() {
@Test
fun `null payment rows mutate nothing on duplicate`() = test {
val (bolt11, _) = testInvoice()
stubPayInvoiceFailure(NodeException.DuplicatePayment("dup"))
stubPayInvoiceFailure(NodeException.DuplicatePayment())
val session = QuickPaySession()
sut.attach(session).test {
sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u))
Expand Down
4 changes: 2 additions & 2 deletions app/src/test/java/to/bitkit/services/LightningServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class LightningServiceTest : BaseUnitTest() {

@Test
fun `stop destroys the node handle when it is already not running`() = test {
whenever(node.stop()).thenThrow(NodeException.NotRunning("not running"))
whenever(node.stop()).thenThrow(NodeException.NotRunning())

sut.stop()

Expand Down Expand Up @@ -403,7 +403,7 @@ class LightningServiceTest : BaseUnitTest() {
// Regression: a failing node stop must still release the handle instead of rethrowing and leaking it
@Test
fun `stop destroys the node handle when node stop throws`() = test {
whenever(node.stop()).thenThrow(NodeException.ConnectionFailed("boom"))
whenever(node.stop()).thenThrow(NodeException.ConnectionFailed())

sut.stop()

Expand Down
69 changes: 69 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4516,6 +4516,75 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
confirmCurrentPayment()
}

@Test
fun `accepted onchain send presents success`() = test {
val address = "bcrt1qaccepted"
balanceState.value = BalanceState(maxSendOnchainSats = 100_000u)
whenever {
lightningRepo.sendOnChain(
address = address,
sats = 1000u,
speed = TransactionSpeed.Medium,
utxosToSpend = null,
isMaxAmount = false,
tags = emptyList(),
)
}.thenReturn(Result.success("accepted-txid"))
whenever(lightningRepo.sync()).thenReturn(Result.success(Unit))
whenever(activityRepo.syncActivities()).thenReturn(Result.success(Unit))
setSendState(
SendUiState(
address = address,
amount = 1000u,
payMethod = SendMethod.ONCHAIN,
speed = TransactionSpeed.Medium,
),
)

sut.sendEffect.test {
confirmCurrentPayment()

assertEquals(SendEffect.PaymentSuccess, awaitItem())
}
assertEquals("accepted-txid", sut.successSendUiState.value.paymentHashOrTxId)
}

@Test
fun `rejected onchain send does not present success`() = test {
val address = "bcrt1qrejected"
balanceState.value = BalanceState(maxSendOnchainSats = 100_000u)
whenever {
lightningRepo.sendOnChain(
address = address,
sats = 1000u,
speed = TransactionSpeed.Medium,
utxosToSpend = null,
isMaxAmount = false,
tags = emptyList(),
)
}.thenReturn(Result.failure(AppError("Broadcast rejected")))
setSendState(
SendUiState(
address = address,
amount = 1000u,
payMethod = SendMethod.ONCHAIN,
speed = TransactionSpeed.Medium,
),
)

sut.sendEffect.test {
confirmCurrentPayment()

expectNoEvents()
}
verify(toastManager).enqueue(
check {
assertEquals("OnchainSendFailedToast", it.testTag)
}
)
assertNull(sut.successSendUiState.value.paymentHashOrTxId)
}

@Test
fun `private lightning contact payment consumes private list before send`() = test {
val bolt11 = "lnbcrt1privatecontact"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1225.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevented rejected, failed, or timed-out on-chain broadcasts from appearing as successful sends.
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.66" }
ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.67" }
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
Expand Down
Loading