add first hop filter to top-up

fixes #86
This commit is contained in:
Carsten Otto
2023-08-14 18:21:45 +02:00
parent 0b25ba9bcc
commit e09fe3f1db
12 changed files with 223 additions and 32 deletions

View File

@@ -122,10 +122,15 @@ The response shows a somewhat readable representation of the payment progress, i
node Z and node A, the whole payment fails (it is not attempted). node Z and node A, the whole payment fails (it is not attempted).
* Invoices (payment requests) created for top-up payments expiry after 30 minutes. This value can be configured as * Invoices (payment requests) created for top-up payments expiry after 30 minutes. This value can be configured as
`expiry_seconds=`. `expiry_seconds=`.
* HTTP `GET`: `/api/payments/top-up/{pubkey}/amount/{amount}/via/{pubkey}`
* As before, but the pubkey specified after "via" is used for the first hop. As such, this can be used to reduce
outbound liquidity to the "via" peer.
* HTTP `POST`: `/api/payments/top-up/{pubkey}/amount/{amount}` * HTTP `POST`: `/api/payments/top-up/{pubkey}/amount/{amount}`
* allows you to lower the fee rate limit (values higher than the computed fee rate limit are ignored) * allows you to lower the fee rate limit (values higher than the computed fee rate limit are ignored)
* allows you to specify a different fee rate weight * allows you to specify a different fee rate weight
* The value provided as `ignoreFeesForOwnChannels` is ignored, for top-up such fees are never ignored * The value provided as `ignoreFeesForOwnChannels` is ignored, for top-up such fees are never ignored
* HTTP `POST`: `/api/payments/top-up/{pubkey}/amount/{amount}/via/{pubkey}`
* As above, see corresponding GET endpoint
The threshold, i.e. the minimum difference between the current local balance and the requested amount, defaults to The threshold, i.e. the minimum difference between the current local balance and the requested amount, defaults to
10,000sat. You can configure this value by setting `threshold_sat=` in the configuration file. 10,000sat. You can configure this value by setting `threshold_sat=` in the configuration file.

View File

@@ -127,6 +127,9 @@ public class EdgeComputation {
if (feeRate >= feeRateLimit) { if (feeRate >= feeRateLimit) {
return true; return true;
} }
if (isEdgeToUnwantedFirstHop(channelEdge, paymentOptions, pubkey)) {
return true;
}
if (isIncomingEdge(channelEdge, pubkey)) { if (isIncomingEdge(channelEdge, pubkey)) {
return false; return false;
} }
@@ -137,6 +140,22 @@ public class EdgeComputation {
return feeRate >= feeRateLimitFirstHops; return feeRate >= feeRateLimitFirstHops;
} }
private boolean isEdgeToUnwantedFirstHop(
DirectedChannelEdge channelEdge,
PaymentOptions paymentOptions,
Pubkey pubkey
) {
boolean isOutgoingEdge = pubkey.equals(channelEdge.source());
if (!isOutgoingEdge) {
return false;
}
Pubkey peerForFirstHop = paymentOptions.peerForFirstHop().orElse(null);
if (peerForFirstHop == null) {
return false;
}
return !peerForFirstHop.equals(channelEdge.target());
}
private boolean isIncomingEdge(DirectedChannelEdge channelEdge, Pubkey ownPubkey) { private boolean isIncomingEdge(DirectedChannelEdge channelEdge, Pubkey ownPubkey) {
return ownPubkey.equals(channelEdge.target()); return ownPubkey.equals(channelEdge.target());
} }

View File

@@ -51,7 +51,12 @@ public class TopUpService {
this.policyService = policyService; this.policyService = policyService;
} }
public PaymentStatus topUp(Pubkey pubkey, Coins amount, PaymentOptions paymentOptionsFromRequest) { public PaymentStatus topUp(
Pubkey pubkey,
Optional<Pubkey> peerForFirstHop,
Coins amount,
PaymentOptions paymentOptionsFromRequest
) {
if (noChannelWith(pubkey)) { if (noChannelWith(pubkey)) {
String alias = nodeService.getAlias(pubkey); String alias = nodeService.getAlias(pubkey);
return PaymentStatus.createFailure("No channel with %s (%s)".formatted(pubkey, alias)); return PaymentStatus.createFailure("No channel with %s (%s)".formatted(pubkey, alias));
@@ -71,10 +76,15 @@ public class TopUpService {
return PaymentStatus.createFailure(reason); return PaymentStatus.createFailure(reason);
} }
return sendPayment(pubkey, topUpAmount, paymentOptionsFromRequest); return sendPayment(pubkey, peerForFirstHop, topUpAmount, paymentOptionsFromRequest);
} }
private PaymentStatus sendPayment(Pubkey pubkey, Coins topUpAmount, PaymentOptions paymentOptionsFromRequest) { private PaymentStatus sendPayment(
Pubkey pubkey,
Optional<Pubkey> peerForFirstHop,
Coins topUpAmount,
PaymentOptions paymentOptions
) {
long ourFeeRate = policyService.getMinimumFeeRateTo(pubkey).orElse(0L); long ourFeeRate = policyService.getMinimumFeeRateTo(pubkey).orElse(0L);
long peerFeeRate = policyService.getMinimumFeeRateFrom(pubkey).orElse(0L); long peerFeeRate = policyService.getMinimumFeeRateFrom(pubkey).orElse(0L);
if (peerFeeRate >= ourFeeRate) { if (peerFeeRate >= ourFeeRate) {
@@ -88,8 +98,10 @@ public class TopUpService {
String alias = nodeService.getAlias(pubkey); String alias = nodeService.getAlias(pubkey);
return PaymentStatus.createFailure("Unable to create payment request (%s, %s)".formatted(pubkey, alias)); return PaymentStatus.createFailure("Unable to create payment request (%s, %s)".formatted(pubkey, alias));
} }
PaymentOptions paymentOptions = getPaymentOptions(pubkey, ourFeeRate, peerFeeRate, paymentOptionsFromRequest); return multiPathPaymentSender.payPaymentRequest(
return multiPathPaymentSender.payPaymentRequest(paymentRequest, paymentOptions); paymentRequest,
getPaymentOptions(pubkey, peerForFirstHop, ourFeeRate, peerFeeRate, paymentOptions)
);
} }
private Coins getThreshold() { private Coins getThreshold() {
@@ -117,16 +129,25 @@ public class TopUpService {
private PaymentOptions getPaymentOptions( private PaymentOptions getPaymentOptions(
Pubkey pubkey, Pubkey pubkey,
Optional<Pubkey> peerForFirstHop,
long ourFeeRate, long ourFeeRate,
long peerFeeRate, long peerFeeRate,
PaymentOptions paymentOptionsFromRequest PaymentOptions paymentOptions
) { ) {
long feeRateLimit = Math.min(ourFeeRate, paymentOptionsFromRequest.feeRateLimit().orElse(Long.MAX_VALUE)); int feeRateWeight = paymentOptions.feeRateWeight().orElse(5);
return PaymentOptions.forTopUp( long feeRateLimit = Math.min(ourFeeRate, paymentOptions.feeRateLimit().orElse(Long.MAX_VALUE));
paymentOptionsFromRequest.feeRateWeight().orElse(5),
return peerForFirstHop.map(value -> PaymentOptions.forTopUp(
feeRateWeight,
feeRateLimit,
peerFeeRate,
pubkey,
value
)).orElseGet(() -> PaymentOptions.forTopUp(
feeRateWeight,
feeRateLimit, feeRateLimit,
peerFeeRate, peerFeeRate,
pubkey pubkey
); ));
} }
} }

View File

@@ -9,7 +9,8 @@ public record PaymentOptions(
Optional<Long> feeRateLimit, Optional<Long> feeRateLimit,
Optional<Long> feeRateLimitExceptIncomingHops, Optional<Long> feeRateLimitExceptIncomingHops,
boolean ignoreFeesForOwnChannels, boolean ignoreFeesForOwnChannels,
Optional<Pubkey> peer Optional<Pubkey> peer,
Optional<Pubkey> peerForFirstHop
) { ) {
public static final PaymentOptions DEFAULT_PAYMENT_OPTIONS = forFeeRateWeight(0); public static final PaymentOptions DEFAULT_PAYMENT_OPTIONS = forFeeRateWeight(0);
@@ -19,6 +20,7 @@ public record PaymentOptions(
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
} }
@@ -29,6 +31,7 @@ public record PaymentOptions(
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
} }
@@ -39,7 +42,25 @@ public record PaymentOptions(
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.of(Math.max(0, feeRateLimit - peerFeeRate)), Optional.of(Math.max(0, feeRateLimit - peerFeeRate)),
false, false,
Optional.of(peer) Optional.of(peer),
Optional.empty()
);
}
public static PaymentOptions forTopUp(
int feeRateWeight,
long feeRateLimit,
long peerFeeRate,
Pubkey peer,
Pubkey peerForFirstHop
) {
return new PaymentOptions(
Optional.of(feeRateWeight),
Optional.of(feeRateLimit),
Optional.of(Math.max(0, feeRateLimit - peerFeeRate)),
false,
Optional.of(peer),
Optional.of(peerForFirstHop)
); );
} }
} }

View File

@@ -13,6 +13,7 @@ import de.cotto.lndmanagej.model.LocalOpenChannel;
import de.cotto.lndmanagej.model.LocalOpenChannelFixtures; import de.cotto.lndmanagej.model.LocalOpenChannelFixtures;
import de.cotto.lndmanagej.model.Policy; import de.cotto.lndmanagej.model.Policy;
import de.cotto.lndmanagej.model.Pubkey; import de.cotto.lndmanagej.model.Pubkey;
import de.cotto.lndmanagej.pickhardtpayments.model.EdgesWithLiquidityInformation;
import de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions; import de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions;
import de.cotto.lndmanagej.service.BalanceService; import de.cotto.lndmanagej.service.BalanceService;
import de.cotto.lndmanagej.service.ChannelService; import de.cotto.lndmanagej.service.ChannelService;
@@ -44,6 +45,7 @@ import static de.cotto.lndmanagej.model.PolicyFixtures.POLICY_DISABLED;
import static de.cotto.lndmanagej.model.PolicyFixtures.POLICY_WITH_BASE_FEE; import static de.cotto.lndmanagej.model.PolicyFixtures.POLICY_WITH_BASE_FEE;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_3;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_4; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_4;
import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS; import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -312,6 +314,22 @@ class EdgeComputationTest {
.contains(EdgeWithLiquidityInformation.forUpperBound(EDGE, EDGE.capacity())); .contains(EdgeWithLiquidityInformation.forUpperBound(EDGE, EDGE.capacity()));
} }
@Test
void ignores_other_peers_if_peer_for_first_hop_is_set() {
Pubkey ownNode = PUBKEY_4;
Pubkey peerForFirstHop = PUBKEY_3;
PaymentOptions paymentOptions =
PaymentOptions.forTopUp(FEE_RATE_WEIGHT, 999, 0, PUBKEY_2, peerForFirstHop);
DirectedChannelEdge edge1 = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, ownNode, peerForFirstHop, POLICY_1);
DirectedChannelEdge edge2 = new DirectedChannelEdge(CHANNEL_ID, CAPACITY, ownNode, PUBKEY, POLICY_1);
when(grpcGraph.getChannelEdges()).thenReturn(Optional.of(Set.of(edge1, edge2)));
EdgesWithLiquidityInformation edges = edgeComputation.getEdges(paymentOptions, MAX_TIME_LOCK_DELTA);
assertThat(edges.edges().stream().map(EdgeWithLiquidityInformation::endNode)).containsExactly(peerForFirstHop);
}
@Test @Test
void getEdgeWithLiquidityInformation_default() { void getEdgeWithLiquidityInformation_default() {
when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4); when(grpcGetInfo.getPubkey()).thenReturn(PUBKEY_4);

View File

@@ -178,6 +178,7 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -185,7 +186,8 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.of(feeRateLimit - peerFeeRate), Optional.of(feeRateLimit - peerFeeRate),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -203,6 +205,7 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -210,7 +213,8 @@ class TopUpServiceTest {
Optional.of(ourFeeRate), Optional.of(ourFeeRate),
Optional.of(ourFeeRate - peerFeeRate), Optional.of(ourFeeRate - peerFeeRate),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -228,6 +232,7 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -235,7 +240,8 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.of(feeRateLimit - peerFeeRate), Optional.of(feeRateLimit - peerFeeRate),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -253,6 +259,7 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -260,7 +267,8 @@ class TopUpServiceTest {
Optional.of(ourFeeRate), Optional.of(ourFeeRate),
Optional.of(ourFeeRate - peerFeeRate), Optional.of(ourFeeRate - peerFeeRate),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -278,6 +286,7 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -285,7 +294,8 @@ class TopUpServiceTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.of(0L), Optional.of(0L),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -299,6 +309,7 @@ class TopUpServiceTest {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PaymentOptions expected = new PaymentOptions( PaymentOptions expected = new PaymentOptions(
@@ -306,7 +317,8 @@ class TopUpServiceTest {
Optional.of(OUR_FEE_RATE), Optional.of(OUR_FEE_RATE),
Optional.of(OUR_FEE_RATE - PEER_FEE_RATE), Optional.of(OUR_FEE_RATE - PEER_FEE_RATE),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
); );
when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE); when(balanceService.getAvailableLocalBalanceForPeer(PUBKEY)).thenReturn(Coins.NONE);
assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected); assertTopUp(AMOUNT, DEFAULT_EXPIRY, given, expected);
@@ -339,6 +351,7 @@ class TopUpServiceTest {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
assertTopUp(expectedTopUpAmount, expiry, emptyPaymentOptions, paymentOptions); assertTopUp(expectedTopUpAmount, expiry, emptyPaymentOptions, paymentOptions);
@@ -350,14 +363,14 @@ class TopUpServiceTest {
PaymentOptions givenPaymentOptions, PaymentOptions givenPaymentOptions,
PaymentOptions expectedPaymentOptions PaymentOptions expectedPaymentOptions
) { ) {
PaymentStatus paymentStatus = topUpService.topUp(PUBKEY, AMOUNT, givenPaymentOptions); PaymentStatus paymentStatus = topUpService.topUp(PUBKEY, Optional.empty(), AMOUNT, givenPaymentOptions);
verify(grpcInvoices).createPaymentRequest(expectedTopUpAmount, DESCRIPTION, expiry); verify(grpcInvoices).createPaymentRequest(expectedTopUpAmount, DESCRIPTION, expiry);
verify(multiPathPaymentSender).payPaymentRequest(DECODED_PAYMENT_REQUEST, expectedPaymentOptions); verify(multiPathPaymentSender).payPaymentRequest(DECODED_PAYMENT_REQUEST, expectedPaymentOptions);
assertThat(paymentStatus.isPending()).isTrue(); assertThat(paymentStatus.isPending()).isTrue();
} }
private void assertFailure(String reason) { private void assertFailure(String reason) {
PaymentStatus paymentStatus = topUpService.topUp(PUBKEY, AMOUNT, DEFAULT_PAYMENT_OPTIONS); PaymentStatus paymentStatus = topUpService.topUp(PUBKEY, Optional.empty(), AMOUNT, DEFAULT_PAYMENT_OPTIONS);
assertThat(paymentStatus.isFailure()).isTrue(); assertThat(paymentStatus.isFailure()).isTrue();
assertThat(readAll(paymentStatus)).map(InstantWithString::string).containsExactly(reason); assertThat(readAll(paymentStatus)).map(InstantWithString::string).containsExactly(reason);
verifyNoInteractions(multiPathPaymentSender); verifyNoInteractions(multiPathPaymentSender);

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import java.util.Optional; import java.util.Optional;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY;
import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2;
import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS; import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -19,6 +20,7 @@ class PaymentOptionsTest {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
)); ));
} }
@@ -30,6 +32,7 @@ class PaymentOptionsTest {
Optional.of(123L), Optional.of(123L),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
)); ));
} }
@@ -41,7 +44,20 @@ class PaymentOptionsTest {
Optional.of(123L), Optional.of(123L),
Optional.of(23L), Optional.of(23L),
false, false,
Optional.of(PUBKEY) Optional.of(PUBKEY),
Optional.empty()
));
}
@Test
void forTopUp_with_peer_for_first_hop() {
assertThat(PaymentOptions.forTopUp(FEE_RATE_WEIGHT, 123, 100, PUBKEY, PUBKEY_2)).isEqualTo(new PaymentOptions(
Optional.of(FEE_RATE_WEIGHT),
Optional.of(123L),
Optional.of(23L),
false,
Optional.of(PUBKEY),
Optional.of(PUBKEY_2)
)); ));
} }

View File

@@ -48,6 +48,7 @@ class PaymentsControllerIT {
Optional.of(999L), Optional.of(999L),
Optional.empty(), Optional.empty(),
false, false,
Optional.empty(),
Optional.empty() Optional.empty()
); );
private static final int FINAL_CLTV_EXPIRY = 0; private static final int FINAL_CLTV_EXPIRY = 0;
@@ -181,29 +182,55 @@ class PaymentsControllerIT {
@Test @Test
void topUp() { void topUp() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
PaymentOptions emptyPaymentOptions = new PaymentOptions( PaymentOptions emptyPaymentOptions = new PaymentOptions(
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
webTestClient.get().uri(url).exchange().expectStatus().isOk(); webTestClient.get().uri(url).exchange().expectStatus().isOk();
verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123), emptyPaymentOptions); verify(topUpService).topUp(PUBKEY, Optional.empty(), Coins.ofSatoshis(123), emptyPaymentOptions);
}
@Test
void topUp_with_peer_for_first_hop() {
when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
PaymentOptions emptyPaymentOptions = new PaymentOptions(
Optional.empty(),
Optional.empty(),
Optional.empty(),
true,
Optional.empty(),
Optional.empty()
);
String uri = "%s/top-up/%s/amount/%s/via/%s".formatted(PREFIX, PUBKEY, "123", PUBKEY_2);
webTestClient.get().uri(uri).exchange().expectStatus().isOk();
verify(topUpService).topUp(PUBKEY, Optional.of(PUBKEY_2), Coins.ofSatoshis(123), emptyPaymentOptions);
} }
@Test @Test
void with_payment_options() { void with_payment_options() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
webTestClient.post().uri(url).contentType(APPLICATION_JSON).bodyValue(DTO_AS_STRING).exchange() webTestClient.post().uri(url).contentType(APPLICATION_JSON).bodyValue(DTO_AS_STRING).exchange()
.expectStatus().isOk(); .expectStatus().isOk();
verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123), PAYMENT_OPTIONS); verify(topUpService).topUp(PUBKEY, Optional.empty(), Coins.ofSatoshis(123), PAYMENT_OPTIONS);
}
@Test
void with_peer_for_first_hop_and_payment_options() {
when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
String uri = "%s/top-up/%s/amount/%s/via/%s".formatted(PREFIX, PUBKEY, "123", PUBKEY_2);
webTestClient.post().uri(uri).contentType(APPLICATION_JSON).bodyValue(DTO_AS_STRING).exchange()
.expectStatus().isOk();
verify(topUpService).topUp(PUBKEY, Optional.of(PUBKEY_2), Coins.ofSatoshis(123), PAYMENT_OPTIONS);
} }
@Test @Test
void no_linebreaks_within_line() { void no_linebreaks_within_line() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
webTestClient.get().uri(url).exchange().expectBody(String.class).value(string -> webTestClient.get().uri(url).exchange().expectBody(String.class).value(string ->
assertThat(string.substring(0, string.length() - 1)).doesNotContain("\n") assertThat(string.substring(0, string.length() - 1)).doesNotContain("\n")
); );
@@ -211,7 +238,7 @@ class PaymentsControllerIT {
@Test @Test
void linebreak_at_end_of_line() { void linebreak_at_end_of_line() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
webTestClient.get().uri(url).exchange().expectBody(String.class).value(string -> webTestClient.get().uri(url).exchange().expectBody(String.class).value(string ->
assertThat(string).endsWith("\n") assertThat(string).endsWith("\n")
); );

View File

@@ -22,6 +22,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.util.Optional;
import static org.springframework.http.MediaType.APPLICATION_NDJSON; import static org.springframework.http.MediaType.APPLICATION_NDJSON;
@RestController @RestController
@@ -119,6 +121,16 @@ public class PaymentsController {
return topUp(pubkey, amount, new PaymentOptionsDto()); return topUp(pubkey, amount, new PaymentOptionsDto());
} }
@Timed
@GetMapping("/top-up/{pubkey}/amount/{amount}/via/{peerForFirstHop}")
public ResponseEntity<Flux<String>> topUp(
@PathVariable Pubkey pubkey,
@PathVariable long amount,
@PathVariable Pubkey peerForFirstHop
) {
return topUp(pubkey, amount, peerForFirstHop, new PaymentOptionsDto());
}
@Timed @Timed
@PostMapping("/top-up/{pubkey}/amount/{amount}") @PostMapping("/top-up/{pubkey}/amount/{amount}")
public ResponseEntity<Flux<String>> topUp( public ResponseEntity<Flux<String>> topUp(
@@ -126,7 +138,25 @@ public class PaymentsController {
@PathVariable long amount, @PathVariable long amount,
@RequestBody PaymentOptionsDto paymentOptionsDto @RequestBody PaymentOptionsDto paymentOptionsDto
) { ) {
PaymentStatus paymentStatus = topUpService.topUp(pubkey, Coins.ofSatoshis(amount), paymentOptionsDto.toModel()); PaymentStatus paymentStatus =
topUpService.topUp(pubkey, Optional.empty(), Coins.ofSatoshis(amount), paymentOptionsDto.toModel());
return toStream(paymentStatus);
}
@Timed
@PostMapping("/top-up/{pubkey}/amount/{amount}/via/{peerForFirstHop}")
public ResponseEntity<Flux<String>> topUp(
@PathVariable Pubkey pubkey,
@PathVariable long amount,
@PathVariable Pubkey peerForFirstHop,
@RequestBody PaymentOptionsDto paymentOptionsDto
) {
PaymentStatus paymentStatus = topUpService.topUp(
pubkey,
Optional.of(peerForFirstHop),
Coins.ofSatoshis(amount),
paymentOptionsDto.toModel()
);
return toStream(paymentStatus); return toStream(paymentStatus);
} }

View File

@@ -31,6 +31,7 @@ public class PaymentOptionsDto {
Optional.ofNullable(feeRateLimit), Optional.ofNullable(feeRateLimit),
Optional.empty(), Optional.empty(),
ignoreFeesForOwnChannels, ignoreFeesForOwnChannels,
Optional.empty(),
Optional.empty() Optional.empty()
); );
} }

View File

@@ -25,6 +25,7 @@ import static de.cotto.lndmanagej.pickhardtpayments.model.MultiPathPaymentFixtur
import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS; import static de.cotto.lndmanagej.pickhardtpayments.model.PaymentOptions.DEFAULT_PAYMENT_OPTIONS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -44,6 +45,7 @@ class PaymentsControllerTest {
Optional.of(999L), Optional.of(999L),
Optional.empty(), Optional.empty(),
false, false,
Optional.empty(),
Optional.empty() Optional.empty()
); );
PAYMENT_OPTIONS_DTO = new PaymentOptionsDto(); PAYMENT_OPTIONS_DTO = new PaymentOptionsDto();
@@ -145,23 +147,39 @@ class PaymentsControllerTest {
@Test @Test
void topUp() { void topUp() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
PaymentOptions emptyPaymentOptions = new PaymentOptions( PaymentOptions emptyPaymentOptions = new PaymentOptions(
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
true, true,
Optional.empty(),
Optional.empty() Optional.empty()
); );
assertThat(controller.topUp(PUBKEY, 123).getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(controller.topUp(PUBKEY, 123).getStatusCode()).isEqualTo(HttpStatus.OK);
verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123), emptyPaymentOptions); verify(topUpService).topUp(PUBKEY, Optional.empty(), Coins.ofSatoshis(123), emptyPaymentOptions);
}
@Test
void topUp_with_peer_for_first_hop() {
when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
assertThat(controller.topUp(PUBKEY, 123, PUBKEY_2).getStatusCode()).isEqualTo(HttpStatus.OK);
verify(topUpService).topUp(any(), eq(Optional.of(PUBKEY_2)), any(), any());
} }
@Test @Test
void topUp_with_payment_options() { void topUp_with_payment_options() {
when(topUpService.topUp(any(), any(), any())).thenReturn(paymentStatus); when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
assertThat(controller.topUp(PUBKEY, 123, PAYMENT_OPTIONS_DTO).getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(controller.topUp(PUBKEY, 123, PAYMENT_OPTIONS_DTO).getStatusCode()).isEqualTo(HttpStatus.OK);
verify(topUpService).topUp(PUBKEY, Coins.ofSatoshis(123), PAYMENT_OPTIONS); verify(topUpService).topUp(PUBKEY, Optional.empty(), Coins.ofSatoshis(123), PAYMENT_OPTIONS);
}
@Test
void topUp_with_peer_for_first_hop_and_payment_options() {
when(topUpService.topUp(any(), any(), any(), any())).thenReturn(paymentStatus);
assertThat(controller.topUp(PUBKEY, 123, PUBKEY_2, PAYMENT_OPTIONS_DTO).getStatusCode())
.isEqualTo(HttpStatus.OK);
verify(topUpService).topUp(PUBKEY, Optional.of(PUBKEY_2), Coins.ofSatoshis(123), PAYMENT_OPTIONS);
} }
@Test @Test

View File

@@ -31,6 +31,7 @@ class PaymentOptionsDtoTest {
Optional.of(feeRateLimit), Optional.of(feeRateLimit),
Optional.empty(), Optional.empty(),
DEFAULT_PAYMENT_OPTIONS.ignoreFeesForOwnChannels(), DEFAULT_PAYMENT_OPTIONS.ignoreFeesForOwnChannels(),
Optional.empty(),
Optional.empty() Optional.empty()
); );
assertThat(dto.toModel()).isEqualTo(expected); assertThat(dto.toModel()).isEqualTo(expected);
@@ -45,6 +46,7 @@ class PaymentOptionsDtoTest {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
false, false,
Optional.empty(),
Optional.empty() Optional.empty()
); );
assertThat(dto.toModel()).isEqualTo(expected); assertThat(dto.toModel()).isEqualTo(expected);