balanceInfo() {
+ ListfundsResponse listfundsResponse = node.listFunds(ListfundsRequest.newBuilder().build());
+
+ MilliSatoshi totalCapacity = listfundsResponse.getChannelsList().stream()
+ .filter(ListfundsChannels::hasAmountMsat)
+ .map(ListfundsChannels::getAmountMsat)
+ .map(it -> new MilliSatoshi(it.getMsat()))
+ .reduce(MoreMilliSatoshi.ZERO, MilliSatoshi::plus);
+
+ // "outbound liquidity": what this node is able to send
+ MilliSatoshi outboundLiquidity = listfundsResponse.getChannelsList().stream()
+ .filter(ListfundsChannels::hasOurAmountMsat)
+ .map(ListfundsChannels::getOurAmountMsat)
+ .map(it -> new MilliSatoshi(it.getMsat()))
+ .reduce(MoreMilliSatoshi.ZERO, MilliSatoshi::plus);
+
+ // "inbound liquidity": what this node is able to receive
+ MilliSatoshi inboundLiquidity = totalCapacity.minus(outboundLiquidity);
+
+ MilliSatoshi onchainFunds = listfundsResponse.getOutputsList().stream()
+ .filter(ListfundsOutputs::hasAmountMsat)
+ .map(ListfundsOutputs::getAmountMsat)
+ .map(it -> new MilliSatoshi(it.getMsat()))
+ .reduce(MoreMilliSatoshi.ZERO, MilliSatoshi::plus);
+
+ return ResponseEntity.ok(BalanceInfo.builder()
+ .channelCount(listfundsResponse.getChannelsCount())
+ .totalCapacityMsat(totalCapacity.getMsat())
+ .outboundMsat(outboundLiquidity.getMsat())
+ .inboundMsat(inboundLiquidity.getMsat())
+ .utxoCount(listfundsResponse.getOutputsCount())
+ .onchainMsat(onchainFunds.getMsat())
+ .build());
+ }
+
+ @Value
+ @Builder
+ static class CreateInvoiceResponse {
+ @NonNull
+ String bolt11;
+
+ @NonNull
+ String paymentHash;
+
+ @NonNull
+ Instant expiresAt;
+
+ @NonNull
+ Long amountMsat;
+ }
+
+
+ @Value
+ @Builder
+ static class BalanceInfo {
+ @NonNull
+ Integer channelCount;
+
+ @NonNull
+ Long totalCapacityMsat;
+
+ @NonNull
+ Long inboundMsat;
+
+ @NonNull
+ Long outboundMsat;
+
+ @NonNull
+ Integer utxoCount;
+
+ @NonNull
+ Long onchainMsat;
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApi.java b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApi.java
new file mode 100644
index 000000000..735ccf3fe
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApi.java
@@ -0,0 +1,28 @@
+package org.tbk.lightning.regtest.example.api;
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.tags.Tags;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+
+/**
+ * A controller that provides an API to interact test user node "Alice".
+ */
+@Slf4j
+@Validated
+@RestController
+@RequestMapping(value = "/api/v1/regtest/test-user-node/alice", produces = MediaType.APPLICATION_JSON_VALUE)
+@Tags({
+ @Tag(name = "node-alice")
+})
+public class LocalTestUserAliceClnNodeApi extends AbstractClnNodeApi {
+
+ public LocalTestUserAliceClnNodeApi(@Qualifier("nodeAliceClnNodeBlockingStub") NodeGrpc.NodeBlockingStub node) {
+ super(node);
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserCharlieClnNodeApi.java b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserCharlieClnNodeApi.java
new file mode 100644
index 000000000..fbeb74fb1
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/LocalTestUserCharlieClnNodeApi.java
@@ -0,0 +1,28 @@
+package org.tbk.lightning.regtest.example.api;
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.tags.Tags;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+
+/**
+ * A controller that provides an API to interact test user node "Charlie".
+ */
+@Slf4j
+@Validated
+@RestController
+@RequestMapping(value = "/api/v1/regtest/test-user-node/charlie", produces = MediaType.APPLICATION_JSON_VALUE)
+@Tags({
+ @Tag(name = "node-charlie")
+})
+public class LocalTestUserCharlieClnNodeApi extends AbstractClnNodeApi {
+
+ public LocalTestUserCharlieClnNodeApi(@Qualifier("nodeCharlieClnNodeBlockingStub") NodeGrpc.NodeBlockingStub node) {
+ super(node);
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/application-development.yml b/lightning-regtest/lightning-regtest-example-application/src/main/resources/application-development.yml
new file mode 100644
index 000000000..b5f7d75ae
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/application-development.yml
@@ -0,0 +1,43 @@
+app.name: tbk-bitcoin-spring-boot-starter-demo (dev)
+app.description: A spring boot bitcoin demo application
+
+spring.application.name: 'tbk-bitcoin-spring-boot-starter-demo-dev'
+spring.http.log-request-details: false
+
+server.port: 8080
+server.use-forward-headers: true
+server.compression.enabled: true
+
+management.server.port: 9001
+
+# LOGGING
+logging.file.name: application-dev.log
+logging.config: classpath:logback-development.xml
+logging.level.org.springframework: INFO
+logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: INFO # TRACE
+logging.level.web: INFO # DEBUG
+
+spring.docker.compose:
+ enabled: true
+ file: ./../../docker/regtest/lightning-regtest-setup-devel/docker-compose.yml
+ stop.command: DOWN # STOP is default, DOWN stops and removes containers and networks
+
+org.tbk.bitcoin.jsonrpc:
+ network: regtest
+ rpchost: http://localhost
+ rpcport: 18443
+ rpcuser: regtest
+ rpcpassword: regtest
+
+org.tbk.bitcoin.regtest:
+ enabled: true
+ # mine blocks every 1 - 10 seconds
+ mining:
+ enabled: true
+ # an address from "default_wallet" with key:
+ # truth fever mom transfer steak immense lake jacket glide bring fancy electric
+ coinbase-reward-address: bcrt1q0xtrupsjmqr7u7xz4meufd3a8pt6v553m8nmvz
+ mine-initial-amount-of-blocks: 0
+ next-block-duration:
+ min-duration: PT1S
+ max-duration: PT10S
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/application.yml b/lightning-regtest/lightning-regtest-example-application/src/main/resources/application.yml
new file mode 100644
index 000000000..516ebbd31
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+app.name: tbk-bitcoin-spring-boot-starter-demo
+app.description: A spring boot bitcoin demo application
+
+spring.application.name: 'tbk-bitcoin-spring-boot-starter-demo'
+spring.http.log-request-details: false
+
+server.port: 8080
+server.use-forward-headers: true
+server.compression.enabled: true
+
+management.server.port: 9001
+
+# LOGGING
+logging.file.name: application.log
+logging.config: classpath:logback.xml
+logging.level.org.springframework: INFO
+logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE
+logging.level.org.tbk.bitcoin.regtest: DEBUG
+
+org.tbk.bitcoin.regtest.enabled: false
+
+org.tbk.bitcoin.jsonrpc:
+ network: mainnet
+ rpchost: http://localhost
+ rpcport: 8332
+ rpcuser: my-bitcoin-rpc-user
+ rpcpassword: my-bitcoin-rpc-password
+
+# all Lightning Node client beans are created programmatically
+org.tbk.lightning.cln.grpc.enabled: false
+org.tbk.lightning.lnd.grpc.enabled: false
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/banner.txt b/lightning-regtest/lightning-regtest-example-application/src/main/resources/banner.txt
new file mode 100644
index 000000000..a5b2fd46b
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/banner.txt
@@ -0,0 +1 @@
+#### ${app.name} Banner
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback-development.xml b/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback-development.xml
new file mode 100644
index 000000000..d0ebff06d
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback-development.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback.xml b/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback.xml
new file mode 100644
index 000000000..9e4d07992
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/logback.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/favicon.ico b/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/favicon.ico
new file mode 100644
index 000000000..7bf16f88a
Binary files /dev/null and b/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/favicon.ico differ
diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/index.html b/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/index.html
new file mode 100644
index 000000000..fecb65e66
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-example-application/src/main/resources/static/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+It works!
+
+
diff --git a/lightning-regtest/lightning-regtest-setup-devel/build.gradle b/lightning-regtest/lightning-regtest-setup-devel/build.gradle
new file mode 100644
index 000000000..a5618e589
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/build.gradle
@@ -0,0 +1,25 @@
+plugins {
+ id 'java'
+}
+
+description = 'lightning regtest setup devel package'
+
+dependencies {
+ api project(':lightning-regtest:lightning-regtest-setup')
+
+ api 'org.springframework.boot:spring-boot-docker-compose'
+}
+
+def IS_CI_ENV = (System.getenv("CI") ?: "false") == "true"
+
+task resetDockerComposeSetupIfNecessary(type: Exec) {
+ setEnabled(!IS_CI_ENV)
+ outputs.upToDateWhen { false }
+ workingDir "${projectDir}"
+ commandLine 'docker-compose', '-f', "$rootDir/docker/regtest/lightning-regtest-setup-devel/docker-compose.yml", 'down', '--volumes'
+}
+
+integrationTest {
+ // docker-compose setup will be started by spring-boot-docker-compose!
+ dependsOn resetDockerComposeSetupIfNecessary
+}
\ No newline at end of file
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/integTest/java/org/tbk/lightning/regtest/setup/devel/RegtestLightningNetworkSetupDevelTest.java b/lightning-regtest/lightning-regtest-setup-devel/src/integTest/java/org/tbk/lightning/regtest/setup/devel/RegtestLightningNetworkSetupDevelTest.java
new file mode 100644
index 000000000..87b752d05
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/integTest/java/org/tbk/lightning/regtest/setup/devel/RegtestLightningNetworkSetupDevelTest.java
@@ -0,0 +1,181 @@
+package org.tbk.lightning.regtest.setup.devel;
+
+import com.google.common.base.Stopwatch;
+import com.google.protobuf.ByteString;
+import fr.acinq.lightning.MilliSatoshi;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.util.Assert;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.client.common.core.proto.*;
+import org.tbk.lightning.client.common.core.proto.CommonLookupInvoiceResponse.InvoiceStatus;
+import org.tbk.lightning.client.common.core.proto.CommonPayResponse.PaymentStatus;
+import org.tbk.lightning.regtest.core.LightningNetworkConstants;
+import org.tbk.lightning.regtest.setup.RegtestLightningNetworkSetup;
+import org.tbk.lightning.regtest.setup.devel.impl.LocalRegtestLightningNetworkSetupConfig;
+import org.tbk.lightning.regtest.setup.util.PaymentRouteVerifier;
+import org.tbk.lightning.regtest.setup.util.SimplePaymentRouteVerifier;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Slf4j
+@SpringBootTest(
+ classes = LocalRegtestLightningNetworkSetupConfig.class,
+ webEnvironment = SpringBootTest.WebEnvironment.NONE
+)
+@ActiveProfiles("test")
+class RegtestLightningNetworkSetupDevelTest {
+
+ private static String randomLabel() {
+ return LocalDateTime.now(ZoneOffset.UTC).toString();
+ }
+
+ private static final PaymentRouteVerifier routeVerifier = new SimplePaymentRouteVerifier();
+
+ // autowiring the setup verifies it finished without errors
+ @Autowired
+ private RegtestLightningNetworkSetup setupFinished;
+
+ @Autowired
+ @Qualifier("nodeAppLightningCommonClient")
+ private LightningCommonClient appNode;
+
+ @Autowired
+ @Qualifier("nodeFaridLightningCommonClient")
+ private LightningCommonClient userNode;
+
+ @BeforeEach
+ void setUp() {
+ // verify nodes are connected via payment path
+ Assert.isTrue(routeVerifier.hasDirectRoute(appNode, userNode), "Sanity check failed: No route between nodes");
+ }
+
+ @Test
+ void itShouldVerifyPaymentCanBeSentOverChannelSuccessfully() {
+ MilliSatoshi millisats = new MilliSatoshi(1_000L);
+
+ // generate an invoice on user node
+ String userInvoiceLabel = randomLabel();
+ Duration expiry = Duration.ofSeconds(30);
+ CommonCreateInvoiceResponse userInvoiceResponse = userNode.createInvoice(CommonCreateInvoiceRequest.newBuilder()
+ .setLabel(userInvoiceLabel)
+ .setExpiry(expiry.toSeconds())
+ .setAmountMsat(millisats.getMsat())
+ .build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ assertThat(userInvoiceResponse.getPaymentRequest()).startsWith("lnbcrt1");
+
+ Stopwatch sw = Stopwatch.createStarted();
+
+ // pay invoice from app node
+ CommonPayResponse appPayResponse = appNode.pay(CommonPayRequest.newBuilder()
+ .setPaymentRequest(userInvoiceResponse.getPaymentRequest())
+ .build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ assertThat(appPayResponse.getStatus()).isEqualTo(PaymentStatus.COMPLETE);
+ assertThat(appPayResponse.getPaymentHash()).isEqualTo(userInvoiceResponse.getPaymentHash());
+ assertThat(appPayResponse.getPaymentPreimage()).isNotIn(null, ByteString.EMPTY);
+
+ log.info("Payment sent on node 'app' after {}", sw);
+
+ CommonLookupInvoiceRequest request = CommonLookupInvoiceRequest.newBuilder()
+ .setPaymentHash(userInvoiceResponse.getPaymentHash())
+ .build();
+
+ CommonLookupInvoiceResponse userLookupInvoiceResponse = Flux.interval(Duration.ZERO, Duration.ofSeconds(1L))
+ .flatMap(it -> userNode.lookupInvoice(request))
+ .flatMap(Mono::justOrEmpty)
+ .filter(it -> it.getStatus() == InvoiceStatus.COMPLETE)
+ .blockFirst(expiry.plus(Duration.ofSeconds(1)));
+
+ assertThat(userLookupInvoiceResponse).isNotNull();
+ assertThat(userLookupInvoiceResponse.getPaymentHash()).isEqualTo(appPayResponse.getPaymentHash());
+ assertThat(userLookupInvoiceResponse.getPaymentPreimage()).isEqualTo(appPayResponse.getPaymentPreimage());
+ assertThat(userLookupInvoiceResponse.getAmountMsat()).isEqualTo(appPayResponse.getAmountMsat());
+ assertThat(userLookupInvoiceResponse.getStatus()).isEqualTo(InvoiceStatus.COMPLETE);
+
+ log.info("Payment received on node 'user' after {}", sw.stop());
+ }
+
+ /**
+ * **NOTE**: This test should give you a feeling about how nodes (especially CLN) will behave in production.
+ *
+ * It can take a long time till the state of a failed payment switches from PENDING to FAILED.
+ * Verify that it will indeed happen and provide the waiting time as log output.
+ * Depending on your node implementation, this can take several minutes!
+ */
+ @Test
+ void itShouldVerifyFailedPaymentsCanBeListed() {
+ // generate an invoice that cannot be paid
+ MilliSatoshi nonPayableAmount = LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.plus(new MilliSatoshi(1));
+
+ // generate an non-payable invoice on user node
+ Duration expiry = Duration.ofMinutes(60);
+ CommonCreateInvoiceResponse userInvoiceResponse = userNode.createInvoice(CommonCreateInvoiceRequest.newBuilder()
+ .setLabel(randomLabel())
+ .setExpiry(expiry.toSeconds())
+ .setAmountMsat(nonPayableAmount.getMsat())
+ .build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ String unaffordableInvoice = userInvoiceResponse.getPaymentRequest();
+ assertThat(unaffordableInvoice).startsWith("lnbcrt1");
+
+ Duration timeout = Duration.ofSeconds(5);
+ try {
+ CommonPayResponse ignoredOnPurpose = appNode.pay(CommonPayRequest.newBuilder()
+ .setPaymentRequest(unaffordableInvoice)
+ .setTimeoutSeconds(Math.toIntExact(timeout.toSeconds()))
+ .build())
+ .block(timeout.plus(Duration.ofSeconds(10)));
+
+ Assertions.fail("Should have thrown exception");
+ } catch (Exception e) {
+ // empty on purpose
+ }
+
+ CommonLookupPaymentRequest lookupPaymentRequest = CommonLookupPaymentRequest.newBuilder()
+ .setPaymentHash(userInvoiceResponse.getPaymentHash())
+ .build();
+
+ Stopwatch sw = Stopwatch.createStarted();
+ PaymentStatus appInitialPaymentStatus = Flux.interval(Duration.ZERO, Duration.ofSeconds(2L))
+ .flatMap(it -> appNode.lookupPayment(lookupPaymentRequest))
+ .doOnNext(it -> log.info("{}", it))
+ .map(CommonLookupPaymentResponse::getStatus)
+ .blockFirst(Duration.ofSeconds(10));
+
+ log.info("Payment was included in `listPays` response after {}", sw);
+
+ // payment might at first still be reported as PENDING
+ assertThat(appInitialPaymentStatus).isIn(PaymentStatus.PENDING, PaymentStatus.FAILED);
+
+ // wait till payment state is reported as FAILED
+ PaymentStatus failedState = Flux.interval(Duration.ZERO, Duration.ofSeconds(2L))
+ .flatMap(it -> appNode.lookupPayment(lookupPaymentRequest))
+ .map(CommonLookupPaymentResponse::getStatus)
+ .filter(it -> it == PaymentStatus.FAILED)
+ .blockFirst(expiry.plus(Duration.ofSeconds(1)));
+
+ log.info("Payment switched from PENDING to FAILED after {}", sw.stop());
+
+ assertThat(failedState).isEqualTo(PaymentStatus.FAILED);
+ }
+}
\ No newline at end of file
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/integTest/resources/application-test.yml b/lightning-regtest/lightning-regtest-setup-devel/src/integTest/resources/application-test.yml
new file mode 100644
index 000000000..e8185bd8e
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/integTest/resources/application-test.yml
@@ -0,0 +1,17 @@
+logging.level.org.tbk.lightning.regtest.setup: DEBUG
+
+spring.docker.compose:
+ enabled: true
+ file: ./../../docker/regtest/lightning-regtest-setup-devel/docker-compose.yml
+ stop.command: DOWN # STOP is default, DOWN clears all container data after shutdown
+ skip.in-tests: false
+
+org.tbk.bitcoin.jsonrpc:
+ network: regtest
+ rpchost: http://localhost
+ rpcport: 18443
+ rpcuser: regtest
+ rpcpassword: regtest
+
+org.tbk.lightning.cln.grpc:
+ enabled: false # all beans are created programmatically
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelClnNodeRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelClnNodeRegistrar.java
new file mode 100644
index 000000000..cdae08d45
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelClnNodeRegistrar.java
@@ -0,0 +1,41 @@
+package org.tbk.lightning.regtest.setup.devel;
+
+import org.tbk.lightning.regtest.setup.AbstractClnNodeRegistrar;
+
+import java.util.HexFormat;
+
+public abstract class AbstractDevelClnNodeRegistrar extends AbstractClnNodeRegistrar {
+
+ /**
+ * Override this method if you do not use the "common" ca cert.
+ *
+ * @return the "common" ca cert
+ */
+ @Override
+ protected byte[] caCert() {
+ // file `docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0d0a4d4949426354434341526567417749424167494943496e716868525364495577436759494b6f5a497a6a304541774977466a45554d424947413155454177774c0d0a5932787549464a7662335167513045774942634e4e7a55774d5441784d4441774d444177576867504e4441354e6a41784d4445774d4441774d4442614d4259780d0a4644415342674e5642414d4d43324e73626942536232393049454e424d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741450d0a6b5730564a4f61617931706156677232587a49636e43644b76644c7539354838503457695a554d6635776844345a7172534259504c46503775364d49714249420d0a67692f6c57354f624149714d517a77357a645355314b4e4e4d457377475159445652305242424977454949445932787567676c7362324e6862476876633351770d0a485159445652304f424259454649563055685347366f6b496169644f33663971313248766f7554424d41384741315564457745422f7751464d414d42416638770d0a436759494b6f5a497a6a304541774944534141775251496742445a4367316b7354483062617242797246414e775368556f5448694b6f766d73514f3873472f650d0a56514943495143377a4a7561612f51766d4437623158537a3334412b6765524b33513345734c6c5534594e706f64734b61773d3d0d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d");
+ }
+
+ /**
+ * Override this method if you do not use the "common" client cert.
+ *
+ * @return the "common" client cert
+ */
+ @Override
+ protected byte[] clientCert() {
+ // file `docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0d0a4d494942526a4342374b41444167454341676b417a435a52636573466d676777436759494b6f5a497a6a304541774977466a45554d424947413155454177774c0d0a5932787549464a7662335167513045774942634e4e7a55774d5441784d4441774d444177576867504e4441354e6a41784d4445774d4441774d4442614d426f780d0a4744415742674e5642414d4d44324e736269426e636e426a49454e73615756756444425a4d424d4742797147534d34394167454743437147534d3439417745480d0a41304941424f48396358366b6a5356704463676b7152794648632f457a5343674f3039714e476b6f6f415a3846434b4b6b6f736b5451364261313345376c31690d0a3946455736306638455667703048376d416e42346f582b394343476a485441624d426b4741315564455151534d42434341324e73626f494a6247396a5957786f0d0a62334e304d416f4743437147534d343942414d4341306b414d455943495144756c546d353670624e6232685952526f4d76786f376466667662735749503763530d0a436f4271506541532f774968414c4e56655673457558636747626746446a635638784f6c6a43453261376c7971354134735739513059464d0d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d");
+ }
+
+ /**
+ * Override this method if you do not use the "common" client key.
+ *
+ * @return the "common" client key
+ */
+ @Override
+ protected byte[] clientKey() {
+ // file `docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client-key.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2050524956415445204b45592d2d2d2d2d0d0a4d494748416745414d424d4742797147534d34394167454743437147534d34394177454842473077617749424151516752747a5971376b4534736e4c4f526f420d0a375941324142764f546e327439302f786d4c7a72386f666b6938796852414e43414154682f58462b7049306c615133494a4b6b6368523350784d30676f4474500d0a616a52704b4b41476642516969704b4c4a45304f67577464784f356459765252467574482f4246594b64422b35674a77654b462f765167680d0a2d2d2d2d2d454e442050524956415445204b45592d2d2d2d2d0d0a");
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelLndNodeRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelLndNodeRegistrar.java
new file mode 100644
index 000000000..bec832523
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/AbstractDevelLndNodeRegistrar.java
@@ -0,0 +1,30 @@
+package org.tbk.lightning.regtest.setup.devel;
+
+import org.tbk.lightning.regtest.setup.AbstractLndNodeRegistrar;
+
+import java.util.HexFormat;
+
+public abstract class AbstractDevelLndNodeRegistrar extends AbstractLndNodeRegistrar {
+
+ /**
+ * Override this method if you do not use the "common" tls cert.
+ *
+ * @return the "common" tls cert
+ */
+ @Override
+ protected byte[] tlsCert() {
+ // file `docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.cert` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949434a544343416375674177494241674952415049716b2f4762316e54526c7842674b36484d69396f77436759494b6f5a497a6a3045417749774f4445660a4d4230474131554543684d576247356b494746316447396e5a57356c636d46305a57516759325679644445564d424d474131554541784d4d4d6d45354e6d5a6c0a597a5a695a444d334d4234584454497a4d4467774e5449784e546b304d466f58445451354d54497a4d54497a4e546b314f566f774f4445664d423047413155450a43684d576247356b494746316447396e5a57356c636d46305a57516759325679644445564d424d474131554541784d4d4d6d45354e6d5a6c597a5a695a444d330a4d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a30444151634451674145363833655234695432447343497949396867664b2b36324b552f2f310a4275726d34315a5a6764387450745071516e50756e31304977313048436734553362727751566e755552454a6947356b352f536e3541784263614f42745443420a736a414f42674e56485138424166384542414d4341715177457759445652306c42417777436759494b775942425155484177457744775944565230544151482f0a42415577417745422f7a416442674e5648513445466751556d694f6775422f7a57514c67635279357352336e6a2b3133492f4d775777594456523052424651770a556f494d4d6d45354e6d5a6c597a5a695a444d3367676c7362324e6862476876633353434248567561586943436e56756158687759574e725a58534342324a310a5a6d4e76626d36484248384141414748454141414141414141414141414141414141414141414748424b775a41415577436759494b6f5a497a6a3045417749440a5341417752514967614377766932554854626b4135342f37376e776c615459304571646e6b62472b64456f5734574435346d4143495143394750316672546b470a6f523065625542705136634a5078512f3542434947334937443141472f6a356176413d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d");
+ }
+
+ /**
+ * Override this method if you do not use the "common" macaroon.
+ *
+ * @return the "common" client macaroon
+ */
+ @Override
+ protected byte[] rpcMacaroon() {
+ // file `docker/regtest/lightning-regtest-setup-devel/data/lnd_common/chain/bitcoin/regtest/admin.macaroon` as hex (with `xxd -ps -u -c 1000`)
+ return HexFormat.of().parseHex("0201036c6e6402f801030a1014fd1c3749388741806876f4d1b869811201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e65726174651204726561640000062083c20ad85cd8cddced3a3a1a033ccb3bc730536dc1c92ee39b2ca3a3c9aa5690");
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAliceRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAliceRegistrar.java
new file mode 100644
index 000000000..9c9533007
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAliceRegistrar.java
@@ -0,0 +1,27 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+class LocalClnNodeAliceRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeAlice";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln1_alice";
+ }
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19936)
+ .sslContext(sslContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAppRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAppRegistrar.java
new file mode 100644
index 000000000..6605530e6
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeAppRegistrar.java
@@ -0,0 +1,58 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.springframework.beans.factory.config.BeanDefinitionCustomizer;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+import java.util.HexFormat;
+
+class LocalClnNodeAppRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ /**
+ * As this client connects to the node the application talks to,
+ * it must be marked as "primary".
+ */
+ @Override
+ protected BeanDefinitionCustomizer beanDefinitionCustomizer() {
+ return bd -> bd.setPrimary(true);
+ }
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeApp";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln0_app";
+ }
+
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19935)
+ .sslContext(sslContext)
+ .build();
+ }
+
+ @Override
+ protected byte[] caCert() {
+ // file `lightning-regtest-setup-devel/docker/data/cln0_app/regtest/ca.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0d0a4d49494263444343415265674177494241674949415438587564366259476377436759494b6f5a497a6a304541774977466a45554d424947413155454177774c0d0a5932787549464a7662335167513045774942634e4e7a55774d5441784d4441774d444177576867504e4441354e6a41784d4445774d4441774d4442614d4259780d0a4644415342674e5642414d4d43324e73626942536232393049454e424d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741450d0a4a32504b436c627a34526b376244596466365472774a456a455246703132517a59474c70494b3237727736436251356f726d372b31386845572b6669387038700d0a39646245346a4462446274376b6236494a744a37364b4e4e4d457377475159445652305242424977454949445932787567676c7362324e6862476876633351770d0a485159445652304f42425945464764676d393635467a38426f792b5143345663666a506b4933322f4d41384741315564457745422f7751464d414d42416638770d0a436759494b6f5a497a6a30454177494452774177524149674b41754841534446354e7649654243362b637562353578334561667a6c5938313237336c77506e450d0a5a4773434944595864765874495658676c694974474350757839352f394165734b694f377a4d6737425a777a6d3362370d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d");
+ }
+
+ @Override
+ protected byte[] clientCert() {
+ // file `lightning-regtest-setup-devel/docker/data/cln0_app/regtest/client.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0d0a4d49494252544342374b41444167454341676b416e4936694152467359786b77436759494b6f5a497a6a304541774977466a45554d424947413155454177774c0d0a5932787549464a7662335167513045774942634e4e7a55774d5441784d4441774d444177576867504e4441354e6a41784d4445774d4441774d4442614d426f780d0a4744415742674e5642414d4d44324e736269426e636e426a49454e73615756756444425a4d424d4742797147534d34394167454743437147534d3439417745480d0a41304941424f724f2f657177514d7752496a2f4c2f7842527843504b67566b59764d504f6d747a43586c417869747179366a79615141726c50496133506534530d0a50776a69624e664a4c757935377a6c4b32546f384b6439615a2f326a485441624d426b4741315564455151534d42434341324e73626f494a6247396a5957786f0d0a62334e304d416f4743437147534d343942414d43413067414d45554349463773344c6d3964565777346443542b797443634a4d7a4f7157375558324c714f45470d0a6542366c6c636661416945412f34354b455a69502b7a3936654e4b484c7542656238794d5569374252554d4a35764d38625472644933453d0d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d");
+ }
+
+ @Override
+ protected byte[] clientKey() {
+ // file `lightning-regtest-setup-devel/docker/data/cln0_app/regtest/client-key.pem` as hex
+ return HexFormat.of().parseHex("2d2d2d2d2d424547494e2050524956415445204b45592d2d2d2d2d0d0a4d494748416745414d424d4742797147534d34394167454743437147534d3439417745484247307761774942415151672b457a6b462b30686a63746e686c54430d0a4e4d73327a666f75685946312b446d383351725266567a6472384f6852414e43414154717a7633717345444d4553492f792f38515563516a796f465a474c7a440d0a7a707263776c35514d59726173756f386d6b414b35547947747a3375456a3849346d7a58795337737565383553746b3650436e66576d66390d0a2d2d2d2d2d454e442050524956415445204b45592d2d2d2d2d");
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeBobRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeBobRegistrar.java
new file mode 100644
index 000000000..d79b7df79
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeBobRegistrar.java
@@ -0,0 +1,28 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+class LocalClnNodeBobRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeBob";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln2_bob";
+ }
+
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19937)
+ .sslContext(sslContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeCharlieRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeCharlieRegistrar.java
new file mode 100644
index 000000000..b0b09ba01
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeCharlieRegistrar.java
@@ -0,0 +1,28 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+class LocalClnNodeCharlieRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeCharlie";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln3_charlie";
+ }
+
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19938)
+ .sslContext(sslContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeDaveRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeDaveRegistrar.java
new file mode 100644
index 000000000..d5955d146
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeDaveRegistrar.java
@@ -0,0 +1,27 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+class LocalClnNodeDaveRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeDave";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln4_dave";
+ }
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19939)
+ .sslContext(sslContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeErinRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeErinRegistrar.java
new file mode 100644
index 000000000..b131a1017
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalClnNodeErinRegistrar.java
@@ -0,0 +1,28 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.ClnRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelClnNodeRegistrar;
+
+class LocalClnNodeErinRegistrar extends AbstractDevelClnNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeErin";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_cln5_erin";
+ }
+
+ @Override
+ protected ClnRpcConfig createClnRpcConfig(SslContext sslContext) {
+ return ClnRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19940)
+ .sslContext(sslContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalLndNodeFaridRegistrar.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalLndNodeFaridRegistrar.java
new file mode 100644
index 000000000..30222e28e
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalLndNodeFaridRegistrar.java
@@ -0,0 +1,29 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import org.lightningj.lnd.wrapper.MacaroonContext;
+import org.tbk.lightning.lnd.grpc.LndRpcConfig;
+import org.tbk.lightning.lnd.grpc.LndRpcConfigImpl;
+import org.tbk.lightning.regtest.setup.devel.AbstractDevelLndNodeRegistrar;
+
+class LocalLndNodeFaridRegistrar extends AbstractDevelLndNodeRegistrar {
+
+ @Override
+ protected String beanNamePrefix() {
+ return "nodeFarid";
+ }
+
+ @Override
+ protected String hostname() {
+ return "regtest_lnd6_farid";
+ }
+ @Override
+ protected LndRpcConfig createLndRpcConfig(SslContext sslContext, MacaroonContext macaroonContext) {
+ return LndRpcConfigImpl.builder()
+ .host("localhost")
+ .port(19941)
+ .sslContext(sslContext)
+ .macaroonContext(macaroonContext)
+ .build();
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalRegtestLightningNetworkSetupConfig.java b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalRegtestLightningNetworkSetupConfig.java
new file mode 100644
index 000000000..bf7111a21
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup-devel/src/main/java/org/tbk/lightning/regtest/setup/devel/impl/LocalRegtestLightningNetworkSetupConfig.java
@@ -0,0 +1,130 @@
+package org.tbk.lightning.regtest.setup.devel.impl;
+
+import com.google.common.collect.ImmutableList;
+import lombok.extern.slf4j.Slf4j;
+import org.bitcoinj.params.RegTestParams;
+import org.consensusj.bitcoin.jsonrpc.BitcoinExtendedClient;
+import org.consensusj.bitcoin.jsonrpc.RpcConfig;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+import org.tbk.lightning.regtest.core.LightningNetworkConstants;
+import org.tbk.lightning.regtest.setup.ChannelDefinition;
+import org.tbk.lightning.regtest.setup.NodeInfo;
+import org.tbk.lightning.regtest.setup.RegtestLightningNetworkSetup;
+import org.tbk.lightning.regtest.setup.util.RouteVerification;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+
+/**
+ * Spring configuration that connects to running lightning nodes
+ * defined in {@see ./lightning-regtest-setup-devel/docker/docker-compose.yml}.
+ * Beans of type {@link NodeGrpc.NodeBlockingStub} are registered and added to the application context,
+ * as well as a {@link RegtestLightningNetworkSetup} in order to create balanced lightning channels between the nodes.
+ */
+@Slf4j
+@Import({
+ LocalClnNodeAppRegistrar.class,
+ LocalClnNodeAliceRegistrar.class,
+ LocalClnNodeBobRegistrar.class,
+ LocalClnNodeCharlieRegistrar.class,
+ LocalClnNodeDaveRegistrar.class,
+ LocalClnNodeErinRegistrar.class,
+ LocalLndNodeFaridRegistrar.class,
+})
+@Configuration(proxyBeanMethods = false)
+public class LocalRegtestLightningNetworkSetupConfig {
+
+ @Bean
+ RpcConfig bitcoinRpcConfig() {
+ String host = "localhost";
+ int port = 18443;
+ String username = "regtest";
+ String password = "regtest";
+ URI uri = URI.create("http://%s:%d".formatted(host, port));
+ return new RpcConfig(RegTestParams.get(), uri, username, password);
+ }
+
+ @Bean
+ BitcoinExtendedClient bitcoinRegtestClient(RpcConfig rpcConfig) {
+ return new BitcoinExtendedClient(rpcConfig);
+ }
+
+ /**
+ * Create a local lightning network according to `./README.md`
+ */
+ @Bean
+ RegtestLightningNetworkSetup regtestLightningNetworkSetup(BitcoinExtendedClient bitcoinRegtestClient,
+ @Qualifier("nodeAppNodeInfo") NodeInfo appClnNode,
+ @Qualifier("nodeAliceNodeInfo") NodeInfo aliceClnNode,
+ @Qualifier("nodeBobNodeInfo") NodeInfo bobClnNode,
+ @Qualifier("nodeCharlieNodeInfo") NodeInfo charlieClnNode,
+ @Qualifier("nodeErinNodeInfo") NodeInfo erinClnNode,
+ @Qualifier("nodeFaridNodeInfo") NodeInfo faridLndNode) throws IOException {
+ RegtestLightningNetworkSetup regtestLightningNetworkSetup = new RegtestLightningNetworkSetup(
+ bitcoinRegtestClient,
+ ImmutableList.builder()
+ // app -> alice
+ .add(ChannelDefinition.builder()
+ .origin(appClnNode)
+ .destination(aliceClnNode)
+ .capacity(LightningNetworkConstants.LARGEST_CHANNEL_SIZE)
+ .pushAmount(LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.div(4))
+ .build())
+ // app -> bob
+ .add(ChannelDefinition.builder()
+ .origin(appClnNode)
+ .destination(bobClnNode)
+ .capacity(LightningNetworkConstants.LARGEST_CHANNEL_SIZE.div(2))
+ .pushAmount(LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.div(2).div(4))
+ .build())
+ // bob -> charlie
+ .add(ChannelDefinition.builder()
+ .origin(bobClnNode)
+ .destination(charlieClnNode)
+ .capacity(LightningNetworkConstants.LARGEST_CHANNEL_SIZE.div(4))
+ .pushAmount(LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.div(4).div(4))
+ .build())
+ // farid -> bob
+ .add(ChannelDefinition.builder()
+ .origin(faridLndNode)
+ .destination(bobClnNode)
+ .capacity(LightningNetworkConstants.LARGEST_CHANNEL_SIZE.div(4))
+ .pushAmount(LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.div(4).div(4))
+ .build())
+ // charlie -> erin (unannounced channel!)
+ .add(ChannelDefinition.builder()
+ .origin(charlieClnNode)
+ .destination(erinClnNode)
+ .capacity(LightningNetworkConstants.LARGEST_CHANNEL_SIZE.div(8))
+ .pushAmount(LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT.div(8).div(4))
+ .announced(false)
+ .build())
+ .build(),
+ ImmutableList.builder()
+ // app -> farid
+ .add(RouteVerification.builder()
+ .origin(appClnNode)
+ .destination(faridLndNode)
+ .checkInterval(Duration.ofSeconds(2))
+ .timeout(Duration.ofMinutes(5))
+ .build())
+ // farid -> alice
+ .add(RouteVerification.builder()
+ .origin(faridLndNode)
+ .destination(aliceClnNode)
+ .checkInterval(Duration.ofSeconds(2))
+ .timeout(Duration.ofMinutes(5))
+ .build())
+ .build()
+ );
+
+ regtestLightningNetworkSetup.run();
+
+ return regtestLightningNetworkSetup;
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup/build.gradle b/lightning-regtest/lightning-regtest-setup/build.gradle
new file mode 100644
index 000000000..2160e7db7
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+ id 'java'
+}
+
+description = 'lightning regtest setup package'
+
+dependencies {
+ api project(':lightning-regtest:lightning-regtest-core')
+
+ api project(':bitcoin-regtest:bitcoin-regtest-core')
+
+ // TODO: temporarily - this module should be independent of the implementation
+ api project(':lightning-commons:lightning-commons-client-cln')
+ api project(':lightning-commons:lightning-commons-client-lnd')
+
+ api 'org.springframework:spring-context'
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractClnNodeRegistrar.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractClnNodeRegistrar.java
new file mode 100644
index 000000000..956106415
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractClnNodeRegistrar.java
@@ -0,0 +1,146 @@
+package org.tbk.lightning.regtest.setup;
+
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslProvider;
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.config.BeanDefinitionCustomizer;
+import org.springframework.beans.factory.support.AbstractBeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionBuilder;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
+import org.springframework.core.type.AnnotationMetadata;
+import org.tbk.lightning.client.common.cln.ClnCommonClient;
+import org.tbk.lightning.cln.grpc.ClnRpcConfig;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+import org.tbk.lightning.regtest.core.LightningNetworkConstants;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.Objects.requireNonNull;
+
+@Slf4j
+public abstract class AbstractClnNodeRegistrar extends AbstractNodeRegistrar {
+
+ @Override
+ public void registerBeanDefinitions(@NonNull AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
+ String beanPrefix = requireNonNull(beanNamePrefix());
+ BeanDefinitionCustomizer beanCustomizer = requireNonNull(beanDefinitionCustomizer());
+
+ SslContext sslContext = createSslContext(caCert(), clientCert(), clientKey());
+ ClnRpcConfig clnRpcConfig = createClnRpcConfig(sslContext);
+ ManagedChannelBuilder> clnChannelBuilder = createClnChannelBuilder(clnRpcConfig);
+ ManagedChannel clnChannel = createClnChannel(clnChannelBuilder);
+ DisposableBean clnChannelShutdownHook = createClnChannelShutdownHook(clnChannel);
+ NodeGrpc.NodeBlockingStub clnNodeBlockingStub = createClnNodeBlockingStub(clnChannel);
+ ClnCommonClient commonClient = new ClnCommonClient(clnNodeBlockingStub);
+ NodeInfo nodeInfo = NodeInfo.builder()
+ .hostname(hostname())
+ .p2pPort(p2pPort())
+ .client(commonClient)
+ .build();
+
+ AbstractBeanDefinition nodeInfoDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(NodeInfo.class, () -> nodeInfo)
+ .getBeanDefinition();
+
+ AbstractBeanDefinition clnChannelShutdownHookDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(DisposableBean.class, () -> clnChannelShutdownHook)
+ .getBeanDefinition();
+
+ AbstractBeanDefinition clnNodeBlockingStubDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(NodeGrpc.NodeBlockingStub.class, () -> clnNodeBlockingStub)
+ .getBeanDefinition();
+
+ AbstractBeanDefinition commonClientDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(ClnCommonClient.class, () -> commonClient)
+ .getBeanDefinition();
+
+ beanCustomizer.customize(clnChannelShutdownHookDefinition);
+ beanCustomizer.customize(clnNodeBlockingStubDefinition);
+ beanCustomizer.customize(commonClientDefinition);
+
+ registry.registerBeanDefinition("%sClnChannelShutdownHook".formatted(beanPrefix), clnChannelShutdownHookDefinition);
+ registry.registerBeanDefinition("%sClnNodeBlockingStub".formatted(beanPrefix), clnNodeBlockingStubDefinition);
+ registry.registerBeanDefinition("%sLightningCommonClient".formatted(beanPrefix), commonClientDefinition);
+ registry.registerBeanDefinition("%sNodeInfo".formatted(beanPrefix), nodeInfoDefinition);
+ }
+
+ abstract protected ClnRpcConfig createClnRpcConfig(SslContext sslContext);
+
+ abstract protected byte[] caCert();
+
+ abstract protected byte[] clientCert();
+
+ abstract protected byte[] clientKey();
+
+ @Override
+ protected Integer p2pPort() {
+ return LightningNetworkConstants.CLN_DEFAULT_REGTEST_P2P_PORT;
+ }
+
+ private SslContext createSslContext(byte[] caCert, byte[] clientCert, byte[] clientKey) {
+ try {
+ try (ByteArrayInputStream caStream = new ByteArrayInputStream(caCert);
+ ByteArrayInputStream certStream = new ByteArrayInputStream(clientCert);
+ ByteArrayInputStream keyStream = new ByteArrayInputStream(clientKey);
+ ) {
+ return GrpcSslContexts.configure(SslContextBuilder.forClient(), SslProvider.OPENSSL)
+ .trustManager()
+ .keyManager(certStream, keyStream)
+ .trustManager(caStream)
+ .build();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ManagedChannelBuilder> createClnChannelBuilder(ClnRpcConfig rpcConfig) {
+ return NettyChannelBuilder.forAddress(rpcConfig.getHost(), rpcConfig.getPort())
+ .sslContext(rpcConfig.getSslContext());
+ }
+
+
+ private ManagedChannel createClnChannel(ManagedChannelBuilder> clnChannelBuilder) {
+ // From https://github.com/grpc/grpc-java/issues/3268#issuecomment-317484178:
+ // > Channels are expensive to create, and the general recommendation is to use one per application,
+ // > shared among the service stubs.
+ return clnChannelBuilder.build();
+ }
+
+ private DisposableBean createClnChannelShutdownHook(ManagedChannel clnChannel) {
+ return () -> {
+ try {
+ Duration timeout = Duration.ofMinutes(2);
+
+ log.debug("Closing grpc managed channel {} …", clnChannel);
+ try {
+ clnChannel.shutdown().awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ log.debug("Closed grpc managed channel {}", clnChannel);
+ } catch (io.grpc.StatusRuntimeException e) {
+ log.error("Error occurred closing managed grpc channel: " + e.getStatus(), e);
+ clnChannel.shutdownNow().awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ log.error("Thread interrupted: " + e.getMessage(), e);
+ clnChannel.shutdownNow().awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ }
+ } catch (Exception e) {
+ log.error("Grpc managed channel did not shutdown cleanly", e);
+ }
+ };
+ }
+
+ private NodeGrpc.NodeBlockingStub createClnNodeBlockingStub(ManagedChannel clnChannel) {
+ return NodeGrpc.newBlockingStub(clnChannel);
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractLndNodeRegistrar.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractLndNodeRegistrar.java
new file mode 100644
index 000000000..0822586e1
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractLndNodeRegistrar.java
@@ -0,0 +1,134 @@
+package org.tbk.lightning.regtest.setup;
+
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslProvider;
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+import org.lightningj.lnd.wrapper.MacaroonContext;
+import org.lightningj.lnd.wrapper.SynchronousLndAPI;
+import org.lightningj.lnd.wrapper.invoices.SynchronousInvoicesAPI;
+import org.lightningj.lnd.wrapper.router.SynchronousRouterAPI;
+import org.lightningj.lnd.wrapper.walletkit.SynchronousWalletKitAPI;
+import org.springframework.beans.factory.config.BeanDefinitionCustomizer;
+import org.springframework.beans.factory.support.AbstractBeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionBuilder;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
+import org.springframework.core.type.AnnotationMetadata;
+import org.tbk.lightning.client.common.lnd.LndCommonClient;
+import org.tbk.lightning.lnd.grpc.LndRpcConfig;
+import org.tbk.lightning.regtest.core.LightningNetworkConstants;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.HexFormat;
+
+import static java.util.Objects.requireNonNull;
+
+@Slf4j
+public abstract class AbstractLndNodeRegistrar extends AbstractNodeRegistrar {
+
+ @Override
+ public void registerBeanDefinitions(@NonNull AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
+ String beanPrefix = requireNonNull(beanNamePrefix());
+ BeanDefinitionCustomizer beanCustomizer = requireNonNull(beanDefinitionCustomizer());
+
+ SslContext sslContext = createSslContext(tlsCert());
+ MacaroonContext macaroonContext = createMacaroonContext(rpcMacaroon());
+ LndRpcConfig rpcConfig = createLndRpcConfig(sslContext, macaroonContext);
+ SynchronousLndAPI synchronousLndAPI = createSynchronousLndAPI(rpcConfig);
+ SynchronousWalletKitAPI synchronousLndWalletKitAPI = createSynchronousLndWalletKitAPI(rpcConfig);
+ SynchronousRouterAPI synchronousLndRouterAPI = createSynchronousLndRouterAPI(rpcConfig);
+ SynchronousInvoicesAPI synchronousLndInvoicesAPI = createSynchronousLndInvoiceAPI(rpcConfig);
+ LndCommonClient commonClient = new LndCommonClient(
+ synchronousLndAPI,
+ synchronousLndWalletKitAPI,
+ synchronousLndRouterAPI,
+ synchronousLndInvoicesAPI
+ );
+ NodeInfo nodeInfo = NodeInfo.builder()
+ .hostname(hostname())
+ .p2pPort(p2pPort())
+ .client(commonClient)
+ .build();
+
+ AbstractBeanDefinition nodeInfoDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(NodeInfo.class, () -> nodeInfo)
+ .getBeanDefinition();
+
+ AbstractBeanDefinition synchronousLndAPIDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(SynchronousLndAPI.class, () -> synchronousLndAPI)
+ .getBeanDefinition();
+
+ AbstractBeanDefinition lndNodeCommonClientDefinition = BeanDefinitionBuilder
+ .genericBeanDefinition(LndCommonClient.class, () -> commonClient)
+ .getBeanDefinition();
+
+ beanCustomizer.customize(synchronousLndAPIDefinition);
+ beanCustomizer.customize(lndNodeCommonClientDefinition);
+
+ registry.registerBeanDefinition("%sLndNodeSynchronousLndAPI".formatted(beanPrefix), synchronousLndAPIDefinition);
+ registry.registerBeanDefinition("%sLightningCommonClient".formatted(beanPrefix), lndNodeCommonClientDefinition);
+ registry.registerBeanDefinition("%sNodeInfo".formatted(beanPrefix), nodeInfoDefinition);
+ }
+
+ abstract protected LndRpcConfig createLndRpcConfig(SslContext sslContext, MacaroonContext macaroonContext);
+
+ abstract protected byte[] tlsCert();
+
+ abstract protected byte[] rpcMacaroon();
+
+ @Override
+ protected Integer p2pPort() {
+ return LightningNetworkConstants.LND_DEFAULT_REGTEST_P2P_PORT;
+ }
+
+ private MacaroonContext createMacaroonContext(byte[] rpcMacaroon) {
+ String hex = HexFormat.of().formatHex(rpcMacaroon);
+ return () -> hex;
+ }
+
+ private SslContext createSslContext(byte[] tlsCert) {
+ try (ByteArrayInputStream certStream = new ByteArrayInputStream(tlsCert)) {
+ return GrpcSslContexts.configure(SslContextBuilder.forClient(), SslProvider.OPENSSL)
+ .trustManager(certStream)
+ .build();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private SynchronousLndAPI createSynchronousLndAPI(LndRpcConfig rpcConfig) {
+ return new SynchronousLndAPI(
+ rpcConfig.getHost(),
+ rpcConfig.getPort(),
+ rpcConfig.getSslContext(),
+ rpcConfig.getMacaroonContext());
+ }
+
+ private SynchronousWalletKitAPI createSynchronousLndWalletKitAPI(LndRpcConfig rpcConfig) {
+ return new SynchronousWalletKitAPI(
+ rpcConfig.getHost(),
+ rpcConfig.getPort(),
+ rpcConfig.getSslContext(),
+ rpcConfig.getMacaroonContext());
+ }
+
+ private SynchronousRouterAPI createSynchronousLndRouterAPI(LndRpcConfig rpcConfig) {
+ return new SynchronousRouterAPI(
+ rpcConfig.getHost(),
+ rpcConfig.getPort(),
+ rpcConfig.getSslContext(),
+ rpcConfig.getMacaroonContext());
+ }
+
+ private SynchronousInvoicesAPI createSynchronousLndInvoiceAPI(LndRpcConfig rpcConfig) {
+ return new SynchronousInvoicesAPI(
+ rpcConfig.getHost(),
+ rpcConfig.getPort(),
+ rpcConfig.getSslContext(),
+ rpcConfig.getMacaroonContext());
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractNodeRegistrar.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractNodeRegistrar.java
new file mode 100644
index 000000000..316db6177
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/AbstractNodeRegistrar.java
@@ -0,0 +1,21 @@
+package org.tbk.lightning.regtest.setup;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.config.BeanDefinitionCustomizer;
+import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
+
+@Slf4j
+abstract class AbstractNodeRegistrar implements ImportBeanDefinitionRegistrar {
+
+ protected BeanDefinitionCustomizer beanDefinitionCustomizer() {
+ return bd -> {
+ // empty on purpose
+ };
+ }
+
+ abstract protected String beanNamePrefix();
+
+ abstract protected String hostname();
+
+ abstract protected Integer p2pPort();
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/ChannelDefinition.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/ChannelDefinition.java
new file mode 100644
index 000000000..7ccf3589c
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/ChannelDefinition.java
@@ -0,0 +1,37 @@
+package org.tbk.lightning.regtest.setup;
+
+import fr.acinq.bitcoin.Satoshi;
+import fr.acinq.lightning.MilliSatoshi;
+import lombok.Builder;
+import lombok.NonNull;
+import lombok.Value;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+import org.tbk.lightning.regtest.core.MoreMilliSatoshi;
+
+import javax.annotation.Nullable;
+import java.util.Objects;
+
+@Value
+@Builder
+public class ChannelDefinition {
+
+ @NonNull
+ NodeInfo origin;
+
+ @NonNull
+ NodeInfo destination;
+
+ @NonNull
+ Satoshi capacity;
+
+ @Builder.Default
+ boolean announced = true;
+
+ @Nullable
+ MilliSatoshi pushAmount;
+
+ public MilliSatoshi getPushAmount() {
+ return Objects.requireNonNullElse(pushAmount, MoreMilliSatoshi.ZERO);
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/NodeInfo.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/NodeInfo.java
new file mode 100644
index 000000000..e627f16f1
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/NodeInfo.java
@@ -0,0 +1,20 @@
+package org.tbk.lightning.regtest.setup;
+
+import lombok.Builder;
+import lombok.NonNull;
+import lombok.Value;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+
+@Value
+@Builder
+public class NodeInfo {
+
+ @NonNull
+ String hostname;
+
+ @NonNull
+ Integer p2pPort;
+
+ @NonNull
+ LightningCommonClient client;
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/RegtestLightningNetworkSetup.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/RegtestLightningNetworkSetup.java
new file mode 100644
index 000000000..7cc657123
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/RegtestLightningNetworkSetup.java
@@ -0,0 +1,524 @@
+package org.tbk.lightning.regtest.setup;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.ByteString;
+import fr.acinq.bitcoin.Satoshi;
+import fr.acinq.lightning.MilliSatoshi;
+import io.grpc.StatusRuntimeException;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.bitcoinj.core.Address;
+import org.bitcoinj.core.Coin;
+import org.bitcoinj.params.RegTestParams;
+import org.consensusj.bitcoin.json.pojo.BlockChainInfo;
+import org.consensusj.bitcoin.jsonrpc.BitcoinExtendedClient;
+import org.tbk.bitcoin.regtest.BitcoindRegtestTestHelper;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.client.common.core.proto.*;
+import org.tbk.lightning.regtest.core.LightningNetworkConstants;
+import org.tbk.lightning.regtest.setup.util.PaymentRouteVerifier;
+import org.tbk.lightning.regtest.setup.util.RouteVerification;
+import org.tbk.lightning.regtest.setup.util.SimplePaymentRouteVerifier;
+import reactor.core.publisher.Flux;
+import reactor.core.scheduler.Schedulers;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.*;
+
+@Slf4j
+public class RegtestLightningNetworkSetup {
+
+ private static final PaymentRouteVerifier routeVerifier = new SimplePaymentRouteVerifier();
+
+
+ private static String hex(ByteString val) {
+ return HexFormat.of().formatHex(val.toByteArray());
+ }
+
+ @NonNull
+ private final BitcoinExtendedClient bitcoinClient;
+
+ @NonNull
+ private final List channelDefinitions;
+
+ @NonNull
+ private final List routeVerifications;
+
+ private final OnchainFaucet onchainFaucet;
+
+ public RegtestLightningNetworkSetup(@NonNull BitcoinExtendedClient bitcoinClient, @NonNull List channelDefinitions, @NonNull List routeVerifications) {
+ this.bitcoinClient = bitcoinClient;
+ this.channelDefinitions = channelDefinitions;
+ this.routeVerifications = routeVerifications;
+ this.onchainFaucet = new OnchainFaucet(bitcoinClient);
+ }
+
+ private final NodeInfos nodeInfos = new NodeInfos();
+
+ public void run() throws IOException {
+ log.info("Will now setup a local lightning network…");
+ this.beforeSetup();
+ this.setupPeers();
+ this.setupChannels();
+ this.printSetupSummary();
+ this.waitForRoutes();
+ this.afterSetup();
+ log.info("Successfully finished setting up local lightning network.");
+ }
+
+ private void beforeSetup() throws IOException {
+ BitcoindRegtestTestHelper.createDescriptorWallet(bitcoinClient, "");
+
+ // it seems to be necessary to mine a single block and wait for the nodes to synchronize.
+ // otherwise, LND seems to be stuck on regtest indefinitely (last check: 2023-12-02).
+ log.debug("Mine a single block and await node synchronization…");
+ bitcoinClient.generateToAddress(1, bitcoinClient.getNewAddress());
+ waitForNodesBlockHeightSynchronization();
+ log.debug("Mine a single block and await node synchronization: Done.");
+
+ log.debug("Initialize on-chain faucet and await node synchronization…");
+ onchainFaucet.init();
+ waitForNodesBlockHeightSynchronization();
+ log.debug("Initialize on-chain faucet and await node synchronization: Done.");
+ }
+
+ private void afterSetup() {
+ nodeInfos.cleanUp();
+ }
+
+ private void waitForRoutes() {
+ for (RouteVerification routeVerification : routeVerifications) {
+ routeVerifier.waitForRouteOrThrow(routeVerification);
+ }
+ }
+
+ private List nodes() {
+ return channelDefinitions.stream()
+ .flatMap(it -> Stream.of(it.getOrigin(), it.getDestination()))
+ .distinct()
+ .sorted(Comparator.comparing(nodeInfos::nodeAlias))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * This method will connect all nodes provided according to the channel definitions.
+ */
+ private void setupPeers() {
+ log.debug("Will now connect peers…");
+
+ List> peers = ImmutableList.>builder()
+ .addAll(channelDefinitions.stream()
+ .map(it -> Tuples.of(it.getOrigin(), it.getDestination()))
+ .toList())
+ .build();
+
+ for (Tuple2 entry : peers) {
+ String originNodeName = nodeInfos.nodeAlias(entry.getT1());
+ String targetNodeName = nodeInfos.nodeAlias(entry.getT2());
+
+ log.debug("Will now connect {} with {}…", originNodeName, targetNodeName);
+ connectPeers(entry.getT1(), entry.getT2());
+ log.debug("{} is connected to peer {}", originNodeName, targetNodeName);
+ }
+
+ log.debug("Successfully finished connecting peers.");
+ }
+ private void connectPeers(NodeInfo origin, NodeInfo dest) {
+ try {
+ CommonConnectResponse ignoredOnPurpose = tryConnectPeers(origin, dest);
+ } catch (Exception e) {
+ // LND error if peer is already connected: `UNKNOWN: already connected to peer: ${pubkey}@${ip}:${port}`
+ boolean isAlreadyConnected = e.getMessage().contains("already connected to peer");
+ if (!isAlreadyConnected) {
+ throw e;
+ }
+ }
+ }
+
+ private CommonConnectResponse tryConnectPeers(NodeInfo origin, NodeInfo dest) {
+ return origin.getClient().connect(CommonConnectRequest.newBuilder()
+ .setIdentityPubkey(nodeInfos.nodeIdBytes(dest))
+ .setHost(dest.getHostname())
+ .setPort(dest.getP2pPort())
+ .build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+ }
+
+ private void printSetupSummary() {
+ Collection nodes = nodes();
+
+ log.info("### Network summary ###");
+ for (NodeInfo node : nodes) {
+ log.info("{}: {}", nodeInfos.nodeAlias(node), nodeInfos.nodeIdHex(node));
+ }
+
+ for (NodeInfo node : nodes) {
+ printNodeSummary(node);
+ }
+ log.info("### end - Network summary - end ###");
+ }
+
+ private void printNodeSummary(NodeInfo node) {
+ String nodeName = nodeInfos.nodeAlias(node);
+
+ log.info("#### {} summary ####", nodeName);
+ CommonListPeersResponse listpeersResponse = node.getClient().listPeers(CommonListPeersRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+ log.info(" {} peers: {}", nodeName, listpeersResponse.getPeersList().stream()
+ .map(it -> nodeInfos.nodeAliasByNodeId(it.getIdentityPubkey()))
+ .collect(Collectors.joining(", ")));
+
+ CommonListPeerChannelsResponse peerChannels = listPeerChannels(node);
+ log.info(" {} channel count: {}", nodeName, peerChannels.getPeerChannelsCount());
+
+ peerChannels.getPeerChannelsList().forEach(it -> {
+ boolean incoming = !it.getInitiator();
+ log.info(" - {} {} channel {} {}--{} {} (active: {}, capacity: {}, local_balance: {}, spendable: {}, receivable: {})",
+ incoming ? "Incoming" : "Outgoing",
+ it.getAnnounced() ? "public" : "unannounced",
+ nodeName,
+ incoming ? "<" : "", incoming ? "" : ">",
+ nodeInfos.nodeAliasByNodeId(it.getRemoteIdentityPubkey()),
+ it.getActive(),
+ new MilliSatoshi(it.getCapacityMsat()),
+ new MilliSatoshi(it.getLocalBalanceMsat()),
+ new MilliSatoshi(it.getEstimatedSpendableMsat()),
+ new MilliSatoshi(it.getEstimatedReceivableMsat()));
+ });
+ }
+
+ private void setupChannels() throws IOException {
+ beforeChannelSetup();
+ setupChannels(channelDefinitions);
+ afterChannelSetup();
+ }
+
+ private void beforeChannelSetup() throws IOException {
+ fundOnchainWallets(this.channelDefinitions);
+ }
+
+ private void afterChannelSetup() throws IOException {
+ // mine a few more blocks to confirm the channels.
+ // even if we do not need to mine any block, mine at least one just to confirm the nodes are syncing correctly!
+ int minBlocks = 1;
+
+ List nodesWithChannelsAwaitingConfirmation = channelDefinitions.stream()
+ .map(it -> List.of(it.getOrigin(), it.getDestination()))
+ .flatMap(Collection::stream)
+ .distinct()
+ .toList();
+
+ int numBlocks = Math.max(minBlocks, nodesWithChannelsAwaitingConfirmation.stream()
+ .mapToInt(it -> {
+ // TODO: get the "funding-confirms" config value form `listConfigs`, once it is available via gRPC
+ // e.g. via `it.listConfigs("funding-confirms")`
+ // till that, return the default value
+ return LightningNetworkConstants.CLN_DEFAULT_CHANNEL_FUNDING_TX_MIN_CONFIRMATIONS;
+ })
+ .max()
+ .orElse(LightningNetworkConstants.CLN_DEFAULT_CHANNEL_FUNDING_TX_MIN_CONFIRMATIONS));
+
+ Address bitcoinNodeAddress = bitcoinClient.getNewAddress();
+ bitcoinClient.generateToAddress(numBlocks, bitcoinNodeAddress);
+ waitForNodesBlockHeightSynchronization();
+ }
+
+ private void fundOnchainWallets(List definitions) throws IOException {
+ List missingChannels = definitions.stream()
+ .filter(this::needsCreation)
+ .toList();
+
+ Map nodesThatNeedFunding = missingChannels.stream()
+ .collect(groupingBy(ChannelDefinition::getOrigin, mapping(
+ ChannelDefinition::getCapacity,
+ reducing(new Satoshi(0), Satoshi::plus)
+ )));
+
+ for (Map.Entry entry : nodesThatNeedFunding.entrySet()) {
+ // control at least as many utxos as the amount of channels to be opened
+ // plus one more to close anchor channels (CLN calls this "min-emergency-msat")
+ int minUtxos = 1 + Math.toIntExact(missingChannels.stream()
+ .filter(it -> entry.getKey().equals(it.getOrigin()))
+ .count());
+ Satoshi feeBuffer = new Satoshi(210_000);
+ // Amount of funds to keep in the wallet to close anchor channels (which don't carry their own transaction fees).
+ // For CLN, this defaults to 25000sat (last checked on 2024-10-10, see https://docs.corelightning.org/reference/lightningd-config#lightning-channel-and-htlc-options)
+ Satoshi minEmergencyFunds = new Satoshi(25_000);
+ Satoshi fundingAmount = entry.getValue().plus(feeBuffer).plus(minEmergencyFunds);
+ fundOnchainWallet(entry.getKey(), fundingAmount, minUtxos);
+ }
+
+ if (!nodesThatNeedFunding.isEmpty()) {
+ int numBlocks = 6;
+ log.debug("Will now mine {} more blocks to confirm the funding UTXOs…", numBlocks);
+ Address bitcoinNodeAddress = bitcoinClient.getNewAddress();
+ bitcoinClient.generateToAddress(numBlocks, bitcoinNodeAddress);
+ waitForNodesBlockHeightSynchronization();
+ }
+ }
+
+ private void fundOnchainWallet(NodeInfo target, Satoshi minAmount, int minUtxos) throws IOException {
+ // reusing the address here is okay (not taking privacy on regtest too seriously on purpose)
+ String address = target.getClient().newAddress(CommonNewAddressRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow()
+ .getAddress();
+
+ for (int i = 0; i < minUtxos; i++) {
+ this.onchainFaucet.send(Address.fromString(RegTestParams.get(), address), minAmount);
+ }
+ }
+
+ private void setupChannels(List channelDefinitions) {
+ for (ChannelDefinition definition : channelDefinitions) {
+ createChannelIfNecessary(definition);
+ }
+ }
+
+ private void createChannelIfNecessary(ChannelDefinition definition) {
+ boolean needsChannelCreation = needsCreation(definition);
+ if (needsChannelCreation) {
+ createChannel(definition);
+ }
+
+ log.debug("Successfully finished setting up lightning channel {} -- {} --> {}: {}.",
+ nodeInfos.nodeAlias(definition.getOrigin()), definition.getCapacity(),
+ nodeInfos.nodeAlias(definition.getDestination()),
+ needsChannelCreation ? "Created" : "Channel already present");
+ }
+
+ private boolean needsCreation(ChannelDefinition definition) {
+ List outgoingChannels = listOutgoingChannels(definition.getOrigin());
+ Optional channelOrEmpty = outgoingChannels.stream()
+ .filter(it -> nodeInfos.nodeIdHex(definition.getDestination()).equals(hex(it.getRemoteIdentityPubkey())))
+ .filter(it -> it.getCapacityMsat() == new MilliSatoshi(definition.getCapacity()).getMsat())
+ .findFirst();
+
+ return channelOrEmpty.isEmpty();
+ }
+
+ private CommonOpenChannelResponse createChannel(ChannelDefinition definition) {
+ return createChannelWithRetryOnErrors(definition, false);
+ }
+
+ private CommonOpenChannelResponse createChannelWithRetryOnErrors(ChannelDefinition definition) {
+ return createChannelWithRetryOnErrors(definition, true);
+ }
+
+ private CommonOpenChannelResponse createChannelWithRetryOnErrors(ChannelDefinition definition, boolean retryOnPeerOfflineError) {
+ try {
+ CommonOpenChannelResponse openChannelResponse = tryCreateChannel(definition);
+
+ String channelOutpoint = "%s:%d".formatted(hex(openChannelResponse.getTxid()), openChannelResponse.getOutputIndex());
+ log.debug("Created channel with capacity {}: {} (pushed {})", definition.getCapacity(), channelOutpoint, definition.getPushAmount());
+
+ return openChannelResponse;
+ } catch (Exception e) {
+ ByteString destinationNodeId = nodeInfos.nodeIdBytes(definition.getDestination());
+ // LND error if peer is offline: `UNKNOWN: peer ${pubkey} is not online`
+ String peerNotOnlineErrorMessage = "peer %s is not online".formatted(hex(destinationNodeId));
+ boolean isPeerOfflineError = e.getMessage().contains(peerNotOnlineErrorMessage);
+ if (retryOnPeerOfflineError && isPeerOfflineError) {
+ log.warn("Channel creation from '{}' to '{}' failed - reconnecting peers and retry!",
+ nodeInfos.nodeAlias(definition.getOrigin()),
+ nodeInfos.nodeAlias(definition.getDestination())
+ );
+ connectPeers(definition.getOrigin(), definition.getDestination());
+ connectPeers(definition.getDestination(), definition.getOrigin());
+ return createChannelWithRetryOnErrors(definition, false);
+ } else {
+ String errorMessage = "Error while opening channel from '%s' to '%s'".formatted(
+ nodeInfos.nodeAlias(definition.getOrigin()),
+ nodeInfos.nodeAlias(definition.getDestination())
+ );
+ log.error(errorMessage, e);
+ throw e;
+ }
+ }
+ }
+
+ private CommonOpenChannelResponse tryCreateChannel(ChannelDefinition definition) {
+ Satoshi onchainFunds = fetchOnchainFunds(definition.getOrigin().getClient());
+ log.debug("{} controls on-chain funds amounting to {}", nodeInfos.nodeAlias(definition.getOrigin()), onchainFunds);
+
+ if (onchainFunds.getSat() <= definition.getCapacity().getSat()) {
+ throw new IllegalStateException("Not enough funds: Cannot create channel of size " + definition.getCapacity());
+ }
+
+ CommonOpenChannelResponse openChannelResponse = definition.getOrigin().getClient().openChannel(CommonOpenChannelRequest.newBuilder()
+ .setIdentityPubkey(nodeInfos.nodeIdBytes(definition.getDestination()))
+ .setAmountMsat(new MilliSatoshi(definition.getCapacity()).getMsat())
+ .setPushMsat(definition.getPushAmount().getMsat())
+ .setAnnounce(definition.isAnnounced())
+ .build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ String channelOutpoint = "%s:%d".formatted(hex(openChannelResponse.getTxid()), openChannelResponse.getOutputIndex());
+ log.debug("Created channel with capacity {}: {} (pushed {})", definition.getCapacity(), channelOutpoint, definition.getPushAmount());
+
+ return openChannelResponse;
+ }
+
+ private Satoshi fetchOnchainFunds(LightningCommonClient node) {
+ CommonListUnspentResponse response = node.listUnspent(CommonListUnspentRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ return new MilliSatoshi(response.getUnspentOutputsList().stream()
+ .mapToLong(UnspentOutput::getAmountMsat)
+ .sum()).truncateToSatoshi();
+ }
+
+ private List listOutgoingChannels(NodeInfo node) {
+ return node.getClient().listPeerChannels(CommonListPeerChannelsRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow()
+ .getPeerChannelsList().stream()
+ .filter(PeerChannel::getInitiator)
+ .toList();
+ }
+
+ private CommonListPeerChannelsResponse listPeerChannels(NodeInfo node) {
+ return node.getClient().listPeerChannels(CommonListPeerChannelsRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+ }
+
+ private void waitForNodesBlockHeightSynchronization() throws IOException {
+ waitForNodeBlockHeightSynchronization(bitcoinClient, nodes());
+ }
+
+ // wait for cln nodes to catch up to the newest block height.
+ // prevents `io.grpc.StatusRuntimeException: RpcError { code: Some(304), message: "Still syncing with bitcoin network" }`
+ private static void waitForNodeBlockHeightSynchronization(BitcoinExtendedClient bitcoin, Collection lnNodes) throws IOException {
+ BlockChainInfo blockChainInfo = bitcoin.getBlockChainInfo();
+ int currentBlockHeight = blockChainInfo.getBlocks();
+
+ // does not need to be more often than every 5 seconds - cln can take quite long to synchronize.
+ Duration checkInterval = Duration.ofSeconds(5);
+ // cln sometimes takes up to ~30 seconds when catching up to more than 100 blocks.
+ // lnd can take even longer as it fetches blocks every 1 seconds if it missed blocks via zmq.
+ Duration timeout = Duration.ofMinutes(3).plusSeconds(2L * currentBlockHeight);
+
+ lnNodes.forEach(it -> waitForNodeBlockHeightSynchronization(it.getClient(), currentBlockHeight, checkInterval, timeout));
+ }
+
+ private static void waitForNodeBlockHeightSynchronization(LightningCommonClient client,
+ int minBlockHeight,
+ Duration checkInterval,
+ Duration timeout) {
+ Flux.interval(Duration.ZERO, checkInterval)
+ .subscribeOn(Schedulers.newSingle("wait-for-block-sync"))
+ .map(it -> {
+ try {
+ CommonInfoResponse info = requireNonNull(client.info(CommonInfoRequest.newBuilder().build())
+ .block(Duration.ofSeconds(30)));
+ boolean hasSyncWarning = !info.getWarningBlockSync().isEmpty();
+ boolean blockHeightInSync = info.getBlockheight() >= minBlockHeight;
+ boolean finished = !hasSyncWarning && blockHeightInSync;
+
+ log.debug("Waiting for block height to reach {} on {}, currently at height {} (warning={}): {}",
+ minBlockHeight, info.getAlias(), info.getBlockheight(),
+ info.getWarningBlockSync(),
+ finished ? "Done" : "Still waiting…");
+
+ return finished;
+ } catch (StatusRuntimeException e) {
+ log.warn("Exception while waiting for block height synchronization: {}", e.getMessage());
+ return false;
+ }
+ })
+ .filter(it -> it)
+ .blockFirst(timeout);
+ }
+
+ @RequiredArgsConstructor
+ private static class OnchainFaucet {
+
+ @NonNull
+ private final BitcoinExtendedClient bitcoinClient;
+
+ private Address address;
+
+ public void init() throws IOException {
+ BitcoindRegtestTestHelper.createDescriptorWallet(bitcoinClient, "");
+
+ this.address = bitcoinClient.getNewAddress();
+
+ BlockChainInfo blockChainInfo = bitcoinClient.getBlockChainInfo();
+ if (blockChainInfo.getBlocks() < 100) {
+ bitcoinClient.generateToAddress(100 - blockChainInfo.getBlocks(), this.address);
+ }
+ this.mineTillBalanceIsPresent(new Satoshi(1));
+ }
+
+ private void mineTillBalanceIsPresent(Satoshi amount) throws IOException {
+ Coin balance = bitcoinClient.getBalance();
+ while (balance.isLessThan(Coin.ofSat(amount.getSat()))) {
+ bitcoinClient.generateToAddress(1, this.address);
+ balance = bitcoinClient.getBalance();
+ }
+ }
+
+ public void send(Address address, Satoshi amount) throws IOException {
+ requireNonNull(this.address, "`address` must not be null. Forgot to call `init`?");
+ this.mineTillBalanceIsPresent(amount);
+ this.bitcoinClient.sendToAddress(address, Coin.ofSat(amount.getSat()));
+ }
+ }
+
+ private static class NodeInfos {
+
+ private final LoadingCache initialClientInfo = CacheBuilder.newBuilder()
+ .build(new CacheLoader<>() {
+ @Override
+ public CommonInfoResponse load(@NonNull LightningCommonClient client) {
+ return client.info(CommonInfoRequest.newBuilder().build())
+ .block(Duration.ofSeconds(30));
+ }
+ });
+
+ public CommonInfoResponse infoByNodeId(ByteString nodeId) {
+ return initialClientInfo.asMap().values().stream()
+ .filter(it -> nodeId.equals(it.getIdentityPubkey()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("Did not find node with given id"));
+ }
+
+ public String nodeAlias(NodeInfo node) {
+ return initialClientInfo.getUnchecked(node.getClient()).getAlias();
+ }
+
+ public String nodeAliasByNodeId(ByteString nodeId) {
+ return infoByNodeId(nodeId).getAlias();
+ }
+
+ public ByteString nodeIdBytes(NodeInfo node) {
+ return initialClientInfo.getUnchecked(node.getClient()).getIdentityPubkey();
+ }
+
+ public String nodeIdHex(NodeInfo node) {
+ return hex(nodeIdBytes(node));
+ }
+
+ public void cleanUp() {
+ initialClientInfo.invalidateAll();
+ initialClientInfo.cleanUp();
+ }
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/PaymentRouteVerifier.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/PaymentRouteVerifier.java
new file mode 100644
index 000000000..fbfb5fd7e
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/PaymentRouteVerifier.java
@@ -0,0 +1,10 @@
+package org.tbk.lightning.regtest.setup.util;
+
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.client.common.core.proto.CommonQueryRouteResponse;
+
+public interface PaymentRouteVerifier {
+ boolean hasDirectRoute(LightningCommonClient origin, LightningCommonClient destination);
+
+ CommonQueryRouteResponse waitForRouteOrThrow(RouteVerification routeVerification);
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/RouteVerification.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/RouteVerification.java
new file mode 100644
index 000000000..9c39e798f
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/RouteVerification.java
@@ -0,0 +1,49 @@
+package org.tbk.lightning.regtest.setup.util;
+
+import lombok.Builder;
+import lombok.NonNull;
+import lombok.Value;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.cln.grpc.client.NodeGrpc;
+import org.tbk.lightning.regtest.setup.NodeInfo;
+
+import java.time.Duration;
+
+@Value
+@Builder
+public class RouteVerification {
+
+ @NonNull
+ NodeInfo origin;
+
+ @NonNull
+ NodeInfo destination;
+
+ @Builder.Default
+ Duration checkInterval = Duration.ofSeconds(2);
+
+ /**
+ * Default timeout to wait for route verification.
+ * This is rather long, as for non-direct peers, this might take quite a while.
+ * e.g.:
+ * - Found a route from A -> D after 145 tries and 2.400 min
+ * - Found a route from A -> C after 88 tries and 1.450 min
+ */
+ @Builder.Default
+ Duration timeout = Duration.ofMinutes(5);
+
+ /**
+ * Configure whether the origin node should be connected to the destination node. Default is `true`.
+ *
+ * @implNote Connecting peers before verifying routes can decrease the time to find a route substantially,
+ * as channel announcements gossiped will be received earlier. e.g.:
+ * **without** connecting peers:
+ * - Found route(s) from A -> Z after 147 tries and 2.434 min
+ * - Found route(s) from A -> Z after 120 tries and 1.983 min
+ * *with* connecting peers:
+ * - Found route(s) from A -> Z after 62 tries and 1.017 min
+ * - Found route(s) from A -> Z after 89 tries and 1.467 min
+ */
+ @Builder.Default
+ boolean enableConnectingPeers = true;
+}
diff --git a/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/SimplePaymentRouteVerifier.java b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/SimplePaymentRouteVerifier.java
new file mode 100644
index 000000000..e32ee43d7
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-setup/src/main/java/org/tbk/lightning/regtest/setup/util/SimplePaymentRouteVerifier.java
@@ -0,0 +1,76 @@
+package org.tbk.lightning.regtest.setup.util;
+
+import com.google.common.base.Stopwatch;
+import com.google.protobuf.ByteString;
+import fr.acinq.bitcoin.Satoshi;
+import fr.acinq.lightning.MilliSatoshi;
+import lombok.extern.slf4j.Slf4j;
+import org.tbk.lightning.client.common.core.LightningCommonClient;
+import org.tbk.lightning.client.common.core.proto.CommonInfoRequest;
+import org.tbk.lightning.client.common.core.proto.CommonInfoResponse;
+import org.tbk.lightning.client.common.core.proto.CommonQueryRouteRequest;
+import org.tbk.lightning.client.common.core.proto.CommonQueryRouteResponse;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+public class SimplePaymentRouteVerifier implements PaymentRouteVerifier {
+
+ @Override
+ public boolean hasDirectRoute(LightningCommonClient origin, LightningCommonClient destination) {
+ CommonInfoResponse destInfo = destination.info(CommonInfoRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ return getRouteInternal(origin, destInfo.getIdentityPubkey())
+ .map(it -> it.getRoutesCount() > 0)
+ .onErrorComplete()
+ .blockOptional(Duration.ofSeconds(30L))
+ .orElse(false);
+ }
+
+ @Override
+ public CommonQueryRouteResponse waitForRouteOrThrow(RouteVerification routeVerification) {
+ CommonInfoResponse originInfo = routeVerification.getOrigin().getClient().info(CommonInfoRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ CommonInfoResponse destInfo = routeVerification.getDestination().getClient().info(CommonInfoRequest.newBuilder().build())
+ .blockOptional(Duration.ofSeconds(30))
+ .orElseThrow();
+
+ log.debug("Waiting for a route from {} -> {} to become available…", originInfo.getAlias(), destInfo.getAlias());
+
+ AtomicInteger tries = new AtomicInteger();
+ Stopwatch sw = Stopwatch.createStarted();
+ CommonQueryRouteResponse route = Flux.interval(Duration.ZERO, Duration.ofSeconds(1L))
+ .doOnNext(it -> tries.incrementAndGet())
+ .flatMap(it -> getRouteInternal(routeVerification.getOrigin().getClient(), destInfo.getIdentityPubkey()).onErrorComplete())
+ .filter(it -> it.getRoutesCount() > 0)
+ .blockFirst(routeVerification.getTimeout());
+
+ if (route != null) {
+ log.debug("Found route(s) from {} -> {} after {} tries and {}: {}",
+ originInfo.getAlias(), destInfo.getAlias(), tries.get(), sw.stop(), route);
+ } else {
+ String error = "Could not find a route from %s -> %s after %s.."
+ .formatted(originInfo.getAlias(), destInfo.getAlias(), routeVerification.getTimeout());
+ throw new IllegalStateException(error);
+ }
+
+ return route;
+ }
+
+ private Mono getRouteInternal(LightningCommonClient origin, ByteString destId) {
+ // take 1 sat instead of 1 msat, as though CLN would allow it, it does not seem to work for LND nodes
+ MilliSatoshi amount = new MilliSatoshi(new Satoshi(1));
+
+ return origin.queryRoutes(CommonQueryRouteRequest.newBuilder()
+ .setAmountMsat(amount.getMsat())
+ .setRemoteIdentityPubkey(destId)
+ .build());
+ }
+}
diff --git a/lightning-regtest/lightning-regtest-starter/build.gradle b/lightning-regtest/lightning-regtest-starter/build.gradle
new file mode 100644
index 000000000..a016167b4
--- /dev/null
+++ b/lightning-regtest/lightning-regtest-starter/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+ id 'java'
+}
+
+description = 'lightning regtest starter package'
+
+dependencies {
+ api project(':lightning-regtest:lightning-regtest-core')
+ api project(':lightning-regtest:lightning-regtest-setup')
+ api project(':lightning-regtest:lightning-regtest-setup-devel')
+ //api project(':lightning-regtest:lightning-regtest-autoconfigure')
+
+ api project(':lnd-grpc-client:lnd-grpc-client-starter')
+ api project(':cln-grpc-client:cln-grpc-client-starter')
+
+ implementation 'org.springframework.boot:spring-boot-starter'
+}
diff --git a/lnd-grpc-client/lnd-grpc-client-core/build.gradle b/lnd-grpc-client/lnd-grpc-client-core/build.gradle
index 0db3f87e7..85716d451 100644
--- a/lnd-grpc-client/lnd-grpc-client-core/build.gradle
+++ b/lnd-grpc-client/lnd-grpc-client-core/build.gradle
@@ -8,7 +8,7 @@ dependencies {
api "org.lightningj:lightningj:${lightningjVersion}"
api "io.grpc:grpc-protobuf:${grpcVersion}"
- api "io.grpc:grpc-stub:$grpcVersion"
+ api "io.grpc:grpc-stub:${grpcVersion}"
api 'javax.json:javax.json-api:1.1.4'
api 'org.glassfish:javax.json:1.1.4'
}
diff --git a/settings.gradle b/settings.gradle
index 8de50180b..bd25235f3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -20,6 +20,16 @@ include 'bitcoin-zeromq-client:bitcoin-zeromq-client-autoconfigure'
include 'bitcoin-zeromq-client:bitcoin-zeromq-client-starter'
include 'bitcoin-zeromq-client:bitcoin-zeromq-client-example-application'
+include 'lightning-regtest:lightning-regtest-core'
+include 'lightning-regtest:lightning-regtest-setup'
+include 'lightning-regtest:lightning-regtest-setup-devel'
+include 'lightning-regtest:lightning-regtest-starter'
+include 'lightning-regtest:lightning-regtest-example-application'
+
+include 'lightning-commons:lightning-commons-client-core'
+include 'lightning-commons:lightning-commons-client-cln'
+include 'lightning-commons:lightning-commons-client-lnd'
+
include 'lnd-grpc-client:lnd-grpc-client-core'
include 'lnd-grpc-client:lnd-grpc-client-autoconfigure'
include 'lnd-grpc-client:lnd-grpc-client-starter'
diff --git a/spring-testcontainer/spring-testcontainer-cln-starter/src/main/java/org/tbk/spring/testcontainer/cln/config/ClnContainerAutoConfiguration.java b/spring-testcontainer/spring-testcontainer-cln-starter/src/main/java/org/tbk/spring/testcontainer/cln/config/ClnContainerAutoConfiguration.java
index 4a0714f62..18e9855f7 100644
--- a/spring-testcontainer/spring-testcontainer-cln-starter/src/main/java/org/tbk/spring/testcontainer/cln/config/ClnContainerAutoConfiguration.java
+++ b/spring-testcontainer/spring-testcontainer-cln-starter/src/main/java/org/tbk/spring/testcontainer/cln/config/ClnContainerAutoConfiguration.java
@@ -32,7 +32,6 @@
@ConditionalOnProperty(value = "org.tbk.spring.testcontainer.cln.enabled", havingValue = "true")
@AutoConfigureAfter(BitcoindContainerAutoConfiguration.class)
public class ClnContainerAutoConfiguration {
-
private final ClnContainerProperties properties;
public ClnContainerAutoConfiguration(ClnContainerProperties properties) {
diff --git a/tbk-bitcoin-common/tbk-bitcoin-common-util/src/main/java/org/tbk/bitcoin/common/util/ShutdownHooks.java b/tbk-bitcoin-common/tbk-bitcoin-common-util/src/main/java/org/tbk/bitcoin/common/util/ShutdownHooks.java
deleted file mode 100644
index b129ccfd9..000000000
--- a/tbk-bitcoin-common/tbk-bitcoin-common-util/src/main/java/org/tbk/bitcoin/common/util/ShutdownHooks.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package org.tbk.bitcoin.common.util;
-
-import lombok.extern.slf4j.Slf4j;
-
-import java.time.Duration;
-import java.util.List;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.TimeUnit;
-
-@Slf4j
-public final class ShutdownHooks {
-
- private ShutdownHooks() {
- throw new UnsupportedOperationException();
- }
-
- public static Thread shutdownHook(ExecutorService executor, Duration duration) {
- return new Thread(() -> {
- try {
- executor.shutdown();
- boolean success = executor.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS);
- if (!success) {
- List runnables = executor.shutdownNow();
- if (!runnables.isEmpty()) {
- log.error("Could not await " + runnables.size() + " tasks from terminating");
- }
- }
- } catch (InterruptedException e) {
- List runnables = executor.shutdownNow();
- log.error("Could not await " + runnables.size() + " tasks from terminating", e);
- }
- });
- }
-}