From d17d61756c223f981451bb796d64785f61781a67 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Thu, 19 May 2022 20:58:10 +0200 Subject: [PATCH] add basic top up service --- .../TopUpConfigurationSettings.java | 21 +++ .../TopUpConfigurationSettingsTest.java | 16 +++ .../pickhardtpayments/TopUpService.java | 78 +++++++++++ .../pickhardtpayments/TopUpServiceTest.java | 126 ++++++++++++++++++ .../PickhardtPaymentsControllerIT.java | 12 ++ .../PickhardtPaymentsController.java | 10 ++ .../PickhardtPaymentsControllerTest.java | 10 ++ 7 files changed, 273 insertions(+) create mode 100644 configuration/src/main/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettings.java create mode 100644 configuration/src/test/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettingsTest.java create mode 100644 pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/TopUpService.java create mode 100644 pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/TopUpServiceTest.java diff --git a/configuration/src/main/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettings.java b/configuration/src/main/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettings.java new file mode 100644 index 00000000..64c8a7a2 --- /dev/null +++ b/configuration/src/main/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettings.java @@ -0,0 +1,21 @@ +package de.cotto.lndmanagej.configuration; + +public enum TopUpConfigurationSettings implements ConfigurationSetting { + THRESHOLD("threshold_sat"); + + private final String name; + + TopUpConfigurationSettings(String name) { + this.name = name; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getSection() { + return "top-up"; + } +} diff --git a/configuration/src/test/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettingsTest.java b/configuration/src/test/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettingsTest.java new file mode 100644 index 00000000..6eb8b35a --- /dev/null +++ b/configuration/src/test/java/de/cotto/lndmanagej/configuration/TopUpConfigurationSettingsTest.java @@ -0,0 +1,16 @@ +package de.cotto.lndmanagej.configuration; + +import org.junit.jupiter.api.Test; + +import static de.cotto.lndmanagej.configuration.TopUpConfigurationSettings.THRESHOLD; +import static org.assertj.core.api.Assertions.assertThat; + +class TopUpConfigurationSettingsTest { + private static final String SECTION_NAME = "top-up"; + + @Test + void threshold() { + assertThat(THRESHOLD.getSection()).isEqualTo(SECTION_NAME); + assertThat(THRESHOLD.getName()).isEqualTo("threshold_sat"); + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/TopUpService.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/TopUpService.java new file mode 100644 index 00000000..9daf45ae --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/TopUpService.java @@ -0,0 +1,78 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.configuration.ConfigurationService; +import de.cotto.lndmanagej.grpc.GrpcInvoices; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.DecodedPaymentRequest; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import de.cotto.lndmanagej.service.BalanceService; +import de.cotto.lndmanagej.service.ChannelService; +import de.cotto.lndmanagej.service.NodeService; +import org.springframework.stereotype.Component; + +import static de.cotto.lndmanagej.configuration.TopUpConfigurationSettings.THRESHOLD; + +@Component +public class TopUpService { + private static final Coins DEFAULT_THRESHOLD = Coins.ofSatoshis(10_000); + private final BalanceService balanceService; + private final GrpcInvoices grpcInvoices; + private final NodeService nodeService; + private final ConfigurationService configurationService; + private final ChannelService channelService; + private final MultiPathPaymentSender multiPathPaymentSender; + + public TopUpService( + BalanceService balanceService, + GrpcInvoices grpcInvoices, + NodeService nodeService, + ConfigurationService configurationService, + ChannelService channelService, + MultiPathPaymentSender multiPathPaymentSender + ) { + this.balanceService = balanceService; + this.grpcInvoices = grpcInvoices; + this.nodeService = nodeService; + this.configurationService = configurationService; + this.channelService = channelService; + this.multiPathPaymentSender = multiPathPaymentSender; + } + + public PaymentStatus topUp(Pubkey pubkey, Coins amount) { + Coins balance = balanceService.getAvailableLocalBalanceForPeer(pubkey); + Coins topUpAmount = amount.subtract(balance); + Coins threshold = getThreshold(); + if (topUpAmount.compareTo(threshold) < 0) { + String reason = "Amount %s below threshold %s (balance %s)".formatted(topUpAmount, threshold, balance); + return PaymentStatus.createFailure(reason); + } + + if (noChannelWith(pubkey)) { + String alias = nodeService.getAlias(pubkey); + return PaymentStatus.createFailure("No channel with " + pubkey + " (" + alias + ")"); + } + + String description = getDescription(pubkey, topUpAmount); + DecodedPaymentRequest paymentRequest = + grpcInvoices.createPaymentRequest(topUpAmount, description).orElse(null); + if (paymentRequest == null) { + String alias = nodeService.getAlias(pubkey); + return PaymentStatus.createFailure("Unable to create payment request (%s, %s)".formatted(pubkey, alias)); + } + return multiPathPaymentSender.payPaymentRequest(paymentRequest, 0); + } + + private Coins getThreshold() { + return configurationService.getIntegerValue(THRESHOLD).map(Coins::ofSatoshis).orElse(DEFAULT_THRESHOLD); + } + + private boolean noChannelWith(Pubkey pubkey) { + return channelService.getOpenChannelsWith(pubkey).isEmpty(); + } + + private String getDescription(Pubkey pubkey, Coins amount) { + String alias = nodeService.getAlias(pubkey); + return "Topping up channel with %s (%s), adding %s".formatted(pubkey, alias, amount); + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/TopUpServiceTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/TopUpServiceTest.java new file mode 100644 index 00000000..fa959dbc --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/TopUpServiceTest.java @@ -0,0 +1,126 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.configuration.ConfigurationService; +import de.cotto.lndmanagej.configuration.TopUpConfigurationSettings; +import de.cotto.lndmanagej.grpc.GrpcInvoices; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus.InstantWithString; +import de.cotto.lndmanagej.service.BalanceService; +import de.cotto.lndmanagej.service.ChannelService; +import de.cotto.lndmanagej.service.NodeService; +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.Optional; +import java.util.Set; + +import static de.cotto.lndmanagej.model.LocalOpenChannelFixtures.LOCAL_OPEN_CHANNEL; +import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TopUpServiceTest { + private static final Coins AMOUNT = Coins.ofSatoshis(123_000); + private static final Coins DEFAULT_THRESHOLD = Coins.ofSatoshis(10_000); + private static final String DESCRIPTION_PREFIX = "Topping up channel with " + PUBKEY + " (alias), adding "; + + @InjectMocks + private TopUpService topUpService; + + @Mock + private BalanceService balanceService; + + @Mock + private GrpcInvoices grpcInvoices; + + @Mock + private NodeService nodeService; + + @Mock + private ConfigurationService configurationService; + + @Mock + private ChannelService channelService; + + @BeforeEach + void setUp() { + lenient().when(nodeService.getAlias(PUBKEY)).thenReturn("alias"); + lenient().when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of(LOCAL_OPEN_CHANNEL)); + } + + @Test + void no_channel_with_peer() { + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); + when(channelService.getOpenChannelsWith(PUBKEY)).thenReturn(Set.of()); + assertNoTopUp("No channel with 027abc123abc123abc123abc123123abc123abc123abc123abc123abc123abc121 (alias)"); + } + + @Test + void empty_channel_with_peer() { + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); + assertTopUp(AMOUNT); + } + + @Test + void local_balance_equals_amount() { + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(AMOUNT); + assertNoTopUp("Amount 0.000 below threshold 10,000.000 (balance 123,000.000)"); + } + + @Test + void local_balance_more_than_amount() { + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(AMOUNT.add(Coins.ofSatoshis(1))); + assertNoTopUp("Amount -1.000 below threshold 10,000.000 (balance 123,001.000)"); + } + + @Test + void missing_amount_is_below_threshold() { + Coins balance = AMOUNT.subtract(DEFAULT_THRESHOLD).add(Coins.ofSatoshis(1)); + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(balance); + assertNoTopUp("Amount 9,999.000 below threshold 10,000.000 (balance 113,001.000)"); + } + + @Test + void missing_amount_is_threshold() { + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(AMOUNT.subtract(DEFAULT_THRESHOLD)); + assertTopUp(DEFAULT_THRESHOLD); + } + + @Test + void missing_amount_is_above_threshold() { + Coins amount = AMOUNT.subtract(DEFAULT_THRESHOLD).subtract(Coins.ofSatoshis(1)); + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(amount); + assertTopUp(DEFAULT_THRESHOLD.add(Coins.ofSatoshis(1))); + } + + @Test + void uses_configured_threshold() { + Coins threshold = DEFAULT_THRESHOLD.add(Coins.ofSatoshis(1)); + when(configurationService.getIntegerValue(TopUpConfigurationSettings.THRESHOLD)) + .thenReturn(Optional.of((int) threshold.satoshis())); + Coins balance = AMOUNT.subtract(DEFAULT_THRESHOLD); + when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(balance); + assertNoTopUp("Amount 10,000.000 below threshold 10,001.000 (balance 113,000.000)"); + } + + private void assertTopUp(Coins expectedTopUpAmount) { + topUpService.topUp(PUBKEY, AMOUNT); + verify(grpcInvoices).createPaymentRequest(expectedTopUpAmount, DESCRIPTION_PREFIX + expectedTopUpAmount); + } + + private void assertNoTopUp(String reason) { + PaymentStatus paymentStatus = topUpService.topUp(PUBKEY, AMOUNT); + assertThat(paymentStatus.isFailure()).isTrue(); + assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)).containsExactly(reason); + verifyNoInteractions(grpcInvoices); + } +} 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 cc5f82e4..e20fcd2f 100644 --- a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java @@ -6,6 +6,7 @@ import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.HexString; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSender; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSplitter; +import de.cotto.lndmanagej.pickhardtpayments.TopUpService; import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; import de.cotto.lndmanagej.service.GraphService; import org.junit.jupiter.api.Test; @@ -26,6 +27,7 @@ import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtur import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.Is.is; +import static org.mockito.Mockito.verify; 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; @@ -46,6 +48,9 @@ class PickhardtPaymentsControllerIT { @MockBean private MultiPathPaymentSender multiPathPaymentSender; + @MockBean + private TopUpService topUpService; + @MockBean @SuppressWarnings("unused") private GraphService graphService; @@ -152,4 +157,11 @@ class PickhardtPaymentsControllerIT { .formatted(PREFIX, PUBKEY, PUBKEY_2, amount.satoshis(), feeRateWeight); mockMvc.perform(get(url)).andExpect(status().isOk()); } + + @Test + void topUp() throws Exception { + String url = "%s/top-up/%s/amount/%s".formatted(PREFIX, PUBKEY, "123"); + mockMvc.perform(get(url)).andExpect(status().isOk()); + verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123)); + } } diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java b/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java index a6ff2c40..628804d7 100644 --- a/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java +++ b/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java @@ -6,6 +6,7 @@ import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSender; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSplitter; +import de.cotto.lndmanagej.pickhardtpayments.TopUpService; import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; import de.cotto.lndmanagej.service.GraphService; @@ -25,17 +26,20 @@ public class PickhardtPaymentsController { private final MultiPathPaymentSplitter multiPathPaymentSplitter; private final MultiPathPaymentSender multiPathPaymentSender; private final PaymentStatusStream paymentStatusStream; + private final TopUpService topUpService; private final GraphService graphService; public PickhardtPaymentsController( MultiPathPaymentSplitter multiPathPaymentSplitter, MultiPathPaymentSender multiPathPaymentSender, PaymentStatusStream paymentStatusStream, + TopUpService topUpService, GraphService graphService ) { this.multiPathPaymentSplitter = multiPathPaymentSplitter; this.multiPathPaymentSender = multiPathPaymentSender; this.paymentStatusStream = paymentStatusStream; + this.topUpService = topUpService; this.graphService = graphService; } @@ -104,6 +108,12 @@ public class PickhardtPaymentsController { return send(source, target, amount, DEFAULT_FEE_RATE_WEIGHT); } + @Timed + @GetMapping("/top-up/{pubkey}/amount/{amount}") + public void topUp(@PathVariable Pubkey pubkey, @PathVariable long amount) { + topUpService.topUp(pubkey, Coins.ofSatoshis(amount)); + } + @Timed @GetMapping("/reset-graph-cache") public void resetGraph() { diff --git a/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java b/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java index 638dc816..9c56fb01 100644 --- a/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java +++ b/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java @@ -5,6 +5,7 @@ import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.HexString; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSender; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSplitter; +import de.cotto.lndmanagej.pickhardtpayments.TopUpService; import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; import de.cotto.lndmanagej.service.GraphService; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +48,9 @@ class PickhardtPaymentsControllerTest { @Mock private GraphService graphService; + @Mock + private TopUpService topUpService; + private final PaymentStatus paymentStatus = new PaymentStatus(HexString.EMPTY); @BeforeEach @@ -112,6 +116,12 @@ class PickhardtPaymentsControllerTest { .isEqualTo(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)); } + @Test + void topUp() { + controller.topUp(PUBKEY, 123); + verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123)); + } + @Test void resetCache() { controller.resetGraph();