|
| 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 | +} |
0 commit comments