|
| 1 | +package to.bitkit.viewmodels |
| 2 | + |
| 3 | +import android.content.Context |
| 4 | +import com.synonym.bitkitcore.ChannelLiquidityOptions |
| 5 | +import com.synonym.bitkitcore.IBtEstimateFeeResponse2 |
| 6 | +import com.synonym.bitkitcore.IBtInfo |
| 7 | +import com.synonym.bitkitcore.IBtInfoOptions |
| 8 | +import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 9 | +import kotlinx.coroutines.flow.MutableStateFlow |
| 10 | +import kotlinx.coroutines.test.advanceUntilIdle |
| 11 | +import org.junit.Before |
| 12 | +import org.junit.Test |
| 13 | +import org.lightningdevkit.ldknode.NodeStatus |
| 14 | +import org.mockito.kotlin.any |
| 15 | +import org.mockito.kotlin.eq |
| 16 | +import org.mockito.kotlin.mock |
| 17 | +import org.mockito.kotlin.verify |
| 18 | +import org.mockito.kotlin.whenever |
| 19 | +import to.bitkit.data.CacheStore |
| 20 | +import to.bitkit.data.SettingsData |
| 21 | +import to.bitkit.data.SettingsStore |
| 22 | +import to.bitkit.models.BalanceState |
| 23 | +import to.bitkit.repositories.BlocktankRepo |
| 24 | +import to.bitkit.repositories.BlocktankState |
| 25 | +import to.bitkit.repositories.LightningRepo |
| 26 | +import to.bitkit.repositories.LightningState |
| 27 | +import to.bitkit.repositories.TransferRepo |
| 28 | +import to.bitkit.repositories.WalletRepo |
| 29 | +import to.bitkit.test.BaseUnitTest |
| 30 | +import kotlin.test.assertEquals |
| 31 | +import kotlin.time.Clock |
| 32 | +import kotlin.time.ExperimentalTime |
| 33 | + |
| 34 | +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) |
| 35 | +class TransferViewModelTest : BaseUnitTest() { |
| 36 | + private lateinit var sut: TransferViewModel |
| 37 | + |
| 38 | + private val context = mock<Context>() |
| 39 | + private val lightningRepo = mock<LightningRepo>() |
| 40 | + private val blocktankRepo = mock<BlocktankRepo>() |
| 41 | + private val walletRepo = mock<WalletRepo>() |
| 42 | + private val settingsStore = mock<SettingsStore>() |
| 43 | + private val cacheStore = mock<CacheStore>() |
| 44 | + private val transferRepo = mock<TransferRepo>() |
| 45 | + private val clock = mock<Clock>() |
| 46 | + |
| 47 | + private val balanceState = MutableStateFlow(BalanceState()) |
| 48 | + private val blocktankState = MutableStateFlow(BlocktankState()) |
| 49 | + private val feeResponse = mock<IBtEstimateFeeResponse2>() |
| 50 | + |
| 51 | + @Before |
| 52 | + fun setUp() { |
| 53 | + whenever(feeResponse.feeSat).thenReturn(LSP_FEE) |
| 54 | + whenever(feeResponse.networkFeeSat).thenReturn(NETWORK_FEE) |
| 55 | + whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) |
| 56 | + whenever(context.getString(any())).thenReturn("") |
| 57 | + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) |
| 58 | + val nodeStatus = mock<NodeStatus>() |
| 59 | + whenever(nodeStatus.isRunning).thenReturn(true) |
| 60 | + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) |
| 61 | + whenever(walletRepo.balanceState).thenReturn(balanceState) |
| 62 | + whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) |
| 63 | + |
| 64 | + sut = TransferViewModel( |
| 65 | + context = context, |
| 66 | + lightningRepo = lightningRepo, |
| 67 | + blocktankRepo = blocktankRepo, |
| 68 | + walletRepo = walletRepo, |
| 69 | + settingsStore = settingsStore, |
| 70 | + cacheStore = cacheStore, |
| 71 | + transferRepo = transferRepo, |
| 72 | + clock = clock, |
| 73 | + ) |
| 74 | + } |
| 75 | + |
| 76 | + @Test |
| 77 | + fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { |
| 78 | + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) |
| 79 | + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) |
| 80 | + // The LSP reports no room for receiving liquidity (maxLspBalanceSat = 0) because the |
| 81 | + // client balance saturates the channel — the regression this guards against. |
| 82 | + whenever(blocktankRepo.calculateLiquidityOptions(any())) |
| 83 | + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) |
| 84 | + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) |
| 85 | + |
| 86 | + sut.updateLimits() |
| 87 | + advanceUntilIdle() |
| 88 | + |
| 89 | + val state = sut.spendingUiState.value |
| 90 | + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.maxAllowedToSend) |
| 91 | + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.balanceAfterFee) |
| 92 | + |
| 93 | + // The order fee must be estimated against the clamped client balance, not the full balance. |
| 94 | + verify(blocktankRepo).estimateOrderFee(eq(LSP_MAX_CLIENT_BALANCE), any(), any()) |
| 95 | + } |
| 96 | + |
| 97 | + @Test |
| 98 | + fun `updateLimits uses the full balance when LSP info is unavailable`() = test { |
| 99 | + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) |
| 100 | + blocktankState.value = BlocktankState(info = null) |
| 101 | + whenever(blocktankRepo.calculateLiquidityOptions(any())) |
| 102 | + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) |
| 103 | + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) |
| 104 | + |
| 105 | + sut.updateLimits() |
| 106 | + advanceUntilIdle() |
| 107 | + |
| 108 | + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) |
| 109 | + // Without an LSP cap the order fee is estimated against the balance after the LSP fee. |
| 110 | + verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE - LSP_FEE), any(), any()) |
| 111 | + } |
| 112 | + |
| 113 | + @Test |
| 114 | + fun `updateLimits sets max to zero when LSP reports zero client balance`() = test { |
| 115 | + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) |
| 116 | + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) |
| 117 | + whenever(blocktankRepo.calculateLiquidityOptions(any())) |
| 118 | + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = 0uL))) |
| 119 | + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) |
| 120 | + |
| 121 | + sut.updateLimits() |
| 122 | + advanceUntilIdle() |
| 123 | + |
| 124 | + assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) |
| 125 | + } |
| 126 | + |
| 127 | + private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( |
| 128 | + defaultLspBalanceSat = LSP_BALANCE, |
| 129 | + minLspBalanceSat = LSP_BALANCE, |
| 130 | + maxLspBalanceSat = 0uL, |
| 131 | + maxClientBalanceSat = maxClientBalanceSat, |
| 132 | + ) |
| 133 | + |
| 134 | + private fun btInfo(lspMaxClientBalance: ULong): IBtInfo { |
| 135 | + val options = mock<IBtInfoOptions>() |
| 136 | + whenever(options.maxClientBalanceSat).thenReturn(lspMaxClientBalance) |
| 137 | + return mock<IBtInfo>().also { whenever(it.options).thenReturn(options) } |
| 138 | + } |
| 139 | + |
| 140 | + private companion object { |
| 141 | + const val ON_CHAIN_BALANCE = 10_000_000uL |
| 142 | + const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL |
| 143 | + const val OPTION_MAX_CLIENT_BALANCE = 1_687_598uL |
| 144 | + const val LSP_BALANCE = 252_368uL |
| 145 | + const val NETWORK_FEE = 2_112uL |
| 146 | + const val SERVICE_FEE = 286uL |
| 147 | + const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE |
| 148 | + } |
| 149 | +} |
0 commit comments