diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityBoundsService.java b/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityBoundsService.java new file mode 100644 index 00000000..c240ed3c --- /dev/null +++ b/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityBoundsService.java @@ -0,0 +1,67 @@ +package de.cotto.lndmanagej.service; + +import com.codahale.metrics.annotation.Timed; +import com.github.benmanes.caffeine.cache.LoadingCache; +import de.cotto.lndmanagej.caching.CacheBuilder; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.LiquidityBounds; +import de.cotto.lndmanagej.model.Pubkey; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.Optional; + +@Component +public class LiquidityBoundsService { + private final MissionControlService missionControlService; + private final LoadingCache entries; + + public LiquidityBoundsService(MissionControlService missionControlService) { + this.missionControlService = missionControlService; + entries = new CacheBuilder() + .withSoftValues(true) + .build(ignored -> new LiquidityBounds()); + } + + @Timed + public Optional getAssumedLiquidityUpperBound(Pubkey source, Pubkey target) { + Coins fromMissionControl = missionControlService.getMinimumOfRecentFailures(source, target).orElse(null); + Coins fromPayments = getInfo(source, target).getUpperBound().orElse(null); + if (fromMissionControl == null && fromPayments == null) { + return Optional.empty(); + } + if (fromMissionControl == null) { + return Optional.of(Objects.requireNonNull(fromPayments)); + } + Coins oneSatBelowFailure = fromMissionControl.subtract(Coins.ofSatoshis(1)); + if (fromPayments == null) { + return Optional.of(oneSatBelowFailure); + } + return Optional.of(oneSatBelowFailure.minimum(fromPayments)); + } + + @Timed + public Coins getAssumedLiquidityLowerBound(Pubkey source, Pubkey target) { + return getInfo(source, target).getLowerBound(); + } + + public void markAsMoved(Pubkey source, Pubkey target, Coins amount) { + getInfo(source, target).move(amount); + } + + public void markAsAvailable(Pubkey source, Pubkey target, Coins amount) { + getInfo(source, target).available(amount); + } + + public void markAsUnavailable(Pubkey source, Pubkey target, Coins amount) { + getInfo(source, target).unavailable(amount); + } + + private LiquidityBounds getInfo(Pubkey source, Pubkey target) { + return entries.get(new TwoPubkeys(source, target)); + } + + @SuppressWarnings("UnusedVariable") + private record TwoPubkeys(Pubkey source, Pubkey target) { + } +} diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityInformationUpdater.java b/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityInformationUpdater.java new file mode 100644 index 00000000..2b08a3b2 --- /dev/null +++ b/backend/src/main/java/de/cotto/lndmanagej/service/LiquidityInformationUpdater.java @@ -0,0 +1,79 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.grpc.GrpcChannelPolicy; +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.FailureCode; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.PaymentAttemptHop; +import de.cotto.lndmanagej.model.PaymentListener; +import de.cotto.lndmanagej.model.Pubkey; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +import static de.cotto.lndmanagej.model.FailureCode.TEMPORARY_CHANNEL_FAILURE; + +@Service +public class LiquidityInformationUpdater implements PaymentListener { + private final GrpcGetInfo grpcGetInfo; + private final GrpcChannelPolicy grpcChannelPolicy; + private final LiquidityBoundsService liquidityBoundsService; + + public LiquidityInformationUpdater( + GrpcGetInfo grpcGetInfo, + GrpcChannelPolicy grpcChannelPolicy, + LiquidityBoundsService liquidityBoundsService + ) { + this.grpcGetInfo = grpcGetInfo; + this.grpcChannelPolicy = grpcChannelPolicy; + this.liquidityBoundsService = liquidityBoundsService; + } + + @Override + public void success(HexString preimage, List paymentAttemptHops) { + Pubkey startNode = grpcGetInfo.getPubkey(); + for (PaymentAttemptHop hop : paymentAttemptHops) { + Pubkey endNode = getOtherNode(hop, startNode).orElse(null); + if (endNode == null) { + return; + } + liquidityBoundsService.markAsMoved(startNode, endNode, hop.amount()); + startNode = endNode; + } + } + + @Override + public void failure(List paymentAttemptHops, FailureCode failureCode, int failureSourceIndex) { + if (!TEMPORARY_CHANNEL_FAILURE.equals(failureCode)) { + return; + } + Pubkey startNode = grpcGetInfo.getPubkey(); + for (int i = 0; i < paymentAttemptHops.size(); i++) { + PaymentAttemptHop hop = paymentAttemptHops.get(i); + Pubkey endNode = getOtherNode(hop, startNode).orElse(null); + if (endNode == null) { + return; + } + if (i < failureSourceIndex) { + liquidityBoundsService.markAsAvailable(startNode, endNode, hop.amount()); + } else { + liquidityBoundsService.markAsUnavailable(startNode, endNode, hop.amount()); + return; + } + startNode = endNode; + } + } + + private Optional getOtherNode(PaymentAttemptHop hop, Pubkey startNode) { + if (hop.targetPubkey().isPresent()) { + return hop.targetPubkey(); + } + if (hop.channelId().isEmpty()) { + return Optional.empty(); + } + ChannelId channelId = hop.channelId().get(); + return grpcChannelPolicy.getOtherPubkey(channelId, startNode); + } +} 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 981b2538..7a665335 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java @@ -139,4 +139,4 @@ class BalanceServiceTest { when(grpcChannels.getChannel(CHANNEL_ID_2)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL_2)); when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, LOCAL_OPEN_CHANNEL_2)); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityBoundsServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityBoundsServiceTest.java new file mode 100644 index 00000000..b647f758 --- /dev/null +++ b/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityBoundsServiceTest.java @@ -0,0 +1,123 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.Coins; +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 java.util.Optional; + +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LiquidityBoundsServiceTest { + @InjectMocks + private LiquidityBoundsService liquidityBoundsService; + + @Mock + private MissionControlService missionControlService; + + @Test + void getAssumedLiquidityUpperBound_unknown() { + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)).isEmpty(); + } + + @Test + void getAssumedLiquidityUpperBound_from_mission_control() { + when(missionControlService.getMinimumOfRecentFailures(PUBKEY, PUBKEY_2)) + .thenReturn(Optional.of(Coins.ofSatoshis(123))); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)) + .contains(Coins.ofSatoshis(122)); + } + + @Test + void getAssumedLiquidityLowerBound_defaults_to_none() { + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.NONE); + } + + @Test + void markAsAvailable() { + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.ofSatoshis(100_000)); + } + + @Test + void markAsAvailable_uses_maximum() { + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(120_000)); + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(130_000)); + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(110_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.ofSatoshis(130_000)); + } + + @Test + void markAsMoved_removes_available_coins() { + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(110_000)); + liquidityBoundsService.markAsMoved(PUBKEY, PUBKEY_2, Coins.ofSatoshis(50_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.ofSatoshis(60_000)); + } + + @Test + void markAsMoved_below_assumed_available() { + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(1)); + liquidityBoundsService.markAsMoved(PUBKEY, PUBKEY_2, Coins.ofSatoshis(50_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.NONE); + } + + @Test + void markAsUnavailable() { + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(50_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)) + .contains(Coins.ofSatoshis(49_999)); + } + + @Test + void markAsUnavailable_uses_minimum() { + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(50_000)); + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(40_000)); + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(60_000)); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)) + .contains(Coins.ofSatoshis(39_999)); + } + + @Test + void markAsUnavailable_with_existing_mission_control_data() { + when(missionControlService.getMinimumOfRecentFailures(PUBKEY, PUBKEY_2)) + .thenReturn(Optional.of(Coins.ofSatoshis(123))); + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)) + .contains(Coins.ofSatoshis(99)); + } + + @Test + void markAsAvailable_more_than_upper_bound() { + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(120)); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)).isEmpty(); + } + + @Test + void markAsAvailable_below_upper_bound() { + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(90)); + assertThat(liquidityBoundsService.getAssumedLiquidityUpperBound(PUBKEY, PUBKEY_2)) + .contains(Coins.ofSatoshis(99)); + } + + @Test + void markAsUnavailable_below_lower_bound() { + liquidityBoundsService.markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(120)); + liquidityBoundsService.markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + assertThat(liquidityBoundsService.getAssumedLiquidityLowerBound(PUBKEY, PUBKEY_2)) + .isEqualTo(Coins.ofSatoshis(99)); + } +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityInformationUpdaterTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityInformationUpdaterTest.java new file mode 100644 index 00000000..fa668c3a --- /dev/null +++ b/backend/src/test/java/de/cotto/lndmanagej/service/LiquidityInformationUpdaterTest.java @@ -0,0 +1,165 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.grpc.GrpcChannelPolicy; +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FailureCode; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.PaymentAttemptHop; +import de.cotto.lndmanagej.model.PaymentListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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 java.util.List; +import java.util.Optional; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_2; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_3; +import static de.cotto.lndmanagej.model.FailureCode.TEMPORARY_CHANNEL_FAILURE; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_4; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +class LiquidityInformationUpdaterTest { + + private final List hopsWithChannelIdsAndPubkeys = List.of( + new PaymentAttemptHop(Optional.of(CHANNEL_ID), Coins.ofSatoshis(100), Optional.of(PUBKEY_2)), + new PaymentAttemptHop(Optional.of(CHANNEL_ID_2), Coins.ofSatoshis(90), Optional.of(PUBKEY_3)), + new PaymentAttemptHop(Optional.of(CHANNEL_ID_3), Coins.ofSatoshis(80), Optional.of(PUBKEY_4)) + ); + + private final List hopsWithChannelIds = List.of( + new PaymentAttemptHop(Optional.of(CHANNEL_ID), Coins.ofSatoshis(100), Optional.empty()), + new PaymentAttemptHop(Optional.of(CHANNEL_ID_2), Coins.ofSatoshis(90), Optional.empty()), + new PaymentAttemptHop(Optional.of(CHANNEL_ID_3), Coins.ofSatoshis(80), Optional.empty()) + ); + + private final List hopsJustWithAmount = List.of( + new PaymentAttemptHop(Optional.empty(), Coins.ofSatoshis(100), Optional.empty()), + new PaymentAttemptHop(Optional.empty(), Coins.ofSatoshis(90), Optional.empty()), + new PaymentAttemptHop(Optional.empty(), Coins.ofSatoshis(80), Optional.empty()) + ); + + @InjectMocks + private LiquidityInformationUpdater liquidityInformationUpdater; + + @Mock + private GrpcGetInfo grpcGetInfo; + + @Mock + private GrpcChannelPolicy grpcChannelPolicy; + + @Mock + private LiquidityBoundsService liquidityBoundsService; + + @BeforeEach + void setUp() { + lenient().when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY); + lenient().when(grpcChannelPolicy.getOtherPubkey(CHANNEL_ID, PUBKEY)).thenReturn(Optional.of(PUBKEY_2)); + lenient().when(grpcChannelPolicy.getOtherPubkey(CHANNEL_ID_2, PUBKEY_2)).thenReturn(Optional.of(PUBKEY_3)); + lenient().when(grpcChannelPolicy.getOtherPubkey(CHANNEL_ID_3, PUBKEY_3)).thenReturn(Optional.of(PUBKEY_4)); + } + + @Test + void is_payment_listener() { + assertThat(liquidityInformationUpdater).isInstanceOf(PaymentListener.class); + } + + @Nested + class Success { + private static final HexString PREIMAGE = new HexString("00"); + + // CPD-OFF + @Test + void success() { + liquidityInformationUpdater.success(PREIMAGE, hopsWithChannelIdsAndPubkeys); + verify(liquidityBoundsService).markAsMoved(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsMoved(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verify(liquidityBoundsService).markAsMoved(PUBKEY_3, PUBKEY_4, Coins.ofSatoshis(80)); + } + + @Test + void success_just_channel_id() { + liquidityInformationUpdater.success(PREIMAGE, hopsWithChannelIds); + verify(liquidityBoundsService).markAsMoved(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsMoved(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verify(liquidityBoundsService).markAsMoved(PUBKEY_3, PUBKEY_4, Coins.ofSatoshis(80)); + } + // CPD-ON + + @Test + void success_without_channel_or_pubkey_information() { + liquidityInformationUpdater.success(PREIMAGE, hopsJustWithAmount); + verifyNoInteractions(liquidityBoundsService); + } + } + + @Nested + class Failure { + @Test + void temporary_channel_failure_on_first_hop() { + liquidityInformationUpdater.failure(hopsWithChannelIdsAndPubkeys, TEMPORARY_CHANNEL_FAILURE, 0); + verify(liquidityBoundsService).markAsUnavailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verifyNoMoreInteractions(liquidityBoundsService); + } + + @Test + void temporary_channel_failure_on_second_hop() { + liquidityInformationUpdater.failure(hopsWithChannelIdsAndPubkeys, TEMPORARY_CHANNEL_FAILURE, 1); + verify(liquidityBoundsService).markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsUnavailable(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verifyNoMoreInteractions(liquidityBoundsService); + } + + // CPD-OFF + @Test + void temporary_channel_failure_on_third_hop() { + liquidityInformationUpdater.failure(hopsWithChannelIdsAndPubkeys, TEMPORARY_CHANNEL_FAILURE, 2); + verify(liquidityBoundsService).markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsAvailable(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verify(liquidityBoundsService).markAsUnavailable(PUBKEY_3, PUBKEY_4, Coins.ofSatoshis(80)); + } + + @Test + void temporary_channel_failure_on_hop_that_does_not_exist() { + liquidityInformationUpdater.failure(hopsWithChannelIdsAndPubkeys, TEMPORARY_CHANNEL_FAILURE, 99); + verify(liquidityBoundsService).markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsAvailable(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verify(liquidityBoundsService).markAsAvailable(PUBKEY_3, PUBKEY_4, Coins.ofSatoshis(80)); + } + + @Test + void temporary_channel_just_channel_information() { + liquidityInformationUpdater.failure(hopsWithChannelIds, TEMPORARY_CHANNEL_FAILURE, 2); + verify(liquidityBoundsService).markAsAvailable(PUBKEY, PUBKEY_2, Coins.ofSatoshis(100)); + verify(liquidityBoundsService).markAsAvailable(PUBKEY_2, PUBKEY_3, Coins.ofSatoshis(90)); + verify(liquidityBoundsService).markAsUnavailable(PUBKEY_3, PUBKEY_4, Coins.ofSatoshis(80)); + } + // CPD-ON + + @Test + void temporary_channel_failure_without_channel_or_pubkey_information() { + liquidityInformationUpdater.failure(hopsJustWithAmount, TEMPORARY_CHANNEL_FAILURE, 2); + verifyNoInteractions(liquidityBoundsService); + } + + @Test + void unknown_failure_code() { + liquidityInformationUpdater.failure(hopsWithChannelIdsAndPubkeys, new FailureCode(99), 2); + verifyNoInteractions(liquidityBoundsService); + } + } +} diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/MissionControlServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/MissionControlServiceTest.java index 3249c984..cc1f137b 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/MissionControlServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/MissionControlServiceTest.java @@ -127,4 +127,4 @@ class MissionControlServiceTest { Set set = Arrays.stream(entries).collect(toCollection(LinkedHashSet::new)); when(grpcMissionControl.getEntries()).thenReturn(Optional.of(set)); } -} \ No newline at end of file +} diff --git a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListener.java b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListener.java index 76b8e382..df527119 100644 --- a/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListener.java +++ b/grpc-adapter/src/main/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListener.java @@ -3,6 +3,7 @@ package de.cotto.lndmanagej.grpc.middleware; import com.google.protobuf.ByteString; import de.cotto.lndmanagej.model.ChannelId; import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FailureCode; import de.cotto.lndmanagej.model.HexString; import de.cotto.lndmanagej.model.PaymentAttemptHop; import de.cotto.lndmanagej.model.PaymentListener; @@ -51,7 +52,7 @@ public class SendToRouteListener extends RequestResponseListener paymentListener.failure(paymentAttemptHops, failureCode, failureSourceIndex) diff --git a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListenerTest.java b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListenerTest.java index 62897ff0..0ad2033c 100644 --- a/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListenerTest.java +++ b/grpc-adapter/src/test/java/de/cotto/lndmanagej/grpc/middleware/SendToRouteListenerTest.java @@ -3,6 +3,7 @@ package de.cotto.lndmanagej.grpc.middleware; import com.google.protobuf.ByteString; import de.cotto.lndmanagej.model.ChannelId; import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FailureCode; import de.cotto.lndmanagej.model.HexString; import de.cotto.lndmanagej.model.PaymentAttemptHop; import de.cotto.lndmanagej.model.PaymentListener; @@ -125,7 +126,7 @@ class SendToRouteListenerTest { .setFailureSourceIndex(3) .build()) .build(), REQUEST_ID); - verify(paymentListener).failure(paymentAttemptHops, 15, 3); + verify(paymentListener).failure(paymentAttemptHops, FailureCode.TEMPORARY_CHANNEL_FAILURE, 3); verifyNoMoreInteractions(paymentListener); } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/FailureCode.java b/model/src/main/java/de/cotto/lndmanagej/model/FailureCode.java new file mode 100644 index 00000000..ebe20242 --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/FailureCode.java @@ -0,0 +1,5 @@ +package de.cotto.lndmanagej.model; + +public record FailureCode(int code) { + public static final FailureCode TEMPORARY_CHANNEL_FAILURE = new FailureCode(15); +} diff --git a/model/src/main/java/de/cotto/lndmanagej/model/LiquidityBounds.java b/model/src/main/java/de/cotto/lndmanagej/model/LiquidityBounds.java new file mode 100644 index 00000000..f8456ba8 --- /dev/null +++ b/model/src/main/java/de/cotto/lndmanagej/model/LiquidityBounds.java @@ -0,0 +1,84 @@ +package de.cotto.lndmanagej.model; + +import com.google.common.annotations.VisibleForTesting; + +import javax.annotation.CheckForNull; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +public class LiquidityBounds { + private static final Duration MAX_AGE = Duration.of(1, ChronoUnit.HOURS); + private Instant lowerBoundLastUpdate; + private Instant upperBoundLastUpdate; + + private Coins lowerBound; + @CheckForNull + private Coins upperBound; + + public LiquidityBounds() { + lowerBound = Coins.NONE; + lowerBoundLastUpdate = Instant.now(); + upperBoundLastUpdate = Instant.now(); + } + + public void move(Coins amount) { + lowerBound = lowerBound.subtract(amount).maximum(Coins.NONE); + } + + @SuppressWarnings("PMD.NullAssignment") + public void available(Coins amount) { + resetOldLowerBound(); + lowerBoundLastUpdate = Instant.now(); + lowerBound = lowerBound.maximum(amount); + if (upperBound != null && lowerBound.compareTo(upperBound) >= 0) { + upperBound = null; + } + } + + public void unavailable(Coins amount) { + resetOldUpperBound(); + upperBoundLastUpdate = Instant.now(); + Coins newUpperBound = amount.subtract(Coins.ofSatoshis(1)); + if (upperBound == null) { + upperBound = newUpperBound; + } else { + upperBound = upperBound.minimum(newUpperBound); + } + lowerBound = lowerBound.minimum(upperBound); + } + + public Coins getLowerBound() { + resetOldLowerBound(); + return lowerBound; + } + + public Optional getUpperBound() { + resetOldUpperBound(); + return Optional.ofNullable(upperBound); + } + + @SuppressWarnings("PMD.NullAssignment") + private void resetOldUpperBound() { + if (upperBoundLastUpdate.isBefore(Instant.now().minus(MAX_AGE))) { + upperBound = null; + } + } + + private void resetOldLowerBound() { + if (lowerBoundLastUpdate.isBefore(Instant.now().minus(MAX_AGE))) { + lowerBound = Coins.NONE; + } + } + + @VisibleForTesting + void setLowerBoundLastUpdate(Instant lowerBoundLastUpdate) { + this.lowerBoundLastUpdate = lowerBoundLastUpdate; + } + + @VisibleForTesting + void setUpperBoundLastUpdate(Instant upperBoundLastUpdate) { + this.upperBoundLastUpdate = upperBoundLastUpdate; + } +} diff --git a/model/src/main/java/de/cotto/lndmanagej/model/PaymentListener.java b/model/src/main/java/de/cotto/lndmanagej/model/PaymentListener.java index 76eb3f96..6408b21f 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/PaymentListener.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/PaymentListener.java @@ -5,5 +5,5 @@ import java.util.List; public interface PaymentListener { void success(HexString preimage, List paymentAttemptHops); - void failure(List paymentAttemptHops, int failureCode, int failureSourceIndex); + void failure(List paymentAttemptHops, FailureCode failureCode, int failureSourceIndex); } diff --git a/model/src/test/java/de/cotto/lndmanagej/model/FailureCodeTest.java b/model/src/test/java/de/cotto/lndmanagej/model/FailureCodeTest.java new file mode 100644 index 00000000..09b8417c --- /dev/null +++ b/model/src/test/java/de/cotto/lndmanagej/model/FailureCodeTest.java @@ -0,0 +1,13 @@ +package de.cotto.lndmanagej.model; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.FailureCode.TEMPORARY_CHANNEL_FAILURE; +import static org.assertj.core.api.Assertions.assertThat; + +class FailureCodeTest { + @Test + void temporaryChannelFailure() { + assertThat(TEMPORARY_CHANNEL_FAILURE.code()).isEqualTo(15); + } +} diff --git a/model/src/test/java/de/cotto/lndmanagej/model/LiquidityBoundsTest.java b/model/src/test/java/de/cotto/lndmanagej/model/LiquidityBoundsTest.java new file mode 100644 index 00000000..188da3ad --- /dev/null +++ b/model/src/test/java/de/cotto/lndmanagej/model/LiquidityBoundsTest.java @@ -0,0 +1,216 @@ +package de.cotto.lndmanagej.model; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class LiquidityBoundsTest { + private final LiquidityBounds liquidityBounds = new LiquidityBounds(); + + @Test + void getLowerBound_initially_zero() { + assertThat(liquidityBounds.getLowerBound()).isEqualTo(Coins.NONE); + } + + @Test + void getLowerBound_after_available_update() { + Coins amount = Coins.ofSatoshis(100); + liquidityBounds.available(amount); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(amount); + } + + @Test + void getLowerBound_two_available_updates() { + Coins amount1 = Coins.ofSatoshis(100); + Coins amount2 = Coins.ofSatoshis(200); + liquidityBounds.available(amount1); + liquidityBounds.available(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(amount2); + } + + @Test + void getLowerBound_two_available_updates_reversed() { + Coins amount1 = Coins.ofSatoshis(200); + Coins amount2 = Coins.ofSatoshis(100); + liquidityBounds.available(amount1); + liquidityBounds.available(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(amount1); + } + + @Test + void getUpperBound_initially_unknown() { + assertThat(liquidityBounds.getUpperBound()).isEmpty(); + } + + @Test + void getUpperBound_after_unavailable_update() { + Coins amount = Coins.ofSatoshis(200); + liquidityBounds.unavailable(amount); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(amount)); + } + + @Test + void getUpperBound_after_two_unavailable_updates() { + Coins amount1 = Coins.ofSatoshis(200); + Coins amount2 = Coins.ofSatoshis(100); + liquidityBounds.unavailable(amount1); + liquidityBounds.unavailable(amount2); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(amount2)); + } + + @Test + void getUpperBound_after_unavailable_updates_reversed() { + Coins amount1 = Coins.ofSatoshis(100); + Coins amount2 = Coins.ofSatoshis(200); + liquidityBounds.unavailable(amount1); + liquidityBounds.unavailable(amount2); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(amount1)); + } + + @Test + void available_update_invalidates_lower_upper_bound() { + Coins amount1 = Coins.ofSatoshis(100); + Coins amount2 = oneSatLessThan(amount1); + liquidityBounds.unavailable(amount1); + liquidityBounds.available(amount2); + assertThat(liquidityBounds.getUpperBound()).isEmpty(); + } + + @Test + void available_update_keeps_higher_upper_bound() { + Coins amount1 = Coins.ofSatoshis(300); + Coins amount2 = Coins.ofSatoshis(298); + liquidityBounds.unavailable(amount1); + liquidityBounds.available(amount2); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(amount1)); + } + + @Test + void unavailable_update_updates_lower_bound() { + Coins amount1 = Coins.ofSatoshis(300); + Coins amount2 = Coins.ofSatoshis(301); + liquidityBounds.available(amount1); + liquidityBounds.unavailable(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(oneSatLessThan(amount2)); + } + + @Test + void unavailable_what_is_assumed_to_be_available() { + Coins amount1 = Coins.ofSatoshis(300); + Coins amount2 = Coins.ofSatoshis(300); + liquidityBounds.available(amount1); + liquidityBounds.unavailable(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(oneSatLessThan(amount2)); + } + + @Test + void unavailable_more_than_available() { + Coins amount1 = Coins.ofSatoshis(300); + Coins amount2 = Coins.ofSatoshis(500); + liquidityBounds.available(amount1); + liquidityBounds.unavailable(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(amount1); + } + + @Test + void move_updates_lower_bound() { + Coins amount1 = Coins.ofSatoshis(100); + Coins amount2 = Coins.ofSatoshis(60); + liquidityBounds.available(amount1); + liquidityBounds.move(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(Coins.ofSatoshis(40)); + } + + @Test + void move_more_than_lower_bound() { + Coins amount1 = Coins.ofSatoshis(100); + Coins amount2 = Coins.ofSatoshis(200); + liquidityBounds.available(amount1); + liquidityBounds.move(amount2); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(Coins.NONE); + } + + @Test + void available_forgets_lower_bound_after_one_hour() { + Coins higherButOld = Coins.ofSatoshis(200); + Coins lowerMoreRecent = Coins.ofSatoshis(100); + liquidityBounds.available(higherButOld); + liquidityBounds.setLowerBoundLastUpdate(oneHourAgo()); + + liquidityBounds.available(lowerMoreRecent); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(lowerMoreRecent); + } + + @Test + void available_updates_last_update_of_lower_bound() { + liquidityBounds.setLowerBoundLastUpdate(oneHourAgo()); + + Coins lowerBound = Coins.ofSatoshis(50); + liquidityBounds.available(lowerBound); + assertThat(liquidityBounds.getLowerBound()).isEqualTo(lowerBound); + } + + @Test + void unavailable_forgets_upper_bound_after_one_hour() { + liquidityBounds.unavailable(Coins.ofSatoshis(100)); + liquidityBounds.setUpperBoundLastUpdate(oneHourAgo()); + + Coins moreRecentAmount = Coins.ofSatoshis(200); + liquidityBounds.unavailable(moreRecentAmount); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(moreRecentAmount)); + } + + @Test + void unavailable_updates_last_update_of_upper_bound() { + liquidityBounds.setUpperBoundLastUpdate(oneHourAgo()); + + Coins moreRecentValue = Coins.ofSatoshis(100); + liquidityBounds.unavailable(moreRecentValue); + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(moreRecentValue)); + } + + @Test + void getLowerBound_does_not_return_old_value() { + liquidityBounds.available(Coins.ofSatoshis(100)); + liquidityBounds.setLowerBoundLastUpdate(oneHourAgo()); + + assertThat(liquidityBounds.getLowerBound()).isEqualTo(Coins.NONE); + } + + @Test + void getLowerBound_value_is_kept_for_old_upper_bound() { + Coins amount = Coins.ofSatoshis(100); + liquidityBounds.available(amount); + liquidityBounds.setUpperBoundLastUpdate(oneHourAgo()); + + assertThat(liquidityBounds.getLowerBound()).isEqualTo(amount); + } + + @Test + void getUpperBound_does_not_return_old_value() { + liquidityBounds.unavailable(Coins.ofSatoshis(100)); + liquidityBounds.setUpperBoundLastUpdate(oneHourAgo()); + + assertThat(liquidityBounds.getUpperBound()).isEmpty(); + } + + @Test + void getUpperBound_value_is_kept_for_old_lower_bound() { + Coins amount = Coins.ofSatoshis(100); + liquidityBounds.unavailable(amount); + liquidityBounds.setLowerBoundLastUpdate(oneHourAgo()); + + assertThat(liquidityBounds.getUpperBound()).contains(oneSatLessThan(amount)); + } + + private Instant oneHourAgo() { + return Instant.now().minus(1, ChronoUnit.HOURS); + } + + private Coins oneSatLessThan(Coins amount) { + return amount.subtract(Coins.ofSatoshis(1)); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputation.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputation.java index 50bfd664..d6baa531 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputation.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputation.java @@ -15,7 +15,7 @@ import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithLiquidityInformation; import de.cotto.lndmanagej.pickhardtpayments.model.EdgesWithLiquidityInformation; import de.cotto.lndmanagej.service.BalanceService; import de.cotto.lndmanagej.service.ChannelService; -import de.cotto.lndmanagej.service.MissionControlService; +import de.cotto.lndmanagej.service.LiquidityBoundsService; import de.cotto.lndmanagej.service.NodeService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public class EdgeComputation { private final ChannelService channelService; private final NodeService nodeService; private final BalanceService balanceService; - private final MissionControlService missionControlService; + private final LiquidityBoundsService liquidityBoundsService; private final LoadingCache cache = new CacheBuilder() .withExpiry(Duration.ofSeconds(10)) .withRefresh(Duration.ofSeconds(5)) @@ -48,14 +48,14 @@ public class EdgeComputation { ChannelService channelService, NodeService nodeService, BalanceService balanceService, - MissionControlService missionControlService + LiquidityBoundsService liquidityBoundsService ) { this.grpcGraph = grpcGraph; this.grpcGetInfo = grpcGetInfo; this.channelService = channelService; this.nodeService = nodeService; this.balanceService = balanceService; - this.missionControlService = missionControlService; + this.liquidityBoundsService = liquidityBoundsService; } public EdgesWithLiquidityInformation getEdges() { @@ -70,8 +70,9 @@ public class EdgeComputation { private EdgeWithLiquidityInformation getEdgeWithLiquidityInformation(Edge edge, Pubkey ownPubkey) { Coins knownLiquidity = getKnownLiquidity(edge, ownPubkey).orElse(null); if (knownLiquidity == null) { - Coins availableLiquidityUpperBound = getAvailableLiquidityUpperBound(edge); - return EdgeWithLiquidityInformation.forUpperBound(edge, availableLiquidityUpperBound); + Coins lowerBound = liquidityBoundsService.getAssumedLiquidityLowerBound(edge.startNode(), edge.endNode()); + Coins upperBound = getAvailableLiquidityUpperBound(edge); + return EdgeWithLiquidityInformation.forLowerAndUpperBound(edge, lowerBound, upperBound); } return EdgeWithLiquidityInformation.forKnownLiquidity(edge, knownLiquidity); } @@ -137,13 +138,7 @@ public class EdgeComputation { private Coins getAvailableLiquidityUpperBound(Edge edge) { Pubkey source = edge.startNode(); Pubkey target = edge.endNode(); - Coins failureAmount = missionControlService.getMinimumOfRecentFailures(source, target).orElse(null); - if (failureAmount == null) { - return edge.capacity(); - } - long satsCapacity = edge.capacity().satoshis(); - long satsNotAvailable = failureAmount.milliSatoshis() / 1_000; - long satsAvailable = Math.max(Math.min(satsNotAvailable - 1, satsCapacity), 0); - return Coins.ofSatoshis(satsAvailable); + Coins upperBound = liquidityBoundsService.getAssumedLiquidityUpperBound(source, target).orElse(null); + return edge.capacity().minimum(upperBound); } } diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputationTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputationTest.java index d367e134..59a724a7 100644 --- a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputationTest.java +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/EdgeComputationTest.java @@ -9,7 +9,7 @@ import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithLiquidityInformation; import de.cotto.lndmanagej.service.BalanceService; import de.cotto.lndmanagej.service.ChannelService; -import de.cotto.lndmanagej.service.MissionControlService; +import de.cotto.lndmanagej.service.LiquidityBoundsService; import de.cotto.lndmanagej.service.NodeService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,16 +57,16 @@ class EdgeComputationTest { private GrpcGraph grpcGraph; @Mock - private MissionControlService missionControlService; + private NodeService nodeService; @Mock - private NodeService nodeService; + private LiquidityBoundsService liquidityBoundsService; @BeforeEach void setUp() { lenient().when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); - lenient().when(missionControlService.getMinimumOfRecentFailures(any(), any())).thenReturn(Optional.empty()); lenient().when(nodeService.getNode(any())).thenReturn(NODE_PEER); + lenient().when(liquidityBoundsService.getAssumedLiquidityLowerBound(any(), any())).thenReturn(Coins.NONE); } @Test @@ -160,12 +160,13 @@ class EdgeComputationTest { } @Test - void adds_upper_bound_from_mission_control() { + void adds_upper_bound_from_liquidity_bounds_service() { mockEdge(); - when(missionControlService.getMinimumOfRecentFailures(EDGE.startNode(), EDGE.endNode())) - .thenReturn(Optional.of(Coins.ofSatoshis(100))); + Coins upperBound = Coins.ofSatoshis(100); + when(liquidityBoundsService.getAssumedLiquidityUpperBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(Optional.of(upperBound)); assertThat(edgeComputation.getEdges().edges()) - .contains(EdgeWithLiquidityInformation.forUpperBound(EDGE, Coins.ofSatoshis(99))); + .contains(EdgeWithLiquidityInformation.forUpperBound(EDGE, upperBound)); } @Test @@ -203,16 +204,48 @@ class EdgeComputationTest { } @Test - void getEdgeWithLiquidityInformation_with_data_from_mission_control() { + void getEdgeWithLiquidityInformation_with_upper_bound() { when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); - Coins recentFailureAmount = Coins.ofSatoshis(456); Coins upperBound = Coins.ofSatoshis(455); - when(missionControlService.getMinimumOfRecentFailures(EDGE.startNode(), EDGE.endNode())) - .thenReturn(Optional.of(recentFailureAmount)); + when(liquidityBoundsService.getAssumedLiquidityUpperBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(Optional.of(upperBound)); assertThat(edgeComputation.getEdgeWithLiquidityInformation(EDGE)) .isEqualTo(EdgeWithLiquidityInformation.forUpperBound(EDGE, upperBound)); } + @Test + void getEdgeWithLiquidityInformation_with_upper_bound_above_capacity() { + when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); + Coins upperBound = EDGE.capacity().add(Coins.ofSatoshis(1)); + when(liquidityBoundsService.getAssumedLiquidityUpperBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(Optional.of(upperBound)); + assertThat(edgeComputation.getEdgeWithLiquidityInformation(EDGE)) + .isEqualTo(EdgeWithLiquidityInformation.forUpperBound(EDGE, EDGE.capacity())); + } + + @Test + void getEdgeWithLiquidityInformation_with_lower_bound() { + when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); + Coins lowerBound = Coins.ofSatoshis(455); + when(liquidityBoundsService.getAssumedLiquidityLowerBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(lowerBound); + assertThat(edgeComputation.getEdgeWithLiquidityInformation(EDGE)) + .isEqualTo(EdgeWithLiquidityInformation.forLowerBound(EDGE, lowerBound)); + } + + @Test + void getEdgeWithLiquidityInformation_with_lower_and_upper_bound() { + when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); + Coins lowerBound = Coins.ofSatoshis(100); + Coins upperBound = Coins.ofSatoshis(455); + when(liquidityBoundsService.getAssumedLiquidityLowerBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(lowerBound); + when(liquidityBoundsService.getAssumedLiquidityUpperBound(EDGE.startNode(), EDGE.endNode())) + .thenReturn(Optional.of(upperBound)); + assertThat(edgeComputation.getEdgeWithLiquidityInformation(EDGE)) + .isEqualTo(EdgeWithLiquidityInformation.forLowerAndUpperBound(EDGE, lowerBound, upperBound)); + } + private void mockEdge() { DirectedChannelEdge edge = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, PUBKEY, PUBKEY_2, POLICY_1); when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(edge)));