Skip to content

Commit 2092faf

Browse files
authored
Merge pull request #346 from synonymdev/feat/ldk-routing-fees
feat: routing fee estimation
2 parents 0ccd550 + dae1c16 commit 2092faf

File tree

14 files changed

+581
-69
lines changed

14 files changed

+581
-69
lines changed

app/detekt-baseline.xml

Lines changed: 13 additions & 32 deletions
Large diffs are not rendered by default.
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
package to.bitkit.services
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import dagger.hilt.android.testing.HiltAndroidRule
7+
import dagger.hilt.android.testing.HiltAndroidTest
8+
import kotlinx.coroutines.delay
9+
import kotlinx.coroutines.runBlocking
10+
import org.junit.After
11+
import org.junit.Before
12+
import org.junit.Rule
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.lightningdevkit.ldknode.Bolt11Invoice
16+
import org.lightningdevkit.ldknode.NodeException
17+
import to.bitkit.data.CacheStore
18+
import to.bitkit.data.keychain.Keychain
19+
import to.bitkit.env.Env
20+
import to.bitkit.repositories.WalletRepo
21+
import to.bitkit.utils.LdkError
22+
import javax.inject.Inject
23+
import kotlin.test.assertEquals
24+
import kotlin.test.assertIs
25+
import kotlin.test.assertNotNull
26+
import kotlin.test.assertTrue
27+
28+
@HiltAndroidTest
29+
@RunWith(AndroidJUnit4::class)
30+
class RoutingFeeEstimationTest {
31+
32+
companion object {
33+
private const val NODE_STARTUP_MAX_RETRIES = 10
34+
private const val NODE_STARTUP_RETRY_DELAY_MS = 1000L
35+
private const val DEFAULT_INVOICE_EXPIRY_SECS = 3600u
36+
}
37+
38+
@get:Rule
39+
var hiltRule = HiltAndroidRule(this)
40+
41+
@Inject
42+
lateinit var keychain: Keychain
43+
44+
@Inject
45+
lateinit var lightningService: LightningService
46+
47+
@Inject
48+
lateinit var walletRepo: WalletRepo
49+
50+
@Inject
51+
lateinit var cacheStore: CacheStore
52+
53+
private val walletIndex = 0
54+
private val appContext = ApplicationProvider.getApplicationContext<Context>()
55+
56+
@Before
57+
fun setUp() {
58+
// Use unique storage path per test to avoid DataStore conflicts
59+
val testStoragePath = "${appContext.filesDir.absolutePath}/${System.currentTimeMillis()}"
60+
Env.initAppStoragePath(testStoragePath)
61+
hiltRule.inject()
62+
println("Starting RoutingFeeEstimation test setup with storage: $testStoragePath")
63+
64+
runBlocking {
65+
println("Wiping keychain before test")
66+
keychain.wipe()
67+
println("Keychain wiped successfully")
68+
}
69+
}
70+
71+
@After
72+
fun tearDown() {
73+
runBlocking {
74+
println("Tearing down RoutingFeeEstimation test")
75+
76+
if (lightningService.status?.isRunning == true) {
77+
try {
78+
lightningService.stop()
79+
} catch (e: Exception) {
80+
println("Error stopping lightning service: ${e.message}")
81+
}
82+
}
83+
try {
84+
lightningService.wipeStorage(walletIndex = walletIndex)
85+
} catch (e: Exception) {
86+
println("Error wiping lightning storage: ${e.message}")
87+
}
88+
89+
println("Resetting cache store to clear DataStore")
90+
try {
91+
cacheStore.reset()
92+
println("Cache store reset successfully")
93+
} catch (e: Exception) {
94+
println("Error resetting cache store: ${e.message}")
95+
}
96+
97+
println("Wiping keychain after test")
98+
keychain.wipe()
99+
println("Keychain wiped successfully")
100+
}
101+
}
102+
103+
104+
@Test
105+
fun testNewRoutingFeeMethodsExist() = runBlocking {
106+
runNode()
107+
108+
val paymentAmountSats = 1000uL
109+
val invoice = createInvoiceWithAmount(
110+
amountSats = paymentAmountSats,
111+
description = "Method existence test"
112+
)
113+
114+
// Just test that the methods exist and can be called - don't worry about results
115+
val bolt11Payment = lightningService.node!!.bolt11Payment()
116+
117+
println("Testing estimateRoutingFees method...")
118+
runCatching {
119+
bolt11Payment.estimateRoutingFees(invoice)
120+
}.fold(
121+
onSuccess = { fees ->
122+
println("estimateRoutingFees returned: $fees msat")
123+
assertTrue(true, "Method exists and returned a value")
124+
},
125+
onFailure = { error ->
126+
println("estimateRoutingFees threw: ${error.message}")
127+
// Method exists if it throws a specific error rather than NoSuchMethodError
128+
assertTrue(
129+
!(error.message?.contains("NoSuchMethodError") == true),
130+
"Method should exist (got: ${error.message})"
131+
)
132+
}
133+
)
134+
135+
val zeroInvoice = createZeroAmountInvoice("Zero amount method test")
136+
println("Testing estimateRoutingFeesUsingAmount method...")
137+
runCatching {
138+
bolt11Payment.estimateRoutingFeesUsingAmount(zeroInvoice, 1_000_000uL)
139+
}.fold(
140+
onSuccess = { fees ->
141+
println("estimateRoutingFeesUsingAmount returned: $fees msat")
142+
assertTrue(true, "Method exists and returned a value")
143+
},
144+
onFailure = { error ->
145+
println("estimateRoutingFeesUsingAmount threw: ${error.message}")
146+
// Method exists if it throws a specific error rather than NoSuchMethodError
147+
assertTrue(
148+
!(error.message?.contains("NoSuchMethodError") == true),
149+
"Method should exist (got: ${error.message})"
150+
)
151+
}
152+
)
153+
}
154+
155+
@Test
156+
fun estimateRoutingFeesForVariableAmountInvoice() = runBlocking {
157+
runNode()
158+
159+
val invoice = createZeroAmountInvoice("Variable amount fee estimation test")
160+
val paymentAmountMsat = 5_000_000uL
161+
162+
runCatching {
163+
lightningService.node!!.bolt11Payment()
164+
.estimateRoutingFeesUsingAmount(invoice, paymentAmountMsat)
165+
}.fold(
166+
onSuccess = { estimatedFeesMsat ->
167+
assertFeesAreReasonable(estimatedFeesMsat, paymentAmountMsat)
168+
},
169+
onFailure = { error ->
170+
handleExpectedRoutingError(error as NodeException)
171+
}
172+
)
173+
}
174+
175+
@Test
176+
fun routeNotFoundErrorIsHandledProperly() = runBlocking {
177+
runNode()
178+
179+
val largeAmountSats = 1_000_000uL
180+
val invoiceToSelf = createInvoiceWithAmount(
181+
amountSats = largeAmountSats,
182+
description = "Route error handling test"
183+
)
184+
185+
runCatching {
186+
lightningService.node!!.bolt11Payment().estimateRoutingFees(invoiceToSelf)
187+
}.fold(
188+
onSuccess = { estimatedFeesMsat ->
189+
assertTrue(
190+
estimatedFeesMsat >= 0u,
191+
"If routing unexpectedly succeeds, fees should be non-negative"
192+
)
193+
},
194+
onFailure = { error ->
195+
assertRoutingErrorOccurred(error as NodeException)
196+
}
197+
)
198+
}
199+
200+
@Test
201+
fun zeroAmountPaymentIsHandledGracefully() = runBlocking {
202+
runNode()
203+
204+
val invoice = createZeroAmountInvoice("Zero amount validation test")
205+
val zeroAmountMsat = 0uL
206+
207+
runCatching {
208+
lightningService.node!!.bolt11Payment()
209+
.estimateRoutingFeesUsingAmount(invoice, zeroAmountMsat)
210+
}.fold(
211+
onSuccess = { estimatedFeesMsat ->
212+
assertEquals(
213+
0uL,
214+
estimatedFeesMsat,
215+
"Zero amount should result in zero fees"
216+
)
217+
},
218+
onFailure = {
219+
assertTrue(
220+
true,
221+
"Zero amount payment throwing an error is acceptable"
222+
)
223+
}
224+
)
225+
}
226+
227+
@Test
228+
fun routingFeesScaleWithPaymentAmount() = runBlocking {
229+
runNode()
230+
231+
val invoice = createZeroAmountInvoice("Fee scaling test")
232+
val smallAmountMsat = 1_000_000uL
233+
val largeAmountMsat = 10_000_000uL
234+
235+
runCatching {
236+
val smallAmountFeesMsat = lightningService.node!!.bolt11Payment()
237+
.estimateRoutingFeesUsingAmount(invoice, smallAmountMsat)
238+
239+
val largeAmountFeesMsat = lightningService.node!!.bolt11Payment()
240+
.estimateRoutingFeesUsingAmount(invoice, largeAmountMsat)
241+
242+
Pair(smallAmountFeesMsat, largeAmountFeesMsat)
243+
}.fold(
244+
onSuccess = { (smallAmountFeesMsat, largeAmountFeesMsat) ->
245+
assertFeesScaleProperly(
246+
smallAmountFeesMsat,
247+
largeAmountFeesMsat,
248+
smallAmountMsat,
249+
largeAmountMsat
250+
)
251+
},
252+
onFailure = { error ->
253+
handleExpectedRoutingError(error as NodeException)
254+
}
255+
)
256+
}
257+
258+
// region utils
259+
260+
private suspend fun runNode() {
261+
println("Creating new wallet")
262+
walletRepo.createWallet(bip39Passphrase = null)
263+
264+
println("Setting up lightning service")
265+
lightningService.setup(walletIndex = walletIndex)
266+
267+
println("Starting lightning node")
268+
lightningService.start()
269+
println("Lightning node started successfully")
270+
271+
waitForNodeInitialization()
272+
273+
println("Syncing wallet")
274+
lightningService.sync()
275+
println("Wallet sync complete")
276+
}
277+
278+
private suspend fun waitForNodeInitialization() {
279+
repeat(NODE_STARTUP_MAX_RETRIES) {
280+
if (lightningService.node != null) return
281+
delay(NODE_STARTUP_RETRY_DELAY_MS)
282+
}
283+
assertNotNull(lightningService.node, "Node should be initialized within timeout")
284+
}
285+
286+
private suspend fun createInvoiceWithAmount(amountSats: ULong, description: String): Bolt11Invoice {
287+
val invoiceString = lightningService.receive(
288+
sat = amountSats,
289+
description = description,
290+
expirySecs = DEFAULT_INVOICE_EXPIRY_SECS
291+
)
292+
return Bolt11Invoice.fromStr(invoiceString)
293+
}
294+
295+
private suspend fun createZeroAmountInvoice(description: String): Bolt11Invoice {
296+
val invoiceString = lightningService.receive(
297+
sat = null,
298+
description = description,
299+
expirySecs = DEFAULT_INVOICE_EXPIRY_SECS
300+
)
301+
return Bolt11Invoice.fromStr(invoiceString)
302+
}
303+
304+
private fun assertFeesAreReasonable(estimatedFeesMsat: ULong, paymentAmountMsat: ULong) {
305+
assertTrue(
306+
estimatedFeesMsat >= 0u,
307+
"Estimated fees should be non-negative, got: $estimatedFeesMsat"
308+
)
309+
assertTrue(
310+
estimatedFeesMsat < paymentAmountMsat,
311+
"Estimated fees should be less than payment amount. Fees: $estimatedFeesMsat, Amount: $paymentAmountMsat msat"
312+
)
313+
}
314+
315+
private fun handleExpectedRoutingError(error: NodeException) {
316+
when (error) {
317+
is NodeException.RouteNotFound -> {
318+
assertTrue(true, "RouteNotFound error is acceptable in test environment")
319+
}
320+
else -> throw LdkError(error)
321+
}
322+
}
323+
324+
private fun assertRoutingErrorOccurred(error: NodeException) {
325+
assertIs<NodeException.RouteNotFound>(error)
326+
}
327+
328+
private fun assertFeesScaleProperly(
329+
smallFees: ULong,
330+
largeFees: ULong,
331+
smallAmount: ULong,
332+
largeAmount: ULong,
333+
) {
334+
assertTrue(
335+
largeFees >= smallFees,
336+
"Fees for larger amounts should be >= fees for smaller amounts. Small: $smallFees, Large: $largeFees"
337+
)
338+
assertFeesAreReasonable(smallFees, smallAmount)
339+
assertFeesAreReasonable(largeFees, largeAmount)
340+
}
341+
342+
// endregion
343+
}

app/src/main/java/to/bitkit/data/CacheStore.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ package to.bitkit.data
22

33
import android.content.Context
44
import androidx.datastore.core.DataStore
5-
import androidx.datastore.core.DataStoreFactory
6-
import androidx.datastore.dataStoreFile
5+
import androidx.datastore.dataStore
76
import dagger.hilt.android.qualifiers.ApplicationContext
87
import kotlinx.coroutines.flow.Flow
98
import kotlinx.coroutines.flow.first
@@ -21,14 +20,16 @@ import to.bitkit.utils.Logger
2120
import javax.inject.Inject
2221
import javax.inject.Singleton
2322

23+
private val Context.appCacheDataStore: DataStore<AppCacheData> by dataStore(
24+
fileName = "app_cache.json",
25+
serializer = AppCacheSerializer
26+
)
27+
2428
@Singleton
2529
class CacheStore @Inject constructor(
2630
@ApplicationContext private val context: Context,
2731
) {
28-
private val store: DataStore<AppCacheData> = DataStoreFactory.create(
29-
serializer = AppCacheSerializer,
30-
produceFile = { context.dataStoreFile("app_cache.json") },
31-
)
32+
private val store = context.appCacheDataStore
3233

3334
val data: Flow<AppCacheData> = store.data
3435

0 commit comments

Comments
 (0)