diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitter.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitter.java index b50e24a5..c1d9340d 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitter.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitter.java @@ -10,7 +10,10 @@ import de.cotto.lndmanagej.pickhardtpayments.model.Route; import de.cotto.lndmanagej.pickhardtpayments.model.Routes; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import static de.cotto.lndmanagej.pickhardtpayments.PickhardtPaymentsConfiguration.DEFAULT_FEE_RATE_WEIGHT; import static java.util.stream.Collectors.toSet; @@ -50,14 +53,14 @@ public class MultiPathPaymentSplitter { if (flows.isEmpty()) { return MultiPathPayment.FAILURE; } - Set routes = Routes.fromFlows(source, target, flows); - Set routesWithLiquidityInformation = getWithLiquidityInformation(routes); + List routes = Routes.fromFlows(source, target, flows); + List routesWithLiquidityInformation = getWithLiquidityInformation(routes); Routes.ensureTotalAmount(routesWithLiquidityInformation, amount); return new MultiPathPayment(routesWithLiquidityInformation); } - private Set getWithLiquidityInformation(Set routes) { - return routes.stream().map(this::getWithLiquidityInformation).collect(toSet()); + private List getWithLiquidityInformation(List routes) { + return routes.stream().map(this::getWithLiquidityInformation).collect(Collectors.toCollection(ArrayList::new)); } private Route getWithLiquidityInformation(Route route) { 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 index fea338d3..db92d8ab 100644 --- 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 @@ -2,23 +2,39 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; -import java.util.Set; +import java.util.List; -public record MultiPathPayment(Coins amount, Coins fees, double probability, Set routes) { - public static final MultiPathPayment FAILURE = new MultiPathPayment(Coins.NONE, Coins.NONE, 0.0, Set.of()); +public record MultiPathPayment( + Coins amount, + Coins fees, + Coins feesWithFirstHop, + double probability, + List routes +) { + public static final MultiPathPayment FAILURE = + new MultiPathPayment(Coins.NONE, Coins.NONE, Coins.NONE, 0.0, List.of()); - public MultiPathPayment(Set routes) { + public MultiPathPayment(List routes) { this( routes.stream().map(Route::amount).reduce(Coins.NONE, Coins::add), routes.stream().map(Route::fees).reduce(Coins.NONE, Coins::add), + routes.stream().map(Route::feesWithFirstHop).reduce(Coins.NONE, Coins::add), routes.stream().mapToDouble(Route::getProbability).reduce(1.0, (a, b) -> a * b), routes ); } public long getFeeRate() { + return getFeeRateForFees(fees); + } + + public long getFeeRateWithFirstHop() { + return getFeeRateForFees(feesWithFirstHop); + } + + private long getFeeRateForFees(Coins feesToConsider) { if (amount.isPositive()) { - return fees.milliSatoshis() * 1_000_000 / amount.milliSatoshis(); + return feesToConsider.milliSatoshis() * 1_000_000 / amount.milliSatoshis(); } return 0; } 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 index 4aead87a..f9c5f1f7 100644 --- 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 @@ -2,6 +2,7 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -11,7 +12,7 @@ import static java.util.stream.Collectors.toMap; public record Route( List edges, Coins amount, - Coins fees, + List feesForHops, Map liquidityInformation ) { public Route(List edges, Coins amount) { @@ -24,6 +25,14 @@ public record Route( } } + public Coins feesWithFirstHop() { + if (edges.isEmpty()) { + return Coins.NONE; + } + Coins forwardAmountForFirstHop = fees().add(amount); + return fees().add(getFeesForEdgeAndAmount(forwardAmountForFirstHop, edges.get(0))); + } + public double getProbability() { return edges.stream().map(this::getProbability).reduce(1.0, (a, b) -> a * b); } @@ -44,10 +53,18 @@ public record Route( return (1.0 * (upperBoundSat + 1 - amountSat)) / (upperBoundSat + 1 - lowerBoundSat); } + public Coins fees() { + return feesForHops.stream().reduce(Coins.NONE, Coins::add); + } + public long getFeeRate() { return fees().milliSatoshis() * 1_000_000 / amount.milliSatoshis(); } + public long getFeeRateWithFirstHop() { + return feesWithFirstHop().milliSatoshis() * 1_000_000 / amount.milliSatoshis(); + } + public Route getForAmount(Coins newAmount) { return new Route(edges, newAmount).withLiquidityInformation(liquidityInformation.values()); } @@ -55,22 +72,40 @@ public record Route( public Route withLiquidityInformation(Collection liquidityInformation) { Map map = liquidityInformation.stream().collect(toMap(EdgeWithLiquidityInformation::edge, e -> e)); - return new Route(edges, amount, fees, map); + return new Route(edges, amount, feesForHops, map); } - private static Coins computeFees(List edges, Coins amount) { + public Coins feeForHop(int hopIndex) { + return feesForHops.get(hopIndex); + } + + public Coins forwardAmountForHop(int hopIndex) { + Coins accumulatedFees = Coins.NONE; + for (int i = hopIndex + 1; i < feesForHops.toArray().length; i++) { + accumulatedFees = accumulatedFees.add(feeForHop(i)); + } + return amount.add(accumulatedFees); + } + + private static List computeFees(List edges, Coins amount) { Coins fees = Coins.NONE; Coins amountWithFees = amount; - for (int i = edges.size() - 1; i >= 0; i--) { - Edge edge = edges.get(i); - long feeRate = edge.policy().feeRate(); - Coins baseFeeForHop = edge.policy().baseFee(); - Coins relativeFees = Coins.ofMilliSatoshis(feeRate * amountWithFees.milliSatoshis() / 1_000_000); - Coins feesForHop = baseFeeForHop.add(relativeFees); + List feesForHops = new ArrayList<>(); + feesForHops.add(Coins.NONE); + for (int i = edges.size() - 1; i > 0; i--) { + Coins feesForHop = getFeesForEdgeAndAmount(amountWithFees, edges.get(i)); amountWithFees = amountWithFees.add(feesForHop); fees = fees.add(feesForHop); + feesForHops.add(0, feesForHop); } - return fees; + return feesForHops; + } + + private static Coins getFeesForEdgeAndAmount(Coins amountWithFees, Edge edge) { + long feeRate = edge.policy().feeRate(); + Coins baseFeeForHop = edge.policy().baseFee(); + Coins relativeFees = Coins.ofMilliSatoshis(feeRate * amountWithFees.milliSatoshis() / 1_000_000); + return baseFeeForHop.add(relativeFees); } private EdgeWithLiquidityInformation getDefault(Edge edge) { 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 index 6f80f035..58fb176a 100644 --- 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 @@ -3,20 +3,19 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.Pubkey; +import java.util.ArrayList; 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) { + public static List fromFlows(Pubkey source, Pubkey target, Flows flows) { Flows flowsCopy = flows.getCopy(); - Set result = new LinkedHashSet<>(); + List result = new ArrayList<>(); List path = flowsCopy.getShortestPath(source, target); while (!path.isEmpty()) { Coins minimum = path.stream().map(flowsCopy::getFlow).reduce(Coins::minimum).orElseThrow(); diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitterTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitterTest.java index ea639fb0..0996be39 100644 --- a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitterTest.java +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSplitterTest.java @@ -84,7 +84,7 @@ class MultiPathPaymentSplitterTest { MultiPathPayment multiPathPayment = multiPathPaymentSplitter.getMultiPathPaymentTo(PUBKEY_2, AMOUNT); Route expectedRoute = new Route(List.of(EDGE), AMOUNT).withLiquidityInformation(Set.of(noInformationFor(EDGE))); - MultiPathPayment expected = new MultiPathPayment(Set.of(expectedRoute)); + MultiPathPayment expected = new MultiPathPayment(List.of(expectedRoute)); assertThat(multiPathPayment).isEqualTo(expected); } 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 index eace7469..e32f97bd 100644 --- 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 @@ -5,6 +5,7 @@ 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 de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE_2; import static org.assertj.core.api.Assertions.assertThat; class MultiPathPaymentTest { @@ -18,28 +19,49 @@ class MultiPathPaymentTest { assertThat(MultiPathPayment.FAILURE.getFeeRate()).isZero(); } + @Test + void feeRateWithFirstHop_failure() { + assertThat(MultiPathPayment.FAILURE.getFeeRateWithFirstHop()).isZero(); + } + @Test void fees_failure() { assertThat(MultiPathPayment.FAILURE.fees()).isEqualTo(Coins.NONE); } + @Test + void feesWithFirstHop_failure() { + assertThat(MultiPathPayment.FAILURE.feesWithFirstHop()).isEqualTo(Coins.NONE); + } + @Test void amount() { - assertThat(MULTI_PATH_PAYMENT.amount()).isEqualTo(ROUTE.amount()); + assertThat(MULTI_PATH_PAYMENT.amount()).isEqualTo(ROUTE.amount().add(ROUTE_2.amount())); } @Test void routes() { - assertThat(MULTI_PATH_PAYMENT.routes()).containsExactly(ROUTE); + assertThat(MULTI_PATH_PAYMENT.routes()).containsExactlyInAnyOrder(ROUTE, ROUTE_2); } @Test void fees() { - assertThat(MULTI_PATH_PAYMENT.fees()).isEqualTo(Coins.ofMilliSatoshis(20)); + assertThat(MULTI_PATH_PAYMENT.fees()).isEqualTo(ROUTE.fees().add(ROUTE_2.fees())); + } + + @Test + void feesWithFirstHop() { + assertThat(MULTI_PATH_PAYMENT.feesWithFirstHop()) + .isEqualTo(ROUTE.feesWithFirstHop().add(ROUTE_2.feesWithFirstHop())); } @Test void feeRate() { - assertThat(MULTI_PATH_PAYMENT.getFeeRate()).isEqualTo(200); + assertThat(MULTI_PATH_PAYMENT.getFeeRate()).isEqualTo(266); + } + + @Test + void feeRateWithFirstHop() { + assertThat(MULTI_PATH_PAYMENT.getFeeRateWithFirstHop()).isEqualTo(466); } } 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 index 0187da5a..2582b643 100644 --- 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 @@ -2,18 +2,22 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.Policy; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; 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.PolicyFixtures.POLICY_1; 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.RouteFixtures.ROUTE; import static java.util.Map.entry; @@ -26,9 +30,7 @@ class RouteTest { @Test void getProbability() { - long capacitySat = EDGE.capacity().satoshis(); - assertThat(ROUTE.getProbability()) - .isEqualTo(1.0 * (capacitySat + 1 - ROUTE.amount().satoshis()) / (capacitySat + 1)); + assertThat(ROUTE.getProbability()).isEqualTo(0.999_985_714_354_421_7); } @Test @@ -86,64 +88,183 @@ class RouteTest { @Test void fees_amount_with_milli_sat() { Coins amount = Coins.ofMilliSatoshis(1_500_000_111); - int ppm = 100; - Coins baseFee = Coins.ofMilliSatoshis(10); + int ppm1 = 50; + int ppm2 = 100; + Coins baseFee1 = Coins.ofMilliSatoshis(15); + Coins baseFee2 = Coins.ofMilliSatoshis(10); Coins expectedFees = - Coins.ofMilliSatoshis((long) (amount.milliSatoshis() * 1.0 * ppm / ONE_MILLION)) - .add(baseFee); - Policy policy = new Policy(ppm, baseFee, true); - assertThat(new Route(List.of(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy)), amount).fees()) + Coins.ofMilliSatoshis((long) (amount.milliSatoshis() * 1.0 * ppm2 / ONE_MILLION)) + .add(baseFee2); + Edge hop1 = new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm1, baseFee1, true)); + Edge hop2 = new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY, new Policy(ppm2, baseFee2, true)); + assertThat(new Route(List.of(hop1, hop2), amount).fees()) .isEqualTo(expectedFees); } + @Test + void fees_one_hop() { + Coins amount = Coins.ofSatoshis(1_500_000); + Coins baseFee = Coins.ofMilliSatoshis(10); + int ppm = 100; + assertThat(new Route(List.of( + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm, baseFee, true)) + ), amount).fees()).isEqualTo(Coins.NONE); + } + @Test void fees_two_hops() { Coins amount = Coins.ofSatoshis(1_500_000); Coins baseFee1 = Coins.ofMilliSatoshis(10); - Coins baseFee2 = Coins.ofMilliSatoshis(1); + Coins baseFee2 = Coins.ofMilliSatoshis(5); int ppm1 = 100; int ppm2 = 200; Coins expectedFees2 = Coins.ofMilliSatoshis((long) (amount.milliSatoshis() * 1.0 * ppm2 / ONE_MILLION)) .add(baseFee2); - Coins expectedFees1 = - Coins.ofMilliSatoshis((long) (amount.add(expectedFees2).milliSatoshis() * 1.0 * ppm1 / ONE_MILLION)) - .add(baseFee1); + Coins expectedFees1 = Coins.NONE; Coins expectedFees = expectedFees1.add(expectedFees2); + Route route = new Route(List.of( + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm1, baseFee1, true)), + new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm2, baseFee2, true)) + ), amount); + assertThat(route.fees()).isEqualTo(expectedFees); + } + + @Test + void fees_three_hops() { + Coins amount = Coins.ofSatoshis(3_000_000); + Coins baseFee1 = Coins.ofMilliSatoshis(100); + Coins baseFee2 = Coins.ofMilliSatoshis(50); + Coins baseFee3 = Coins.ofMilliSatoshis(10); + int ppm1 = 100; + int ppm2 = 200; + int ppm3 = 300; + Coins expectedFees3 = Coins.NONE; + Coins expectedFees2 = + Coins.ofMilliSatoshis((long) (amount.milliSatoshis() * 1.0 * ppm3 / ONE_MILLION)) + .add(baseFee3); + long amountWithFeesLastHop = amount.add(expectedFees2).milliSatoshis(); + Coins expectedFees1 = Coins.ofMilliSatoshis( + (long) (amountWithFeesLastHop * 1.0 * ppm2 / ONE_MILLION) + ).add(baseFee2); + Coins expectedFees = expectedFees1.add(expectedFees2).add(expectedFees3); assertThat(new Route(List.of( new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm1, baseFee1, true)), - new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY, new Policy(ppm2, baseFee2, true)) + new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY, new Policy(ppm2, baseFee2, true)), + new Edge(CHANNEL_ID_3, PUBKEY_3, PUBKEY_4, CAPACITY, new Policy(ppm3, baseFee3, true)) ), amount).fees()).isEqualTo(expectedFees); } @Test - void feeRate_one_hop_without_base_fee() { - int feeRate = 987; - Policy policy = new Policy(feeRate, Coins.ofMilliSatoshis(0), true); + void feesWithFirstHop_empty() { + assertThat(new Route(List.of(), Coins.ofSatoshis(1_500_000)).feesWithFirstHop()).isEqualTo(Coins.NONE); + } + + @Test + void feesWithFirstHop_one_hop() { + Coins amount = Coins.ofSatoshis(1_500_000); + Coins baseFee = Coins.ofMilliSatoshis(10); + int ppm = 100; + Coins expectedFees = Coins.ofMilliSatoshis(amount.milliSatoshis() * ppm / ONE_MILLION).add(baseFee); + assertThat(new Route(List.of( + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm, baseFee, true)) + ), amount).feesWithFirstHop()).isEqualTo(expectedFees); + } + + @Test + void feesWithFirstHop_three_hops() { + Coins amount = Coins.ofSatoshis(1_500_000); + Coins baseFee1 = Coins.ofMilliSatoshis(10); + Coins baseFee2 = Coins.ofMilliSatoshis(5); + Coins baseFee3 = Coins.ofMilliSatoshis(1); + int ppm1 = 100; + int ppm2 = 200; + int ppm3 = 300; + Coins feesForThirdHop = Coins.ofMilliSatoshis(amount.milliSatoshis() * ppm3 / ONE_MILLION).add(baseFee3); + Coins feesForSecondHop = + Coins.ofMilliSatoshis(amount.add(feesForThirdHop).milliSatoshis() * ppm2 / ONE_MILLION).add(baseFee2); + Coins amountForFirstHop = amount.add(feesForThirdHop).add(feesForSecondHop); + Coins feesForFirstHop = + Coins.ofMilliSatoshis(amountForFirstHop.milliSatoshis() * ppm1 / ONE_MILLION).add(baseFee1); + Coins expectedFees = feesForFirstHop.add(feesForSecondHop).add(feesForThirdHop); + assertThat(new Route(List.of( + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm1, baseFee1, true)), + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm2, baseFee2, true)), + new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, new Policy(ppm3, baseFee3, true)) + ), amount).feesWithFirstHop()).isEqualTo(expectedFees); + } + + @Test + void feeForHop() { + Coins amount = Coins.ofSatoshis(2_000); + int ppm1 = 123; + int ppm2 = 456; + int ppm3 = 789; + Coins expectedFees3 = Coins.NONE; + Coins expectedFees2 = + Coins.ofMilliSatoshis((long) (amount.milliSatoshis() * 1.0 * ppm3 / ONE_MILLION)); + Coins expectedFees1 = + Coins.ofMilliSatoshis((long) (amount.add(expectedFees2).milliSatoshis() * 1.0 * ppm2 / ONE_MILLION)); + Route route = createRoute(amount, ppm1, ppm2, ppm3); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(route.feeForHop(2)).isEqualTo(expectedFees3); + softly.assertThat(route.feeForHop(1)).isEqualTo(expectedFees2); + softly.assertThat(route.feeForHop(0)).isEqualTo(expectedFees1); + softly.assertAll(); + } + + @Test + void forwardAmountForHop() { + Coins amount = Coins.ofSatoshis(2_000); + Route route = createRoute(amount, 123, 456, 789); + Coins feeForHop2 = route.feeForHop(1); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(route.forwardAmountForHop(2)).isEqualTo(amount); + softly.assertThat(route.forwardAmountForHop(1)).isEqualTo(amount); + softly.assertThat(route.forwardAmountForHop(0)).isEqualTo(amount.add(feeForHop2)); + softly.assertAll(); + } + + @Test + void feeRate_two_hops_without_base_fee() { + int feeRate1 = 100; + int feeRate2 = 987; Coins amount = Coins.ofSatoshis(1_234_000); - assertThat(new Route(List.of(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy)), amount).getFeeRate()) - .isEqualTo(feeRate); + Route route = createRoute(amount, feeRate1, feeRate2); + assertThat(route.getFeeRate()).isEqualTo(feeRate2); } @Test void feeRate_one_hop_with_base_fee() { - int feeRate = 987; - Policy policy = new Policy(feeRate, Coins.ofSatoshis(10_000), true); + int feeRate1 = 100; + int feeRate2 = 987; + Policy policy1 = new Policy(feeRate1, Coins.ofSatoshis(100_000), true); + Policy policy2 = new Policy(feeRate2, Coins.ofSatoshis(10_000), true); Coins amount = Coins.ofSatoshis(1_234_567); - assertThat(new Route(List.of(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy)), amount).getFeeRate()) + Edge hop1 = new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy1); + Edge hop2 = new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY, policy2); + assertThat(new Route(List.of(hop1, hop2), amount).getFeeRate()) .isEqualTo(9087); } @Test - void feeRate_two_hops() { - int feeRate1 = 100; - int feeRate2 = 350; - Policy policy1 = new Policy(feeRate1, Coins.ofMilliSatoshis(0), true); - Policy policy2 = new Policy(feeRate2, Coins.ofMilliSatoshis(0), true); + void feeRate_three_hops() { + int feeRate1 = 50; + int feeRate2 = 100; + int feeRate3 = 350; Coins amount = Coins.ofSatoshis(1_234_567); - Edge hop1 = new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy1); - Edge hop2 = new Edge(CHANNEL_ID_2, PUBKEY_2, PUBKEY_3, CAPACITY, policy2); - assertThat(new Route(List.of(hop1, hop2), amount).getFeeRate()).isEqualTo(feeRate1 + feeRate2); + assertThat(createRoute(amount, feeRate1, feeRate2, feeRate3).getFeeRate()) + .isEqualTo(feeRate2 + feeRate3); + } + + @Test + void feeRateWithFirstHop_three_hops() { + int feeRate1 = 50; + int feeRate2 = 100; + int feeRate3 = 350; + Coins amount = Coins.ofSatoshis(1_234_567); + assertThat(createRoute(amount, feeRate1, feeRate2, feeRate3).getFeeRateWithFirstHop()) + .isEqualTo(feeRate1 + feeRate2 + feeRate3); } @Test @@ -168,9 +289,9 @@ class RouteTest { EdgeWithLiquidityInformation.forUpperBound(EDGE, Coins.ofSatoshis(123)) )); Coins newAmount = Coins.ofSatoshis(1_000); - Coins updateFees = Coins.ofMilliSatoshis(200); + List updatedFeesForHops = List.of(Coins.ofMilliSatoshis(200), Coins.ofMilliSatoshis(200), Coins.NONE); assertThat(original.getForAmount(newAmount)) - .isEqualTo(new Route(original.edges(), newAmount, updateFees, original.liquidityInformation())); + .isEqualTo(new Route(original.edges(), newAmount, updatedFeesForHops, original.liquidityInformation())); } @Test @@ -197,4 +318,12 @@ class RouteTest { EdgeWithLiquidityInformation.forKnownLiquidity(edge, Coins.ofSatoshis(knownLiquiditySat)); return route.withLiquidityInformation(Set.of(edgeWithLiquidityInformation)); } + + private Route createRoute(Coins amount, int... feeRates) { + List edges = Arrays.stream(feeRates) + .mapToObj(ppm -> new Policy(ppm, Coins.NONE, true)) + .map(policy -> new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, policy)) + .toList(); + return new Route(edges, amount); + } } 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 index c9c1e719..79cbce33 100644 --- 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 @@ -5,7 +5,6 @@ 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; @@ -80,7 +79,7 @@ class RoutesTest { @Test void ensureTotalAmount_adds_to_only_route() { - Set routes = Routes.fromFlows(PUBKEY, PUBKEY_2, FLOWS); + List 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))); @@ -92,7 +91,7 @@ class RoutesTest { flows.add(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, POLICY_1), Coins.ofSatoshis(2)); flows.add(new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, CAPACITY, POLICY_1), Coins.ofSatoshis(1)); flows.add(new Edge(CHANNEL_ID_3, PUBKEY, PUBKEY_2, CAPACITY, POLICY_1), Coins.ofSatoshis(3)); - Set routes = Routes.fromFlows(PUBKEY, PUBKEY_2, flows); + List 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, POLICY_1)), Coins.ofSatoshis(2)), 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 index 43e4e602..19c0342e 100644 --- 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 @@ -1,9 +1,10 @@ package de.cotto.lndmanagej.pickhardtpayments.model; -import java.util.Set; +import java.util.List; import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE_2; public class MultiPathPaymentFixtures { - public static final MultiPathPayment MULTI_PATH_PAYMENT = new MultiPathPayment(Set.of(ROUTE)); + public static final MultiPathPayment MULTI_PATH_PAYMENT = new MultiPathPayment(List.of(ROUTE, ROUTE_2)); } 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 index 30596e8a..25bbfeed 100644 --- 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 @@ -5,7 +5,11 @@ import de.cotto.lndmanagej.model.Coins; import java.util.List; 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; public class RouteFixtures { - public static final Route ROUTE = new Route(List.of(EDGE), Coins.ofSatoshis(100)); + public static final Route ROUTE = new Route(List.of(EDGE, EDGE_2_3, EDGE_3_4), Coins.ofSatoshis(100)); + public static final Route ROUTE_2 = new Route(List.of(EDGE_1_3, EDGE_2_3), Coins.ofSatoshis(200)); } diff --git a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java index d932f6a0..56243379 100644 --- a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java @@ -12,10 +12,13 @@ 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.ChannelIdFixtures.CHANNEL_ID_3; +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.pickhardtpayments.PickhardtPaymentsConfiguration.DEFAULT_FEE_RATE_WEIGHT; import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.Is.is; @@ -43,7 +46,9 @@ class PickhardtPaymentsControllerIT { void sendTo() throws Exception { Coins amount = MULTI_PATH_PAYMENT.amount(); String amountAsString = String.valueOf(amount.satoshis()); + String route1AmountAsString = String.valueOf(ROUTE.amount().satoshis()); String feesAsString = String.valueOf(MULTI_PATH_PAYMENT.fees().milliSatoshis()); + String feesWithFirstHopAsString = String.valueOf(MULTI_PATH_PAYMENT.feesWithFirstHop().milliSatoshis()); double expectedProbability = MULTI_PATH_PAYMENT.probability(); when(multiPathPaymentSplitter.getMultiPathPaymentTo(PUBKEY, amount, DEFAULT_FEE_RATE_WEIGHT)) .thenReturn(MULTI_PATH_PAYMENT); @@ -51,13 +56,26 @@ class PickhardtPaymentsControllerIT { .andExpect(jsonPath("$.probability", is(expectedProbability))) .andExpect(jsonPath("$.amountSat", is(amountAsString))) .andExpect(jsonPath("$.feesMilliSat", is(feesAsString))) - .andExpect(jsonPath("$.feeRate", is(200))) - .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))) - .andExpect(jsonPath("$.routes[0].feesMilliSat", is(feesAsString))) - .andExpect(jsonPath("$.routes[0].feeRate", is(200))); + .andExpect(jsonPath("$.feesWithFirstHopMilliSat", is(feesWithFirstHopAsString))) + .andExpect(jsonPath("$.feeRate", is(266))) + .andExpect(jsonPath("$.feeRateWithFirstHop", is(466))) + .andExpect(jsonPath("$.routes", hasSize(2))) + .andExpect(jsonPath("$.routes[0].amountSat", is(route1AmountAsString))) + .andExpect(jsonPath("$.routes[0].channelIds", contains( + CHANNEL_ID.toString(), + CHANNEL_ID_3.toString(), + CHANNEL_ID_5.toString() + ))) + .andExpect(jsonPath("$.routes[0].probability", + is(ROUTE.getProbability()))) + .andExpect(jsonPath("$.routes[0].feesMilliSat", + is(String.valueOf(ROUTE.fees().milliSatoshis())))) + .andExpect(jsonPath("$.routes[0].feesWithFirstHopMilliSat", + is(String.valueOf(ROUTE.feesWithFirstHop().milliSatoshis())))) + .andExpect(jsonPath("$.routes[0].feeRate", + is((int) ROUTE.getFeeRate()))) + .andExpect(jsonPath("$.routes[0].feeRateWithFirstHop", + is((int) ROUTE.getFeeRateWithFirstHop()))); } @Test @@ -89,13 +107,8 @@ class PickhardtPaymentsControllerIT { .andExpect(jsonPath("$.probability", is(expectedProbability))) .andExpect(jsonPath("$.amountSat", is(amountAsString))) .andExpect(jsonPath("$.feesMilliSat", is(feesAsString))) - .andExpect(jsonPath("$.feeRate", is(200))) - .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))) - .andExpect(jsonPath("$.routes[0].feesMilliSat", is(feesAsString))) - .andExpect(jsonPath("$.routes[0].feeRate", is(200))); + .andExpect(jsonPath("$.feeRate", is(266))) + .andExpect(jsonPath("$.routes", hasSize(2))); } @Test 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 index 513546a2..9e5933fb 100644 --- a/web/src/main/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDto.java +++ b/web/src/main/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDto.java @@ -4,14 +4,14 @@ 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, String feesMilliSat, + String feesWithFirstHopMilliSat, long feeRate, + long feeRateWithFirstHop, List routes ) { public static MultiPathPaymentDto fromModel(MultiPathPayment multiPathPayment) { @@ -19,12 +19,14 @@ public record MultiPathPaymentDto( String.valueOf(multiPathPayment.amount().satoshis()), multiPathPayment.probability(), String.valueOf(multiPathPayment.fees().milliSatoshis()), + String.valueOf(multiPathPayment.feesWithFirstHop().milliSatoshis()), multiPathPayment.getFeeRate(), + multiPathPayment.getFeeRateWithFirstHop(), getRoutes(multiPathPayment.routes()) ); } - private static List getRoutes(Set routes) { - return routes.stream().map(RouteDto::fromModel).collect(Collectors.toList()); + private static List getRoutes(List routes) { + return routes.stream().map(RouteDto::fromModel).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 index fa7b8ab0..e0755007 100644 --- a/web/src/main/java/de/cotto/lndmanagej/controller/dto/RouteDto.java +++ b/web/src/main/java/de/cotto/lndmanagej/controller/dto/RouteDto.java @@ -11,7 +11,9 @@ public record RouteDto( List channelIds, double probability, String feesMilliSat, - long feeRate + String feesWithFirstHopMilliSat, + long feeRate, + long feeRateWithFirstHop ) { public static RouteDto fromModel(Route route) { return new RouteDto( @@ -19,7 +21,9 @@ public record RouteDto( route.edges().stream().map(Edge::channelId).toList(), route.getProbability(), String.valueOf(route.fees().milliSatoshis()), - route.getFeeRate() + String.valueOf(route.feesWithFirstHop().milliSatoshis()), + route.getFeeRate(), + route.getFeeRateWithFirstHop() ); } 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 index f6240c92..70fe74be 100644 --- a/web/src/test/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDtoTest.java +++ b/web/src/test/java/de/cotto/lndmanagej/controller/dto/MultiPathPaymentDtoTest.java @@ -5,22 +5,48 @@ import org.junit.jupiter.api.Test; import java.util.List; 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_5; import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; +import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE_2; 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"; - String feesMilliSat = "20"; + double probability = 0.999_966_667_099_090_3; + String amountSat1 = "100"; + String amountSat2 = "200"; + String amountSatSum = "300"; + String feesMilliSat = "40"; + RouteDto route1 = new RouteDto( + amountSat1, + List.of(CHANNEL_ID, CHANNEL_ID_3, CHANNEL_ID_5), + 0.999_985_714_354_421_7, + feesMilliSat, + "60", + ROUTE.getFeeRate(), + ROUTE.getFeeRateWithFirstHop() + ); + RouteDto route2 = new RouteDto( + amountSat2, + List.of(CHANNEL_ID_2, CHANNEL_ID_3), + 0.999_980_952_472_562_4, + feesMilliSat, + "80", + ROUTE_2.getFeeRate(), + ROUTE_2.getFeeRateWithFirstHop() + ); assertThat(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)).isEqualTo(new MultiPathPaymentDto( - amountSat, + amountSatSum, probability, String.valueOf(MULTI_PATH_PAYMENT.fees().milliSatoshis()), + String.valueOf(MULTI_PATH_PAYMENT.feesWithFirstHop().milliSatoshis()), MULTI_PATH_PAYMENT.getFeeRate(), - List.of(new RouteDto(amountSat, List.of(CHANNEL_ID), probability, feesMilliSat, ROUTE.getFeeRate()))) + MULTI_PATH_PAYMENT.getFeeRateWithFirstHop(), + List.of(route1, route2)) ); } } diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/dto/RouteDtoTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/dto/RouteDtoTest.java index 332cd90e..f5b6c5e9 100644 --- a/web/src/test/java/de/cotto/lndmanagej/controller/dto/RouteDtoTest.java +++ b/web/src/test/java/de/cotto/lndmanagej/controller/dto/RouteDtoTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import java.util.List; import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_3; +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID_5; import static de.cotto.lndmanagej.pickhardtpayments.model.RouteFixtures.ROUTE; import static org.assertj.core.api.Assertions.assertThat; @@ -13,10 +15,12 @@ class RouteDtoTest { void fromModel() { RouteDto expected = new RouteDto( "100", - List.of(CHANNEL_ID), + List.of(CHANNEL_ID, CHANNEL_ID_3, CHANNEL_ID_5), ROUTE.getProbability(), String.valueOf(ROUTE.fees().milliSatoshis()), - ROUTE.getFeeRate() + String.valueOf(ROUTE.feesWithFirstHop().milliSatoshis()), + ROUTE.getFeeRate(), + ROUTE.getFeeRateWithFirstHop() ); assertThat(RouteDto.fromModel(ROUTE)) .isEqualTo(expected);