diff --git a/PickhardtPayments.md b/PickhardtPayments.md new file mode 100644 index 00000000..a2a49e63 --- /dev/null +++ b/PickhardtPayments.md @@ -0,0 +1,76 @@ +# #PickhardtPayments + +## Work in progress! + +See https://arxiv.org/abs/2107.05322. +Please reach out to me on Twitter (https://twitter.com/c_otto83) to discuss more about this! + +The implementation is based on the piecewise linearization approach: +https://lists.linuxfoundation.org/pipermail/lightning-dev/2022-March/003510.html. + +# Requirements +1. Currently (as of v0.14.3-beta, May 2022) lnd does not allow sending a replacement shard once a shard of an active MPP + fails. This, sadly, is necessary to complete MPPs that regularly run into temporary channel failures due to lack of + funds. See https://github.com/lightningnetwork/lnd/issues/5746 for a (possible) fix. You might want to stick to + testnet until this is properly fixed! +2. The graph algorithm implementation used to do the heavy lifting currently is only supported for amd64 (x86_64) on + Linux, Windows, and Mac systems. See https://github.com/C-Otto/lnd-manageJ/issues/13. +3. You need to enable middleware support in lnd: add a section `[rpcmiddleware]` with `rpcmiddleware.enable=true` to + your `lnd.conf`, restart lnd and restart lnd-manageJ. Once enabled, lnd-manageJ will spy on every RPC request and + response, without changing/blocking any of the data. However, despite the read-only configuration, requests may + fail because of this if lnd-manageJ does not respond in time (crash, shutdown, ...). + See https://github.com/lightningnetwork/lnd/issues/6409. + +# Fee Rate Weight +The following endpoints allow you to specify a fee rate weight. +The default fee rate weight is 0, which optimizes the computation for reliability and ignores fees. + +Any value > 0 takes fees into account. Pick higher fee rate weights to compute cheaper routes. + Note that the probability is still taken into account, even with high fee rate weights. As such, a massive channel + may be picked, even though it charges a high fee rate. + +A value of 1 seems to be a good compromise (using the default quantization value) + +# Configuration options +You can configure the following values in the `[pickhardt-payments]` section of your `~/.config/lnd-manageJ.conf` +configuration file: + +* `liquidity_information_max_age_in_seconds` (default 600, 10 minutes): + * lower/upper bound information observed from payment failures are only kept this long + * this information is kept for each pair of peers + * once any value (lower bound, upper bound, amount in-flight) is updated, the "age" is reset +* `use_mission_control` (default: false) + * regularly augment upper bound information based on information provided by lnd, as part of "mission control" + * this is not as helpful, as lnd-manageJ collects the same information in real-time +* `quantization` (default 10000, in satoshis): + * only consider payment shards with a multiple of this number to lower computational complexity: when sending 20k + sat with a quantization of 10k sat, either one shard worth 20k sat is attempted, or two shards worth 10k + * when sending amounts lower than the configured quantization, the amount itself is used as the quantization + * even if the amount you try to send is not divisible by the configured quantization, the resulting MPP still covers + the whole amount +* `piecewise_linear_approximations` (default: 5): + * this corresponds to `N` in the paper + +# MPP computation + +You can compute an MPP based on #PickhardtPayments using any of the following endpoints: + +* `/beta/pickhardt-payments/from/{source}/to/{target}/amount/{amount}/fee-rate-weight/{feeRateWeight}` + * compute an MPP from the given node `source` to the given node `target` +* `/beta/pickhardt-payments/from/{source}/to/{target}/amount/{amount}` + * as above, with default fee rate weight 0 +* `/beta/pickhardt-payments/to/{pubkey}/amount/{amount}/fee-rate-weight/{feeRateWeight}` + * originate payments from the own node +* `/beta/pickhardt-payments/to/{pubkey}/amount/{amount}` + * as above, with default fee rate weight 0 + +# Paying invoices + +Warning: Don't do this on mainnet, yet! This is very much work in progress. + +* `/beta/pickhardt-payments/pay-payment-request/{paymentRequest}/fee-rate-weight/{feeRateWeight}` + * Pay the given payment request (also known as invoice) using the configured fee rate weight. +* `/beta/pickhardt-payments/pay-payment-request/{paymentRequest}` + * as above, with default fee rate weight 0 + +The response shows a somewhat readable representation of the payment progress, including the final result. 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 d0a821a9..acc41800 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/Coins.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/Coins.java @@ -91,4 +91,9 @@ public record Coins(long milliSatoshis) implements Comparable { double coins = BigDecimal.valueOf(milliSatoshis, SCALE).doubleValue(); return String.format(Locale.ENGLISH, "%,.3f", coins); } + + public String toStringSat() { + long coins = BigDecimal.valueOf(milliSatoshis(), SCALE).longValue(); + return String.format(Locale.ENGLISH, "%,d", coins); + } } diff --git a/model/src/main/java/de/cotto/lndmanagej/model/Route.java b/model/src/main/java/de/cotto/lndmanagej/model/Route.java index 00304eee..486c7737 100644 --- a/model/src/main/java/de/cotto/lndmanagej/model/Route.java +++ b/model/src/main/java/de/cotto/lndmanagej/model/Route.java @@ -144,13 +144,4 @@ public class Route { Coins relativeFees = Coins.ofMilliSatoshis(feeRate * amountWithFees.milliSatoshis() / 1_000_000); return baseFeeForHop.add(relativeFees); } - - @Override - public String toString() { - return "Route{" + - "edgesWithLiquidityInformation=" + edgesWithLiquidityInformation + - ", amount=" + amount + - ", feesForHops=" + feesForHops + - '}'; - } } 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 bdd567e4..5ede155e 100644 --- a/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java +++ b/model/src/test/java/de/cotto/lndmanagej/model/CoinsTest.java @@ -173,6 +173,16 @@ class CoinsTest { assertThat(Coins.ofSatoshis(12_345)).hasToString("12,345.000"); } + @Test + void toStringSat() { + assertThat(Coins.ofSatoshis(12_345).toStringSat()).isEqualTo("12,345"); + } + + @Test + void toStringSat_with_milli_sat() { + assertThat(Coins.ofMilliSatoshis(12_345_999).toStringSat()).isEqualTo("12,345"); + } + @Test void justMilliCoins() { assertThat(Coins.ofSatoshis(12_300_000)).hasToString("12,300,000.000"); diff --git a/pickhardt-payments/build.gradle b/pickhardt-payments/build.gradle index ed6695e8..33354385 100644 --- a/pickhardt-payments/build.gradle +++ b/pickhardt-payments/build.gradle @@ -11,6 +11,9 @@ dependencies { implementation 'com.google.ortools:ortools-java:9.3.10497' implementation 'org.eclipse.collections:eclipse-collections:11.0.0' testImplementation testFixtures(project(':model')) + integrationTestImplementation project(':backend') + integrationTestImplementation project(':grpc-adapter') + integrationTestImplementation testFixtures(project(':model')) testFixturesImplementation testFixtures(project(':model')) } diff --git a/pickhardt-payments/src/integrationTest/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverIT.java b/pickhardt-payments/src/integrationTest/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverIT.java new file mode 100644 index 00000000..27cf176d --- /dev/null +++ b/pickhardt-payments/src/integrationTest/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverIT.java @@ -0,0 +1,78 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.SendToRouteObserver; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.service.LiquidityInformationUpdater; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static de.cotto.lndmanagej.model.DecodedPaymentRequestFixtures.DECODED_PAYMENT_REQUEST; +import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE; +import static org.assertj.core.api.Assertions.assertThatCode; + +@ExtendWith(MockitoExtension.class) +class MultiPathPaymentObserverIT { + private static final HexString PAYMENT_HASH = DECODED_PAYMENT_REQUEST.paymentHash(); + private static final Duration DURATION = Duration.ofSeconds(1); + private static final Coins IN_FLIGHT = Coins.ofSatoshis(100); + + @InjectMocks + private MultiPathPaymentObserver multiPathPaymentObserver; + + @Mock + @SuppressWarnings("unused") + private LiquidityInformationUpdater liquidityInformationUpdater; + + private final Executor executor = Executors.newCachedThreadPool(); + + @Test + @Timeout(30) + void waitForInFlightChange_stuck() { + multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); + assertThatCode( + () -> multiPathPaymentObserver.waitForInFlightChange(DURATION, PAYMENT_HASH, IN_FLIGHT) + ).doesNotThrowAnyException(); + } + + @Test + @Timeout(value = 900, unit = TimeUnit.MILLISECONDS) + void waitForInFlightChange_changed_from_other_thread() { + SendToRouteObserver sendToRouteObserver = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); + executor.execute(() -> unlockAfterSomeMilliSeconds(sendToRouteObserver)); + + assertThatCode( + () -> multiPathPaymentObserver.waitForInFlightChange(DURATION, PAYMENT_HASH, IN_FLIGHT) + ).doesNotThrowAnyException(); + } + + @Test + @Timeout(value = 900, unit = TimeUnit.MILLISECONDS) + void waitForInFlightChange_two_waiting_changed_from_other_thread() { + SendToRouteObserver sendToRouteObserver = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); + executor.execute(() -> unlockAfterSomeMilliSeconds(sendToRouteObserver)); + executor.execute(() -> multiPathPaymentObserver.waitForInFlightChange(DURATION, PAYMENT_HASH, IN_FLIGHT)); + + assertThatCode( + () -> multiPathPaymentObserver.waitForInFlightChange(DURATION, PAYMENT_HASH, IN_FLIGHT) + ).doesNotThrowAnyException(); + } + + private void unlockAfterSomeMilliSeconds(SendToRouteObserver sendToRouteObserver) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + // ignore + } + sendToRouteObserver.onError(new NullPointerException()); + } +} 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 index 637f1e8b..8a1980f8 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputation.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/FlowComputation.java @@ -56,10 +56,7 @@ public class FlowComputation { private int getQuantization(Coins amount) { int quantization = configurationService.getIntegerValue(QUANTIZATION) .orElse(DEFAULT_QUANTIZATION); - if (amount.satoshis() < quantization) { - return (int) amount.satoshis(); - } - return quantization; + return (int) Math.min(amount.satoshis(), quantization); } } diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserver.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserver.java index 01774a96..fa71b132 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserver.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserver.java @@ -11,17 +11,21 @@ import de.cotto.lndmanagej.pickhardtpayments.model.PaymentInformation; import de.cotto.lndmanagej.service.LiquidityInformationUpdater; import org.springframework.stereotype.Component; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.function.Function; @Component public class MultiPathPaymentObserver { private final LiquidityInformationUpdater liquidityInformationUpdater; private final Map map = new ConcurrentHashMap<>(); + private final Map latches = new ConcurrentHashMap<>(); public MultiPathPaymentObserver(LiquidityInformationUpdater liquidityInformationUpdater) { this.liquidityInformationUpdater = liquidityInformationUpdater; @@ -39,12 +43,19 @@ public class MultiPathPaymentObserver { return get(paymentHash).settled(); } - public boolean isFailed(HexString paymentHash) { - return get(paymentHash).failed(); + public Optional getFailureCode(HexString paymentHash) { + return get(paymentHash).failureCode(); } private void addInFlight(HexString paymentHash, Coins amount) { update(paymentHash, value -> value.withAdditionalInFlight(amount)); + latches.compute(paymentHash, (key, value) -> { + if (value == null) { + return null; + } + value.countDown(); + return null; + }); } private PaymentInformation get(HexString paymentHash) { @@ -77,6 +88,23 @@ public class MultiPathPaymentObserver { return result; } + public void waitForInFlightChange(Duration timeout, HexString paymentHash, Coins referenceInFlight) { + while (getInFlight(paymentHash).equals(referenceInFlight)) { + try { + boolean changedWithinTimeout = getLatch(paymentHash).await(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (!changedWithinTimeout) { + return; + } + } catch (InterruptedException ignored) { + return; + } + } + } + + private CountDownLatch getLatch(HexString paymentHash) { + return latches.compute(paymentHash, (key, value) -> value == null ? new CountDownLatch(1) : value); + } + private class SendToRouteObserverImpl implements SendToRouteObserver { private final Route route; private final HexString paymentHash; @@ -97,7 +125,7 @@ public class MultiPathPaymentObserver { public void onValue(HexString preimage, FailureCode failureCode) { if (preimage.equals(HexString.EMPTY)) { if (failureCode.isErrorFromFinalNode()) { - update(paymentHash, PaymentInformation::withIsFailed); + update(paymentHash, paymentInformation -> paymentInformation.withFailureCode(failureCode)); } } else { update(paymentHash, PaymentInformation::withIsSettled); diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSender.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSender.java index a19b1b4e..b5dab8b2 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSender.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSender.java @@ -1,52 +1,27 @@ package de.cotto.lndmanagej.pickhardtpayments; import de.cotto.lndmanagej.grpc.GrpcPayments; -import de.cotto.lndmanagej.grpc.GrpcSendToRoute; -import de.cotto.lndmanagej.grpc.SendToRouteObserver; -import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.DecodedPaymentRequest; -import de.cotto.lndmanagej.model.HexString; -import de.cotto.lndmanagej.model.Pubkey; -import de.cotto.lndmanagej.model.Route; -import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; import org.springframework.stereotype.Component; -import java.util.List; - @Component public class MultiPathPaymentSender { private final GrpcPayments grpcPayments; - private final GrpcSendToRoute grpcSendToRoute; - private final MultiPathPaymentSplitter multiPathPaymentSplitter; - private final MultiPathPaymentObserver multiPathPaymentObserver; + private final PaymentLoop paymentLoop; - public MultiPathPaymentSender( - GrpcPayments grpcPayments, - GrpcSendToRoute grpcSendToRoute, - MultiPathPaymentSplitter multiPathPaymentSplitter, - MultiPathPaymentObserver multiPathPaymentObserver - ) { + public MultiPathPaymentSender(GrpcPayments grpcPayments, PaymentLoop paymentLoop) { this.grpcPayments = grpcPayments; - this.grpcSendToRoute = grpcSendToRoute; - this.multiPathPaymentSplitter = multiPathPaymentSplitter; - this.multiPathPaymentObserver = multiPathPaymentObserver; + this.paymentLoop = paymentLoop; } - public MultiPathPayment payPaymentRequest(String paymentRequest, int feeRateWeight) { + public PaymentStatus payPaymentRequest(String paymentRequest, int feeRateWeight) { DecodedPaymentRequest decodedPaymentRequest = grpcPayments.decodePaymentRequest(paymentRequest).orElse(null); if (decodedPaymentRequest == null) { - return MultiPathPayment.FAILURE; + return PaymentStatus.UNABLE_TO_DECODE_PAYMENT_REQUEST; } - Pubkey destination = decodedPaymentRequest.destination(); - Coins amount = decodedPaymentRequest.amount(); - MultiPathPayment multiPathPayment = - multiPathPaymentSplitter.getMultiPathPaymentTo(destination, amount, feeRateWeight); - List routes = multiPathPayment.routes(); - HexString paymentHash = decodedPaymentRequest.paymentHash(); - for (Route route : routes) { - SendToRouteObserver sendToRouteObserver = multiPathPaymentObserver.getFor(route, paymentHash); - grpcSendToRoute.sendToRoute(route, decodedPaymentRequest, sendToRouteObserver); - } - return multiPathPayment; + PaymentStatus paymentStatus = new PaymentStatus(decodedPaymentRequest.paymentHash()); + paymentLoop.start(decodedPaymentRequest, feeRateWeight, paymentStatus); + return paymentStatus; } } diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoop.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoop.java new file mode 100644 index 00000000..693dda1f --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoop.java @@ -0,0 +1,135 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcSendToRoute; +import de.cotto.lndmanagej.grpc.SendToRouteObserver; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.DecodedPaymentRequest; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.Pubkey; +import de.cotto.lndmanagej.model.Route; +import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; + +@Component +public class PaymentLoop { + private static final Duration TIMEOUT = Duration.ofMinutes(5); + private static final String TIMEOUT_MESSAGE = + "Stopping payment loop, full amount is in-flight, but no failure/settle message received within timeout. " + + "The payment might settle/fail in the future."; + + private final MultiPathPaymentObserver multiPathPaymentObserver; + private final MultiPathPaymentSplitter multiPathPaymentSplitter; + private final GrpcSendToRoute grpcSendToRoute; + + public PaymentLoop( + MultiPathPaymentObserver multiPathPaymentObserver, + MultiPathPaymentSplitter multiPathPaymentSplitter, + GrpcSendToRoute grpcSendToRoute + ) { + this.multiPathPaymentObserver = multiPathPaymentObserver; + this.multiPathPaymentSplitter = multiPathPaymentSplitter; + this.grpcSendToRoute = grpcSendToRoute; + } + + @Async + public void start(DecodedPaymentRequest decodedPaymentRequest, int feeRateWeight, PaymentStatus paymentStatus) { + new Instance(decodedPaymentRequest, feeRateWeight, paymentStatus).start(); + } + + private class Instance { + private final DecodedPaymentRequest decodedPaymentRequest; + private final int feeRateWeight; + private final PaymentStatus paymentStatus; + private final HexString paymentHash; + private final Pubkey destination; + private final Coins totalAmountToSend; + private Coins inFlight = Coins.NONE; + + public Instance(DecodedPaymentRequest decodedPaymentRequest, int feeRateWeight, PaymentStatus paymentStatus) { + this.decodedPaymentRequest = decodedPaymentRequest; + this.feeRateWeight = feeRateWeight; + this.paymentStatus = paymentStatus; + paymentHash = decodedPaymentRequest.paymentHash(); + destination = decodedPaymentRequest.destination(); + totalAmountToSend = decodedPaymentRequest.amount(); + } + + private void start() { + int loopIterationCounter = 0; + while (shouldContinue()) { + loopIterationCounter++; + Coins residualAmount = totalAmountToSend.subtract(inFlight); + if (Coins.NONE.equals(residualAmount)) { + paymentStatus.info(TIMEOUT_MESSAGE); + return; + } + addLoopIterationInfo(loopIterationCounter, residualAmount); + MultiPathPayment multiPathPayment = + multiPathPaymentSplitter.getMultiPathPaymentTo(destination, residualAmount, feeRateWeight); + if (multiPathPayment.isFailure()) { + paymentStatus.failed( + "Unable to find route (trying to send %s)".formatted(residualAmount.toStringSat()) + ); + return; + } + Coins currentResidualAmount = getCurrentResidualAmount(); + if (!residualAmount.equals(currentResidualAmount)) { + paymentStatus.info( + "Residual amount changed from %s to %s during route computation, restarting.".formatted( + residualAmount.toStringSat(), + currentResidualAmount.toStringSat() + ) + ); + continue; + } + List routes = multiPathPayment.routes(); + for (Route route : routes) { + SendToRouteObserver sendToRouteObserver = multiPathPaymentObserver.getFor(route, paymentHash); + grpcSendToRoute.sendToRoute(route, decodedPaymentRequest, sendToRouteObserver); + paymentStatus.sending(route); + } + } + } + + private void addLoopIterationInfo(int loopCounter, Coins residualAmount) { + long inFlightMilliSat = inFlight.milliSatoshis(); + long totalMilliSat = totalAmountToSend.milliSatoshis(); + double percentageInFlight = (int) (1000.0 * inFlightMilliSat / totalMilliSat) / 10.0; + paymentStatus.info("#%d: Sending %s (%s%% = %s in flight)".formatted( + loopCounter, residualAmount.toStringSat(), percentageInFlight, inFlight.toStringSat()) + ); + } + + private Coins getCurrentResidualAmount() { + updateInformation(); + return totalAmountToSend.subtract(inFlight); + } + + private boolean shouldContinue() { + updateInformation(); + if (!paymentStatus.isPending()) { + return false; + } + boolean fullAmountInFlight = inFlight.equals(decodedPaymentRequest.amount()); + if (fullAmountInFlight) { + multiPathPaymentObserver.waitForInFlightChange(TIMEOUT, paymentHash, decodedPaymentRequest.amount()); + updateInformation(); + } + return paymentStatus.isPending(); + } + + private void updateInformation() { + inFlight = multiPathPaymentObserver.getInFlight(paymentHash); + if (multiPathPaymentObserver.isSettled(paymentHash)) { + paymentStatus.settled(); + } + multiPathPaymentObserver.getFailureCode(paymentHash).ifPresent(paymentStatus::failed); + } + + } +} diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformation.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformation.java index b85fc801..77f7d01a 100644 --- a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformation.java +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformation.java @@ -1,19 +1,22 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FailureCode; -public record PaymentInformation(Coins inFlight, boolean settled, boolean failed) { - public static final PaymentInformation DEFAULT = new PaymentInformation(Coins.NONE, false, false); +import java.util.Optional; + +public record PaymentInformation(Coins inFlight, boolean settled, Optional failureCode) { + public static final PaymentInformation DEFAULT = new PaymentInformation(Coins.NONE, false, Optional.empty()); public PaymentInformation withAdditionalInFlight(Coins amount) { - return new PaymentInformation(inFlight.add(amount), settled, failed); + return new PaymentInformation(inFlight.add(amount), settled, failureCode); } public PaymentInformation withIsSettled() { - return new PaymentInformation(inFlight, true, failed); + return new PaymentInformation(inFlight, true, failureCode); } - public PaymentInformation withIsFailed() { - return new PaymentInformation(inFlight, settled, true); + public PaymentInformation withFailureCode(FailureCode failureCode) { + return new PaymentInformation(inFlight, settled, Optional.of(failureCode)); } } diff --git a/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatus.java b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatus.java new file mode 100644 index 00000000..c375cc0c --- /dev/null +++ b/pickhardt-payments/src/main/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatus.java @@ -0,0 +1,115 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import com.google.common.annotations.VisibleForTesting; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.EdgeWithLiquidityInformation; +import de.cotto.lndmanagej.model.FailureCode; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.Route; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public class PaymentStatus { + private boolean success; + private boolean failure; + private int numberOfAttemptedRoutes; + private final List messages = new ArrayList<>(); + + public static final PaymentStatus UNABLE_TO_DECODE_PAYMENT_REQUEST; + + static { + UNABLE_TO_DECODE_PAYMENT_REQUEST = new PaymentStatus(HexString.EMPTY); + UNABLE_TO_DECODE_PAYMENT_REQUEST.failed("Unable to decode payment request"); + } + + public PaymentStatus(HexString paymentHash) { + info("Initializing payment " + paymentHash); + } + + public void settled() { + success = true; + addMessage("Settled"); + + } + + public void failed(FailureCode failureCode) { + failure = true; + addMessage("Failed with " + failureCode.toString()); + } + + public void failed(String message) { + failure = true; + addMessage(message); + } + + public void info(String message) { + addMessage(message); + } + + public void sending(Route route) { + numberOfAttemptedRoutes++; + String formattedRoute = getFormattedRoute(route); + addMessage("Sending to route #%d: %s".formatted(numberOfAttemptedRoutes, formattedRoute)); + } + + public boolean isSuccess() { + return success; + } + + public boolean isFailure() { + return failure; + } + + public boolean isPending() { + return !success && !failure; + } + + public int getNumberOfAttemptedRoutes() { + return numberOfAttemptedRoutes; + } + + public List getMessages() { + return messages; + } + + private void addMessage(String message) { + messages.add(new InstantWithString(message)); + } + + private String getFormattedRoute(Route route) { + List edgeInformation = route.getEdgesWithLiquidityInformation().stream() + .map(this::getFormattedEdge) + .toList(); + return route.getAmount().toStringSat() + ": " + edgeInformation + ", " + + route.getFeeRate() + "ppm, probability " + route.getProbability(); + } + + private String getFormattedEdge(EdgeWithLiquidityInformation edge) { + StringBuilder stringBuilder = new StringBuilder(edge.channelId().toString()).append(" ("); + Coins lowerBound = edge.availableLiquidityLowerBound(); + Coins upperBound = edge.availableLiquidityUpperBound(); + Coins capacity = edge.capacity(); + if (lowerBound.equals(upperBound)) { + stringBuilder.append("known ").append(lowerBound.toStringSat()); + } else { + if (lowerBound.isPositive()) { + stringBuilder.append("min ").append(lowerBound.toStringSat()).append(", "); + } + if (!upperBound.equals(capacity)) { + stringBuilder.append("max ").append(upperBound.toStringSat()).append(", "); + } + stringBuilder.append("cap ").append(capacity.toStringSat()); + } + stringBuilder.append(')'); + return stringBuilder.toString(); + } + + @VisibleForTesting + public record InstantWithString(Instant instant, String string) { + InstantWithString(String string) { + this(Instant.now(), string); + } + } +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverTest.java index e0ff4330..4e3ecc77 100644 --- a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverTest.java +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentObserverTest.java @@ -13,21 +13,26 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Optional; import static de.cotto.lndmanagej.model.DecodedPaymentRequestFixtures.DECODED_PAYMENT_REQUEST; +import static de.cotto.lndmanagej.model.FailureCode.FINAL_INCORRECT_CLTV_EXPIRY; import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE; import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE_2; import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE_3; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class MultiPathPaymentObserverTest { private static final HexString PAYMENT_HASH = DECODED_PAYMENT_REQUEST.paymentHash(); + private static final Duration DURATION = Duration.ofMillis(100); + @InjectMocks private MultiPathPaymentObserver multiPathPaymentObserver; @@ -96,31 +101,31 @@ class MultiPathPaymentObserverTest { } @Test - void isFailed_success() { + void getFailureCode_success() { SendToRouteObserver observer = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); observer.onValue(new HexString("AABBCC"), FailureCode.UNKNOWN_FAILURE); - assertThat(multiPathPaymentObserver.isFailed(PAYMENT_HASH)).isFalse(); + assertThat(multiPathPaymentObserver.getFailureCode(PAYMENT_HASH)).isEmpty(); } @Test - void isFailed_failure_from_final_node() { + void getFailureCode_failure_from_final_node() { SendToRouteObserver observer = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); - observer.onValue(HexString.EMPTY, FailureCode.FINAL_INCORRECT_CLTV_EXPIRY); - assertThat(multiPathPaymentObserver.isFailed(PAYMENT_HASH)).isTrue(); + observer.onValue(HexString.EMPTY, FINAL_INCORRECT_CLTV_EXPIRY); + assertThat(multiPathPaymentObserver.getFailureCode(PAYMENT_HASH)).contains(FINAL_INCORRECT_CLTV_EXPIRY); } @Test - void isFailed_failure_from_other_node() { + void getFailureCode_failure_from_other_node() { SendToRouteObserver observer = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); observer.onValue(HexString.EMPTY, FailureCode.PERMANENT_CHANNEL_FAILURE); - assertThat(multiPathPaymentObserver.isFailed(PAYMENT_HASH)).isFalse(); + assertThat(multiPathPaymentObserver.getFailureCode(PAYMENT_HASH)).isEmpty(); } @Test - void isFailed_error() { + void getFailureCode_error() { SendToRouteObserver observer = multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); observer.onError(new NullPointerException()); - assertThat(multiPathPaymentObserver.isFailed(PAYMENT_HASH)).isFalse(); + assertThat(multiPathPaymentObserver.getFailureCode(PAYMENT_HASH)).isEmpty(); } @Test @@ -150,6 +155,15 @@ class MultiPathPaymentObserverTest { assertThat(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)).isEqualTo(expectedAmount); } + @Test + void waitForInFlightChange_already_changed() { + multiPathPaymentObserver.getFor(ROUTE, PAYMENT_HASH); + assumeThat(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)).isNotEqualTo(Coins.ofSatoshis(23)); + assertThatCode( + () -> multiPathPaymentObserver.waitForInFlightChange(DURATION, PAYMENT_HASH, Coins.ofSatoshis(23)) + ).doesNotThrowAnyException(); + } + private List hops() { List edges = ROUTE.getEdges(); List result = new ArrayList<>(); diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSenderTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSenderTest.java index 217bb29b..edf6b90a 100644 --- a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSenderTest.java +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/MultiPathPaymentSenderTest.java @@ -1,38 +1,24 @@ package de.cotto.lndmanagej.pickhardtpayments; import de.cotto.lndmanagej.grpc.GrpcPayments; -import de.cotto.lndmanagej.grpc.GrpcSendToRoute; -import de.cotto.lndmanagej.grpc.SendToRouteObserver; -import de.cotto.lndmanagej.model.HexString; -import de.cotto.lndmanagej.model.Route; -import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus.InstantWithString; +import org.assertj.core.api.SoftAssertions; 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.LinkedHashMap; -import java.util.Map; import java.util.Optional; import static de.cotto.lndmanagej.model.DecodedPaymentRequestFixtures.DECODED_PAYMENT_REQUEST; -import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; -import static java.util.Objects.requireNonNull; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class MultiPathPaymentSenderTest { - private static final int FEE_RATE_WEIGHT = 213; - private static final String PAYMENT_REQUEST = "abc"; - @InjectMocks private MultiPathPaymentSender multiPathPaymentSender; @@ -40,61 +26,43 @@ class MultiPathPaymentSenderTest { private GrpcPayments grpcPayments; @Mock - private GrpcSendToRoute grpcSendToRoute; - - @Mock - private MultiPathPaymentSplitter multiPathPaymentSplitter; - - @Mock - private MultiPathPaymentObserver multiPathPaymentObserver; + private PaymentLoop paymentLoop; @Test - void payment_request_cannot_be_decoded() { - when(grpcPayments.decodePaymentRequest(any())).thenReturn(Optional.empty()); - MultiPathPayment multiPathPayment = multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, FEE_RATE_WEIGHT); - assertThat(multiPathPayment.isFailure()).isTrue(); - verifyNoInteractions(grpcSendToRoute); + void unable_to_decode_payment_request() { + PaymentStatus paymentStatus = multiPathPaymentSender.payPaymentRequest("foo", 123); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isFailure()).isTrue(); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)) + .contains("Unable to decode payment request"); + softly.assertAll(); + verifyNoInteractions(paymentLoop); } @Test - void failure_from_splitter() { - when(grpcPayments.decodePaymentRequest(PAYMENT_REQUEST)).thenReturn(Optional.of(DECODED_PAYMENT_REQUEST)); - when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), any(), anyInt())) - .thenReturn(MultiPathPayment.FAILURE); - MultiPathPayment multiPathPayment = multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, FEE_RATE_WEIGHT); - assertThat(multiPathPayment.isFailure()).isTrue(); - verifyNoInteractions(grpcSendToRoute); + void returns_initialized_payment_status() { + when(grpcPayments.decodePaymentRequest(DECODED_PAYMENT_REQUEST.paymentRequest())) + .thenReturn(Optional.of(DECODED_PAYMENT_REQUEST)); + + int feeRateWeight = 123; + PaymentStatus paymentStatus = + multiPathPaymentSender.payPaymentRequest(DECODED_PAYMENT_REQUEST.paymentRequest(), feeRateWeight); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isFalse(); + softly.assertThat(paymentStatus.isFailure()).isFalse(); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)) + .contains("Initializing payment " + DECODED_PAYMENT_REQUEST.paymentHash()); + softly.assertAll(); } @Test - void sends_for_each_route() { - when(grpcPayments.decodePaymentRequest(PAYMENT_REQUEST)).thenReturn(Optional.of(DECODED_PAYMENT_REQUEST)); - when(multiPathPaymentSplitter.getMultiPathPaymentTo( - DECODED_PAYMENT_REQUEST.destination(), - DECODED_PAYMENT_REQUEST.amount(), - FEE_RATE_WEIGHT) - ).thenReturn(MULTI_PATH_PAYMENT); - MultiPathPayment multiPathPayment = multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, FEE_RATE_WEIGHT); - assertThat(multiPathPayment.isFailure()).isFalse(); - for (Route route : MULTI_PATH_PAYMENT.routes()) { - verify(grpcSendToRoute).sendToRoute(eq(route), eq(DECODED_PAYMENT_REQUEST), any()); - } - } + void starts_payment_loop() { + when(grpcPayments.decodePaymentRequest(DECODED_PAYMENT_REQUEST.paymentRequest())) + .thenReturn(Optional.of(DECODED_PAYMENT_REQUEST)); - @Test - void registers_observers_for_routes() { - when(grpcPayments.decodePaymentRequest(any())).thenReturn(Optional.of(DECODED_PAYMENT_REQUEST)); - when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), any(), anyInt())).thenReturn(MULTI_PATH_PAYMENT); - HexString paymentHash = DECODED_PAYMENT_REQUEST.paymentHash(); - Map expected = new LinkedHashMap<>(); - for (Route route : MULTI_PATH_PAYMENT.routes()) { - SendToRouteObserver expectedObserver = mock(SendToRouteObserver.class); - when(multiPathPaymentObserver.getFor(route, paymentHash)).thenReturn(expectedObserver); - expected.put(route, expectedObserver); - } - multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, FEE_RATE_WEIGHT); - for (Route route : MULTI_PATH_PAYMENT.routes()) { - verify(grpcSendToRoute).sendToRoute(route, DECODED_PAYMENT_REQUEST, requireNonNull(expected.get(route))); - } + int feeRateWeight = 123; + PaymentStatus paymentStatus = + multiPathPaymentSender.payPaymentRequest(DECODED_PAYMENT_REQUEST.paymentRequest(), feeRateWeight); + verify(paymentLoop).start(DECODED_PAYMENT_REQUEST, feeRateWeight, paymentStatus); } } diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoopTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoopTest.java new file mode 100644 index 00000000..62945275 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/PaymentLoopTest.java @@ -0,0 +1,304 @@ +package de.cotto.lndmanagej.pickhardtpayments; + +import de.cotto.lndmanagej.grpc.GrpcSendToRoute; +import de.cotto.lndmanagej.grpc.SendToRouteObserver; +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.FailureCode; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.Route; +import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus.InstantWithString; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static de.cotto.lndmanagej.model.DecodedPaymentRequestFixtures.DECODED_PAYMENT_REQUEST; +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT; +import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtures.MULTI_PATH_PAYMENT_2; +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PaymentLoopTest { + private static final HexString PAYMENT_HASH = DECODED_PAYMENT_REQUEST.paymentHash(); + private static final int FEE_RATE_WEIGHT = 123; + private static final String MPP_1_ROUTE_1 = + "100: [712345x123x1 (cap 21,000,000), 799999x456x3 (cap 21,000,000), 799999x456x5 (cap 21,000,000)], " + + "400ppm, probability 0.9999857143544217"; + private static final String MPP_1_ROUTE_2 = + "200: [799999x456x2 (cap 21,000,000), 799999x456x3 (cap 21,000,000)], " + + "200ppm, probability 0.9999809524725624"; + private static final String MPP_2_ROUTE_1 = + "[799999x456x3 (cap 21,000,000), 799999x456x5 (cap 21,000,000)], " + + "200ppm, probability 0.9999952381011338"; + + @InjectMocks + private PaymentLoop paymentLoop; + + @Mock + private MultiPathPaymentObserver multiPathPaymentObserver; + + @Mock + private MultiPathPaymentSplitter multiPathPaymentSplitter; + + @Mock + private GrpcSendToRoute grpcSendToRoute; + + private PaymentStatus paymentStatus; + + @BeforeEach + void setUp() { + lenient().when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)).thenReturn(Coins.NONE); + paymentStatus = new PaymentStatus(PAYMENT_HASH); + } + + @Test + void failure_from_splitter() { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), any(), anyInt())) + .thenReturn(MultiPathPayment.FAILURE); + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isFailure()).isTrue(); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)).contains(""); + verifyNoInteractions(grpcSendToRoute); + } + + @Test + void requests_route_with_expected_parameters() { + mockSuccessOnFirstAttempt(); + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + verify(multiPathPaymentSplitter).getMultiPathPaymentTo( + DECODED_PAYMENT_REQUEST.destination(), + DECODED_PAYMENT_REQUEST.amount(), + FEE_RATE_WEIGHT + ); + } + + @Test + void sends_to_each_route() { + mockSuccessOnFirstAttempt(); + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + for (Route route : MULTI_PATH_PAYMENT.routes()) { + verify(grpcSendToRoute).sendToRoute(eq(route), eq(DECODED_PAYMENT_REQUEST), any()); + } + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isTrue(); + softly.assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(MULTI_PATH_PAYMENT.routes().size()); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)).containsExactly( + "Initializing payment " + PAYMENT_HASH, + "#1: Sending 123 (0.0% = 0 in flight)", + "Sending to route #1: " + MPP_1_ROUTE_1, + "Sending to route #2: " + MPP_1_ROUTE_2, + "Settled" + ); + softly.assertAll(); + } + + @Test + void aborts_for_stuck_pending_amount() { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), any(), anyInt())).thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)) + .thenReturn(Coins.NONE) + .thenReturn(DECODED_PAYMENT_REQUEST.amount()); + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isFalse(); + softly.assertThat(paymentStatus.isFailure()).isFalse(); + softly.assertAll(); + } + + @Test + void registers_observers_for_routes() { + mockSuccessOnFirstAttempt(); + + Map expected = new LinkedHashMap<>(); + for (Route route : MULTI_PATH_PAYMENT.routes()) { + SendToRouteObserver expectedObserver = mock(SendToRouteObserver.class); + when(multiPathPaymentObserver.getFor(route, PAYMENT_HASH)).thenReturn(expectedObserver); + expected.put(route, expectedObserver); + } + + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + for (Route route : MULTI_PATH_PAYMENT.routes()) { + verify(grpcSendToRoute).sendToRoute(route, DECODED_PAYMENT_REQUEST, requireNonNull(expected.get(route))); + } + } + + @Test + void attempts_second_mpp_on_failure() { + Coins totalAmountToSend = DECODED_PAYMENT_REQUEST.amount(); + Coins pendingAfterFirstAttempt = Coins.ofSatoshis(120); + mockPartialFailureInFirstAttempt(pendingAfterFirstAttempt); + + Coins amountForSecondAttempt = totalAmountToSend.subtract(pendingAfterFirstAttempt); + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(amountForSecondAttempt), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT_2); + + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + for (Route route : MULTI_PATH_PAYMENT_2.routes()) { + verify(grpcSendToRoute).sendToRoute(eq(route), eq(DECODED_PAYMENT_REQUEST), any()); + } + int numberOfRoutes = MULTI_PATH_PAYMENT.routes().size() + MULTI_PATH_PAYMENT_2.routes().size(); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isTrue(); + softly.assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(numberOfRoutes); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)).containsExactly( + "Initializing payment " + PAYMENT_HASH, + "#1: Sending 123 (0.0% = 0 in flight)", + "Sending to route #1: " + MPP_1_ROUTE_1, + "Sending to route #2: " + MPP_1_ROUTE_2, + "#2: Sending 3 (97.2% = 120 in flight)", + "Sending to route #3: 50: " + MPP_2_ROUTE_1, + "Settled" + ); + softly.assertAll(); + } + + @Test + void fails_after_waiting_for_shard_failure_or_settled_payment() { + Coins totalAmountToSend = DECODED_PAYMENT_REQUEST.amount(); + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(DECODED_PAYMENT_REQUEST.amount()), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)) + .thenReturn(Coins.NONE) + .thenReturn(Coins.NONE) + .thenReturn(totalAmountToSend) + .thenReturn(totalAmountToSend); + + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + verify(multiPathPaymentObserver).waitForInFlightChange(any(), eq(PAYMENT_HASH), eq(totalAmountToSend)); + assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)) + .contains("Stopping payment loop, full amount is in-flight, but no failure/settle message received " + + "within timeout. The payment might settle/fail in the future."); + } + + @Test + void does_not_send_out_outdated_routes_if_residual_amount_changed_after_route_computation() { + Coins totalAmount = DECODED_PAYMENT_REQUEST.amount(); + Coins outdatedResidualAmount = Coins.ofSatoshis(1); + Coins actualResidualAmount = Coins.ofSatoshis(2); + + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(totalAmount), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(outdatedResidualAmount), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(actualResidualAmount), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT_2); + + when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)) + .thenReturn(Coins.NONE) + .thenReturn(Coins.NONE) + .thenReturn(totalAmount.subtract(outdatedResidualAmount)) + .thenReturn(totalAmount.subtract(actualResidualAmount)) + .thenReturn(totalAmount.subtract(actualResidualAmount)); + + when(multiPathPaymentObserver.isSettled(PAYMENT_HASH)) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(true); + + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isTrue(); + softly.assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(3); + softly.assertThat(paymentStatus.getMessages().stream().map(InstantWithString::string)).containsExactly( + "Initializing payment " + PAYMENT_HASH, + "#1: Sending 123 (0.0% = 0 in flight)", + "Sending to route #1: " + MPP_1_ROUTE_1, + "Sending to route #2: " + MPP_1_ROUTE_2, + "#2: Sending 1 (99.1% = 122 in flight)", + "Residual amount changed from 1 to 2 during route computation, restarting.", + "#3: Sending 2 (98.3% = 121 in flight)", + "Sending to route #3: 50: " + MPP_2_ROUTE_1, + "Settled" + ); + softly.assertAll(); + + InOrder inOrder = Mockito.inOrder(multiPathPaymentSplitter); + inOrder.verify(multiPathPaymentSplitter).getMultiPathPaymentTo(any(), eq(totalAmount), anyInt()); + inOrder.verify(multiPathPaymentSplitter).getMultiPathPaymentTo(any(), eq(outdatedResidualAmount), anyInt()); + inOrder.verify(multiPathPaymentSplitter).getMultiPathPaymentTo(any(), eq(actualResidualAmount), anyInt()); + + verify(grpcSendToRoute, times(3)).sendToRoute(any(), any(), any()); + } + + @Test + void aborts_on_failure_from_destination_node() { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(DECODED_PAYMENT_REQUEST.amount()), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT); + + when(multiPathPaymentObserver.getFailureCode(PAYMENT_HASH)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(FailureCode.MPP_TIMEOUT)); + + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + verify(multiPathPaymentSplitter, times(1)).getMultiPathPaymentTo(any(), any(), anyInt()); + assertThat(paymentStatus.isFailure()).isTrue(); + } + + @Test + void aborts_if_no_route_can_be_computed() { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(DECODED_PAYMENT_REQUEST.amount()), anyInt())) + .thenReturn(MultiPathPayment.FAILURE); + paymentLoop.start(DECODED_PAYMENT_REQUEST, FEE_RATE_WEIGHT, paymentStatus); + assertThat(paymentStatus.isFailure()).isTrue(); + } + + // CPD-OFF + private void mockSuccessOnFirstAttempt() { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), any(), anyInt())).thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentObserver.isSettled(PAYMENT_HASH)) + .thenReturn(false) + .thenReturn(false) + .thenReturn(true); + when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)) + .thenReturn(Coins.NONE) + .thenReturn(Coins.NONE) + .thenReturn(DECODED_PAYMENT_REQUEST.amount()) + .thenReturn(DECODED_PAYMENT_REQUEST.amount()); + } + + private void mockPartialFailureInFirstAttempt(Coins pendingAfterFirstAttempt) { + when(multiPathPaymentSplitter.getMultiPathPaymentTo(any(), eq(DECODED_PAYMENT_REQUEST.amount()), anyInt())) + .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentObserver.isSettled(PAYMENT_HASH)) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(false) + .thenReturn(true); + when(multiPathPaymentObserver.getInFlight(PAYMENT_HASH)) + .thenReturn(Coins.NONE) + .thenReturn(Coins.NONE) + .thenReturn(pendingAfterFirstAttempt) + .thenReturn(pendingAfterFirstAttempt); + } + // CPD-ON +} diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformationTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformationTest.java index 83466d21..3804703a 100644 --- a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformationTest.java +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentInformationTest.java @@ -3,11 +3,14 @@ package de.cotto.lndmanagej.pickhardtpayments.model; import de.cotto.lndmanagej.model.Coins; import org.junit.jupiter.api.Test; +import java.util.Optional; + +import static de.cotto.lndmanagej.model.FailureCode.PERMANENT_CHANNEL_FAILURE; import static org.assertj.core.api.Assertions.assertThat; class PaymentInformationTest { private static final PaymentInformation PAYMENT_INFORMATION = - new PaymentInformation(Coins.ofSatoshis(123), false, false); + new PaymentInformation(Coins.ofSatoshis(123), false, Optional.empty()); @Test void inFlight() { @@ -21,29 +24,32 @@ class PaymentInformationTest { @Test void failed() { - assertThat(PAYMENT_INFORMATION.failed()).isFalse(); + assertThat(PAYMENT_INFORMATION.failureCode()).isEmpty(); } @Test void withAdditionalInFlight() { assertThat(PAYMENT_INFORMATION.withAdditionalInFlight(Coins.ofSatoshis(77))) - .isEqualTo(new PaymentInformation(Coins.ofSatoshis(200), false, false)); + .isEqualTo(new PaymentInformation(Coins.ofSatoshis(200), false, Optional.empty())); } @Test void withIsSettled() { assertThat(PAYMENT_INFORMATION.withIsSettled()) - .isEqualTo(new PaymentInformation(Coins.ofSatoshis(123), true, false)); + .isEqualTo(new PaymentInformation(Coins.ofSatoshis(123), true, Optional.empty())); } @Test void withIsFailed() { - assertThat(PAYMENT_INFORMATION.withIsFailed()) - .isEqualTo(new PaymentInformation(Coins.ofSatoshis(123), false, true)); + assertThat(PAYMENT_INFORMATION.withFailureCode(PERMANENT_CHANNEL_FAILURE)).isEqualTo(new PaymentInformation( + Coins.ofSatoshis(123), + false, + Optional.of(PERMANENT_CHANNEL_FAILURE) + )); } @Test void default_values() { - assertThat(PaymentInformation.DEFAULT).isEqualTo(new PaymentInformation(Coins.NONE, false, false)); + assertThat(PaymentInformation.DEFAULT).isEqualTo(new PaymentInformation(Coins.NONE, false, Optional.empty())); } } diff --git a/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatusTest.java b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatusTest.java new file mode 100644 index 00000000..dfb61355 --- /dev/null +++ b/pickhardt-payments/src/test/java/de/cotto/lndmanagej/pickhardtpayments/model/PaymentStatusTest.java @@ -0,0 +1,201 @@ +package de.cotto.lndmanagej.pickhardtpayments.model; + +import de.cotto.lndmanagej.model.Coins; +import de.cotto.lndmanagej.model.EdgeWithLiquidityInformation; +import de.cotto.lndmanagej.model.FailureCode; +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.model.Route; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static de.cotto.lndmanagej.model.EdgeFixtures.EDGE; +import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE; +import static de.cotto.lndmanagej.model.RouteFixtures.ROUTE_2; +import static org.assertj.core.api.Assertions.assertThat; + +class PaymentStatusTest { + private static final HexString PAYMENT_HASH = new HexString("AABBCC001122"); + private static final String ROUTE_PREFIX = "Sending to route #1: 100: "; + private final PaymentStatus paymentStatus = new PaymentStatus(PAYMENT_HASH); + + @Nested + class Initial { + @Test + void isSuccess() { + assertThat(paymentStatus.isSuccess()).isFalse(); + } + + @Test + void isFailure() { + assertThat(paymentStatus.isFailure()).isFalse(); + } + + @Test + void isPending() { + assertThat(paymentStatus.isPending()).isTrue(); + } + + @Test + void getMessages() { + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .containsExactly("Initializing payment " + PAYMENT_HASH); + } + + @Test + void getNumberOfAttemptedRoutes() { + assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(0); + } + } + + @Nested + class Sending { + @Test + void increases_route_counter() { + paymentStatus.sending(ROUTE); + assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(1); + } + + @Test + void increases_route_counter_again() { + paymentStatus.sending(ROUTE); + paymentStatus.sending(ROUTE_2); + assertThat(paymentStatus.getNumberOfAttemptedRoutes()).isEqualTo(2); + } + + @Test + void adds_message() { + paymentStatus.sending(ROUTE); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)).contains( + ROUTE_PREFIX + + "[712345x123x1 (cap 21,000,000), " + + "799999x456x3 (cap 21,000,000), " + + "799999x456x5 (cap 21,000,000)], " + + "400ppm, probability 0.9999857143544217" + ); + } + + @Test + void adds_message_with_min() { + sendSingleEdge(EdgeWithLiquidityInformation.forLowerBound(EDGE, Coins.ofSatoshis(10))); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)).contains( + ROUTE_PREFIX + "[712345x123x1 (min 10, cap 21,000,000)], 0ppm, probability 0.9999957142838776" + ); + } + + @Test + void adds_message_with_max() { + sendSingleEdge(EdgeWithLiquidityInformation.forUpperBound(EDGE, Coins.ofSatoshis(11))); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)).contains( + ROUTE_PREFIX + "[712345x123x1 (max 11, cap 21,000,000)], 0ppm, probability 0.0" + ); + } + + @Test + void adds_message_with_known() { + sendSingleEdge(EdgeWithLiquidityInformation.forKnownLiquidity(EDGE, Coins.ofSatoshis(12))); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)).contains( + ROUTE_PREFIX + "[712345x123x1 (known 12)], 0ppm, probability 0.0" + ); + } + + @Test + void adds_message_for_second_route() { + paymentStatus.sending(ROUTE); + paymentStatus.sending(ROUTE_2); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .contains("Sending to route #2: 200: " + + "[799999x456x2 (cap 21,000,000), " + + "799999x456x3 (cap 21,000,000)], " + + "200ppm, probability 0.9999809524725624"); + } + + private void sendSingleEdge(EdgeWithLiquidityInformation edge) { + paymentStatus.sending(new Route(List.of(edge), Coins.ofSatoshis(100))); + } + } + + @Test + void info() { + paymentStatus.info("hallo!"); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .contains("hallo!"); + } + + @Nested + class Failed { + @Test + void adds_message() { + paymentStatus.failed("hallo :("); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .contains("hallo :("); + } + + @Test + void marks_as_failed() { + paymentStatus.failed("hallo :("); + assertFailure(); + } + + @Test + void marks_as_failed_from_failure_code() { + paymentStatus.failed(FailureCode.PERMANENT_CHANNEL_FAILURE); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .contains("Failed with PERMANENT_CHANNEL_FAILURE"); + } + + @Test + void message_from_failure_code() { + paymentStatus.failed(FailureCode.PERMANENT_CHANNEL_FAILURE); + assertFailure(); + } + + private void assertFailure() { + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isFailure()).isTrue(); + softly.assertThat(paymentStatus.isSuccess()).isFalse(); + softly.assertThat(paymentStatus.isPending()).isFalse(); + softly.assertAll(); + } + } + + @Nested + class Settled { + @Test + void adds_message() { + paymentStatus.settled(); + assertThat(paymentStatus.getMessages().stream().map(PaymentStatus.InstantWithString::string)) + .contains("Settled"); + } + + @Test + void marks_as_settled() { + paymentStatus.settled(); + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(paymentStatus.isSuccess()).isTrue(); + softly.assertThat(paymentStatus.isFailure()).isFalse(); + softly.assertThat(paymentStatus.isPending()).isFalse(); + softly.assertAll(); + } + } + + @Nested + @SuppressWarnings("ClassCanBeStatic") + class InstantWithStringTest { + @Test + void string() { + PaymentStatus.InstantWithString instantWithString = new PaymentStatus.InstantWithString("x"); + assertThat(instantWithString.string()).isEqualTo("x"); + } + + @Test + void just_string_adds_timestamp() { + PaymentStatus.InstantWithString instantWithString = new PaymentStatus.InstantWithString("x"); + assertThat(instantWithString.instant()) + .isBetween(Instant.now().minusSeconds(1), Instant.now()); + } + } +} diff --git a/web/build.gradle b/web/build.gradle index 1f999b7d..bbb62fa9 100644 --- a/web/build.gradle +++ b/web/build.gradle @@ -15,3 +15,7 @@ dependencies { integrationTestImplementation testFixtures(project(':model')) integrationTestImplementation testFixtures(project(':pickhardt-payments')) } + +pitest { + testStrengthThreshold = 99 +} diff --git a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PaymentStatusStreamIT.java b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PaymentStatusStreamIT.java new file mode 100644 index 00000000..d4a1d28e --- /dev/null +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PaymentStatusStreamIT.java @@ -0,0 +1,49 @@ +package de.cotto.lndmanagej.controller; + +import de.cotto.lndmanagej.model.HexString; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +class PaymentStatusStreamIT { + private static final String NEWLINE = "\n"; + + private final PaymentStatusStream paymentStatusStream = new PaymentStatusStream(); + + private final Executor executor = Executors.newCachedThreadPool(); + + @Test + void convertsToJsonDelimitedByNewlines() throws IOException { + PaymentStatus paymentStatus = new PaymentStatus(new HexString("1234567890AABBCC")); + executor.execute(() -> { + sleep(); + paymentStatus.info("info1"); + sleep(); + paymentStatus.settled(); + }); + StreamingResponseBody streamingResponseBody = paymentStatusStream.getFor(paymentStatus); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + streamingResponseBody.writeTo(outputStream); + String line1 = "\\{\"timestamp\":\".*\",\"message\":\"Initializing payment 1234567890aabbcc\"}"; + String line2 = "\\{\"timestamp\":\".*\",\"message\":\"info1\"}"; + String line3 = "\\{\"timestamp\":\".*\",\"message\":\"Settled\"}"; + assertThat(outputStream.toString(StandardCharsets.UTF_8)) + .matches(line1 + NEWLINE + line2 + NEWLINE + line3 + NEWLINE); + } + + private void sleep() { + try { + Thread.sleep(110); + } catch (InterruptedException ignored) { + // ignored + } + } +} 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 df6b2af5..d4c6cb69 100644 --- a/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java +++ b/web/src/integrationTest/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerIT.java @@ -3,8 +3,10 @@ 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.model.HexString; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSender; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSplitter; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -29,7 +31,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SuppressWarnings("CPD-START") -@Import(ObjectMapperConfiguration.class) +@Import({ObjectMapperConfiguration.class, PaymentStatusStream.class}) @WebMvcTest(controllers = PickhardtPaymentsController.class) class PickhardtPaymentsControllerIT { private static final String PREFIX = "/beta/pickhardt-payments"; @@ -47,19 +49,21 @@ class PickhardtPaymentsControllerIT { @SuppressWarnings("unused") private ChannelIdResolver channelIdResolver; + private final PaymentStatus paymentStatus = new PaymentStatus(HexString.EMPTY); + @Test void payPaymentRequest() throws Exception { when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, DEFAULT_FEE_RATE_WEIGHT)) - .thenReturn(MULTI_PATH_PAYMENT); + .thenReturn(paymentStatus); String url = "%s/pay-payment-request/%s".formatted(PREFIX, PAYMENT_REQUEST); - mockMvc.perform(get(url)).andExpect(status().isOk()); + mockMvc.perform(get(url)) + .andExpect(status().isOk()); } @Test void payPaymentRequest_with_fee_rate_weight() throws Exception { int feeRateWeight = 987; - when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, feeRateWeight)) - .thenReturn(MULTI_PATH_PAYMENT); + when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, feeRateWeight)).thenReturn(paymentStatus); String url = "%s/pay-payment-request/%s/fee-rate-weight/%d".formatted(PREFIX, PAYMENT_REQUEST, feeRateWeight); mockMvc.perform(get(url)).andExpect(status().isOk()); } diff --git a/web/src/main/java/de/cotto/lndmanagej/controller/PaymentStatusStream.java b/web/src/main/java/de/cotto/lndmanagej/controller/PaymentStatusStream.java new file mode 100644 index 00000000..8ea887d1 --- /dev/null +++ b/web/src/main/java/de/cotto/lndmanagej/controller/PaymentStatusStream.java @@ -0,0 +1,58 @@ +package de.cotto.lndmanagej.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus.InstantWithString; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +public class PaymentStatusStream { + private final ObjectMapper objectMapper; + + public PaymentStatusStream() { + this.objectMapper = new ObjectMapper(); + } + + public StreamingResponseBody getFor(PaymentStatus paymentStatus) { + return response -> { + int seenMessages = 0; + do { + List messages = paymentStatus.getMessages(); + int oldMessages = seenMessages; + seenMessages = messages.size(); + for (int i = oldMessages; i < messages.size(); i++) { + InstantWithString instantWithString = messages.get(i); + Object messageToWrite = new Message( + instantWithString.instant().toString(), + instantWithString.string() + ); + String json = objectMapper.writeValueAsString(messageToWrite); + response.write(json.getBytes(StandardCharsets.UTF_8)); + response.write('\n'); + response.flush(); + } + } while (notDone(paymentStatus, seenMessages)); + }; + } + + private boolean notDone(PaymentStatus paymentStatus, int seenMessages) { + sleep(); + return paymentStatus.getMessages().size() > seenMessages || paymentStatus.isPending(); + } + + private void sleep() { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + // ignored + } + } + + @SuppressWarnings("UnusedVariable") + private record Message(String timestamp, String message) { + } +} 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 64cd26f6..ec2cae44 100644 --- a/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java +++ b/web/src/main/java/de/cotto/lndmanagej/controller/PickhardtPaymentsController.java @@ -7,38 +7,51 @@ import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSender; import de.cotto.lndmanagej.pickhardtpayments.MultiPathPaymentSplitter; import de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPayment; +import de.cotto.lndmanagej.pickhardtpayments.model.PaymentStatus; +import org.springframework.http.ResponseEntity; 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; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import static de.cotto.lndmanagej.pickhardtpayments.PickhardtPaymentsConfiguration.DEFAULT_FEE_RATE_WEIGHT; +import static org.springframework.http.MediaType.APPLICATION_NDJSON; @RestController @RequestMapping("/beta/pickhardt-payments/") public class PickhardtPaymentsController { private final MultiPathPaymentSplitter multiPathPaymentSplitter; private final MultiPathPaymentSender multiPathPaymentSender; + private final PaymentStatusStream paymentStatusStream; public PickhardtPaymentsController( MultiPathPaymentSplitter multiPathPaymentSplitter, - MultiPathPaymentSender multiPathPaymentSender + MultiPathPaymentSender multiPathPaymentSender, + PaymentStatusStream paymentStatusStream ) { this.multiPathPaymentSplitter = multiPathPaymentSplitter; this.multiPathPaymentSender = multiPathPaymentSender; + this.paymentStatusStream = paymentStatusStream; } @Timed @GetMapping("/pay-payment-request/{paymentRequest}") - public MultiPathPaymentDto payPaymentRequest(@PathVariable String paymentRequest) { + public ResponseEntity payPaymentRequest(@PathVariable String paymentRequest) { return payPaymentRequest(paymentRequest, DEFAULT_FEE_RATE_WEIGHT); } @Timed @GetMapping("/pay-payment-request/{paymentRequest}/fee-rate-weight/{feeRateWeight}") - public MultiPathPaymentDto payPaymentRequest(@PathVariable String paymentRequest, @PathVariable int feeRateWeight) { - MultiPathPayment multiPathPayment = multiPathPaymentSender.payPaymentRequest(paymentRequest, feeRateWeight); - return MultiPathPaymentDto.fromModel(multiPathPayment); + public ResponseEntity payPaymentRequest( + @PathVariable String paymentRequest, + @PathVariable int feeRateWeight + ) { + PaymentStatus paymentStatus = multiPathPaymentSender.payPaymentRequest(paymentRequest, feeRateWeight); + StreamingResponseBody streamingResponseBody = paymentStatusStream.getFor(paymentStatus); + return ResponseEntity.ok() + .contentType(APPLICATION_NDJSON) + .body(streamingResponseBody); } @Timed 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 bfac702c..67ba39da 100644 --- a/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java +++ b/web/src/test/java/de/cotto/lndmanagej/controller/PickhardtPaymentsControllerTest.java @@ -2,25 +2,34 @@ package de.cotto.lndmanagej.controller; import de.cotto.lndmanagej.controller.dto.MultiPathPaymentDto; 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.model.PaymentStatus; +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 org.springframework.http.HttpStatus; + +import java.nio.charset.StandardCharsets; 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 org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PickhardtPaymentsControllerTest { private static final String PAYMENT_REQUEST = "xxx"; + private static final String STREAM_RESPONSE = "beep beep boop!"; @InjectMocks private PickhardtPaymentsController controller; @@ -30,20 +39,30 @@ class PickhardtPaymentsControllerTest { @Mock private MultiPathPaymentSender multiPathPaymentSender; + @Mock + private PaymentStatusStream paymentStatusStream; + + private final PaymentStatus paymentStatus = new PaymentStatus(HexString.EMPTY); + + @BeforeEach + void setUp() { + lenient().when(paymentStatusStream.getFor(any())) + .thenReturn(outputStream -> outputStream.write(STREAM_RESPONSE.getBytes(StandardCharsets.UTF_8))); + } + @Test void payPaymentRequest() { when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, DEFAULT_FEE_RATE_WEIGHT)) - .thenReturn(MULTI_PATH_PAYMENT); - assertThat(controller.payPaymentRequest(PAYMENT_REQUEST)) - .isEqualTo(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)); + .thenReturn(paymentStatus); + assertThat(controller.payPaymentRequest(PAYMENT_REQUEST).getStatusCode()) + .isEqualTo(HttpStatus.OK); } @Test void payPaymentRequest_with_fee_rate_weight() { - when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, 456)) - .thenReturn(MULTI_PATH_PAYMENT); - assertThat(controller.payPaymentRequest(PAYMENT_REQUEST, 456)) - .isEqualTo(MultiPathPaymentDto.fromModel(MULTI_PATH_PAYMENT)); + when(multiPathPaymentSender.payPaymentRequest(PAYMENT_REQUEST, 456)).thenReturn(paymentStatus); + assertThat(controller.payPaymentRequest(PAYMENT_REQUEST, 456).getStatusCode()) + .isEqualTo(HttpStatus.OK); } @Test