From 81281ab484642ec5b19a720f0e01add4e7b5eced Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Sun, 27 Feb 2022 15:02:24 +0100 Subject: [PATCH] add channel balance fluctuation warning --- backend/build.gradle | 1 + .../lndmanagej/service/BalanceService.java | 19 ++- ...nelBalanceFluctuationWarningsProvider.java | 50 ++++++++ .../service/BalanceServiceTest.java | 30 +++++ ...alanceFluctuationWarningsProviderTest.java | 109 ++++++++++++++++++ .../persistence/BalancesRepositoryIT.java | 102 ++++++++++++++++ .../lndmanagej/balances/BalancesDao.java | 5 + .../balances/persistence/BalancesDaoImpl.java | 36 ++++++ .../persistence/BalancesRepository.java | 4 + .../persistence/BalancesDaoImplTest.java | 60 ++++++++++ .../ChannelBalanceFluctuationWarning.java | 16 +++ .../ChannelBalanceFluctuationWarningTest.java | 29 +++++ .../warnings/ChannelWarningFixtures.java | 2 + 13 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProvider.java create mode 100644 backend/src/test/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProviderTest.java create mode 100644 model/src/main/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarning.java create mode 100644 model/src/test/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarningTest.java diff --git a/backend/build.gradle b/backend/build.gradle index 82963c16..7f2fb079 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation project(':forwarding-history') implementation project(':grpc-adapter') implementation project(':model') + implementation project(':balances') implementation project(':onlinepeers') implementation project(':selfpayments') implementation project(':transactions') diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java b/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java index 88a3a39f..4814163e 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java @@ -1,6 +1,7 @@ package de.cotto.lndmanagej.service; import com.codahale.metrics.annotation.Timed; +import de.cotto.lndmanagej.balances.BalancesDao; import de.cotto.lndmanagej.grpc.GrpcChannels; import de.cotto.lndmanagej.model.BalanceInformation; import de.cotto.lndmanagej.model.Channel; @@ -16,10 +17,16 @@ import java.util.Optional; public class BalanceService { private final GrpcChannels grpcChannels; private final ChannelService channelService; + private final BalancesDao balancesDao; - public BalanceService(GrpcChannels grpcChannels, ChannelService channelService) { + public BalanceService( + GrpcChannels grpcChannels, + ChannelService channelService, + BalancesDao balancesDao + ) { this.grpcChannels = grpcChannels; this.channelService = channelService; + this.balancesDao = balancesDao; } @Timed @@ -66,4 +73,14 @@ public class BalanceService { return grpcChannels.getChannel(channelId) .map(LocalOpenChannel::getBalanceInformation); } + + @Timed + public Optional getLocalBalanceMinimum(ChannelId channelId, int days) { + return balancesDao.getLocalBalanceMinimum(channelId, days); + } + + @Timed + public Optional getLocalBalanceMaximum(ChannelId channelId, int days) { + return balancesDao.getLocalBalanceMaximum(channelId, days); + } } diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProvider.java b/backend/src/main/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProvider.java new file mode 100644 index 00000000..8731682e --- /dev/null +++ b/backend/src/main/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProvider.java @@ -0,0 +1,50 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.LocalChannel; +import de.cotto.lndmanagej.model.warnings.ChannelBalanceFluctuationWarning; +import de.cotto.lndmanagej.model.warnings.ChannelWarning; +import de.cotto.lndmanagej.service.warnings.ChannelWarningsProvider; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.stream.Stream; + +@Component +public class ChannelBalanceFluctuationWarningsProvider implements ChannelWarningsProvider { + private static final int LOWER_THRESHOLD = 10; + private static final int UPPER_THRESHOLD = 90; + private static final int DAYS = 14; + + private final ChannelService channelService; + private final BalanceService balanceService; + + public ChannelBalanceFluctuationWarningsProvider(ChannelService channelService, BalanceService balanceService) { + this.channelService = channelService; + this.balanceService = balanceService; + } + + @Override + public Stream getChannelWarnings(ChannelId channelId) { + return Stream.of(getBalanceFluctuatingWarning(channelId)).flatMap(Optional::stream); + } + + private Optional getBalanceFluctuatingWarning(ChannelId channelId) { + Coins capacity = channelService.getLocalChannel(channelId).map(LocalChannel::getCapacity).orElse(null); + if (capacity == null) { + return Optional.empty(); + } + Coins min = balanceService.getLocalBalanceMinimum(channelId, DAYS).orElse(null); + Coins max = balanceService.getLocalBalanceMaximum(channelId, DAYS).orElse(null); + if (min == null || max == null) { + return Optional.empty(); + } + int minPercentage = (int) (min.milliSatoshis() * 100.0 / capacity.milliSatoshis()); + int maxPercentage = (int) (max.milliSatoshis() * 100.0 / capacity.milliSatoshis()); + if (minPercentage < LOWER_THRESHOLD && maxPercentage > UPPER_THRESHOLD) { + return Optional.of(new ChannelBalanceFluctuationWarning(minPercentage, maxPercentage, DAYS)); + } + return Optional.empty(); + } +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java index 466a6727..981b2538 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java @@ -1,5 +1,6 @@ package de.cotto.lndmanagej.service; +import de.cotto.lndmanagej.balances.BalancesDao; import de.cotto.lndmanagej.grpc.GrpcChannels; import de.cotto.lndmanagej.model.BalanceInformation; import de.cotto.lndmanagej.model.Coins; @@ -33,6 +34,9 @@ class BalanceServiceTest { @Mock private ChannelService channelService; + @Mock + private BalancesDao balancesDao; + @Test void getBalanceInformation_for_pubkey() { BalanceInformation expected = new BalanceInformation( @@ -104,6 +108,32 @@ class BalanceServiceTest { assertThat(balanceService.getAvailableRemoteBalanceForPeer(PUBKEY)).isEqualTo(Coins.NONE); } + @Test + void getLocalBalanceMinimum_empty() { + assertThat(balanceService.getLocalBalanceMinimum(CHANNEL_ID, 7)).isEmpty(); + } + + @Test + void getLocalBalanceMinimum() { + int days = 7; + Coins coins = Coins.ofSatoshis(123); + when(balancesDao.getLocalBalanceMinimum(CHANNEL_ID, days)).thenReturn(Optional.of(coins)); + assertThat(balanceService.getLocalBalanceMinimum(CHANNEL_ID, days)).contains(coins); + } + + @Test + void getLocalBalanceMaximum_empty() { + assertThat(balanceService.getLocalBalanceMaximum(CHANNEL_ID, 7)).isEmpty(); + } + + @Test + void getLocalBalanceMaximum() { + int days = 7; + Coins coins = Coins.ofSatoshis(123); + when(balancesDao.getLocalBalanceMaximum(CHANNEL_ID, days)).thenReturn(Optional.of(coins)); + assertThat(balanceService.getLocalBalanceMaximum(CHANNEL_ID, days)).contains(coins); + } + private void mockChannels() { when(grpcChannels.getChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); when(grpcChannels.getChannel(CHANNEL_ID_2)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL_2)); diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProviderTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProviderTest.java new file mode 100644 index 00000000..d3e3b7dd --- /dev/null +++ b/backend/src/test/java/de/cotto/lndmanagej/service/ChannelBalanceFluctuationWarningsProviderTest.java @@ -0,0 +1,109 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.warnings.ChannelBalanceFluctuationWarning; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.annotation.Nullable; +import java.util.Optional; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChannelBalanceFluctuationWarningsProviderTest { + private static final int LOWER_THRESHOLD = 10; + private static final int UPPER_THRESHOLD = 90; + private static final int DAYS = 14; + + @InjectMocks + private ChannelBalanceFluctuationWarningsProvider warningsProvider; + + @Mock + private ChannelService channelService; + + @Mock + private BalanceService balanceService; + + @BeforeEach + void setUp() { + when(channelService.getLocalChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + } + + @Test + void getChannelWarnings_open_channel_not_found() { + when(channelService.getLocalChannel(CHANNEL_ID)).thenReturn(Optional.empty()); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_both_within_bounds() { + mockMinMax(LOWER_THRESHOLD, UPPER_THRESHOLD); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_minimum_not_found() { + mockMinMax(null, UPPER_THRESHOLD); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_maximum_not_found() { + mockMinMax(LOWER_THRESHOLD, null); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_minimum_and_maximum_not_found() { + mockMinMax(null, null); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_just_below_minimum() { + mockMinMax(LOWER_THRESHOLD - 1, UPPER_THRESHOLD); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings_just_above_maximum() { + mockMinMax(LOWER_THRESHOLD, UPPER_THRESHOLD + 1); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).isEmpty(); + } + + @Test + void getChannelWarnings() { + mockMinMax(LOWER_THRESHOLD - 1, UPPER_THRESHOLD + 1); + ChannelBalanceFluctuationWarning expectedWarning = + new ChannelBalanceFluctuationWarning(LOWER_THRESHOLD - 1, UPPER_THRESHOLD + 1, DAYS); + assertThat(warningsProvider.getChannelWarnings(CHANNEL_ID)).contains(expectedWarning); + } + + private void mockMinMax(@Nullable Integer min, @Nullable Integer max) { + Coins capacity = LOCAL_OPEN_CHANNEL.getCapacity(); + if (min == null) { + when(balanceService.getLocalBalanceMinimum(CHANNEL_ID, DAYS)) + .thenReturn(Optional.empty()); + } else { + Coins minLocalAvailable = Coins.ofSatoshis((long) (capacity.satoshis() / 100.0 * min)); + when(balanceService.getLocalBalanceMinimum(CHANNEL_ID, DAYS)) + .thenReturn(Optional.of(minLocalAvailable)); + } + if (max == null) { + when(balanceService.getLocalBalanceMaximum(CHANNEL_ID, DAYS)) + .thenReturn(Optional.empty()); + } else { + Coins maxLocalAvailable = Coins.ofSatoshis((long) (capacity.satoshis() / 100.0 * max)); + when(balanceService.getLocalBalanceMaximum(CHANNEL_ID, DAYS)) + .thenReturn(Optional.of(maxLocalAvailable)); + } + } +} \ No newline at end of file diff --git a/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java b/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java index 32a4b6a1..fff85bac 100644 --- a/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java +++ b/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java @@ -1,14 +1,26 @@ package de.cotto.lndmanagej.balances.persistence; +import de.cotto.lndmanagej.balances.Balances; +import de.cotto.lndmanagej.model.BalanceInformation; +import de.cotto.lndmanagej.model.Coins; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + import static de.cotto.lndmanagej.balances.BalancesFixtures.BALANCES; import static de.cotto.lndmanagej.balances.BalancesFixtures.BALANCES_OLD; +import static de.cotto.lndmanagej.balances.BalancesFixtures.TIMESTAMP; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.LOCAL_BALANCE; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.LOCAL_RESERVE; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.REMOTE_BALANCE; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.REMOTE_RESERVE; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; @DataJpaTest class BalancesRepositoryIT { @@ -48,4 +60,94 @@ class BalancesRepositoryIT { assertThat(repository.findTopByChannelIdOrderByTimestampDesc(CHANNEL_ID.getShortChannelId())) .map(BalancesJpaDto::toModel).contains(BALANCES); } + + @Test + void minimum_empty() { + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + CHANNEL_ID.getShortChannelId(), + 14 + )).isEmpty(); + } + + @Test + void minimum() { + assumeThat(localBalance(BALANCES) < localBalance(BALANCES_OLD)).isTrue(); + repository.save(BalancesJpaDto.fromModel(BALANCES)); + repository.save(BalancesJpaDto.fromModel(BALANCES_OLD)); + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + CHANNEL_ID.getShortChannelId(), + 14 + )).map(BalancesJpaDto::toModel) + .map(Balances::balanceInformation) + .map(BalanceInformation::localBalance) + .contains(BALANCES.balanceInformation().localBalance()); + } + + @Test + void minimum_too_old() { + LocalDateTime now = TIMESTAMP; + Balances balancesMoreThanMinimum = createBalances(LOCAL_BALANCE.add(Coins.ofSatoshis(1)), now); + Balances balancesMinimumButOld = createBalances(LOCAL_BALANCE, now.minusDays(1)); + long timestamp = balancesMinimumButOld.timestamp().toEpochSecond(ZoneOffset.UTC); + + repository.save(BalancesJpaDto.fromModel(balancesMinimumButOld)); + repository.save(BalancesJpaDto.fromModel(balancesMoreThanMinimum)); + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + CHANNEL_ID.getShortChannelId(), + timestamp + )).map(BalancesJpaDto::toModel) + .map(Balances::balanceInformation) + .map(BalanceInformation::localBalance) + .contains(balancesMoreThanMinimum.balanceInformation().localBalance()); + } + + private Balances createBalances(Coins localBalance, LocalDateTime timestamp) { + BalanceInformation balanceInformation = + new BalanceInformation(localBalance, LOCAL_RESERVE, REMOTE_BALANCE, REMOTE_RESERVE); + return new Balances(timestamp, CHANNEL_ID, balanceInformation); + } + + @Test + void maximum_empty() { + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc( + CHANNEL_ID.getShortChannelId(), + 14 + )).isEmpty(); + } + + @Test + void maximum() { + assumeThat(localBalance(BALANCES_OLD) > localBalance(BALANCES)).isTrue(); + repository.save(BalancesJpaDto.fromModel(BALANCES)); + repository.save(BalancesJpaDto.fromModel(BALANCES_OLD)); + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc( + CHANNEL_ID.getShortChannelId(), + 14 + )).map(BalancesJpaDto::toModel) + .map(Balances::balanceInformation) + .map(BalanceInformation::localBalance) + .contains(BALANCES_OLD.balanceInformation().localBalance()); + } + + @Test + void maximum_too_old() { + LocalDateTime now = TIMESTAMP; + Balances balancesLessThanMaximum = createBalances(LOCAL_BALANCE.subtract(Coins.ofSatoshis(1)), now); + Balances balancesMaximumButOld = createBalances(LOCAL_BALANCE, now.minusDays(1)); + long timestamp = balancesMaximumButOld.timestamp().toEpochSecond(ZoneOffset.UTC); + + repository.save(BalancesJpaDto.fromModel(balancesMaximumButOld)); + repository.save(BalancesJpaDto.fromModel(balancesLessThanMaximum)); + assertThat(repository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + CHANNEL_ID.getShortChannelId(), + timestamp + )).map(BalancesJpaDto::toModel) + .map(Balances::balanceInformation) + .map(BalanceInformation::localBalance) + .contains(balancesLessThanMaximum.balanceInformation().localBalance()); + } + + private long localBalance(Balances balances) { + return balances.balanceInformation().localBalance().milliSatoshis(); + } } \ No newline at end of file diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java b/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java index c7dd9a3d..2d339c4c 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java @@ -1,6 +1,7 @@ package de.cotto.lndmanagej.balances; import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; import java.util.Optional; @@ -8,4 +9,8 @@ public interface BalancesDao { void saveBalances(Balances balances); Optional getMostRecentBalances(ChannelId channelId); + + Optional getLocalBalanceMinimum(ChannelId channelId, int days); + + Optional getLocalBalanceMaximum(ChannelId channelId, int days); } diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java index 53c8723e..b7397b57 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java @@ -2,10 +2,14 @@ package de.cotto.lndmanagej.balances.persistence; import de.cotto.lndmanagej.balances.Balances; import de.cotto.lndmanagej.balances.BalancesDao; +import de.cotto.lndmanagej.model.BalanceInformation; import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; import org.springframework.stereotype.Component; import javax.transaction.Transactional; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Optional; @Component @@ -27,4 +31,36 @@ class BalancesDaoImpl implements BalancesDao { return balancesRepository.findTopByChannelIdOrderByTimestampDesc(channelId.getShortChannelId()) .map(BalancesJpaDto::toModel); } + + @Override + public Optional getLocalBalanceMinimum(ChannelId channelId, int days) { + return getLocalBalance( + balancesRepository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + channelId.getShortChannelId(), + getTimestamp(days) + ) + ); + } + + @Override + public Optional getLocalBalanceMaximum(ChannelId channelId, int days) { + long timestamp = getTimestamp(days); + return getLocalBalance( + balancesRepository.findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc( + channelId.getShortChannelId(), + timestamp + ) + ); + } + + private Optional getLocalBalance(Optional balances) { + return balances + .map(BalancesJpaDto::toModel) + .map(Balances::balanceInformation) + .map(BalanceInformation::localBalance); + } + + private long getTimestamp(int daysInPast) { + return ZonedDateTime.now(ZoneOffset.UTC).minusDays(daysInPast).toEpochSecond(); + } } diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java index 50fcd250..25ac4b95 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java @@ -6,4 +6,8 @@ import java.util.Optional; public interface BalancesRepository extends JpaRepository { Optional findTopByChannelIdOrderByTimestampDesc(long channelId); + + Optional findTopByChannelIdAndTimestampAfterOrderByLocalBalance(long channelId, long timestamp); + + Optional findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc(long channelId, long timestamp); } diff --git a/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java b/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java index 874318c7..a9b8cc33 100644 --- a/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java +++ b/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java @@ -1,5 +1,8 @@ package de.cotto.lndmanagej.balances.persistence; +import de.cotto.lndmanagej.balances.Balances; +import de.cotto.lndmanagej.model.BalanceInformation; +import de.cotto.lndmanagej.model.Coins; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -7,14 +10,21 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Optional; import static de.cotto.lndmanagej.balances.BalancesFixtures.BALANCES; import static de.cotto.lndmanagej.balances.BalancesFixtures.TIMESTAMP; import static de.cotto.lndmanagej.model.BalanceInformationFixtures.BALANCE_INFORMATION; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.LOCAL_RESERVE; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.REMOTE_BALANCE; +import static de.cotto.lndmanagej.model.BalanceInformationFixtures.REMOTE_RESERVE; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.longThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -60,4 +70,54 @@ class BalancesDaoImplTest { .thenReturn(Optional.of(BalancesJpaDto.fromModel(BALANCES))); assertThat(dao.getMostRecentBalances(CHANNEL_ID)).contains(BALANCES); } + + @Test + void getLocalBalanceMinimum_empty() { + assertThat(dao.getLocalBalanceMinimum(CHANNEL_ID, 7)).isEmpty(); + } + + @Test + void getLocalBalanceMinimum() { + int days = 14; + Coins localBalance = Coins.ofSatoshis(456); + Balances balances = getWithLocalBalance(localBalance); + when(balancesRepository.findTopByChannelIdAndTimestampAfterOrderByLocalBalance( + eq(CHANNEL_ID.getShortChannelId()), + anyLong() + )).thenReturn(Optional.of(BalancesJpaDto.fromModel(balances))); + assertThat(dao.getLocalBalanceMinimum(CHANNEL_ID, days)).contains(localBalance); + + long expectedTimestamp = ZonedDateTime.now(ZoneOffset.UTC).minusDays(days).toEpochSecond(); + verify(balancesRepository).findTopByChannelIdAndTimestampAfterOrderByLocalBalance(anyLong(), + longThat(timestamp -> timestamp > 0.95 * expectedTimestamp && timestamp < 1.05 * expectedTimestamp) + ); + } + + @Test + void getLocalBalanceMaximum_empty() { + assertThat(dao.getLocalBalanceMaximum(CHANNEL_ID, 7)).isEmpty(); + } + + @Test + void getLocalBalanceMaximum() { + int days = 14; + Coins localBalance = Coins.ofSatoshis(456); + Balances balances = getWithLocalBalance(localBalance); + when(balancesRepository.findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc( + eq(CHANNEL_ID.getShortChannelId()), + anyLong() + )).thenReturn(Optional.of(BalancesJpaDto.fromModel(balances))); + assertThat(dao.getLocalBalanceMaximum(CHANNEL_ID, days)).contains(localBalance); + + long expectedTimestamp = ZonedDateTime.now(ZoneOffset.UTC).minusDays(days).toEpochSecond(); + verify(balancesRepository).findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc(anyLong(), + longThat(timestamp -> timestamp > 0.95 * expectedTimestamp && timestamp < 1.05 * expectedTimestamp) + ); + } + + private Balances getWithLocalBalance(Coins localBalance) { + BalanceInformation balanceInformation = + new BalanceInformation(localBalance, LOCAL_RESERVE, REMOTE_BALANCE, REMOTE_RESERVE); + return new Balances(TIMESTAMP, CHANNEL_ID, balanceInformation); + } } \ No newline at end of file diff --git a/model/src/main/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarning.java b/model/src/main/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarning.java new file mode 100644 index 00000000..0fa7138f --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarning.java @@ -0,0 +1,16 @@ +package de.cotto.lndmanagej.model.warnings; + +public record ChannelBalanceFluctuationWarning( + int minLocalBalancePercentage, + int maxLocalBalancePercentage, + int days +) implements ChannelWarning { + @Override + public String description() { + return "Channel balance fluctuated between %d%% and %d%% in the past %d days".formatted( + minLocalBalancePercentage, + maxLocalBalancePercentage, + days + ); + } +} diff --git a/model/src/test/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarningTest.java b/model/src/test/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarningTest.java new file mode 100644 index 00000000..ccffff48 --- /dev/null +++ b/model/src/test/java/de/cotto/lndmanagej/model/warnings/ChannelBalanceFluctuationWarningTest.java @@ -0,0 +1,29 @@ +package de.cotto.lndmanagej.model.warnings; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.warnings.ChannelWarningFixtures.CHANNEL_BALANCE_FLUCTUATION_WARNING; +import static org.assertj.core.api.Assertions.assertThat; + +class ChannelBalanceFluctuationWarningTest { + @Test + void minLocalBalancePercentage() { + assertThat(CHANNEL_BALANCE_FLUCTUATION_WARNING.minLocalBalancePercentage()).isEqualTo(2); + } + + @Test + void maxLocalBalancePercentage() { + assertThat(CHANNEL_BALANCE_FLUCTUATION_WARNING.maxLocalBalancePercentage()).isEqualTo(97); + } + + @Test + void days() { + assertThat(CHANNEL_BALANCE_FLUCTUATION_WARNING.days()).isEqualTo(7); + } + + @Test + void description() { + assertThat(CHANNEL_BALANCE_FLUCTUATION_WARNING.description()) + .isEqualTo("Channel balance fluctuated between 2% and 97% in the past 7 days"); + } +} \ No newline at end of file diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/warnings/ChannelWarningFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/warnings/ChannelWarningFixtures.java index 4030c458..167f85a2 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/warnings/ChannelWarningFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/warnings/ChannelWarningFixtures.java @@ -2,4 +2,6 @@ package de.cotto.lndmanagej.model.warnings; public class ChannelWarningFixtures { public static final ChannelNumUpdatesWarning CHANNEL_NUM_UPDATES_WARNING = new ChannelNumUpdatesWarning(101_000L); + public static final ChannelBalanceFluctuationWarning CHANNEL_BALANCE_FLUCTUATION_WARNING = + new ChannelBalanceFluctuationWarning(2, 97, 7); }