keep information about liquidity bounds

and use it for pickhardt payments
This commit is contained in:
Carsten Otto
2022-04-12 20:27:58 +02:00
parent 7d5fff1245
commit d68efdcbab
15 changed files with 813 additions and 31 deletions

View File

@@ -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<TwoPubkeys, LiquidityBounds> entries;
public LiquidityBoundsService(MissionControlService missionControlService) {
this.missionControlService = missionControlService;
entries = new CacheBuilder()
.withSoftValues(true)
.build(ignored -> new LiquidityBounds());
}
@Timed
public Optional<Coins> 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) {
}
}

View File

@@ -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<PaymentAttemptHop> 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<PaymentAttemptHop> 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<Pubkey> 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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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));
}
}

View File

@@ -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<PaymentAttemptHop> 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<PaymentAttemptHop> 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<PaymentAttemptHop> 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);
}
}
}

View File

@@ -127,4 +127,4 @@ class MissionControlServiceTest {
Set<MissionControlEntry> set = Arrays.stream(entries).collect(toCollection(LinkedHashSet::new));
when(grpcMissionControl.getEntries()).thenReturn(Optional.of(set));
}
}
}

View File

@@ -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<SendToRouteRequ
.toList();
if (preimage.isEmpty()) {
Failure failure = response.getFailure();
int failureCode = failure.getCodeValue();
FailureCode failureCode = new FailureCode(failure.getCodeValue());
int failureSourceIndex = failure.getFailureSourceIndex();
paymentListeners.forEach(
paymentListener -> paymentListener.failure(paymentAttemptHops, failureCode, failureSourceIndex)

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
package de.cotto.lndmanagej.model;
public record FailureCode(int code) {
public static final FailureCode TEMPORARY_CHANNEL_FAILURE = new FailureCode(15);
}

View File

@@ -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<Coins> 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;
}
}

View File

@@ -5,5 +5,5 @@ import java.util.List;
public interface PaymentListener {
void success(HexString preimage, List<PaymentAttemptHop> paymentAttemptHops);
void failure(List<PaymentAttemptHop> paymentAttemptHops, int failureCode, int failureSourceIndex);
void failure(List<PaymentAttemptHop> paymentAttemptHops, FailureCode failureCode, int failureSourceIndex);
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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<Object, EdgesWithLiquidityInformation> 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);
}
}

View File

@@ -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)));