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 a40d5e24..326b135b 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 @@ -31,9 +31,8 @@ public class MultiPathPaymentSplitter { 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); + return new MultiPathPayment(routes); } } 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 668cdbf3..3273fd47 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 @@ -4,6 +4,15 @@ 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()); +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 MultiPathPayment(Set routes) { + this( + routes.stream().map(Route::amount).reduce(Coins.NONE, Coins::add), + routes.stream().map(Route::fees).reduce(Coins.NONE, Coins::add), + routes.stream().mapToDouble(Route::getProbability).reduce(1.0, (a, b) -> a * b), + routes + ); + } } 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 4759bff2..70fbc668 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 @@ -4,7 +4,11 @@ import de.cotto.lndmanagej.model.Coins; import java.util.List; -public record Route(List edges, Coins amount) { +public record Route(List edges, Coins amount, Coins fees) { + public Route(List edges, Coins amount) { + this(edges, amount, computeFees(edges, amount)); + } + public Route { if (!amount.isPositive()) { throw new IllegalArgumentException("Amount must be positive, is " + amount); @@ -21,4 +25,19 @@ public record Route(List edges, Coins amount) { public Route getForAmount(Coins newAmount) { return new Route(edges, newAmount); } + + private static Coins 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); + amountWithFees = amountWithFees.add(feesForHop); + fees = fees.add(feesForHop); + } + return fees; + } } 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 a3007624..0d93fb90 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 @@ -2,6 +2,7 @@ package de.cotto.lndmanagej.pickhardtpayments; import de.cotto.lndmanagej.grpc.GrpcGetInfo; import de.cotto.lndmanagej.model.Coins; +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.pickhardtpayments.model.MultiPathPayment; @@ -16,11 +17,15 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.Set; +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.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_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; @@ -63,8 +68,7 @@ class MultiPathPaymentSplitterTest { when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, AMOUNT)).thenReturn(new Flows(FLOW)); when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY); MultiPathPayment multiPathPayment = multiPathPaymentSplitter.getMultiPathPaymentTo(PUBKEY_2, AMOUNT); - MultiPathPayment expected = - new MultiPathPayment(AMOUNT, FLOW.getProbability(), Set.of(new Route(List.of(EDGE), AMOUNT))); + MultiPathPayment expected = new MultiPathPayment(Set.of(new Route(List.of(EDGE), AMOUNT))); assertThat(multiPathPayment).isEqualTo(expected); } @@ -73,9 +77,10 @@ class MultiPathPaymentSplitterTest { 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)); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, halfOfCapacity)).thenReturn(new Flows(flow)); - MultiPathPayment multiPathPayment = multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + MultiPathPayment multiPathPayment = + multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, halfOfCapacity); assertThat(multiPathPayment.probability()) .isEqualTo((1.0 * halfOfCapacity.satoshis() + 1) / (capacitySat + 1)); @@ -83,16 +88,18 @@ class MultiPathPaymentSplitterTest { @Test void getMultiPathPayment_two_flows_probability() { - long capacitySat = EDGE.capacity().satoshis(); + long capacitySat = 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)); + Flow flow1 = new Flow(new Edge(CHANNEL_ID, PUBKEY, PUBKEY_2, CAPACITY, POLICY_1), halfOfCapacity); + Flow flow2 = new Flow(new Edge(CHANNEL_ID_2, PUBKEY, PUBKEY_2, CAPACITY_2, POLICY_1), CAPACITY_2); + Coins totalAmount = halfOfCapacity.add(CAPACITY_2); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, totalAmount)).thenReturn(new Flows(flow1, flow2)); - MultiPathPayment multiPathPayment = multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + MultiPathPayment multiPathPayment = + multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, totalAmount); double probabilityFlow1 = (1.0 * halfOfCapacity.satoshis() + 1) / (capacitySat + 1); - double probabilityFlow2 = 1.0 / (EDGE_3_2.capacity().satoshis() + 1); + double probabilityFlow2 = 1.0 / (CAPACITY_2.satoshis() + 1); assertThat(multiPathPayment.probability()).isEqualTo(probabilityFlow1 * probabilityFlow2); } @@ -102,9 +109,10 @@ class MultiPathPaymentSplitterTest { 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)); + when(flowComputation.getOptimalFlows(PUBKEY, PUBKEY_2, EDGE.capacity())).thenReturn(new Flows(flow1, flow2)); - MultiPathPayment multiPathPayment = multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, AMOUNT); + MultiPathPayment multiPathPayment = + multiPathPaymentSplitter.getMultiPathPayment(PUBKEY, PUBKEY_2, EDGE.capacity()); assertThat(multiPathPayment.probability()).isEqualTo(1.0 / (capacitySat + 1)); } 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 dd1381ee..37ade9c8 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 @@ -1,5 +1,6 @@ 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.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; @@ -21,4 +22,9 @@ class MultiPathPaymentTest { void routes() { assertThat(MULTI_PATH_PAYMENT.routes()).containsExactly(ROUTE); } + + @Test + void fees() { + assertThat(MULTI_PATH_PAYMENT.fees()).isEqualTo(Coins.ofMilliSatoshis(20)); + } } 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 09a14952..b75bb019 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 @@ -1,16 +1,26 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Policy; import org.junit.jupiter.api.Test; import java.util.List; +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.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.RouteFixtures.ROUTE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; class RouteTest { + + private static final int ONE_MILLION = 1_000_000; + @Test void getProbability() { long capacitySat = EDGE.capacity().satoshis(); @@ -18,6 +28,39 @@ class RouteTest { .isEqualTo(1.0 * (capacitySat + 1 - ROUTE.amount().satoshis()) / (capacitySat + 1)); } + @Test + void fees_amount_with_milli_sat() { + Coins amount = Coins.ofMilliSatoshis(1_500_000_111); + int ppm = 100; + Coins baseFee = 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()) + .isEqualTo(expectedFees); + } + + @Test + void fees_two_hops() { + Coins amount = Coins.ofSatoshis(1_500_000); + Coins baseFee1 = Coins.ofMilliSatoshis(10); + Coins baseFee2 = Coins.ofMilliSatoshis(1); + 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 expectedFees = expectedFees1.add(expectedFees2); + 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)) + ), amount).fees()).isEqualTo(expectedFees); + } + @Test void zero_amount() { assertThatIllegalArgumentException().isThrownBy(() -> new Route(List.of(), Coins.NONE)); 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 015ebbdb..43e4e602 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 @@ -5,6 +5,5 @@ 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)); + public static final MultiPathPayment MULTI_PATH_PAYMENT = new MultiPathPayment(Set.of(ROUTE)); }