diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/MissionControlService.java b/backend/src/main/java/de/cotto/lndmanagej/service/MissionControlService.java index c8e4741f..637edc00 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/MissionControlService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/MissionControlService.java @@ -8,7 +8,6 @@ import de.cotto.lndmanagej.model.Pubkey; import org.springframework.stereotype.Component; -import javax.annotation.Nullable; import java.time.Duration; import java.time.Instant; import java.util.LinkedHashMap; @@ -62,16 +61,6 @@ private Optional>> populateFailureMapFromMissionC private void setMinimum(Map> map, Pubkey source, Pubkey target, Coins amount) { Map innerMap = map.compute(source, (k, v) -> v == null ? new LinkedHashMap<>() : v); - innerMap.compute(target, (k, v) -> minimum(v, amount)); - } - - private Coins minimum(@Nullable Coins existingValue, Coins newAmount) { - if (existingValue == null) { - return newAmount; - } - if (existingValue.compareTo(newAmount) <= 0) { - return existingValue; - } - return newAmount; + innerMap.compute(target, (k, v) -> amount.minimum(v)); } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/Coins.java b/model/src/main/java/de/cotto/lndmanagej/model/Coins.java index aadf385b..7b231dca 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/Coins.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/Coins.java @@ -47,6 +47,10 @@ public Coins minimum(@Nullable Coins other) { return other; } + public Coins negate() { + return Coins.ofMilliSatoshis(-milliSatoshis); + } + public boolean isPositive() { return compareTo(NONE) > 0; } diff --git a/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java b/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java index 979014aa..60e68ec3 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java @@ -206,4 +206,19 @@ void minimum_same_prefers_this() { void minimum_null() { assertThat(Coins.ofMilliSatoshis(1).minimum(null)).isEqualTo(Coins.ofMilliSatoshis(1)); } + + @Test + void negate_zero() { + assertThat(Coins.NONE.negate()).isEqualTo(Coins.NONE); + } + + @Test + void negate_positive() { + assertThat(Coins.ofMilliSatoshis(123).negate()).isEqualTo(Coins.ofMilliSatoshis(-123)); + } + + @Test + void negate_negative() { + assertThat(Coins.ofSatoshis(-1).negate()).isEqualTo(Coins.ofMilliSatoshis(1_000)); + } } diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelIdFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelIdFixtures.java index 07bc68dc..f15b99c0 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelIdFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/ChannelIdFixtures.java @@ -5,10 +5,12 @@ public class ChannelIdFixtures { public static final String CHANNEL_ID_COMPACT_2 = "799999x456x2"; public static final String CHANNEL_ID_COMPACT_3 = "799999x456x3"; public static final String CHANNEL_ID_COMPACT_4 = "799999x456x4"; + public static final String CHANNEL_ID_COMPACT_5 = "799999x456x5"; public static final ChannelId CHANNEL_ID = ChannelId.fromCompactForm(CHANNEL_ID_COMPACT); public static final ChannelId CHANNEL_ID_2 = ChannelId.fromCompactForm(CHANNEL_ID_COMPACT_2); public static final ChannelId CHANNEL_ID_3 = ChannelId.fromCompactForm(CHANNEL_ID_COMPACT_3); public static final ChannelId CHANNEL_ID_4 = ChannelId.fromCompactForm(CHANNEL_ID_COMPACT_4); + public static final ChannelId CHANNEL_ID_5 = ChannelId.fromCompactForm(CHANNEL_ID_COMPACT_5); public static final long CHANNEL_ID_SHORT = CHANNEL_ID.getShortChannelId(); public static final long CHANNEL_ID_2_SHORT = CHANNEL_ID_2.getShortChannelId(); } diff --git a/pickhardt-payments/build.gradle b/pickhardt-payments/build.gradle new file mode 100644 index 00000000..8507df90 --- /dev/null +++ b/pickhardt-payments/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'lnd-manageJ.java-library-conventions' +} + +dependencies { + implementation project(':backend') + implementation project(':caching') + implementation project(':model') + implementation project(':grpc-adapter') + implementation 'com.google.ortools:ortools-java:9.2.9972' + testImplementation testFixtures(project(':model')) + testFixturesImplementation testFixtures(project(':model')) +} \ No newline at end of file diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializer.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializer.java new file mode 100644 index 00000000..206a89ae --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializer.java @@ -0,0 +1,70 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import com.google.ortools.graph.MinCostFlow; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformation; +import de.cotto.lndmanagej.pickhardtpayments.model.IntegerMapping; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Map; + +class ArcInitializer { + + private final MinCostFlow minCostFlow; + private final IntegerMapping pubkeyToIntegerMapping; + private final Map edgeMapping; + private final long quantization; + private final int piecewiseLinearApproximations; + + public ArcInitializer( + MinCostFlow minCostFlow, + IntegerMapping integerMapping, + Map edgeMapping, + long quantization, + int piecewiseLinearApproximations + ) { + this.minCostFlow = minCostFlow; + this.pubkeyToIntegerMapping = integerMapping; + this.edgeMapping = edgeMapping; + this.quantization = quantization; + this.piecewiseLinearApproximations = piecewiseLinearApproximations; + } + + public void addArcs(Collection edgesWithCapacityInformation) { + Coins maximumCapacity = getMaximumCapacity(edgesWithCapacityInformation); + for (EdgeWithCapacityInformation edgeWithCapacityInformation : edgesWithCapacityInformation) { + addArcs(edgeWithCapacityInformation, maximumCapacity); + } + } + + private void addArcs(EdgeWithCapacityInformation edgeWithCapacityInformation, Coins maximumCapacity) { + long capacitySat = edgeWithCapacityInformation.availableCapacity().satoshis(); + if (capacitySat < quantization) { + return; + } + int startNode = pubkeyToIntegerMapping.getMappedInteger(edgeWithCapacityInformation.edge().startNode()); + int endNode = pubkeyToIntegerMapping.getMappedInteger(edgeWithCapacityInformation.edge().endNode()); + long capacity = capacitySat / quantization; + long unitCost = maximumCapacity.satoshis() / quantization / capacity; + long capacityPiece = capacity / piecewiseLinearApproximations; + for (int i = 1; i <= piecewiseLinearApproximations; i++) { + int arcIndex = minCostFlow.addArcWithCapacityAndUnitCost( + startNode, + endNode, + capacityPiece, + i * unitCost + ); + edgeMapping.put(arcIndex, edgeWithCapacityInformation.edge()); + } + } + + private Coins getMaximumCapacity(Collection edgesWithCapacityInformation) { + return edgesWithCapacityInformation.stream() + .map(EdgeWithCapacityInformation::availableCapacity) + .max(Comparator.naturalOrder()) + .orElse(Coins.NONE); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputation.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputation.java new file mode 100644 index 00000000..e579718a --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputation.java @@ -0,0 +1,123 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.grpc.GrpcGraph; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.DirectedChannelEdge; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformation; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import de.cotto.lndmanagej.service.BalanceService; +import de.cotto.lndmanagej.service.ChannelService; +import de.cotto.lndmanagej.service.MissionControlService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +@Component +public class FlowComputation { + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final GrpcGraph grpcGraph; + private final GrpcGetInfo grpcGetInfo; + private final ChannelService channelService; + private final BalanceService balanceService; + private final MissionControlService missionControlService; + private final long quantization; + private final int piecewiseLinearApproximations; + + public FlowComputation( + GrpcGraph grpcGraph, + GrpcGetInfo grpcGetInfo, + ChannelService channelService, + BalanceService balanceService, + MissionControlService missionControlService, + @Value("${lndmanagej.pickhardtpayments.quantization:10000}") long quantization, + @Value("${lndmanagej.pickhardtpayments.piecewiseLinearApproximations:5}") int piecewiseLinearApproximations + ) { + this.grpcGraph = grpcGraph; + this.grpcGetInfo = grpcGetInfo; + this.channelService = channelService; + this.balanceService = balanceService; + this.missionControlService = missionControlService; + this.quantization = quantization; + this.piecewiseLinearApproximations = piecewiseLinearApproximations; + } + + public Flows getOptimalFlows(Pubkey source, Pubkey target, Coins amount) { + MinCostFlowSolver minCostFlowSolver = new MinCostFlowSolver( + getEdges(), + Map.of(source, amount), + Map.of(target, amount), + quantization, piecewiseLinearApproximations + ); + return minCostFlowSolver.solve(); + } + + private Set getEdges() { + Set channelEdges = grpcGraph.getChannelEdges().orElse(null); + if (channelEdges == null) { + logger.warn("Unable to get graph"); + return Set.of(); + } + Set edgesWithCapacityInformation = new LinkedHashSet<>(); + Pubkey ownPubkey = grpcGetInfo.getPubkey(); + for (DirectedChannelEdge channelEdge : channelEdges) { + if (!channelEdge.policy().enabled()) { + continue; + } + ChannelId channelId = channelEdge.channelId(); + Pubkey pubkey1 = channelEdge.source(); + Pubkey pubkey2 = channelEdge.target(); + Edge edge = new Edge(channelId, pubkey1, pubkey2, channelEdge.capacity()); + Coins availableCapacity = getAvailableCapacity(channelEdge, ownPubkey); + EdgeWithCapacityInformation edgeWithCapacityInformation = + new EdgeWithCapacityInformation(edge, availableCapacity); + edgesWithCapacityInformation.add(edgeWithCapacityInformation); + } + return edgesWithCapacityInformation; + } + + private Coins getAvailableCapacity(DirectedChannelEdge channelEdge, Pubkey ownPubKey) { + Pubkey source = channelEdge.source(); + Coins capacity = channelEdge.capacity(); + ChannelId channelId = channelEdge.channelId(); + if (ownPubKey.equals(source)) { + return getLocalChannelAvailableLocal(capacity, channelId); + } + Pubkey target = channelEdge.target(); + if (ownPubKey.equals(target)) { + return getLocalChannelAvailableRemote(capacity, channelId); + } + Coins failureAmount = missionControlService.getMinimumOfRecentFailures(source, target).orElse(null); + if (failureAmount == null) { + return capacity; + } + return getAvailableUpperBoundBelowRecentFailure(capacity, failureAmount); + } + + private Coins getAvailableUpperBoundBelowRecentFailure(Coins capacity, Coins failureAmount) { + long satsCapacity = capacity.satoshis(); + long satsNotAvailable = failureAmount.milliSatoshis() / 1_000; + long satsAvailable = Math.max(Math.min(satsNotAvailable - 1, satsCapacity), 0); + return Coins.ofSatoshis(satsAvailable); + } + + private Coins getLocalChannelAvailableLocal(Coins capacity, ChannelId channelId) { + return channelService.getLocalChannel(channelId) + .map(c -> balanceService.getAvailableLocalBalance(channelId)) + .orElse(capacity); + } + + private Coins getLocalChannelAvailableRemote(Coins capacity, ChannelId channelId) { + return channelService.getLocalChannel(channelId) + .map(c -> balanceService.getAvailableRemoteBalance(channelId)) + .orElse(capacity); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolver.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolver.java new file mode 100644 index 00000000..c634c9f0 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolver.java @@ -0,0 +1,91 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import com.google.ortools.Loader; +import com.google.ortools.graph.MinCostFlow; +import com.google.ortools.graph.MinCostFlowBase; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformation; +import de.cotto.lndmanagej.pickhardtpayments.model.Flow; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import de.cotto.lndmanagej.pickhardtpayments.model.IntegerMapping; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import static com.google.ortools.graph.MinCostFlowBase.Status.OPTIMAL; + +class MinCostFlowSolver { + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final MinCostFlow minCostFlow = new MinCostFlow(); + private final IntegerMapping integerMapping = new IntegerMapping<>(); + private final Map edgeMapping = new LinkedHashMap<>(); + private final long quantization; + + static { + Loader.loadNativeLibraries(); + } + + public MinCostFlowSolver( + Collection edgesWithCapacityInformation, + Map sources, + Map sinks, + long quantization, + int piecewiseLinearApproximations + ) { + this.quantization = quantization; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + quantization, + piecewiseLinearApproximations + ); + arcInitializer.addArcs(edgesWithCapacityInformation); + setSupply(sources, sinks); + } + + public Flows solve() { + MinCostFlowBase.Status status = minCostFlow.solve(); + if (status != OPTIMAL) { + logger.warn("Solving the min cost flow problem failed. Solver status: {}", status); + return new Flows(); + } + Flows flows = new Flows(); + for (int i = 0; i < minCostFlow.getNumArcs(); i++) { + long flowAmount = minCostFlow.getFlow(i); + if (flowAmount == 0) { + continue; + } + Edge edge = Objects.requireNonNull(edgeMapping.get(i)); + Flow flow = new Flow(edge, Coins.ofSatoshis(flowAmount * quantization)); + flows.add(flow); + } + return flows; + } + + private void setSupply(Map sources, Map sinks) { + Coins totalSourceAmount = sources.values().stream().reduce(Coins::add).orElse(Coins.NONE); + Coins totalSinkAmount = sinks.values().stream().reduce(Coins::add).orElse(Coins.NONE); + if (!totalSourceAmount.equals(totalSinkAmount)) { + throw new IllegalArgumentException( + "Source and sink amounts are different, got " + totalSourceAmount + " and " + totalSinkAmount + ); + } + for (Map.Entry entry : sources.entrySet()) { + Pubkey node = entry.getKey(); + long supply = entry.getValue().satoshis() / quantization; + minCostFlow.setNodeSupply(integerMapping.getMappedInteger(node), supply); + } + for (Map.Entry entry : sinks.entrySet()) { + Pubkey node = entry.getKey(); + long supply = -entry.getValue().satoshis() / quantization; + minCostFlow.setNodeSupply(integerMapping.getMappedInteger(node), supply); + } + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputation.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputation.java new file mode 100644 index 00000000..17884e7c --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputation.java @@ -0,0 +1,39 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.Route; +import de.cotto.lndmanagej.pickhardtpayments.model.Routes; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class MultiPathPaymentComputation { + private final GrpcGetInfo grpcGetInfo; + private final FlowComputation flowComputation; + + public MultiPathPaymentComputation(GrpcGetInfo grpcGetInfo, FlowComputation flowComputation) { + this.flowComputation = flowComputation; + this.grpcGetInfo = grpcGetInfo; + } + + public MultiPathPayment getMultiPathPaymentTo(Pubkey target, Coins amount) { + Pubkey source = grpcGetInfo.getPubkey(); + return getMultiPathPayment(source, target, amount); + } + + public MultiPathPayment getMultiPathPayment(Pubkey source, Pubkey target, Coins amount) { + Flows flows = flowComputation.getOptimalFlows(source, target, amount); + if (flows.isEmpty()) { + return MultiPathPayment.FAILURE; + } + double probability = flows.getProbability(); + Set routes = Routes.fromFlows(source, target, flows); + Routes.ensureTotalAmount(routes, amount); + return new MultiPathPayment(amount, probability, routes); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Edge.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Edge.java new file mode 100644 index 00000000..714f0548 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Edge.java @@ -0,0 +1,8 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; + +public record Edge(ChannelId channelId, Pubkey startNode, Pubkey endNode, Coins capacity) { +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformation.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformation.java new file mode 100644 index 00000000..2419b205 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformation.java @@ -0,0 +1,6 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +public record EdgeWithCapacityInformation(Edge edge, Coins availableCapacity) { +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flow.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flow.java new file mode 100644 index 00000000..87120d49 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flow.java @@ -0,0 +1,19 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +public record Flow(Edge edge, Coins amount) { + public Flow { + if (amount.isNonPositive()) { + throw new IllegalArgumentException("Amount must be positive"); + } + if (edge.startNode().equals(edge.endNode())) { + throw new IllegalArgumentException("Source and target must be different"); + } + } + + public double getProbability() { + long capacitySat = edge.capacity().satoshis(); + return 1.0 * (capacitySat + 1 - amount.satoshis()) / (capacitySat + 1); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flows.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flows.java new file mode 100644 index 00000000..f31a5727 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Flows.java @@ -0,0 +1,138 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; + +import static java.util.Objects.requireNonNull; + +public class Flows { + public static final Flows NONE = new Flows(); + private final Map> map; + + public Flows(Flow... flows) { + map = new LinkedHashMap<>(); + Arrays.stream(flows).forEach(this::add); + } + + public boolean isEmpty() { + return map.isEmpty(); + } + + public Coins getFlow(Edge edge) { + Flow flow = map.getOrDefault(edge.startNode(), Map.of()).get(edge); + if (flow == null) { + return Coins.NONE; + } + return flow.amount(); + } + + public double getProbability() { + return map.values().stream() + .flatMap(i -> i.values().stream()) + .mapToDouble(Flow::getProbability) + .reduce(1.0, (d1, d2) -> d1 * d2); + } + + public void add(Flow flow) { + add(flow.edge(), flow.amount()); + } + + public void add(Edge edge, Coins amount) { + map.compute(edge.startNode(), (p, innerMap) -> { + Map newValue = new LinkedHashMap<>(); + if (innerMap == null) { + newValue.put(edge, new Flow(edge, amount)); + return newValue; + } + innerMap.compute(edge, (e, f) -> { + if (f == null) { + return new Flow(edge, amount); + } + Coins combinedAmount = amount.add(f.amount()); + if (combinedAmount.equals(Coins.NONE)) { + return null; + } + return new Flow(edge, combinedAmount); + }); + if (innerMap.isEmpty()) { + return null; + } + return innerMap; + }); + } + + public Set getFlowsFrom(Pubkey pubkey) { + return new LinkedHashSet<>(map.getOrDefault(pubkey, Map.of()).values()); + } + + public List getShortestPath(Pubkey source, Pubkey target) { + Set seen = new LinkedHashSet<>(); + seen.add(source); + + Map> routes = new LinkedHashMap<>(); + routes.put(source, List.of()); + + Queue todo = new ArrayDeque<>(); + todo.add(source); + + while (!todo.isEmpty()) { + Pubkey pubkey = todo.remove(); + if (pubkey.equals(target)) { + return requireNonNull(routes.get(pubkey)); + } + List route = routes.get(pubkey); + for (Flow flow : getFlowsFrom(pubkey)) { + Edge edge = flow.edge(); + Pubkey successor = edge.endNode(); + if (seen.add(successor)) { + List newRoute = new ArrayList<>(route); + newRoute.add(edge); + routes.put(successor, newRoute); + todo.add(successor); + } + } + } + return List.of(); + } + + public Flows getCopy() { + Flows copy = new Flows(); + map.values().stream().flatMap(m -> m.values().stream()).forEach(copy::add); + return copy; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + Flows otherFlow = (Flows) other; + return Objects.equals(map, otherFlow.map); + } + + @Override + public int hashCode() { + return Objects.hash(map); + } + + @Override + public String toString() { + return "Flows{" + + "map=" + map + + '}'; + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMapping.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMapping.java new file mode 100644 index 00000000..529f2272 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMapping.java @@ -0,0 +1,25 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public class IntegerMapping { + private final Map mapping = new LinkedHashMap<>(); + private final Map reverseMapping = new LinkedHashMap<>(); + private int counter; + + public IntegerMapping() { + // default constructor + } + + public int getMappedInteger(K key) { + Integer integer = mapping.computeIfAbsent(key, k -> counter++); + reverseMapping.put(integer, key); + return integer; + } + + public K getKey(int integer) { + return Objects.requireNonNull(reverseMapping.get(integer)); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPayment.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPayment.java new file mode 100644 index 00000000..668cdbf3 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPayment.java @@ -0,0 +1,9 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +import java.util.Set; + +public record MultiPathPayment(Coins amount, double probability, Set routes) { + public static final MultiPathPayment FAILURE = new MultiPathPayment(Coins.NONE, 0, Set.of()); +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Route.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Route.java new file mode 100644 index 00000000..4759bff2 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Route.java @@ -0,0 +1,24 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +import java.util.List; + +public record Route(List edges, Coins amount) { + public Route { + if (!amount.isPositive()) { + throw new IllegalArgumentException("Amount must be positive, is " + amount); + } + } + + public double getProbability() { + return edges.stream().map(edge -> { + long capacitySat = edge.capacity().satoshis(); + return (1.0 * (capacitySat + 1 - amount.satoshis())) / (capacitySat + 1); + }).reduce(1.0, (a, b) -> a * b); + } + + public Route getForAmount(Coins newAmount) { + return new Route(edges, newAmount); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Routes.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Routes.java new file mode 100644 index 00000000..6f80f035 --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/Routes.java @@ -0,0 +1,41 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; + +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class Routes { + private Routes() { + // do not instantiate me + } + + public static Set fromFlows(Pubkey source, Pubkey target, Flows flows) { + Flows flowsCopy = flows.getCopy(); + Set result = new LinkedHashSet<>(); + List path = flowsCopy.getShortestPath(source, target); + while (!path.isEmpty()) { + Coins minimum = path.stream().map(flowsCopy::getFlow).reduce(Coins::minimum).orElseThrow(); + for (Edge edge : path) { + flowsCopy.add(edge, minimum.negate()); + } + Route route = new Route(path, minimum); + result.add(route); + path = flowsCopy.getShortestPath(source, target); + } + return result; + } + + public static void ensureTotalAmount(Collection routes, Coins amount) { + Route highProbabilityRoute = routes.stream().max(Comparator.comparing(Route::getProbability)).orElseThrow(); + Coins routesAmount = routes.stream().map(Route::amount).reduce(Coins.NONE, Coins::add); + Coins remainder = amount.subtract(routesAmount); + routes.remove(highProbabilityRoute); + Route fixedRoute = highProbabilityRoute.getForAmount(highProbabilityRoute.amount().add(remainder)); + routes.add(fixedRoute); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializerTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializerTest.java new file mode 100644 index 00000000..6fc6a4ac --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/ArcInitializerTest.java @@ -0,0 +1,205 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import com.google.ortools.Loader; +import com.google.ortools.graph.MinCostFlow; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformation; +import de.cotto.lndmanagej.pickhardtpayments.model.IntegerMapping; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_1_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_2_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_4; +import static org.assertj.core.api.Assertions.assertThat; + +class ArcInitializerTest { + private static final int QUANTIZATION = 1; + private static final int PIECEWISE_LINEAR_APPROXIMATIONS = 1; + + static { + Loader.loadNativeLibraries(); + } + + private final MinCostFlow minCostFlow = new MinCostFlow(); + private final IntegerMapping integerMapping = new IntegerMapping<>(); + private final Map edgeMapping = new LinkedHashMap<>(); + private final ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + + @Test + void no_edge() { + arcInitializer.addArcs(Set.of()); + assertThat(minCostFlow.getNumArcs()).isZero(); + } + + @Test + void ignores_edge_with_zero_capacity() { + arcInitializer.addArcs(Set.of(new EdgeWithCapacityInformation(EDGE, Coins.NONE))); + assertThat(minCostFlow.getNumArcs()).isZero(); + } + + @Test + void edge_with_capacity_equal_to_quantization_amount() { + int quantization = 10_000; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + arcInitializer.addArcs(Set.of(new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(quantization)))); + assertThat(minCostFlow.getNumArcs()).isOne(); + } + + @Test + void adds_edge_to_edgeMapping() { + int piecesPerChannel = 2; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + QUANTIZATION, + piecesPerChannel + ); + arcInitializer.addArcs(List.of( + new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(100)), + new EdgeWithCapacityInformation(EDGE_2_3, Coins.ofSatoshis(200)) + )); + assertThat(edgeMapping.get(0)).isEqualTo(EDGE); + assertThat(edgeMapping.get(piecesPerChannel)).isEqualTo(EDGE_2_3); + } + + @Test + void ignores_edge_with_capacity_smaller_than_quantization_amount() { + int quantization = 10_000; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + arcInitializer.addArcs(Set.of(new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(quantization - 1)))); + assertThat(minCostFlow.getNumArcs()).isZero(); + } + + @Test + void uses_quantization_for_capacity() { + int quantization = 100; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + arcInitializer.addArcs(Set.of(new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(20_123)))); + assertThat(minCostFlow.getCapacity(0)).isEqualTo(201); + } + + @Test + void uses_quantization_for_unit_cost() { + int quantization = 100; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + arcInitializer.addArcs(List.of( + new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(20_123)), + new EdgeWithCapacityInformation(EDGE_3_4, Coins.ofSatoshis(1_000_000)) + )); + assertThat(minCostFlow.getUnitCost(0)).isEqualTo(49); + } + + @Test + void one_edge() { + arcInitializer.addArcs(Set.of(new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(1)))); + assertThat(minCostFlow.getNumArcs()).isOne(); + } + + @Test + @SuppressWarnings("PMD.JUnitTestContainsTooManyAsserts") + void edges_with_piecewise_linear_approximation() { + int piecewiseLinearApproximations = 5; + ArcInitializer arcInitializer = new ArcInitializer( + minCostFlow, + integerMapping, + edgeMapping, + QUANTIZATION, + piecewiseLinearApproximations + ); + arcInitializer.addArcs(List.of( + new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(10_000)), + new EdgeWithCapacityInformation(EDGE_2_3, Coins.ofSatoshis(30_000)) + )); + assertThat(minCostFlow.getNumArcs()).isEqualTo(10); + assertThat(minCostFlow.getUnitCost(0)).isEqualTo(3); + assertThat(minCostFlow.getCapacity(0)).isEqualTo(2_000); + assertThat(minCostFlow.getUnitCost(1)).isEqualTo(6); + assertThat(minCostFlow.getCapacity(1)).isEqualTo(2_000); + assertThat(minCostFlow.getUnitCost(2)).isEqualTo(9); + assertThat(minCostFlow.getCapacity(2)).isEqualTo(2_000); + assertThat(minCostFlow.getUnitCost(3)).isEqualTo(12); + assertThat(minCostFlow.getCapacity(3)).isEqualTo(2_000); + assertThat(minCostFlow.getUnitCost(4)).isEqualTo(15); + assertThat(minCostFlow.getCapacity(4)).isEqualTo(2_000); + } + + @Test + void two_edges() { + EdgeWithCapacityInformation edge1 = new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(1)); + EdgeWithCapacityInformation edge2 = new EdgeWithCapacityInformation(EDGE_1_3, Coins.ofSatoshis(2)); + arcInitializer.addArcs(Set.of(edge1, edge2)); + assertThat(minCostFlow.getNumArcs()).isEqualTo(2); + } + + @Test + void parallel_edges_are_not_combined() { + EdgeWithCapacityInformation edge = new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(1)); + arcInitializer.addArcs(List.of(edge, edge)); + assertThat(minCostFlow.getNumArcs()).isEqualTo(2); + } + + @Test + void computes_unit_cost_based_on_maximum_capacity() { + EdgeWithCapacityInformation edge1 = new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(3)); + EdgeWithCapacityInformation edge2 = new EdgeWithCapacityInformation(EDGE_1_3, Coins.ofSatoshis(21)); + arcInitializer.addArcs(List.of(edge1, edge2)); + assertThat(minCostFlow.getUnitCost(0)).isEqualTo(7L); + assertThat(minCostFlow.getUnitCost(1)).isEqualTo(1L); + } + + @Test + void unit_cost_is_rounded_down() { + EdgeWithCapacityInformation edge1 = new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(3)); + EdgeWithCapacityInformation edge2 = new EdgeWithCapacityInformation(EDGE_1_3, Coins.ofSatoshis(20)); + arcInitializer.addArcs(List.of(edge1, edge2)); + assertThat(minCostFlow.getUnitCost(0)).isEqualTo(6L); + } + + @Test + void computes_unit_cost_based_on_maximum_capacity_without_combining_parallel_edges() { + EdgeWithCapacityInformation edge1 = new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(2)); + EdgeWithCapacityInformation edge2 = new EdgeWithCapacityInformation(EDGE_1_3, Coins.ofSatoshis(20)); + EdgeWithCapacityInformation edge3 = new EdgeWithCapacityInformation(EDGE_1_3, Coins.ofSatoshis(10)); + arcInitializer.addArcs(List.of(edge1, edge2, edge3)); + assertThat(minCostFlow.getUnitCost(0)).isEqualTo(10L); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputationTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputationTest.java new file mode 100644 index 00000000..24faae59 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputationTest.java @@ -0,0 +1,174 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.grpc.GrpcGraph; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.DirectedChannelEdge; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.Flow; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import de.cotto.lndmanagej.service.BalanceService; +import de.cotto.lndmanagej.service.ChannelService; +import de.cotto.lndmanagej.service.MissionControlService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +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.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static de.cotto.lndmanagej.model.PolicyFixtures.POLICY_1; +import static de.cotto.lndmanagej.model.PolicyFixtures.POLICY_2; +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.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FlowComputationTest { + private static final Coins LARGE = Coins.ofSatoshis(10_000_000); + private static final Coins SMALL = Coins.ofSatoshis(100); + + private FlowComputation flowComputation; + + @Mock + private BalanceService balanceService; + + @Mock + private ChannelService channelService; + + @Mock + private GrpcGetInfo grpcGetInfo; + + @Mock + private GrpcGraph grpcGraph; + + @Mock + private MissionControlService missionControlService; + + @BeforeEach + void setUp() { + int piecewiseLinearApproximations = 1; + long quantization = 1; + flowComputation = new FlowComputation( + grpcGraph, + grpcGetInfo, + channelService, + balanceService, + missionControlService, + quantization, + piecewiseLinearApproximations + ); + lenient().when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY); + lenient().when(missionControlService.getMinimumOfRecentFailures(any(), any())).thenReturn(Optional.empty()); + } + + @Test + void solve_no_graph() { + assertThat(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, Coins.ofSatoshis(1))).isEqualTo(new Flows()); + } + + @Test + void solve_edge_disabled() { + DirectedChannelEdge disabledEdge = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, PUBKEY, PUBKEY_2, POLICY_1); + assumeThat(POLICY_1.enabled()).isFalse(); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(disabledEdge))); + assertThat(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, Coins.ofSatoshis(1))).isEqualTo(new Flows()); + } + + @Test + void solve() { + Coins amount = Coins.ofSatoshis(1); + DirectedChannelEdge enabledEdge = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, PUBKEY, PUBKEY_2, POLICY_2); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(enabledEdge))); + Flow expectedFlow = new Flow(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, amount)).isEqualTo(new Flows(expectedFlow)); + } + + @Test + void solve_avoids_sending_from_local_channel_lacking_capacity() { + // TODO use balance of local channel as known balance, not upper bound + when(channelService.getLocalChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + when(channelService.getLocalChannel(CHANNEL_ID_2)).thenReturn(Optional.empty()); + when(balanceService.getAvailableLocalBalance(CHANNEL_ID)).thenReturn(Coins.ofSatoshis(1)); + Coins amount = Coins.ofSatoshis(100); + DirectedChannelEdge largerButDepletedChannel = + new DirectedChannelEdge(CHANNEL_ID, LARGE, PUBKEY, PUBKEY_2, POLICY_2); + DirectedChannelEdge smallerChannel = + new DirectedChannelEdge(CHANNEL_ID_2, SMALL, PUBKEY, PUBKEY_2, POLICY_2); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of( + largerButDepletedChannel, + smallerChannel + ))); + Flow expectedFlow = new Flow(new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, SMALL), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, amount)).isEqualTo(new Flows(expectedFlow)); + } + + @Test + void solve_avoids_sending_to_local_channel_lacking_capacity() { + // TODO use balance of local channel as known balance, not upper bound + when(channelService.getLocalChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + when(channelService.getLocalChannel(CHANNEL_ID_2)).thenReturn(Optional.empty()); + when(balanceService.getAvailableRemoteBalance(CHANNEL_ID)).thenReturn(Coins.ofSatoshis(1)); + Coins amount = Coins.ofSatoshis(100); + DirectedChannelEdge largerButDepletedChannel = + new DirectedChannelEdge(CHANNEL_ID, LARGE, PUBKEY_2, PUBKEY, POLICY_2); + DirectedChannelEdge smallerChannel = + new DirectedChannelEdge(CHANNEL_ID_2, SMALL, PUBKEY_2, PUBKEY, POLICY_2); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of( + largerButDepletedChannel, + smallerChannel + ))); + Flow expectedFlow = new Flow(new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY, SMALL), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY_2, PUBKEY, amount)).isEqualTo(new Flows(expectedFlow)); + } + + @Test + void solve_with_other_peer_as_start_node() { + Coins amount = Coins.ofSatoshis(100); + DirectedChannelEdge edge = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, PUBKEY_2, PUBKEY_3, POLICY_2); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(edge))); + Flow expectedFlow = new Flow(new Edge(CHANNEL_ID, PUBKEY_2, PUBKEY_3, CAPACITY), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY_2, PUBKEY_3, amount)).isEqualTo(new Flows(expectedFlow)); + } + + @Test + void solve_with_recent_mission_control_failure_as_upper_bound() { + Coins amount = Coins.ofSatoshis(100); + DirectedChannelEdge edge1a = new DirectedChannelEdge(CHANNEL_ID, LARGE, PUBKEY_2, PUBKEY_3, POLICY_2); + DirectedChannelEdge edge1b = new DirectedChannelEdge(CHANNEL_ID_2, LARGE, PUBKEY_3, PUBKEY_4, POLICY_2); + DirectedChannelEdge edge2 = new DirectedChannelEdge(CHANNEL_ID_3, SMALL, PUBKEY_2, PUBKEY_4, POLICY_2); + when(missionControlService.getMinimumOfRecentFailures(PUBKEY_2, PUBKEY_3)) + .thenReturn(Optional.of(Coins.ofSatoshis(100))); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(edge1a, edge1b, edge2))); + Flow expectedFlow = new Flow(new Edge(CHANNEL_ID_3, PUBKEY_2, PUBKEY_4, SMALL), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY_2, PUBKEY_4, amount)).isEqualTo(new Flows(expectedFlow)); + } + + @Test + void solve_recent_mission_control_failure_with_higher_amount() { + Coins amount = Coins.ofSatoshis(100); + DirectedChannelEdge edge1a = new DirectedChannelEdge(CHANNEL_ID, LARGE, PUBKEY_3, PUBKEY_4, POLICY_2); + DirectedChannelEdge edge1b = new DirectedChannelEdge(CHANNEL_ID_2, LARGE, PUBKEY_4, PUBKEY, POLICY_2); + DirectedChannelEdge edge2 = new DirectedChannelEdge(CHANNEL_ID_3, SMALL, PUBKEY_3, PUBKEY, POLICY_2); + when(missionControlService.getMinimumOfRecentFailures(PUBKEY_3, PUBKEY_4)) + .thenReturn(Optional.of(Coins.ofSatoshis(5_000_000))); + when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(edge1a, edge1b, edge2))); + Flow expectedFlow1 = new Flow(new Edge(CHANNEL_ID, PUBKEY_3, PUBKEY_4, LARGE), amount); + Flow expectedFlow2 = new Flow(new Edge(CHANNEL_ID_2, PUBKEY_4, PUBKEY, LARGE), amount); + assertThat(flowComputation.getOptimalFlows(PUBKEY_3, PUBKEY, amount)) + .isEqualTo(new Flows(expectedFlow1, expectedFlow2)); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolverTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolverTest.java new file mode 100644 index 00000000..8905b007 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MinCostFlowSolverTest.java @@ -0,0 +1,287 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformation; +import de.cotto.lndmanagej.pickhardtpayments.model.Flow; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +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 de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_1_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_2_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_4; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class MinCostFlowSolverTest { + + private static final long QUANTIZATION = 1; + private static final int PIECEWISE_LINEAR_APPROXIMATIONS = 1; + private static final Coins ONE_SAT = Coins.ofSatoshis(PIECEWISE_LINEAR_APPROXIMATIONS); + private static final Coins TWO_SATS = ONE_SAT.add(ONE_SAT); + private static final Coins MANY_SATS = Coins.ofSatoshis(100_000_000); + + private static final Flow FLOW_1_2 = new Flow(EDGE, ONE_SAT); + private static final Flow FLOW_1_3 = new Flow(EDGE_1_3, ONE_SAT); + private static final Flow FLOW_2_3 = new Flow(EDGE_2_3, ONE_SAT); + private static final Flow FLOW_3_4 = new Flow(EDGE_3_4, ONE_SAT); + + @Test + void no_edge() { + Set edgesWithCapacityInformation = Set.of(); + Map sources = Map.of(PUBKEY, ONE_SAT); + Map sinks = Map.of(PUBKEY_2, ONE_SAT); + assertThatCode( + () -> new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ) + ).doesNotThrowAnyException(); + } + + @Test + void no_sink_and_no_source() { + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE, ONE_SAT)); + Map sources = Map.of(); + Map sinks = Map.of(); + assertThatCode( + () -> new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ) + ).doesNotThrowAnyException(); + } + + @Test + void no_source() { + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE_2_3, ONE_SAT)); + Map sources = Map.of(); + Map sinks = Map.of(PUBKEY_3, ONE_SAT); + assertThatIllegalArgumentException().isThrownBy( + () -> new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ) + ); + } + + @Test + void no_sink() { + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE_2_3, ONE_SAT)); + Map sources = Map.of(PUBKEY_2, ONE_SAT); + Map sinks = Map.of(); + assertThatIllegalArgumentException().isThrownBy( + () -> new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ) + ); + } + + @Test + void sink_amount_does_not_match_source_amount() { + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE, ONE_SAT)); + Map sources = Map.of(PUBKEY, ONE_SAT); + Map sinks = Map.of(PUBKEY_2, TWO_SATS); + assertThatIllegalArgumentException().isThrownBy( + () -> new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ) + ); + } + + @Test + void solve_simple() { + Coins amount = Coins.ofSatoshis(PIECEWISE_LINEAR_APPROXIMATIONS); + List edgesWithCapacityInformation = + List.of(new EdgeWithCapacityInformation(EDGE_2_3, amount)); + + Flows flows = solve(edgesWithCapacityInformation, PUBKEY_2, PUBKEY_3, amount); + + assertThat(flows).isEqualTo(new Flows(FLOW_2_3)); + } + + @Test + void solve_no_solution_due_to_gap() { + Coins amount = Coins.ofSatoshis(PIECEWISE_LINEAR_APPROXIMATIONS); + List edgesWithCapacityInformation = List.of( + new EdgeWithCapacityInformation(EDGE, amount), + new EdgeWithCapacityInformation(EDGE_3_4, amount) + ); + + Flows flows = solve(edgesWithCapacityInformation, PUBKEY, PUBKEY_4, amount); + + assertThat(flows).isEqualTo(new Flows()); + } + + @Test + void solve_with_quantization() { + int quantization = 10_000; + Coins amount = Coins.ofSatoshis(100_000); + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE_2_3, amount)); + + Map sources = Map.of(PUBKEY_2, amount); + Map sinks = Map.of(PUBKEY_3, amount); + Flows flows = new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ).solve(); + + assertThat(flows).isEqualTo(new Flows(new Flow(EDGE_2_3, amount))); + } + + @Test + void solve_with_quantization_but_requested_amount_not_divisible() { + int quantization = 10_000; + Coins amount = Coins.ofSatoshis(123_456); + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE_3_4, MANY_SATS)); + + Map sources = Map.of(PUBKEY_3, amount); + Map sinks = Map.of(PUBKEY_4, amount); + Flows flows = new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ).solve(); + + assertThat(flows).isEqualTo(new Flows(new Flow(EDGE_3_4, Coins.ofSatoshis(120_000)))); + } + + @Test + void quantization_larger_than_smallest_channel() { + int quantization = 10_000; + Set edgesWithCapacityInformation = + Set.of(new EdgeWithCapacityInformation(EDGE_2_3, ONE_SAT)); + + Map sources = Map.of(PUBKEY_2, ONE_SAT); + Map sinks = Map.of(PUBKEY_3, ONE_SAT); + Flows flows = new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + quantization, + PIECEWISE_LINEAR_APPROXIMATIONS + ).solve(); + + assertThat(flows).isEqualTo(new Flows()); + } + + @Test + void solve_two_sources_two_sinks() { + Coins amount = Coins.ofSatoshis(PIECEWISE_LINEAR_APPROXIMATIONS); + List edgesWithCapacityInformation = List.of( + new EdgeWithCapacityInformation(EDGE, amount), + new EdgeWithCapacityInformation(EDGE_3_4, amount) + ); + Map sources = Map.of(PUBKEY, amount, PUBKEY_3, amount); + Map sinks = Map.of(PUBKEY_2, amount, PUBKEY_4, amount); + MinCostFlowSolver minCostFlowSolver = new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + + assertThat(minCostFlowSolver.solve()).isEqualTo(new Flows(FLOW_1_2, FLOW_3_4)); + } + + @Test + void solve_one_source_two_sinks() { + List edgesWithCapacityInformation = List.of( + new EdgeWithCapacityInformation(EDGE, ONE_SAT), + new EdgeWithCapacityInformation(EDGE_1_3, ONE_SAT) + ); + Map sources = Map.of(PUBKEY, TWO_SATS); + Map sinks = Map.of(PUBKEY_2, ONE_SAT, PUBKEY_3, ONE_SAT); + MinCostFlowSolver minCostFlowSolver = new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ); + + assertThat(minCostFlowSolver.solve()).isEqualTo(new Flows(FLOW_1_2, FLOW_1_3)); + } + + @Test + void solve_long_path() { + List edgesWithCapacityInformation = List.of( + new EdgeWithCapacityInformation(EDGE, ONE_SAT), + new EdgeWithCapacityInformation(EDGE_2_3, MANY_SATS), + new EdgeWithCapacityInformation(EDGE_3_4, ONE_SAT) + ); + Flows flows = solve(edgesWithCapacityInformation, PUBKEY, PUBKEY_4, ONE_SAT); + + assertThat(flows).isEqualTo(new Flows(FLOW_1_2, FLOW_2_3, FLOW_3_4)); + } + + @Test + void solve_long_path_with_cycle() { + List edgesWithCapacityInformation = List.of( + new EdgeWithCapacityInformation(EDGE, TWO_SATS), + new EdgeWithCapacityInformation(EDGE_2_3, MANY_SATS), + new EdgeWithCapacityInformation(EDGE_3_2, MANY_SATS), + new EdgeWithCapacityInformation(EDGE_3_4, TWO_SATS) + ); + Flows flows = solve(edgesWithCapacityInformation, PUBKEY, PUBKEY_4, ONE_SAT); + + assertThat(flows).isEqualTo(new Flows(FLOW_1_2, FLOW_2_3, FLOW_3_4)); + } + + private Flows solve( + List edgesWithCapacityInformation, + Pubkey source, + Pubkey target, + Coins amount + ) { + Map sources = Map.of(source, amount); + Map sinks = Map.of(target, amount); + return new MinCostFlowSolver( + edgesWithCapacityInformation, + sources, + sinks, + QUANTIZATION, + PIECEWISE_LINEAR_APPROXIMATIONS + ).solve(); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputationTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputationTest.java new file mode 100644 index 00000000..58f25049 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentComputationTest.java @@ -0,0 +1,119 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcGetInfo; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.pickhardtpayments.model.Flow; +import de.cotto.lndmanagej.pickhardtpayments.model.Flows; +import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.Route; +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 java.util.List; +import java.util.Set; + +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_4; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MultiPathPaymentComputationTest { + private static final Coins AMOUNT = Coins.ofSatoshis(1_234); + @InjectMocks + private MultiPathPaymentComputation multiPathPaymentComputation; + + @Mock + private FlowComputation flowComputation; + + @Mock + private GrpcGetInfo grpcGetInfo; + + @BeforeEach + void setUp() { + when(flowComputation.getOptimalFlows(any(), any(), any())).thenReturn(new Flows()); + } + + @Test + void getMultiPathPaymentTo_uses_own_pubkey_as_source() { + when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); + multiPathPaymentComputation.getMultiPathPaymentTo(PUBKEY_2, AMOUNT); + verify(flowComputation).getOptimalFlows(PUBKEY_4, PUBKEY_2, AMOUNT); + } + + @Test + void getMultiPathPayment_failure() { + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + assertThat(multiPathPayment.probability()).isZero(); + } + + @Test + void getMultiPathPaymentTo() { + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(FLOW)); + when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY); + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPaymentTo(PUBKEY_2, AMOUNT); + MultiPathPayment expected = + new MultiPathPayment(AMOUNT, FLOW.getProbability(), Set.of(new Route(List.of(EDGE), AMOUNT))); + assertThat(multiPathPayment).isEqualTo(expected); + } + + @Test + void getMultiPathPayment_one_flow_probability() { + long capacitySat = EDGE.capacity().satoshis(); + Coins halfOfCapacity = Coins.ofSatoshis(capacitySat / 2); + Flow flow = new Flow(EDGE, halfOfCapacity); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(flow)); + + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + + assertThat(multiPathPayment.probability()) + .isEqualTo((1.0 * halfOfCapacity.satoshis() + 1) / (capacitySat + 1)); + } + + @Test + void getMultiPathPayment_two_flows_probability() { + long capacitySat = EDGE.capacity().satoshis(); + Coins halfOfCapacity = Coins.ofSatoshis(capacitySat / 2); + Flow flow1 = new Flow(EDGE, halfOfCapacity); + Flow flow2 = new Flow(EDGE_3_2, EDGE_3_2.capacity()); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(flow1, flow2)); + + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + + double probabilityFlow1 = (1.0 * halfOfCapacity.satoshis() + 1) / (capacitySat + 1); + double probabilityFlow2 = 1.0 / (EDGE_3_2.capacity().satoshis() + 1); + assertThat(multiPathPayment.probability()).isEqualTo(probabilityFlow1 * probabilityFlow2); + } + + @Test + void getMultiPathPayment_two_flows_through_same_channel_probability() { + long capacitySat = EDGE.capacity().satoshis(); + Coins halfOfCapacity = Coins.ofSatoshis(capacitySat / 2); + Flow flow1 = new Flow(EDGE, halfOfCapacity); + Flow flow2 = new Flow(EDGE, halfOfCapacity); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(flow1, flow2)); + + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + + assertThat(multiPathPayment.probability()).isEqualTo(1.0 / (capacitySat + 1)); + } + + @Test + void getMultiPathPayment_adds_remainder_to_route() { + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(FLOW)); + assumeThat(FLOW.amount()).isLessThan(AMOUNT); + MultiPathPayment multiPathPayment = multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + assertThat(multiPathPayment.routes().iterator().next().amount()).isEqualTo(AMOUNT); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeTest.java new file mode 100644 index 00000000..cd46df21 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeTest.java @@ -0,0 +1,32 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static org.assertj.core.api.Assertions.assertThat; + +class EdgeTest { + @Test + void channelId() { + assertThat(EDGE.channelId()).isEqualTo(CHANNEL_ID); + } + + @Test + void startNode() { + assertThat(EDGE.startNode()).isEqualTo(PUBKEY); + } + + @Test + void endNode() { + assertThat(EDGE.endNode()).isEqualTo(PUBKEY_2); + } + + @Test + void capacity() { + assertThat(EDGE.capacity()).isEqualTo(CAPACITY); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationTest.java new file mode 100644 index 00000000..037785cd --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationTest.java @@ -0,0 +1,20 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeWithCapacityInformationFixtures.EDGE_WITH_CAPACITY_INFORMATION; +import static org.assertj.core.api.Assertions.assertThat; + +class EdgeWithCapacityInformationTest { + @Test + void edge() { + assertThat(EDGE_WITH_CAPACITY_INFORMATION.edge()).isEqualTo(EDGE); + } + + @Test + void availableCapacity() { + assertThat(EDGE_WITH_CAPACITY_INFORMATION.availableCapacity()).isEqualTo(Coins.ofSatoshis(123)); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowTest.java new file mode 100644 index 00000000..acff3b29 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowTest.java @@ -0,0 +1,53 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class FlowTest { + @Test + void amount_must_not_be_zero() { + assertThatIllegalArgumentException().isThrownBy( + () -> new Flow(EDGE, Coins.NONE) + ).withMessage("Amount must be positive"); + } + + @Test + void amount_must_not_be_negative() { + assertThatIllegalArgumentException().isThrownBy( + () -> new Flow(EDGE, Coins.ofSatoshis(-1)) + ).withMessage("Amount must be positive"); + } + + @Test + void source_and_target_must_be_different() { + assertThatIllegalArgumentException().isThrownBy( + () -> new Flow(new Edge(CHANNEL_ID, PUBKEY, PUBKEY, CAPACITY), Coins.ofSatoshis(1)) + ).withMessage("Source and target must be different"); + } + + @Test + void edge() { + assertThat(FLOW.edge()).isEqualTo(EDGE); + } + + @Test + void amount() { + assertThat(FLOW.amount()).isEqualTo(Coins.ofSatoshis(1)); + } + + @Test + void getProbability() { + Coins capacitySat = FLOW.edge().capacity(); + long flowSat = FLOW.amount().satoshis(); + assertThat(FLOW.getProbability()) + .isEqualTo(1.0 * (capacitySat.satoshis() + 1 - flowSat) / (capacitySat.satoshis() + 1)); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsTest.java new file mode 100644 index 00000000..e6f47812 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsTest.java @@ -0,0 +1,192 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +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.ChannelIdFixtures.CHANNEL_ID_4; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_5; +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 de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_1_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_2_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_4; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowsFixtures.FLOWS; +import static org.assertj.core.api.Assertions.assertThat; + +class FlowsTest { + @Test + void isEmpty() { + assertThat(new Flows().isEmpty()).isTrue(); + } + + @Test + void isEmpty_false() { + assertThat(new Flows(FLOW).isEmpty()).isFalse(); + } + + @Test + void add_to_empty() { + Flows flows = new Flows(); + flows.add(FLOW); + assertThat(flows.isEmpty()).isFalse(); + } + + @Test + void add_amounts_cancel_each_other() { + Flows flows = new Flows(); + flows.add(EDGE, Coins.ofSatoshis(1)); + flows.add(EDGE, Coins.ofSatoshis(-1)); + assertThat(flows.isEmpty()).isTrue(); + } + + @Test + void add_different_channel() { + Flows flows = new Flows(); + flows.add(new Flow(EDGE, Coins.ofSatoshis(2))); + flows.add(new Flow(EDGE_1_3, Coins.ofSatoshis(1))); + assertThat(flows.getFlowsFrom(EDGE.startNode())).hasSize(2); + } + + @Test + void getFlowsFrom() { + Flows flows = new Flows(FLOW); + assertThat(flows.getFlowsFrom(FLOW.edge().startNode())).containsExactly(FLOW); + } + + @Test + void constructor_combines_flows_along_same_channel() { + Flows flows = new Flows(FLOW, FLOW); + assertThat(flows.getFlowsFrom(FLOW.edge().startNode())).containsExactly(new Flow(EDGE, Coins.ofSatoshis(2))); + } + + @Test + void add_combines_flows_along_same_channel() { + Flows flows = new Flows(FLOW); + flows.add(FLOW); + assertThat(flows.getFlowsFrom(FLOW.edge().startNode())).containsExactly(new Flow(EDGE, Coins.ofSatoshis(2))); + } + + @Test + void getFlow_no_entry_for_pubkey() { + assertThat(new Flows().getFlow(EDGE)).isEqualTo(Coins.NONE); + } + + @Test + void getFlow_no_entry_for_edge() { + Flows flows = new Flows(FLOW); + assertThat(flows.getFlow(EDGE_1_3)).isEqualTo(Coins.NONE); + } + + @Test + void getFlow() { + Flows flows = new Flows(FLOW); + assertThat(flows.getFlow(EDGE)).isEqualTo(FLOW.amount()); + } + + @Test + void getProbability() { + Flows flows = new Flows(FLOW); + assertThat(flows.getProbability()).isEqualTo(FLOW.getProbability()); + } + + @Test + void getProbability_two_separate_flows() { + Flows flows = new Flows(FLOW, FLOW_2); + assertThat(flows.getProbability()).isEqualTo(FLOW.getProbability() * FLOW_2.getProbability()); + } + + @Test + void getProbability_added_amount() { + Flows flows = new Flows(FLOW, FLOW); + long flowAmount = 2 * FLOW.amount().satoshis(); + long capacitySat = FLOW.edge().capacity().satoshis(); + double expected = 1.0 * (capacitySat + 1 - flowAmount) / (capacitySat + 1); + assertThat(flows.getProbability()).isEqualTo(expected); + } + + @Test + void getCopy() { + Flows original = new Flows(FLOW); + Flows copy = original.getCopy(); + assertThat(copy.getFlow(FLOW.edge())).isEqualTo(FLOW.amount()); + } + + @Test + void getCopy_changing_copy_does_not_change_original() { + Flows original = new Flows(FLOW); + Flows copy = original.getCopy(); + copy.add(FLOW_3); + assertThat(copy.getFlow(FLOW_3.edge())).isEqualTo(FLOW_3.amount()); + assertThat(original.getFlow(FLOW_3.edge())).isEqualTo(Coins.NONE); + } + + @Test + void getCopy_changing_original_does_not_change_copy() { + Flows original = new Flows(FLOW); + Flows copy = original.getCopy(); + original.add(FLOW_2); + assertThat(original.getFlow(FLOW_2.edge())).isEqualTo(FLOW_2.amount()); + assertThat(copy.getFlow(FLOW_2.edge())).isEqualTo(Coins.NONE); + } + + @Test + void getShortestPath_already_there() { + assertThat(FLOWS.getShortestPath(PUBKEY, PUBKEY)).isEmpty(); + } + + @Test + void getShortestPath_unreachable() { + assertThat(FLOWS.getShortestPath(PUBKEY_4, PUBKEY)).isEmpty(); + } + + @Test + void getShortestPath_simple() { + assertThat(FLOWS.getShortestPath(PUBKEY, PUBKEY_4)).containsExactly(EDGE, EDGE_2_3, EDGE_3_4); + } + + @Test + void getShortestPath_complex() { + Coins coins = Coins.ofSatoshis(1); + Edge edge1to2 = new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY); + Edge edge2to3 = new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY); + Edge edge2to1 = new Edge(CHANNEL_ID_3, PUBKEY_2, PUBKEY, CAPACITY); + Edge edge1to3 = new Edge(CHANNEL_ID_4, PUBKEY, PUBKEY_3, CAPACITY); + Edge edge3to4 = new Edge(CHANNEL_ID_5, PUBKEY_3, PUBKEY_4, CAPACITY); + Flows flows = new Flows( + new Flow(edge1to2, coins), + new Flow(edge2to3, coins), + new Flow(edge2to1, coins), + new Flow(edge1to3, coins), + new Flow(edge3to4, coins) + ); + assertThat(flows.getShortestPath(PUBKEY, PUBKEY_4)).containsExactly(edge1to3, edge3to4); + } + + @Test + void testEquals() { + // https://github.com/jqno/equalsverifier/issues/613 + EqualsVerifier.forClass(Flows.class).withPrefabValues(Flow.class, FLOW, FLOW_2).usingGetClass().verify(); + } + + @Test + void testToString() { + assertThat(FLOWS).hasToString( + "Flows{map={" + + PUBKEY + "={" + EDGE + "=Flow[edge=" + EDGE + ", amount=1.000]}, " + + PUBKEY_2 + "={" + EDGE_2_3 + "=Flow[edge=" + EDGE_2_3 + ", amount=2.000]}, " + + PUBKEY_3 + "={" + EDGE_3_4 + "=Flow[edge=" + EDGE_3_4 + ", amount=3.000]}" + + "}}" + ); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMappingTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMappingTest.java new file mode 100644 index 00000000..fb8588f2 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/IntegerMappingTest.java @@ -0,0 +1,55 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Pubkey; +import org.junit.jupiter.api.Test; + +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; + +class IntegerMappingTest { + private final IntegerMapping integerMapping = new IntegerMapping<>(); + + @Test + void unknown() { + assertThat(integerMapping.getMappedInteger(PUBKEY)).isZero(); + } + + @Test + void known() { + integerMapping.getMappedInteger(PUBKEY); + assertThat(integerMapping.getMappedInteger(PUBKEY)).isZero(); + assertThat(integerMapping.getKey(0)).isEqualTo(PUBKEY); + } + + @Test + void second_node() { + integerMapping.getMappedInteger(PUBKEY); + assertThat(integerMapping.getMappedInteger(PUBKEY_2)).isOne(); + assertThat(integerMapping.getKey(1)).isEqualTo(PUBKEY_2); + } + + @Test + void second_node_known() { + integerMapping.getMappedInteger(PUBKEY); + integerMapping.getMappedInteger(PUBKEY_2); + assertThat(integerMapping.getMappedInteger(PUBKEY_2)).isOne(); + assertThat(integerMapping.getKey(1)).isEqualTo(PUBKEY_2); + } + + @Test + void two_nodes() { + integerMapping.getMappedInteger(PUBKEY); + integerMapping.getMappedInteger(PUBKEY_2); + assertThat(integerMapping.getMappedInteger(PUBKEY)).isZero(); + assertThat(integerMapping.getMappedInteger(PUBKEY_2)).isOne(); + } + + @Test + void two_nodes_getKey() { + integerMapping.getMappedInteger(PUBKEY); + integerMapping.getMappedInteger(PUBKEY_2); + assertThat(integerMapping.getKey(0)).isEqualTo(PUBKEY); + assertThat(integerMapping.getKey(1)).isEqualTo(PUBKEY_2); + } +} \ No newline at end of file diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentTest.java new file mode 100644 index 00000000..dd1381ee --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentTest.java @@ -0,0 +1,24 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; +import static org.assertj.core.api.Assertions.assertThat; + +class MultiPathPaymentTest { + @Test + void probability_failure() { + assertThat(MultiPathPayment.FAILURE.probability()).isZero(); + } + + @Test + void amount() { + assertThat(MULTI_PATH_PAYMENT.amount()).isEqualTo(ROUTE.amount()); + } + + @Test + void routes() { + assertThat(MULTI_PATH_PAYMENT.routes()).containsExactly(ROUTE); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteTest.java new file mode 100644 index 00000000..09a14952 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteTest.java @@ -0,0 +1,36 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class RouteTest { + @Test + void getProbability() { + long capacitySat = EDGE.capacity().satoshis(); + assertThat(ROUTE.getProbability()) + .isEqualTo(1.0 * (capacitySat + 1 - ROUTE.amount().satoshis()) / (capacitySat + 1)); + } + + @Test + void zero_amount() { + assertThatIllegalArgumentException().isThrownBy(() -> new Route(List.of(), Coins.NONE)); + } + + @Test + void negative_amount() { + assertThatIllegalArgumentException().isThrownBy(() -> new Route(List.of(), Coins.ofSatoshis(-1))); + } + + @Test + void getRouteForAmount() { + Coins newAmount = Coins.ofSatoshis(1_000); + assertThat(ROUTE.getForAmount(newAmount)).isEqualTo(new Route(ROUTE.edges(), newAmount)); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RoutesTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RoutesTest.java new file mode 100644 index 00000000..443a9197 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/RoutesTest.java @@ -0,0 +1,107 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +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.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.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowsFixtures.FLOWS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class RoutesTest { + private final Flows flows = new Flows(); + + @Test + void fromFlows_empty() { + assertThat(Routes.fromFlows(PUBKEY, PUBKEY_2, flows)).isEmpty(); + } + + @Test + void fromFlows_source_is_target() { + assertThat(Routes.fromFlows(PUBKEY, PUBKEY, FLOWS)).isEmpty(); + } + + @Test + void fromFlows_simple() { + assertThat(Routes.fromFlows(PUBKEY, PUBKEY_2, new Flows(FLOW))) + .containsExactly(new Route(List.of(EDGE), Coins.ofSatoshis(1))); + } + + @Test + void fromFlows_does_not_mutate_flows() { + Coins expected = FLOW.amount(); + Routes.fromFlows(PUBKEY, PUBKEY_2, FLOWS); + assertThat(FLOWS.getFlow(EDGE)).isEqualTo(expected); + } + + @Test + void fromFlows_impossible() { + assertThat(Routes.fromFlows(PUBKEY_3, PUBKEY, FLOWS)).isEmpty(); + } + + @Test + void fromFlows_two_parallel_channels() { + Edge edge1 = createEdgeWithChannelId(CHANNEL_ID); + Edge edge2 = createEdgeWithChannelId(CHANNEL_ID_2); + flows.add(edge1, Coins.ofSatoshis(10)); + flows.add(edge2, Coins.ofSatoshis(20)); + assertThat(Routes.fromFlows(PUBKEY, PUBKEY_2, flows)).containsExactlyInAnyOrder( + new Route(List.of(edge1), Coins.ofSatoshis(10)), + new Route(List.of(edge2), Coins.ofSatoshis(20)) + ); + } + + @Test + void fromFlows_two_channels_joining() { + Edge edge1a = createEdgeWithChannelId(CHANNEL_ID); + Edge edge1b = createEdgeWithChannelId(CHANNEL_ID_2); + Edge edge2 = new Edge(CHANNEL_ID, PUBKEY_2, PUBKEY_3, CAPACITY); + flows.add(edge1a, Coins.ofSatoshis(10)); + flows.add(edge1b, Coins.ofSatoshis(10)); + flows.add(edge2, Coins.ofSatoshis(20)); + assertThat(Routes.fromFlows(PUBKEY, PUBKEY_3, flows)).containsExactlyInAnyOrder( + new Route(List.of(edge1a, edge2), Coins.ofSatoshis(10)), + new Route(List.of(edge1b, edge2), Coins.ofSatoshis(10)) + ); + } + + @Test + void ensureTotalAmount_adds_to_only_route() { + Set routes = Routes.fromFlows(PUBKEY, PUBKEY_2, FLOWS); + assumeThat(routes).containsExactly(new Route(List.of(EDGE), Coins.ofSatoshis(1))); + Routes.ensureTotalAmount(routes, Coins.ofSatoshis(2)); + assertThat(routes).containsExactly(new Route(List.of(EDGE), Coins.ofSatoshis(2))); + } + + @Test + void ensureTotalAmount_adds_to_route_with_highest_probability() { + Flows flows = new Flows(); + flows.add(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY), Coins.ofSatoshis(2)); + flows.add(new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, CAPACITY), Coins.ofSatoshis(1)); + flows.add(new Edge(CHANNEL_ID_3, PUBKEY, PUBKEY_2, CAPACITY), Coins.ofSatoshis(3)); + Set routes = Routes.fromFlows(PUBKEY, PUBKEY_2, flows); + Routes.ensureTotalAmount(routes, Coins.ofSatoshis(7)); + assertThat(routes).containsExactlyInAnyOrder( + new Route(List.of(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY)), Coins.ofSatoshis(2)), + new Route(List.of(new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, CAPACITY)), Coins.ofSatoshis(2)), + new Route(List.of(new Edge(CHANNEL_ID_3, PUBKEY, PUBKEY_2, CAPACITY)), Coins.ofSatoshis(3)) + ); + } + + private Edge createEdgeWithChannelId(ChannelId channelId) { + return new Edge(channelId, PUBKEY, PUBKEY_2, CAPACITY); + } + +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeFixtures.java new file mode 100644 index 00000000..28acc6ec --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeFixtures.java @@ -0,0 +1,21 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY; +import static de.cotto.lndmanagej.model.ChannelFixtures.CAPACITY_2; +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.ChannelIdFixtures.CHANNEL_ID_4; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_5; +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; + +public class EdgeFixtures { + public static final Edge EDGE = new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY); + public static final Edge EDGE_1_3 = new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_3, CAPACITY); + public static final Edge EDGE_2_3 = new Edge(CHANNEL_ID_3, PUBKEY_2, PUBKEY_3, CAPACITY); + public static final Edge EDGE_3_2 = new Edge(CHANNEL_ID_4, PUBKEY_3, PUBKEY_2, CAPACITY_2); + public static final Edge EDGE_3_4 = new Edge(CHANNEL_ID_5, PUBKEY_3, PUBKEY_4, CAPACITY); +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationFixtures.java new file mode 100644 index 00000000..0481a65d --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/EdgeWithCapacityInformationFixtures.java @@ -0,0 +1,10 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; + +public class EdgeWithCapacityInformationFixtures { + public static final EdgeWithCapacityInformation EDGE_WITH_CAPACITY_INFORMATION = + new EdgeWithCapacityInformation(EDGE, Coins.ofSatoshis(123)); +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowFixtures.java new file mode 100644 index 00000000..e97dcb10 --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowFixtures.java @@ -0,0 +1,13 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_2_3; +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE_3_4; + +public class FlowFixtures { + public static final Flow FLOW = new Flow(EDGE, Coins.ofSatoshis(1)); + public static final Flow FLOW_2 = new Flow(EDGE_2_3, Coins.ofSatoshis(2)); + public static final Flow FLOW_3 = new Flow(EDGE_3_4, Coins.ofSatoshis(3)); +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsFixtures.java new file mode 100644 index 00000000..092d3155 --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/FlowsFixtures.java @@ -0,0 +1,9 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.FlowFixtures.FLOW_3; + +public class FlowsFixtures { + public static final Flows FLOWS = new Flows(FLOW, FLOW_2, FLOW_3); +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentFixtures.java new file mode 100644 index 00000000..015ebbdb --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/MultiPathPaymentFixtures.java @@ -0,0 +1,10 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import java.util.Set; + +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; + +public class MultiPathPaymentFixtures { + public static final MultiPathPayment MULTI_PATH_PAYMENT = + new MultiPathPayment(ROUTE.amount(), ROUTE.getProbability(), Set.of(ROUTE)); +} diff --git a/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteFixtures.java b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteFixtures.java new file mode 100644 index 00000000..30596e8a --- /dev/null +++ b/pickhardt-payments/src/testFixtures/java/de/cotto/lndmanagej/pickhardtpayments/model/RouteFixtures.java @@ -0,0 +1,11 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; + +import java.util.List; + +import static de.cotto.lndmanagej.pickhardtpayments.model.EdgeFixtures.EDGE; + +public class RouteFixtures { + public static final Route ROUTE = new Route(List.of(EDGE), Coins.ofSatoshis(100)); +} diff --git a/settings.gradle b/settings.gradle index 5ddff46b..53b551a9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,7 @@ include 'hardcoded' include 'invoices' include 'onlinepeers' include 'payments' +include 'pickhardt-payments' include 'privatechannels' include 'selfpayments' include 'statistics' diff --git a/web/build.gradle b/web/build.gradle index d59c17e4..1f999b7d 100644 --- a/web/build.gradle +++ b/web/build.gradle @@ -5,10 +5,13 @@ plugins { dependencies { implementation('org.springframework.boot:spring-boot-starter-web') implementation project(':backend') + implementation project(':pickhardt-payments') implementation project(':model') testImplementation testFixtures(project(':model')) + testImplementation testFixtures(project(':pickhardt-payments')) integrationTestImplementation 'com.ryantenney.metrics:metrics-spring:3.1.3' integrationTestImplementation('org.springframework.boot:spring-boot-starter-web') integrationTestImplementation project(':backend') integrationTestImplementation testFixtures(project(':model')) -} \ No newline at end of file + integrationTestImplementation testFixtures(project(':pickhardt-payments')) +} diff --git a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java new file mode 100644 index 00000000..a74b3a8e --- /dev/null +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java @@ -0,0 +1,73 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.controller.dto.ObjectMapperConfiguration; +import de.cotto.lndmanagej.model.ChannelIdResolver; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentComputation; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.core.Is.is; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@SuppressWarnings("CPD-START") +@Import(ObjectMapperConfiguration.class) +@WebMvcTest(controllers = PickhardtPaymentsController.class) +class PickhardtPaymentsControllerIT { + private static final String PREFIX = "/beta/pickhardt-payments"; + @Autowired + private MockMvc mockMvc; + + @MockBean + private MultiPathPaymentComputation multiPathPaymentComputation; + + @MockBean + @SuppressWarnings("unused") + private ChannelIdResolver channelIdResolver; + + @Test + void sendTo() throws Exception { + Coins amount = MULTI_PATH_PAYMENT.amount(); + String amountAsString = String.valueOf(amount.satoshis()); + double expectedProbability = MULTI_PATH_PAYMENT.probability(); + when(multiPathPaymentComputation.getMultiPathPaymentTo(PUBKEY, amount)) + .thenReturn(MULTI_PATH_PAYMENT); + mockMvc.perform(get(PREFIX + "/to/" + PUBKEY + "/amount/" + amount.satoshis())) + .andExpect(jsonPath("$.probability", is(expectedProbability))) + .andExpect(jsonPath("$.amountSat", is(amountAsString))) + .andExpect(jsonPath("$.routes", hasSize(1))) + .andExpect(jsonPath("$.routes[0].amountSat", is(amountAsString))) + .andExpect(jsonPath("$.routes[0].channelIds", contains(CHANNEL_ID.toString()))) + .andExpect(jsonPath("$.routes[0].probability", is(expectedProbability))); + } + + @Test + void send() throws Exception { + Coins amount = MULTI_PATH_PAYMENT.amount(); + String amountAsString = String.valueOf(amount.satoshis()); + double expectedProbability = MULTI_PATH_PAYMENT.probability(); + when(multiPathPaymentComputation.getMultiPathPaymentTo(PUBKEY, amount)) + .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, Coins.ofSatoshis(1_234))) + .thenReturn(MULTI_PATH_PAYMENT); + mockMvc.perform(get(PREFIX + "/from/" + PUBKEY + "/to/" + PUBKEY_2 + "/amount/" + 1_234)) + .andExpect(jsonPath("$.probability", is(expectedProbability))) + .andExpect(jsonPath("$.amountSat", is(amountAsString))) + .andExpect(jsonPath("$.routes", hasSize(1))) + .andExpect(jsonPath("$.routes[0].amountSat", is(amountAsString))) + .andExpect(jsonPath("$.routes[0].channelIds", contains(CHANNEL_ID.toString()))) + .andExpect(jsonPath("$.routes[0].probability", is(expectedProbability))); + } +} diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java b/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java new file mode 100644 index 00000000..be9c0f60 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java @@ -0,0 +1,42 @@ +package de.cotto.lndmanagej.controller; + +import com.codahale.metrics.annotation.Timed; +import de.cotto.lndmanagej.controller.dto.MultiPathPaymentDto; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentComputation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/beta/pickhardt-payments/") +public class PickhardtPaymentsController { + private final MultiPathPaymentComputation multiPathPaymentComputation; + + public PickhardtPaymentsController(MultiPathPaymentComputation multiPathPaymentComputation) { + this.multiPathPaymentComputation = multiPathPaymentComputation; + } + + @Timed + @GetMapping("/to/{pubkey}/amount/{amount}") + public MultiPathPaymentDto sendTo( + @PathVariable Pubkey pubkey, + @PathVariable long amount + ) { + Coins coins = Coins.ofSatoshis(amount); + return MultiPathPaymentDto.fromModel(multiPathPaymentComputation.getMultiPathPaymentTo(pubkey, coins)); + } + + @Timed + @GetMapping("/from/{source}/to/{target}/amount/{amount}") + public MultiPathPaymentDto send( + @PathVariable Pubkey source, + @PathVariable Pubkey target, + @PathVariable long amount + ) { + Coins coins = Coins.ofSatoshis(amount); + return MultiPathPaymentDto.fromModel(multiPathPaymentComputation.getMultiPathPayment(source, target, coins)); + } +} diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDto.java b/web/src/main/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDto.java new file mode 100644 index 00000000..5ca537f6 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDto.java @@ -0,0 +1,22 @@ +package de.cotto.lndmanagej.controller.dto; + +import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.Route; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public record MultiPathPaymentDto(String amountSat, double probability, List routes) { + public static MultiPathPaymentDto fromModel(MultiPathPayment multiPathPayment) { + return new MultiPathPaymentDto( + String.valueOf(multiPathPayment.amount().satoshis()), + multiPathPayment.probability(), + getRoutes(multiPathPayment.routes()) + ); + } + + private static List getRoutes(Set routes) { + return routes.stream().map(RouteDto::fromModel).collect(Collectors.toList()); + } +} diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/dto/RouteDto.java b/web/src/main/java/de/cotto/lndmanagej/controller/dto/RouteDto.java new file mode 100644 index 00000000..016944a6 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/dto/RouteDto.java @@ -0,0 +1,18 @@ +package de.cotto.lndmanagej.controller.dto; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.pickhardtpayments.model.Edge; +import de.cotto.lndmanagej.pickhardtpayments.model.Route; + +import java.util.List; + +public record RouteDto(String amountSat, List channelIds, double probability) { + public static RouteDto fromModel(Route route) { + return new RouteDto( + String.valueOf(route.amount().satoshis()), + route.edges().stream().map(Edge::channelId).toList(), + route.getProbability() + ); + } + +} diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java new file mode 100644 index 00000000..09f408ef --- /dev/null +++ b/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java @@ -0,0 +1,42 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.controller.dto.MultiPathPaymentDto; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentComputation; +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 static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PickhardtPaymentsControllerTest { + + @InjectMocks + private PickhardtPaymentsController controller; + + @Mock + private MultiPathPaymentComputation multiPathPaymentComputation; + + @Test + void sendTo() { + when(multiPathPaymentComputation.getMultiPathPaymentTo(PUBKEY, Coins.ofSatoshis(456))) + .thenReturn(MULTI_PATH_PAYMENT); + assertThat(controller.sendTo(PUBKEY, 456)) + .isEqualTo(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)); + } + + @Test + void send() { + when(multiPathPaymentComputation.getMultiPathPayment(PUBKEY, PUBKEY_2, Coins.ofSatoshis(123))) + .thenReturn(MULTI_PATH_PAYMENT); + assertThat(controller.send(PUBKEY, PUBKEY_2, 123)) + .isEqualTo(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)); + } +} diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDtoTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDtoTest.java new file mode 100644 index 00000000..7436834f --- /dev/null +++ b/web/src/test/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDtoTest.java @@ -0,0 +1,22 @@ +package de.cotto.lndmanagej.controller.dto; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static org.assertj.core.api.Assertions.assertThat; + +class MultiPathPaymentDtoTest { + @Test + void fromModel() { + double probability = 0.999_995_238_095_464_9; + String amountSat = "100"; + assertThat(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)).isEqualTo(new MultiPathPaymentDto( + amountSat, + probability, + List.of(new RouteDto(amountSat, List.of(CHANNEL_ID), probability))) + ); + } +}