diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/OffChainCostService.java b/backend/src/main/java/de/cotto/lndmanagej/service/OffChainCostService.java new file mode 100644 index 00000000..3a9af3c3 --- /dev/null +++ b/backend/src/main/java/de/cotto/lndmanagej/service/OffChainCostService.java @@ -0,0 +1,67 @@ +package de.cotto.lndmanagej.service; + +import com.codahale.metrics.annotation.Timed; +import de.cotto.lndmanagej.model.Channel; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.model.SelfPayment; +import org.springframework.stereotype.Component; + +@Component +public class OffChainCostService { + private final SelfPaymentsService selfPaymentsService; + private final ChannelService channelService; + + public OffChainCostService(SelfPaymentsService selfPaymentsService, ChannelService channelService) { + this.selfPaymentsService = selfPaymentsService; + this.channelService = channelService; + } + + @Timed + public Coins getRebalanceSourceCostsForChannel(ChannelId channelId) { + return selfPaymentsService.getSelfPaymentsFromChannel(channelId).stream() + .filter(selfPayment -> memoMentionsChannel(selfPayment, channelId)) + .map(SelfPayment::fees) + .reduce(Coins.NONE, Coins::add); + } + + @Timed + public Coins getRebalanceSourceCostsForPeer(Pubkey pubkey) { + return channelService.getAllChannelsWith(pubkey).parallelStream() + .map(Channel::getId) + .map(this::getRebalanceSourceCostsForChannel) + .reduce(Coins.NONE, Coins::add); + } + + @Timed + public Coins getRebalanceTargetCostsForChannel(ChannelId channelId) { + return selfPaymentsService.getSelfPaymentsToChannel(channelId).stream() + .filter(this::memoDoesNotMentionFirstHopChannel) + .map(SelfPayment::fees) + .reduce(Coins.NONE, Coins::add); + } + + @Timed + public Coins getRebalanceTargetCostsForPeer(Pubkey pubkey) { + return channelService.getAllChannelsWith(pubkey).parallelStream() + .map(Channel::getId) + .map(this::getRebalanceTargetCostsForChannel) + .reduce(Coins.NONE, Coins::add); + } + + private boolean memoMentionsChannel(SelfPayment selfPayment, ChannelId expectedChannel) { + String memo = selfPayment.memo(); + return memo.contains(String.valueOf(expectedChannel.getShortChannelId())) + || memo.contains(expectedChannel.getCompactForm()) + || memo.contains(expectedChannel.getCompactFormLnd()); + } + + private boolean memoDoesNotMentionFirstHopChannel(SelfPayment selfPayment) { + ChannelId firstChannel = selfPayment.firstChannel().orElse(null); + if (firstChannel == null) { + return true; + } + return !memoMentionsChannel(selfPayment, firstChannel); + } +} diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/SelfPaymentsService.java b/backend/src/main/java/de/cotto/lndmanagej/service/SelfPaymentsService.java index b1b579fd..cc9f1cb9 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/SelfPaymentsService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/SelfPaymentsService.java @@ -75,7 +75,7 @@ public class SelfPaymentsService { Pubkey pubkey, Function> provider ) { - return channelService.getAllChannelsWith(pubkey).stream() + return channelService.getAllChannelsWith(pubkey).parallelStream() .map(Channel::getId) .map(provider) .flatMap(List::stream) diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/OffChainCostServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/OffChainCostServiceTest.java new file mode 100644 index 00000000..cc6ca2a0 --- /dev/null +++ b/backend/src/test/java/de/cotto/lndmanagej/service/OffChainCostServiceTest.java @@ -0,0 +1,213 @@ +package de.cotto.lndmanagej.service; + +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.Payment; +import de.cotto.lndmanagej.model.PaymentHop; +import de.cotto.lndmanagej.model.PaymentRoute; +import de.cotto.lndmanagej.model.SelfPayment; +import de.cotto.lndmanagej.model.SettledInvoice; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +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.CoopClosedChannelFixtures.CLOSED_CHANNEL_2; +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static de.cotto.lndmanagej.model.PaymentFixtures.PAYMENT_CREATION_DATE_TIME; +import static de.cotto.lndmanagej.model.PaymentFixtures.PAYMENT_FEES; +import static de.cotto.lndmanagej.model.PaymentFixtures.PAYMENT_HASH; +import static de.cotto.lndmanagej.model.PaymentFixtures.PAYMENT_INDEX; +import static de.cotto.lndmanagej.model.PaymentFixtures.PAYMENT_VALUE; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static de.cotto.lndmanagej.model.SettledInvoiceFixtures.ADD_INDEX; +import static de.cotto.lndmanagej.model.SettledInvoiceFixtures.AMOUNT_PAID; +import static de.cotto.lndmanagej.model.SettledInvoiceFixtures.HASH; +import static de.cotto.lndmanagej.model.SettledInvoiceFixtures.SETTLE_DATE; +import static de.cotto.lndmanagej.model.SettledInvoiceFixtures.SETTLE_INDEX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OffChainCostServiceTest { + private static final Coins COST_FOR_TWO_SELF_PAYMENTS = PAYMENT_FEES.add(PAYMENT_FEES); + + @InjectMocks + private OffChainCostService offChainCostService; + + @Mock + private SelfPaymentsService selfPaymentsService; + + @Mock + private ChannelService channelService; + + @Test + void getRebalanceSourceCostsForChannel_no_self_payments() { + assertThat(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)).isEqualTo(Coins.NONE); + } + + @Test + void getRebalanceSourceCostsForChannel_short_channel_id_in_memo() { + mockSelfPaymentsFromChannel("rebalance from " + CHANNEL_ID.getShortChannelId()); + assertThat(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceSourceCostsForChannel_compact_channel_id_in_memo() { + mockSelfPaymentsFromChannel("rebalance from " + CHANNEL_ID.getCompactForm()); + assertThat(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceSourceCostsForChannel_compact_lnd_channel_id_in_memo() { + mockSelfPaymentsFromChannel("rebalance from " + CHANNEL_ID.getCompactFormLnd()); + assertThat(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceSourceCostsForChannel_id_not_in_memo() { + mockSelfPaymentsFromChannel("something"); + assertThat(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)) + .isEqualTo(Coins.NONE); + } + + @Test + void getRebalanceSourceCostsForPeer() { + ChannelId id1 = LOCAL_OPEN_CHANNEL.getId(); + ChannelId id2 = CLOSED_CHANNEL_2.getId(); + when(channelService.getAllChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, CLOSED_CHANNEL_2)); + when(selfPaymentsService.getSelfPaymentsFromChannel(id1)).thenReturn(List.of(getSelfPayment(id1.toString()))); + when(selfPaymentsService.getSelfPaymentsFromChannel(id2)).thenReturn(List.of(getSelfPayment(id2.toString()))); + assertThat(offChainCostService.getRebalanceSourceCostsForPeer(PUBKEY)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_no_self_payments() { + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)).isEqualTo(Coins.NONE); + } + + @Test + void getRebalanceTargetCostsForChannel_short_channel_id_in_memo() { + mockSelfPaymentsToChannel("foo bar " + CHANNEL_ID_2.getShortChannelId() + "!"); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_compact_channel_id_in_memo() { + mockSelfPaymentsToChannel("something something " + CHANNEL_ID_2.getCompactForm()); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_compact_lnd_channel_id_in_memo() { + mockSelfPaymentsToChannel(CHANNEL_ID_2.getCompactFormLnd()); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_id_not_in_memo() { + mockSelfPaymentsToChannel("something"); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_other_channel_id_in_memo() { + mockSelfPaymentsToChannel("rebalance to " + CHANNEL_ID_3); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + @Test + void getRebalanceTargetCostsForChannel_source_channel_id_in_memo() { + mockSelfPaymentsToChannel("something: " + CHANNEL_ID); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(Coins.NONE); + } + + @Test + void getRebalanceTargetCostsForChannel_without_first_channel_information() { + Payment payment = new Payment( + PAYMENT_INDEX, PAYMENT_HASH, PAYMENT_CREATION_DATE_TIME, PAYMENT_VALUE, PAYMENT_FEES, List.of() + ); + SettledInvoice settledInvoice = new SettledInvoice( + ADD_INDEX, + SETTLE_INDEX, + SETTLE_DATE, + HASH, + AMOUNT_PAID, + "something: " + CHANNEL_ID, + Optional.empty(), + Optional.of(CHANNEL_ID_2) + ); + SelfPayment selfPayment = new SelfPayment(payment, settledInvoice); + when(selfPaymentsService.getSelfPaymentsToChannel(CHANNEL_ID_2)).thenReturn(List.of(selfPayment)); + assertThat(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID_2)) + .isEqualTo(PAYMENT_FEES); + } + + @Test + void getRebalanceTargetCostsForPeer() { + ChannelId id1 = LOCAL_OPEN_CHANNEL.getId(); + ChannelId id2 = CLOSED_CHANNEL_2.getId(); + when(channelService.getAllChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL, CLOSED_CHANNEL_2)); + when(selfPaymentsService.getSelfPaymentsToChannel(id1)).thenReturn(List.of(getSelfPayment(""))); + when(selfPaymentsService.getSelfPaymentsToChannel(id2)).thenReturn(List.of(getSelfPayment(""))); + assertThat(offChainCostService.getRebalanceTargetCostsForPeer(PUBKEY)) + .isEqualTo(COST_FOR_TWO_SELF_PAYMENTS); + } + + private void mockSelfPaymentsFromChannel(String memo) { + when(selfPaymentsService.getSelfPaymentsFromChannel(CHANNEL_ID)).thenReturn(List.of( + getSelfPayment(memo), + getSelfPayment(memo) + )); + } + + private void mockSelfPaymentsToChannel(String memo) { + when(selfPaymentsService.getSelfPaymentsToChannel(CHANNEL_ID_2)).thenReturn(List.of( + getSelfPayment(memo), + getSelfPayment(memo) + )); + } + + private SelfPayment getSelfPayment(String memo) { + List routes = getSingleRoute(); + Payment payment = new Payment( + PAYMENT_INDEX, PAYMENT_HASH, PAYMENT_CREATION_DATE_TIME, PAYMENT_VALUE, PAYMENT_FEES, routes + ); + SettledInvoice settledInvoice = new SettledInvoice( + ADD_INDEX, + SETTLE_INDEX, + SETTLE_DATE, + HASH, + AMOUNT_PAID, + memo, + Optional.empty(), + Optional.of(CHANNEL_ID_2) + ); + return new SelfPayment(payment, settledInvoice); + } + + private List getSingleRoute() { + PaymentHop firstHop = new PaymentHop(CHANNEL_ID, Coins.NONE); + PaymentHop lastHop = new PaymentHop(CHANNEL_ID_2, Coins.NONE); + PaymentRoute route = new PaymentRoute(List.of(firstHop, lastHop)); + return List.of(route); + } +} \ No newline at end of file diff --git a/model/src/testFixtures/java/de/cotto/lndmanagej/model/PaymentFixtures.java b/model/src/testFixtures/java/de/cotto/lndmanagej/model/PaymentFixtures.java index f2f40acf..0851b8f0 100644 --- a/model/src/testFixtures/java/de/cotto/lndmanagej/model/PaymentFixtures.java +++ b/model/src/testFixtures/java/de/cotto/lndmanagej/model/PaymentFixtures.java @@ -16,8 +16,8 @@ public class PaymentFixtures { public static final String PAYMENT_HASH_3 = "aaa1"; public static final Coins PAYMENT_VALUE = Coins.ofSatoshis(1_000_000); public static final Coins PAYMENT_FEES = Coins.ofMilliSatoshis(10); - private static final List ONE_ROUTE_4_TO_2 = List.of(PAYMENT_ROUTE_4_TO_2); - private static final List TWO_ROUTES = List.of(PAYMENT_ROUTE_4_TO_1, PAYMENT_ROUTE_3_TO_1); + public static final List ONE_ROUTE_4_TO_2 = List.of(PAYMENT_ROUTE_4_TO_2); + public static final List TWO_ROUTES = List.of(PAYMENT_ROUTE_4_TO_1, PAYMENT_ROUTE_3_TO_1); public static final LocalDateTime PAYMENT_CREATION_DATE_TIME = LocalDateTime.of(2021, 12, 5, 22, 22, 22, 500_000_000); diff --git a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/OffChainCostsControllerIT.java b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/OffChainCostsControllerIT.java new file mode 100644 index 00000000..2d597a3e --- /dev/null +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/OffChainCostsControllerIT.java @@ -0,0 +1,60 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.model.ChannelIdResolver; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.service.OffChainCostService; +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.test.web.servlet.MockMvc; + +import static de.cotto.lndmanagej.model.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +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.content; + +@WebMvcTest(controllers = OffChainCostsController.class) +class OffChainCostsControllerIT { + private static final String CHANNEL_PREFIX = "/api/channel/" + CHANNEL_ID.getShortChannelId() + "/"; + private static final String NODE_PREFIX = "/api/node/" + PUBKEY + "/"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + @SuppressWarnings("unused") + private ChannelIdResolver channelIdResolver; + + @MockBean + private OffChainCostService offChainCostService; + + @Test + void getRebalanceSourceCostsForChannel() throws Exception { + when(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)).thenReturn(Coins.ofMilliSatoshis(123)); + mockMvc.perform(get(CHANNEL_PREFIX + "/rebalance-source-costs/")) + .andExpect(content().string("123")); + } + + @Test + void getRebalanceSourceCostsForPeer() throws Exception { + when(offChainCostService.getRebalanceSourceCostsForPeer(PUBKEY)).thenReturn(Coins.ofMilliSatoshis(124)); + mockMvc.perform(get(NODE_PREFIX + "/rebalance-source-costs/")) + .andExpect(content().string("124")); + } + + @Test + void getRebalanceTargetCostsForChannel() throws Exception { + when(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID)).thenReturn(Coins.ofMilliSatoshis(125)); + mockMvc.perform(get(CHANNEL_PREFIX + "/rebalance-target-costs/")) + .andExpect(content().string("125")); + } + + @Test + void getRebalanceTargetCostsForPeer() throws Exception { + when(offChainCostService.getRebalanceTargetCostsForPeer(PUBKEY)).thenReturn(Coins.ofMilliSatoshis(126)); + mockMvc.perform(get(NODE_PREFIX + "/rebalance-target-costs/")) + .andExpect(content().string("126")); + } +} \ No newline at end of file diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/OffChainCostsController.java b/web/src/main/java/de/cotto/lndmanagej/controller/OffChainCostsController.java new file mode 100644 index 00000000..0d061663 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/OffChainCostsController.java @@ -0,0 +1,44 @@ +package de.cotto.lndmanagej.controller; + +import com.codahale.metrics.annotation.Timed; +import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.service.OffChainCostService; +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("/api/") +public class OffChainCostsController { + private final OffChainCostService offChainCostService; + + public OffChainCostsController(OffChainCostService offChainCostService) { + this.offChainCostService = offChainCostService; + } + + @Timed + @GetMapping("/node/{pubkey}/rebalance-source-costs") + public long getRebalanceSourceCostsForPeer(@PathVariable Pubkey pubkey) { + return offChainCostService.getRebalanceSourceCostsForPeer(pubkey).milliSatoshis(); + } + + @Timed + @GetMapping("/channel/{channelId}/rebalance-source-costs") + public long getRebalanceSourceCostsForChannel(@PathVariable ChannelId channelId) { + return offChainCostService.getRebalanceSourceCostsForChannel(channelId).milliSatoshis(); + } + + @Timed + @GetMapping("/node/{pubkey}/rebalance-target-costs") + public long getRebalanceTargetCostsForPeer(@PathVariable Pubkey pubkey) { + return offChainCostService.getRebalanceTargetCostsForPeer(pubkey).milliSatoshis(); + } + + @Timed + @GetMapping("/channel/{channelId}/rebalance-target-costs") + public long getRebalanceTargetCostsForChannel(@PathVariable ChannelId channelId) { + return offChainCostService.getRebalanceTargetCostsForChannel(channelId).milliSatoshis(); + } +} diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/OffChainCostsControllerTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/OffChainCostsControllerTest.java new file mode 100644 index 00000000..acaa529d --- /dev/null +++ b/web/src/test/java/de/cotto/lndmanagej/controller/OffChainCostsControllerTest.java @@ -0,0 +1,47 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.service.OffChainCostService; +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.ChannelIdFixtures.CHANNEL_ID; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OffChainCostsControllerTest { + @InjectMocks + private OffChainCostsController offChainCostsController; + + @Mock + private OffChainCostService offChainCostService; + + @Test + void getRebalanceSourceCostsForChannel() { + when(offChainCostService.getRebalanceSourceCostsForChannel(CHANNEL_ID)).thenReturn(Coins.ofMilliSatoshis(123)); + assertThat(offChainCostsController.getRebalanceSourceCostsForChannel(CHANNEL_ID)).isEqualTo(123); + } + + @Test + void getRebalanceSourceCostsForPeer() { + when(offChainCostService.getRebalanceSourceCostsForPeer(PUBKEY)).thenReturn(Coins.ofMilliSatoshis(123)); + assertThat(offChainCostsController.getRebalanceSourceCostsForPeer(PUBKEY)).isEqualTo(123); + } + + @Test + void getRebalanceTargetCostsForChannel() { + when(offChainCostService.getRebalanceTargetCostsForChannel(CHANNEL_ID)).thenReturn(Coins.ofMilliSatoshis(123)); + assertThat(offChainCostsController.getRebalanceTargetCostsForChannel(CHANNEL_ID)).isEqualTo(123); + } + + @Test + void getRebalanceTargetCostsForPeer() { + when(offChainCostService.getRebalanceTargetCostsForPeer(PUBKEY)).thenReturn(Coins.ofMilliSatoshis(123)); + assertThat(offChainCostsController.getRebalanceTargetCostsForPeer(PUBKEY)).isEqualTo(123); + } +} \ No newline at end of file