diff --git a/.run/LightningRegtestExampleApplication.run.xml b/.run/LightningRegtestExampleApplication.run.xml new file mode 100644 index 000000000..d2d42ac40 --- /dev/null +++ b/.run/LightningRegtestExampleApplication.run.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/bitcoin-regtest/bitcoin-regtest-autoconfigure/src/main/java/org/tbk/bitcoin/regtest/config/BitcoinRegtestAutoConfiguration.java b/bitcoin-regtest/bitcoin-regtest-autoconfigure/src/main/java/org/tbk/bitcoin/regtest/config/BitcoinRegtestAutoConfiguration.java index ff1375914..1f49a1dd0 100644 --- a/bitcoin-regtest/bitcoin-regtest-autoconfigure/src/main/java/org/tbk/bitcoin/regtest/config/BitcoinRegtestAutoConfiguration.java +++ b/bitcoin-regtest/bitcoin-regtest-autoconfigure/src/main/java/org/tbk/bitcoin/regtest/config/BitcoinRegtestAutoConfiguration.java @@ -42,8 +42,7 @@ BitcoinExtendedClient bitcoinRegtestClient(RpcConfig rpcConfig) { boolean isRegtest = configuredNetworkId.equals(requiredNetworkId); if (!isRegtest) { - String errorMessage = String.format("Bitcoin must be configured with network '%s' - got '%s'", - requiredNetworkId, configuredNetworkId); + String errorMessage = "Bitcoin must be configured with network '%s' - got '%s'".formatted(requiredNetworkId, configuredNetworkId); throw new BeanCreationNotAllowedException("bitcoinRegtestClient", errorMessage); } diff --git a/bitcoin-regtest/bitcoin-regtest-core/build.gradle b/bitcoin-regtest/bitcoin-regtest-core/build.gradle index 8fb12820d..8d4aa6719 100644 --- a/bitcoin-regtest/bitcoin-regtest-core/build.gradle +++ b/bitcoin-regtest/bitcoin-regtest-core/build.gradle @@ -14,5 +14,4 @@ dependencies { integTestImplementation project(':spring-testcontainer:spring-testcontainer-test') integTestImplementation project(':spring-testcontainer:spring-testcontainer-bitcoind-starter') - } diff --git a/bitcoin-regtest/bitcoin-regtest-core/src/main/java/org/tbk/bitcoin/regtest/mining/StaticCoinbaseRewardAddressSupplier.java b/bitcoin-regtest/bitcoin-regtest-core/src/main/java/org/tbk/bitcoin/regtest/mining/StaticCoinbaseRewardAddressSupplier.java index ff2e383b8..1d3bed3b9 100644 --- a/bitcoin-regtest/bitcoin-regtest-core/src/main/java/org/tbk/bitcoin/regtest/mining/StaticCoinbaseRewardAddressSupplier.java +++ b/bitcoin-regtest/bitcoin-regtest-core/src/main/java/org/tbk/bitcoin/regtest/mining/StaticCoinbaseRewardAddressSupplier.java @@ -5,12 +5,11 @@ import static java.util.Objects.requireNonNull; - @SuppressFBWarnings(value = {"EI_EXPOSE_REP"}, justification = "on purpose") public final class StaticCoinbaseRewardAddressSupplier implements CoinbaseRewardAddressSupplier { private final Address address; - + @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "class from external dependency") public StaticCoinbaseRewardAddressSupplier(Address client) { this.address = requireNonNull(client); @@ -21,4 +20,3 @@ public Address get() { return this.address; } } - diff --git a/bitcoin-zeromq-client/bitcoin-zeromq-client-core/src/test/java/org/tbk/bitcoin/zeromq/client/MessagePublishServiceTest.java b/bitcoin-zeromq-client/bitcoin-zeromq-client-core/src/test/java/org/tbk/bitcoin/zeromq/client/MessagePublishServiceTest.java index c524c2996..ef4c76f0a 100644 --- a/bitcoin-zeromq-client/bitcoin-zeromq-client-core/src/test/java/org/tbk/bitcoin/zeromq/client/MessagePublishServiceTest.java +++ b/bitcoin-zeromq-client/bitcoin-zeromq-client-core/src/test/java/org/tbk/bitcoin/zeromq/client/MessagePublishServiceTest.java @@ -37,7 +37,7 @@ void itShouldPublishGenesisBlock() throws TimeoutException, ExecutionException, genesisBlockPublishService.startAsync(); genesisBlockPublishService.awaitRunning(Duration.ofSeconds(10)); - List blocks = blocksRef.get(10, TimeUnit.SECONDS); + List blocks = blocksRef.get(20, TimeUnit.SECONDS); assertThat(blocks, hasSize(1)); assertThat(blocks.get(0), is(GenesisBlock.get().toByteArray())); diff --git a/build.gradle b/build.gradle index 0c3e08079..fe4fad4b9 100644 --- a/build.gradle +++ b/build.gradle @@ -372,10 +372,13 @@ configure(subprojects.findAll { project -> project.subprojects.isEmpty() && apply from: "${project.rootDir}/proto.gradle" } -// enable publishing for all subproject except for "example applications" +// enable publishing for all subprojects except for some not intended to be or not ready yet +// (e.g. "example applications" or modules still in beta) configure(subprojects.findAll { project -> project.subprojects.isEmpty() && !project.pluginManager.hasPlugin('org.springframework.boot') && - project.name.indexOf('-example-application') < 0 }) { + project.name.indexOf('-example-application') < 0 && + !project.name.startsWith('lightning-commons-') && + !project.name.startsWith('lightning-regtest-') }) { apply from: "${project.rootDir}/publish.gradle" } diff --git a/changelog.md b/changelog.md index 0c455834b..14601302a 100644 --- a/changelog.md +++ b/changelog.md @@ -79,6 +79,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - autoconfig property prefix of bitcoin jsonrpc cache changed from "jsonrpc.cache" to "jsonrpc-cache" - bitcoin-regtest-starter does not import testcontainer dependencies for bitcoin, electrumx and electrum-daemon automatically anymore +### Added +- module: initial version of modules `lightning-commons-client-*` + ### Changed - improved default specs for bitcoin jsonrpc client caches - upgrade: update spring-boot from v3.1.0 to v3.1.4 diff --git a/docker/regtest/lightning-regtest-setup-devel/README.md b/docker/regtest/lightning-regtest-setup-devel/README.md new file mode 100644 index 000000000..7a9a4a062 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/README.md @@ -0,0 +1,50 @@ +Local Regtest Lightning Network Docker Setup +=== + +## Bitcoin + +## Local Regtest Lightning Network +### Nodes +#### CLN 0 (app) +The lightning node controlled by the application. + +#### CLN 1 (Alice) +A user lightning node with a direct channel to the app node. + +#### CLN 2 (Bob) +A user lightning node with a direct channel to the app node. + +#### CLN 3 (Charlie) +A user lightning node with a direct channel to Bob and a private channel to Erin. + +#### CLN 4 (Dave) +A user lightning node _without_ channels. +This is node solely exists to test the specific behaviour when no route can be found. + +#### CLN 5 (Erin) +A node with a single incoming private channel from Charlie. + +#### LND 6 (Farid) +A user lightning node with a direct channel to Charlie. + + +### Channels +```mermaid +flowchart TB + app -->|16_777_215 sat| alice + app -->|8_388_607 sat| bob + bob -->|4_194_303 sat| charlie + farid -->|4_194_303 sat| bob + charlie -. private 2_097_151 sat .-> erin + app ~~~ dave + alice ~~~ dave + bob ~~~ dave + charlie ~~~ dave + erin ~~~ dave + farid ~~~ dave +``` + + +## Resources +- Node Personas: https://en.wikipedia.org/wiki/Alice_and_Bob#Cast_of_characters +- Mermaid Flowchart: https://mermaid.js.org/syntax/flowchart.html diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca-key.pem new file mode 100644 index 000000000..7fdb765cd --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXYu7d9TgoMJMSZ7h +u3OC3pIcdMPamt67+OjJTjLD0zmhRANCAAQnY8oKVvPhGTtsNh1/pOvAkSMREWnX +ZDNgYukgrbuvDoJtDmiubv7XyERb5+Lynyn11sTiMNsNu3uRvogm0nvo +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca.pem new file mode 100644 index 000000000..935301694 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBcDCCARegAwIBAgIIAT8Xud6bYGcwCgYIKoZIzj0EAwIwFjEUMBIGA1UEAwwL +Y2xuIFJvb3QgQ0EwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAxMDEwMDAwMDBaMBYx +FDASBgNVBAMMC2NsbiBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +J2PKClbz4Rk7bDYdf6TrwJEjERFp12QzYGLpIK27rw6CbQ5orm7+18hEW+fi8p8p +9dbE4jDbDbt7kb6IJtJ76KNNMEswGQYDVR0RBBIwEIIDY2xugglsb2NhbGhvc3Qw +HQYDVR0OBBYEFGdgm965Fz8Boy+QC4VcfjPkI32/MA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDRwAwRAIgKAuHASDF5NvIeBC6+cub55x3EafzlY81273lwPnE +ZGsCIDYXdvXtIVXgliItGCPux95/9AesKiO7zMg7BZwzm3b7 +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client-key.pem new file mode 100644 index 000000000..93af739b7 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+EzkF+0hjctnhlTC +NMs2zfouhYF1+Dm83QrRfVzdr8OhRANCAATqzv3qsEDMESI/y/8QUcQjyoFZGLzD +zprcwl5QMYrasuo8mkAK5TyGtz3uEj8I4mzXyS7sue85Stk6PCnfWmf9 +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client.pem new file mode 100644 index 000000000..be8be7c69 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/client.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBRTCB7KADAgECAgkAnI6iARFsYxkwCgYIKoZIzj0EAwIwFjEUMBIGA1UEAwwL +Y2xuIFJvb3QgQ0EwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAxMDEwMDAwMDBaMBox +GDAWBgNVBAMMD2NsbiBncnBjIENsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABOrO/eqwQMwRIj/L/xBRxCPKgVkYvMPOmtzCXlAxitqy6jyaQArlPIa3Pe4S +PwjibNfJLuy57zlK2To8Kd9aZ/2jHTAbMBkGA1UdEQQSMBCCA2NsboIJbG9jYWxo +b3N0MAoGCCqGSM49BAMCA0gAMEUCIF7s4Lm9dVWw4dCT+ytCcJMzOqW7UX2LqOEG +eB6llcfaAiEA/45KEZiP+z96eNKHLuBeb8yMUi7BRUMJ5vM8bTrdI3E= +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/hsm_secret new file mode 100644 index 000000000..bcdad2fee --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/hsm_secret @@ -0,0 +1 @@ +jqj bNNI!]X5`^ \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server-key.pem new file mode 100644 index 000000000..15f0d0849 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglJQWbG4UHnzwM45k +qtnScyBWMb9SwQQpIbdjKlZwuTuhRANCAAQFVq5oq8U43x/0qrLsQNB/dA91Nl5t +fTKIjhCgbODJtyNZBzCvCZfV3SjiSWZVUzQ6ovOACXMOkURspDVlw8+0 +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server.pem new file mode 100644 index 000000000..1002f6a12 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln0_app/regtest/server.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBRDCB66ADAgECAggMDiolY5j7zjAKBggqhkjOPQQDAjAWMRQwEgYDVQQDDAtj +bG4gUm9vdCBDQTAgFw03NTAxMDEwMDAwMDBaGA80MDk2MDEwMTAwMDAwMFowGjEY +MBYGA1UEAwwPY2xuIGdycGMgU2VydmVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD +QgAEBVauaKvFON8f9Kqy7EDQf3QPdTZebX0yiI4QoGzgybcjWQcwrwmX1d0o4klm +VVM0OqLzgAlzDpFEbKQ1ZcPPtKMdMBswGQYDVR0RBBIwEIIDY2xugglsb2NhbGhv +c3QwCgYIKoZIzj0EAwIDSAAwRQIgVi+1vCRVxN5UAN3iI6tyZmSgHXQIRcWu25Dz +lKICU70CIQDIbCYTMsX+JK5f/Om/7xdWVbZsy4AUkLNiHXREUiIIZA== +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln1_alice/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln1_alice/regtest/hsm_secret new file mode 100644 index 000000000..baa365067 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln1_alice/regtest/hsm_secret @@ -0,0 +1 @@ +z߉.{8Y /W=P2k1 \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln2_bob/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln2_bob/regtest/hsm_secret new file mode 100644 index 000000000..74f1886bb --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln2_bob/regtest/hsm_secret @@ -0,0 +1,2 @@ +]"A? +Feu߉'(Gx=u \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln3_charlie/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln3_charlie/regtest/hsm_secret new file mode 100644 index 000000000..5a8113aa5 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln3_charlie/regtest/hsm_secret @@ -0,0 +1 @@ +tm7AD[]De{{q \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln4_dave/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln4_dave/regtest/hsm_secret new file mode 100644 index 000000000..291faee1f --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln4_dave/regtest/hsm_secret @@ -0,0 +1 @@ +1BXdRIhFoNsSGw \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln5_erin/regtest/hsm_secret b/docker/regtest/lightning-regtest-setup-devel/data/cln5_erin/regtest/hsm_secret new file mode 100644 index 000000000..ae73dc1b4 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln5_erin/regtest/hsm_secret @@ -0,0 +1 @@ +10SӅ5"\59"j)%\* \ No newline at end of file diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca-key.pem new file mode 100644 index 000000000..5f3903f35 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvy/Up8PPYMOKvuWk +ZBzRUgrASUUQXxOKNDPoIT6qQOShRANCAASRbRUk5prLWlpWCvZfMhycJ0q90u73 +kfw/haJlQx/nCEPhmqtIFg8sU/u7owioEgGCL+Vbk5sAioxDPDnN1JTU +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca.pem new file mode 100644 index 000000000..cc97873a9 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBcTCCARegAwIBAgIICInqhhRSdIUwCgYIKoZIzj0EAwIwFjEUMBIGA1UEAwwL +Y2xuIFJvb3QgQ0EwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAxMDEwMDAwMDBaMBYx +FDASBgNVBAMMC2NsbiBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +kW0VJOaay1paVgr2XzIcnCdKvdLu95H8P4WiZUMf5whD4ZqrSBYPLFP7u6MIqBIB +gi/lW5ObAIqMQzw5zdSU1KNNMEswGQYDVR0RBBIwEIIDY2xugglsb2NhbGhvc3Qw +HQYDVR0OBBYEFIV0UhSG6okIaidO3f9q12HvouTBMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIgBDZCg1ksTH0barByrFANwShUoTHiKovmsQO8sG/e +VQICIQC7zJuaa/QvmD7b1XSz34A+geRK3Q3EsLlU4YNpodsKaw== +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client-key.pem new file mode 100644 index 000000000..096aaaa58 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRtzYq7kE4snLORoB +7YA2ABvOTn2t90/xmLzr8ofki8yhRANCAATh/XF+pI0laQ3IJKkchR3PxM0goDtP +ajRpKKAGfBQiipKLJE0OgWtdxO5dYvRRFutH/BFYKdB+5gJweKF/vQgh +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client.pem new file mode 100644 index 000000000..b340597ee --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/client.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBRjCB7KADAgECAgkAzCZRcesFmggwCgYIKoZIzj0EAwIwFjEUMBIGA1UEAwwL +Y2xuIFJvb3QgQ0EwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAxMDEwMDAwMDBaMBox +GDAWBgNVBAMMD2NsbiBncnBjIENsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABOH9cX6kjSVpDcgkqRyFHc/EzSCgO09qNGkooAZ8FCKKkoskTQ6Ba13E7l1i +9FEW60f8EVgp0H7mAnB4oX+9CCGjHTAbMBkGA1UdEQQSMBCCA2NsboIJbG9jYWxo +b3N0MAoGCCqGSM49BAMCA0kAMEYCIQDulTm56pbNb2hYRRoMvxo7dffvbsWIP7cS +CoBqPeAS/wIhALNVeVsEuXcgGbgFDjcV8xOljCE2a7lyq5A4sW9Q0YFM +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server-key.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server-key.pem new file mode 100644 index 000000000..48a7674ff --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOoEzc7KLI1WmiMCe +vDfVWADAjavAA3EiBvSEyEWCCd6hRANCAAQfvDyYvADVA9NnLG7shorcDKLSTit1 +HO/3Bxae5Rwkb56F3JgRQZRNL7QewzYkdcS+dOmpXJQXOqChblHLaF3x +-----END PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server.pem b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server.pem new file mode 100644 index 000000000..d61ea273f --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/cln_common/regtest/server.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBQzCB7KADAgECAgkAtS75duLswR8wCgYIKoZIzj0EAwIwFjEUMBIGA1UEAwwL +Y2xuIFJvb3QgQ0EwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAxMDEwMDAwMDBaMBox +GDAWBgNVBAMMD2NsbiBncnBjIFNlcnZlcjBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABB+8PJi8ANUD02csbuyGitwMotJOK3Uc7/cHFp7lHCRvnoXcmBFBlE0vtB7D +NiR1xL506alclBc6oKFuUctoXfGjHTAbMBkGA1UdEQQSMBCCA2NsboIJbG9jYWxo +b3N0MAoGCCqGSM49BAMCA0YAMEMCH3u9x++QrCVqm5Dd99QSZxgrNQqXJvAHQ8+G +2RKdj+kCIEPKSWo81qQApXjlGnQ0hCV7MM92jtgh9kAF2Jmtz4Hv +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.cert b/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.cert new file mode 100644 index 000000000..0907d9661 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.cert @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICJTCCAcugAwIBAgIRAPIqk/Gb1nTRlxBgK6HMi9owCgYIKoZIzj0EAwIwODEf +MB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMMmE5NmZl +YzZiZDM3MB4XDTIzMDgwNTIxNTk0MFoXDTQ5MTIzMTIzNTk1OVowODEfMB0GA1UE +ChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMMmE5NmZlYzZiZDM3 +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE683eR4iT2DsCIyI9hgfK+62KU//1 +Burm41ZZgd8tPtPqQnPun10Iw10HCg4U3brwQVnuUREJiG5k5/Sn5AxBcaOBtTCB +sjAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUmiOguB/zWQLgcRy5sR3nj+13I/MwWwYDVR0RBFQw +UoIMMmE5NmZlYzZiZDM3gglsb2NhbGhvc3SCBHVuaXiCCnVuaXhwYWNrZXSCB2J1 +ZmNvbm6HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAGHBKwZAAUwCgYIKoZIzj0EAwID +SAAwRQIgaCwvi2UHTbkA54/77nwlaTY0EqdnkbG+dEoW4WD54mACIQC9GP1frTkG +oR0ebUBpQ6cJPxQ/5BCIG3I7D1AG/j5avA== +-----END CERTIFICATE----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.key b/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.key new file mode 100644 index 000000000..3b6f97f13 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/lnd_common/tls.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFLbJLk5tErs0mZh1+1esClqINqlCOIQO97c0GB0m9uQoAoGCCqGSM49 +AwEHoUQDQgAE683eR4iT2DsCIyI9hgfK+62KU//1Burm41ZZgd8tPtPqQnPun10I +w10HCg4U3brwQVnuUREJiG5k5/Sn5AxBcQ== +-----END EC PRIVATE KEY----- diff --git a/docker/regtest/lightning-regtest-setup-devel/data/pg/init/1-create-additional-databases.sh b/docker/regtest/lightning-regtest-setup-devel/data/pg/init/1-create-additional-databases.sh new file mode 100755 index 000000000..e2cbce666 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/data/pg/init/1-create-additional-databases.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo " Creating user and database '$database'..." + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE ROLE $database WITH LOGIN ENCRYPTED PASSWORD '$database' VALID UNTIL 'infinity'; + CREATE DATABASE $database; + ALTER DATABASE $database OWNER TO $database; +EOSQL +} + +if [ -n "$POSTGRES_ADDITIONAL_DATABASES" ]; then + echo "Additional database creation requested: $POSTGRES_ADDITIONAL_DATABASES" + for db in $(echo $POSTGRES_ADDITIONAL_DATABASES | tr ',' ' '); do + create_user_and_database $db + done + echo "Additional databases created." +fi diff --git a/docker/regtest/lightning-regtest-setup-devel/docker-compose.yml b/docker/regtest/lightning-regtest-setup-devel/docker-compose.yml new file mode 100644 index 000000000..4c8fdde17 --- /dev/null +++ b/docker/regtest/lightning-regtest-setup-devel/docker-compose.yml @@ -0,0 +1,433 @@ +version: "3" + +services: + postgres: + restart: always + container_name: regtest_db + image: postgres:17.0-alpine3.20 + volumes: + - postgres-data:/postgresql_data + - ./data/pg/init:/docker-entrypoint-initdb.d/ + environment: + POSTGRES_ADDITIONAL_DATABASES: regtest_cln1_alice,regtest_cln2_bob,regtest_cln3_charlie,regtest_cln4_dave,regtest_cln5_erin,regtest_lnd6_farid,regtest_cln0_app + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + PGDATA: /postgresql_data + ports: + - "5889:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready", "--dbname=regtest_cln0_app" ] + interval: 10s + timeout: 5s + retries: 5 + + bitcoind: + container_name: regtest_bitcoind + image: polarlightning/bitcoind:27.0 + restart: always + volumes: + - bitcoind-data:/home/bitcoin/.bitcoin/regtest + command: + -regtest=1 + -server=1 + -whitelist=0.0.0.0/0 + -txindex=1 + -debug=rpc + -dns=0 + -dnsseed=0 + -networkactive=0 + -uacomment=tbkdevbitcoindregtest + -printtoconsole=1 + -printpriority=1 + -logtimemicros=1 + -rpcauth=regtest:169f6ba28badca1d912ac0defebc8ceb$$2550c68dfde8ca3b4892415fa8779b6ea656a44e7af1642332afa06c1979e014 + -rpcauth=app:f821d634f8b4c2fa2c63fb88a184960e$$225d98d20183093c377a6090c86b9a740380067d092a203831d5ce3ba168cc6c + -rpcauth=alice:5e191aea02d8e4ddc6ae20cd24f46032$$f9379455baec014bfc4010602b40609586ce2a6f09ede080f70cac9ef920b8da + -rpcauth=bob:15c79f76cfcf280680e24e708e743d41$$b529fc69466a868790ddbc17f7ecc76d894b60a3f7f8e58a8832932445a73cb9 + -rpcauth=charlie:c32657f44f0302f9f0facd25f730a151$$c90dbe5778cd65b23e4f5d931452fd4cfa4dc9bf695bb530de6d1533ec2ca8bb + -rpcauth=dave:94d43df76ac813b13c958f99fb0e44c4$$3dde024ded4adc112021ed97e4741c3710a52a8526076188c14b2358b594be05 + -rpcauth=erin:39558bb938c4932e9ab179d75552495e$$c2f8a80cf4496d340bf5777ce45183e6c05dd9470bf17e90ba9a3dcbfee223e2 + -rpcbind=0.0.0.0 + -rpcallowip=0.0.0.0/0 + -zmqpubrawblock=tcp://0.0.0.0:28332 + -zmqpubrawtx=tcp://0.0.0.0:28333 + -zmqpubhashblock=tcp://0.0.0.0:28334 + -zmqpubhashtx=tcp://0.0.0.0:28335 + -fallbackfee=0.00000253 + expose: + - "18443" + - "28332" + - "28333" + - "28334" + - "28335" + ports: + - "18443:18443" + - "28332:28332" + - "28333:28333" + - "28334:28334" + - "28335:28335" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "bitcoin-cli", "-chain=regtest", "-getinfo" ] + interval: 10s + retries: 5 + + cln0_app: + container_name: regtest_cln0_app + image: polarlightning/clightning:24.08.1 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + cln1_alice: + condition: service_started # service_healthy + cln2_bob: + condition: service_started # service_healthy + cln3_charlie: + condition: service_started # service_healthy + cln4_dave: + condition: service_started # service_healthy + cln5_erin: + condition: service_started # service_healthy + lnd6_farid: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln0_app' + - '--wallet=postgres://regtest_cln0_app:regtest_cln0_app@regtest_db:5432/regtest_cln0_app' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=app' + - '--bitcoin-rpcpassword=app' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--log-file=-' # means to log to stdout also! + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln0_app/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln0_app/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln0_app/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln0_app/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln0_app/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln0_app/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln0_app/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19935:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + cln1_alice: + container_name: regtest_cln1_alice + image: polarlightning/clightning:24.08.1 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln1_alice' + - '--wallet=postgres://regtest_cln1_alice:regtest_cln1_alice@regtest_db:5432/regtest_cln1_alice' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=alice' + - '--bitcoin-rpcpassword=alice' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--log-file=-' # means to log to stdout also! + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln1_alice/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln_common/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln_common/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln_common/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln_common/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln_common/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln_common/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19936:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + cln2_bob: + container_name: regtest_cln2_bob + #image: polarlightning/clightning:24.08.1 + # An LND instance will connect to this node and does not like version >v23.11.2 + # Always fails with "peer ${pubkey} is not online" (last checked: 2024-10-10) + image: polarlightning/clightning:23.11.2 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln2_bob' + - '--wallet=postgres://regtest_cln2_bob:regtest_cln2_bob@regtest_db:5432/regtest_cln2_bob' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=bob' + - '--bitcoin-rpcpassword=bob' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=-' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + #- '--disable-plugin=offers' # TODO: 'offers' plugin errors with v24.05 - disable temporarily (see https://github.com/ElementsProject/lightning/pull/7379) + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln2_bob/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln_common/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln_common/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln_common/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln_common/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln_common/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln_common/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19937:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + cln3_charlie: + container_name: regtest_cln3_charlie + image: polarlightning/clightning:24.08.1 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln3_charlie' + - '--wallet=postgres://regtest_cln3_charlie:regtest_cln3_charlie@regtest_db:5432/regtest_cln3_charlie' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=charlie' + - '--bitcoin-rpcpassword=charlie' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--log-file=-' # means to log to stdout also! + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln3_charlie/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln_common/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln_common/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln_common/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln_common/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln_common/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln_common/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19938:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + cln4_dave: + container_name: regtest_cln4_dave + image: polarlightning/clightning:24.08.1 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln4_dave' + - '--wallet=postgres://regtest_cln4_dave:regtest_cln4_dave@regtest_db:5432/regtest_cln4_dave' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=dave' + - '--bitcoin-rpcpassword=dave' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--log-file=-' # means to log to stdout also! + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln4_dave/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln_common/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln_common/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln_common/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln_common/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln_common/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln_common/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19939:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + cln5_erin: + container_name: regtest_cln5_erin + image: polarlightning/clightning:24.08.1 + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + environment: + LIGHTNINGD_POSTGRES_NO_VACUUM: 1 + command: + - '--alias=cln5_erin' # hack: use the container name as alias (to programmatically connect in setup) + - '--wallet=postgres://regtest_cln5_erin:regtest_cln5_erin@regtest_db:5432/regtest_cln5_erin' + - '--bitcoin-rpcconnect=regtest_bitcoind' + - '--bitcoin-rpcport=18443' + - '--bitcoin-rpcuser=erin' + - '--bitcoin-rpcpassword=erin' + - '--grpc-port=19935' + - '--network=regtest' + - '--log-level=debug' + - '--log-file=/home/clightning/.lightning/regtest/debug.log' # write log file for inspection + - '--log-file=-' # means to log to stdout also! + - '--funding-confirms=1' + - '--database-upgrade=true' # required if a non-release version wants to (irrevocably!) upgrade the db + - '--allow-deprecated-apis=false' + - '--disable-dns' + # see all dev options: https://github.com/ElementsProject/lightning/blob/v24.08.1/lightningd/options.c#L812 + - '--dev-fast-gossip' + - '--dev-fast-reconnect' + - '--dev-bitcoind-poll=1' + - '--dev-allow-localhost' # Announce and allow announcements for localhost address + - '--developer' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/cln5_erin/regtest/hsm_secret:/home/clightning/.lightning/regtest/hsm_secret:ro + - ./data/cln_common/regtest/ca.pem:/home/clightning/.lightning/regtest/ca.pem + - ./data/cln_common/regtest/ca-key.pem:/home/clightning/.lightning/regtest/ca-key.pem + - ./data/cln_common/regtest/client.pem:/home/clightning/.lightning/regtest/client.pem + - ./data/cln_common/regtest/client-key.pem:/home/clightning/.lightning/regtest/client-key.pem + - ./data/cln_common/regtest/server.pem:/home/clightning/.lightning/regtest/server.pem + - ./data/cln_common/regtest/server-key.pem:/home/clightning/.lightning/regtest/server-key.pem + ports: + - "19940:19935" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lightning-cli", "--network=regtest", "getinfo" ] + interval: 10s + retries: 5 + + lnd6_farid: + container_name: regtest_lnd6_farid + image: polarlightning/lnd:0.18.2-beta + restart: always + depends_on: + bitcoind: + condition: service_healthy + postgres: + condition: service_healthy + command: + - '--alias=lnd6_farid' + - '--bitcoin.active' + - '--bitcoin.regtest' + - '--bitcoin.node=bitcoind' + - '--bitcoin.defaultchanconfs=1' + - '--bitcoind.rpchost=regtest_bitcoind:18443' + - '--bitcoind.zmqpubrawblock=tcp://regtest_bitcoind:28332' + - '--bitcoind.zmqpubrawtx=tcp://regtest_bitcoind:28333' + - '--bitcoind.rpcuser=regtest' + - '--bitcoind.rpcpass=regtest' + - '--db.backend=postgres' + - '--db.postgres.dsn=postgresql://regtest_lnd6_farid:regtest_lnd6_farid@regtest_db:5432/regtest_lnd6_farid' + - '--db.postgres.timeout=0' + - '--noseedbackup' + - '--restlisten=regtest_lnd6_farid:8080' + - '--rpclisten=regtest_lnd6_farid:10009' + - '--rpclisten=localhost:10009' # needed for healthcheck + - '--tlscertduration=876000h' + - '--debuglevel=debug' + - '--maxpendingchannels=21' + - '--trickledelay=1000' + # Skip setting up macaroons for now, as it does not work properly and ends up with exception + # `io.grpc.StatusRuntimeException: UNKNOWN: verification failed: signature mismatch after caveat verification` + - '--no-macaroons' + volumes: + # mount all certs/keys individually (to avoid creating files on the host system by the container) + - ./data/lnd_common/tls.cert:/home/lnd/.lnd/tls.cert + - ./data/lnd_common/tls.key:/home/lnd/.lnd/tls.key + ports: + - "19841:8080" + - "19941:10009" + healthcheck: + test: [ "CMD", "/entrypoint.sh", "lncli", "--network=regtest", "--no-macaroons", "getinfo" ] + interval: 10s + retries: 5 + +volumes: + bitcoind-data: + postgres-data: diff --git a/lightning-commons/lightning-commons-client-cln/build.gradle b/lightning-commons/lightning-commons-client-cln/build.gradle new file mode 100644 index 000000000..2d1657fa2 --- /dev/null +++ b/lightning-commons/lightning-commons-client-cln/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' +} + +description = 'ln common client cln package' + +dependencies { + api project(':lightning-commons:lightning-commons-client-core') + api "io.github.theborakompanioni:cln-grpc-client-core:${clnGrpcClientVersion}" +} diff --git a/lightning-commons/lightning-commons-client-cln/src/main/java/org/tbk/lightning/client/common/cln/ClnCommonClient.java b/lightning-commons/lightning-commons-client-cln/src/main/java/org/tbk/lightning/client/common/cln/ClnCommonClient.java new file mode 100644 index 000000000..aff0d43ed --- /dev/null +++ b/lightning-commons/lightning-commons-client-cln/src/main/java/org/tbk/lightning/client/common/cln/ClnCommonClient.java @@ -0,0 +1,367 @@ +package org.tbk.lightning.client.common.cln; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.cln.grpc.client.*; +import reactor.core.publisher.Mono; + +import java.util.BitSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** + * See: CLN API Docs + */ +@Slf4j +@RequiredArgsConstructor +public class ClnCommonClient implements LightningCommonClient { + + @NonNull + private final NodeGrpc.NodeBlockingStub client; + + @Override + public Mono info(CommonInfoRequest request) { + return Mono.fromCallable(() -> { + GetinfoResponse response = client.getinfo(GetinfoRequest.newBuilder().build()); + return CommonInfoResponse.newBuilder() + .setIdentityPubkey(response.getId()) + .setAlias(response.getAlias()) + .setColor(response.getColor()) + .setNumPeers(response.getNumPeers()) + .setNumPendingChannels(response.getNumPendingChannels()) + .setNumActiveChannels(response.getNumActiveChannels()) + .setNumInactiveChannels(response.getNumInactiveChannels()) + .setVersion(response.getVersion()) + .addChain(Chain.newBuilder() + .setChain("bitcoin") + .setNetwork(response.getNetwork()) + .build()) + .setBlockheight(response.getBlockheight()) + .setWarningBlockSync(Optional.of(response.getWarningBitcoindSync()) + .filter(it -> !it.isBlank()) + .or(() -> Optional.of(response.getWarningLightningdSync()).filter(it -> !it.isBlank())) + .orElse("")) + .build(); + }); + } + + @Override + public Mono connect(CommonConnectRequest request) { + return Mono.fromCallable(() -> { + ConnectRequest.Builder builder = ConnectRequest.newBuilder() + .setId(HexFormat.of().formatHex(request.getIdentityPubkey().toByteArray())); + + if (request.hasHost()) { + builder.setHost(request.getHost()); + } + if (request.hasPort()) { + builder.setPort(request.getPort()); + } + + ConnectResponse connectResponse = client.connectPeer(builder.build()); + + log.trace("'connectPeer' returned': {}", connectResponse); + + return CommonConnectResponse.newBuilder().build(); + }); + } + + @Override + public Mono newAddress(CommonNewAddressRequest request) { + return Mono.fromCallable(() -> { + NewaddrResponse response = client.newAddr(NewaddrRequest.newBuilder().build()); + + return CommonNewAddressResponse.newBuilder() + .setAddress(response.getBech32()) + .build(); + }); + } + + @Override + public Mono createInvoice(CommonCreateInvoiceRequest request) { + return Mono.fromCallable(() -> { + AmountOrAny amountOrAny = request.hasAmountMsat() ? AmountOrAny.newBuilder() + .setAmount(Amount.newBuilder() + .setMsat(request.getAmountMsat()) + .build()) + .build() : AmountOrAny.newBuilder() + .setAny(true) + .build(); + + InvoiceRequest.Builder builder = InvoiceRequest.newBuilder() + .setAmountMsat(amountOrAny) + .setLabel(request.getLabel()); + + if (request.hasDescription()) { + builder.setDescription(request.getDescription()); + } + if (request.hasExpiry()) { + builder.setExpiry(request.getExpiry()); + } + + InvoiceResponse response = client.invoice(builder.build()); + + return CommonCreateInvoiceResponse.newBuilder() + .setPaymentRequest(response.getBolt11()) + .setPaymentHash(response.getPaymentHash()) + .build(); + }); + } + + @Override + public Mono listPeers(CommonListPeersRequest request) { + return Mono.fromCallable(() -> { + ListpeersResponse response = client.listPeers(ListpeersRequest.newBuilder().build()); + + List peers = response.getPeersList().stream() + .map(it -> Peer.newBuilder() + .setIdentityPubkey(it.getId()) + .addAllNetworkAddresses(it.getNetaddrList()) + .build()) + .toList(); + + return CommonListPeersResponse.newBuilder() + .addAllPeers(peers) + .build(); + }); + } + + @Override + public Mono openChannel(CommonOpenChannelRequest request) { + return Mono.fromCallable(() -> { + FundchannelRequest.Builder builder = FundchannelRequest.newBuilder() + .setId(request.getIdentityPubkey()) + .setAmount(AmountOrAll.newBuilder() + .setAmount(Amount.newBuilder() + .setMsat(request.getAmountMsat()) + .build()) + .build()) + .setPushMsat(Amount.newBuilder() + .setMsat(request.hasPushMsat() ? request.getPushMsat() : 0) + .build()) + .setAnnounce(!request.hasAnnounce() || request.getAnnounce()) + // TODO: remove after fundchannel grpc response issue has been fixed and merged. + // Issue: https://github.com/ElementsProject/lightning/issues/7627 + // Docs: https://docs.corelightning.org/reference/lightning-fundchannel#description + .addChannelType(12).addChannelType(46); + + if (request.hasSatPerVbyte()) { + builder.setFeerate(Feerate.newBuilder() + .setPerkb(request.getSatPerVbyte() * 1_000) + .build()); + } + if (request.hasTargetConf()) { + builder.setMinconf(request.getTargetConf()); + } + if (request.hasMinUtxoDepth()) { + builder.setMindepth(request.getMinUtxoDepth()); + } + if (request.hasCloseToAddress()) { + builder.setCloseTo(request.getCloseToAddress()); + } + + FundchannelResponse response = client.fundChannel(builder.build()); + + return CommonOpenChannelResponse.newBuilder() + .setTxid(response.getTxid()) + .setOutputIndex(response.getOutnum()) + .build(); + }); + } + + @Override + public Mono listUnspent(CommonListUnspentRequest request) { + return Mono.fromCallable(() -> { + ListfundsResponse response = client.listFunds(ListfundsRequest.newBuilder() + .setSpent(false) + .build()); + + List unspentOutputs = response.getOutputsList().stream() + .map(it -> UnspentOutput.newBuilder() + .setTxid(it.getTxid()) + .setOutputIndex(it.getOutput()) + .setAmountMsat(it.getAmountMsat().getMsat()) + .setScriptPubkey(it.getScriptpubkey()) + .build()) + .toList(); + + return CommonListUnspentResponse.newBuilder() + .addAllUnspentOutputs(unspentOutputs) + .build(); + }); + } + + @Override + public Mono listPeerChannels(CommonListPeerChannelsRequest request) { + return Mono.fromCallable(() -> { + ListpeerchannelsResponse response = client.listPeerChannels(ListpeerchannelsRequest.newBuilder().build()); + + List outgoingChannels = response.getChannelsList().stream() + .map(it -> { + PeerChannel.Builder builder = PeerChannel.newBuilder() + .setRemoteIdentityPubkey(it.getPeerId()) + .setCapacityMsat(it.getTotalMsat().getMsat()) + .setAnnounced(!it.getPrivate()) + .setActive(it.getState() == ListpeerchannelsChannels.ListpeerchannelsChannelsState.CHANNELD_NORMAL) + .setInitiator(it.getOpener() == ChannelSide.LOCAL); + + if (it.hasToUsMsat()) { + builder.setLocalBalanceMsat(it.getToUsMsat().getMsat()); + builder.setRemoteBalanceMsat(it.getTotalMsat().getMsat() - it.getToUsMsat().getMsat()); + } + if (it.hasSpendableMsat()) { + builder.setEstimatedSpendableMsat(it.getSpendableMsat().getMsat()); + } + if (it.hasReceivableMsat()) { + builder.setEstimatedReceivableMsat(it.getReceivableMsat().getMsat()); + } + return builder.build(); + }) + .toList(); + + return CommonListPeerChannelsResponse.newBuilder() + .addAllPeerChannels(outgoingChannels) + .build(); + }); + } + + @Override + public Mono pay(CommonPayRequest request) { + return Mono.fromCallable(() -> { + PayRequest.Builder builder = PayRequest.newBuilder() + .setBolt11(request.getPaymentRequest()); + + if (request.hasAmountMsat()) { + builder.setAmountMsat(Amount.newBuilder() + .setMsat(request.getAmountMsat()) + .build()); + } + if (request.hasTimeoutSeconds()) { + builder.setRetryFor(request.getTimeoutSeconds()); + } + if (request.hasMaxFeeMsat()) { + builder.setMaxfee(Amount.newBuilder() + .setMsat(request.getMaxFeeMsat()) + .build()); + } + + PayResponse response = client.pay(builder.build()); + + PaymentStatus status = switch (response.getStatus()) { + case COMPLETE -> PaymentStatus.COMPLETE; + case PENDING -> PaymentStatus.PENDING; + case FAILED -> PaymentStatus.FAILED; + case UNRECOGNIZED -> PaymentStatus.UNKNOWN; + }; + + return CommonPayResponse.newBuilder() + .setPaymentHash(response.getPaymentHash()) + .setStatus(status) + .setAmountMsat(response.getAmountMsat().getMsat()) + .setPaymentPreimage(response.getPaymentPreimage()) + .build(); + }); + } + + @Override + public Mono lookupInvoice(CommonLookupInvoiceRequest request) { + return Mono.fromCallable(() -> { + ListinvoicesResponse response = client.listInvoices(ListinvoicesRequest.newBuilder() + .setPaymentHash(request.getPaymentHash()) + .build()); + + if (response.getInvoicesCount() <= 0) { + return null; // results in empty mono + } + + ListinvoicesInvoices invoice = response.getInvoices(response.getInvoicesCount() - 1); + + InvoiceStatus status = switch (invoice.getStatus()) { + case UNPAID -> InvoiceStatus.PENDING; + case PAID -> InvoiceStatus.COMPLETE; + case EXPIRED -> InvoiceStatus.CANCELLED; + case UNRECOGNIZED -> InvoiceStatus.UNKNOWN; + }; + + return CommonLookupInvoiceResponse.newBuilder() + .setPaymentHash(invoice.getPaymentHash()) + .setPaymentPreimage(invoice.getPaymentPreimage()) + .setAmountMsat(invoice.getAmountMsat().getMsat()) + .setStatus(status) + .build(); + }); + } + + @Override + public Mono lookupPayment(CommonLookupPaymentRequest request) { + return Mono.fromCallable(() -> { + ListpaysResponse response = client.listPays(ListpaysRequest.newBuilder() + .setPaymentHash(request.getPaymentHash()) + .build()); + + if (response.getPaysCount() <= 0) { + return null; + } + + ListpaysPays payment = response.getPays(response.getPaysCount() - 1); + + PaymentStatus status = switch (payment.getStatus()) { + case COMPLETE -> PaymentStatus.COMPLETE; + case PENDING -> PaymentStatus.PENDING; + case FAILED -> PaymentStatus.FAILED; + case UNRECOGNIZED -> PaymentStatus.UNKNOWN; + }; + + CommonLookupPaymentResponse.Builder responseBuilder = CommonLookupPaymentResponse.newBuilder() + .setPaymentHash(payment.getPaymentHash()) + .setStatus(status); + + if (payment.hasPreimage()) { + responseBuilder.setPaymentPreimage(payment.getPreimage()); + } + + // TODO: next release will contain the amount + // if (payment.hasAmount()) { + // responseBuilder.setAmountMsat(payment.getAmount().getMsat()) + // } + + return responseBuilder.build(); + }); + } + + @Override + public Mono queryRoutes(CommonQueryRouteRequest request) { + return Mono.fromCallable(() -> { + GetrouteResponse response = this.client.getRoute(GetrouteRequest.newBuilder() + .setAmountMsat(Amount.newBuilder().setMsat(request.getAmountMsat()).build()) + .setId(request.getRemoteIdentityPubkey()) + .setRiskfactor(0) // we are just interested if a route exists + .setFuzzpercent(0) + .build()); + + if (response.getRouteList().isEmpty()) { + return CommonQueryRouteResponse.newBuilder().build(); + } + + // cln `getroute` response represents a single route + CommonQueryRouteResponse.Route route = CommonQueryRouteResponse.Route.newBuilder() + .addAllHops(response.getRouteList().stream() + .map(hop -> CommonQueryRouteResponse.Hop.newBuilder() + .setIdentityPubkey(hop.getId()) + .setAmountMsat(hop.getAmountMsat().getMsat()) + .build()) + .toList()) + .build(); + + return CommonQueryRouteResponse.newBuilder() + .addRoutes(route) + .build(); + }); + } +} diff --git a/lightning-commons/lightning-commons-client-core/build.gradle b/lightning-commons/lightning-commons-client-core/build.gradle new file mode 100644 index 000000000..20a7aef41 --- /dev/null +++ b/lightning-commons/lightning-commons-client-core/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java' +} + +apply from: "${project.rootDir}/proto.gradle" + +description = 'ln common client core package' + +dependencies { + api 'io.projectreactor:reactor-core' +} diff --git a/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/LightningCommonClient.java b/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/LightningCommonClient.java new file mode 100644 index 000000000..f55faff3f --- /dev/null +++ b/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/LightningCommonClient.java @@ -0,0 +1,31 @@ +package org.tbk.lightning.client.common.core; + +import org.tbk.lightning.client.common.core.proto.*; +import reactor.core.publisher.Mono; + +public interface LightningCommonClient { + Mono info(CommonInfoRequest request); + + Mono connect(CommonConnectRequest request); + + Mono newAddress(CommonNewAddressRequest request); + + Mono createInvoice(CommonCreateInvoiceRequest request); + + Mono listPeers(CommonListPeersRequest request); + + Mono openChannel(CommonOpenChannelRequest request); + + Mono listUnspent(CommonListUnspentRequest request); + + Mono listPeerChannels(CommonListPeerChannelsRequest request); + + Mono pay(CommonPayRequest request); + + Mono lookupInvoice(CommonLookupInvoiceRequest request); + + Mono lookupPayment(CommonLookupPaymentRequest request); + + Mono queryRoutes(CommonQueryRouteRequest request); + +} diff --git a/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/core.proto b/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/core.proto new file mode 100644 index 000000000..51887ec2a --- /dev/null +++ b/lightning-commons/lightning-commons-client-core/src/main/java/org/tbk/lightning/client/common/core/core.proto @@ -0,0 +1,205 @@ +syntax = "proto3"; + +package lncommonclient; + +import "google/protobuf/any.proto"; +import "google/protobuf/struct.proto"; + +option java_package = "org.tbk.lightning.client.common.core.proto"; +option java_outer_classname = "LnCommonClientProtos"; +option java_multiple_files = true; + +message CommonInfoRequest { +} + +message CommonInfoResponse { + bytes identityPubkey = 1 [json_name = "identity_pubkey"]; + optional string alias = 2 [json_name = "alias"]; + bytes color = 3 [json_name = "color"]; + uint32 num_peers = 4 [json_name = "num_peers"]; + uint32 num_pending_channels = 5 [json_name = "num_pending_channels"]; + uint32 num_active_channels = 6 [json_name = "num_active_channels"]; + uint32 num_inactive_channels = 7 [json_name = "num_inactive_channels"]; + string version = 8 [json_name = "version"]; + // A list of active chains the node is connected to + repeated Chain chain = 9 [json_name = "chains"]; + uint32 blockheight = 10 [json_name = "blockheight"]; + // a warning if either bitcoin or lightning node is not yet synced to chain; can be empty. + string warning_block_sync = 11 [json_name = "warning_block_sync"]; +} + +message Chain { + // The blockchain the node is on (i.e. bitcoin) + string chain = 1 [json_name = "chain"]; + + // The network the node is on (e.g. regtest, testnet, mainnet, etc.) + string network = 2 [json_name = "network"]; +} + +message CommonConnectRequest { + bytes identityPubkey = 1 [json_name = "identity_pubkey"]; + optional string host = 2 [json_name = "host"]; + optional uint32 port = 3 [json_name = "port"]; +} + +message CommonConnectResponse { +} + + +message CommonNewAddressRequest { +} + +message CommonNewAddressResponse { + string address = 1 [json_name = "address"]; +} + + +message CommonCreateInvoiceRequest { + optional uint64 amountMsat = 1 [json_name = "amount_msat"]; + optional string description = 2 [json_name = "description"]; + string label = 3 [json_name = "label"]; + optional uint64 expiry = 4 [json_name = "expiry"]; +} + +message CommonCreateInvoiceResponse { + string paymentRequest = 1 [json_name = "payment_request"]; + bytes paymentHash = 2 [json_name = "payment_hash"]; +} + + +message CommonListPeersRequest { +} + +message CommonListPeersResponse { + repeated Peer peers = 1 [json_name = "peers"]; +} + +message Peer { + bytes identityPubkey = 1 [json_name = "identity_pubkey"]; + repeated string networkAddresses = 2 [json_name = "network_addresses"]; +} + + +message CommonOpenChannelRequest { + bytes identityPubkey = 1 [json_name = "identity_pubkey"]; + uint64 amountMsat = 2 [json_name = "amount_msat"]; + optional uint32 satPerVbyte = 3 [json_name = "sat_per_vbyte"]; + optional uint64 pushMsat = 4 [json_name = "push_msat"]; + optional uint32 targetConf = 5 [json_name = "target_conf"]; + optional bool announce = 6 [json_name = "announce"]; + optional uint32 minUtxoDepth = 7 [json_name = "min_utxo_depth"]; + optional string closeToAddress = 8 [json_name = "close_to_address"]; +} + +message CommonOpenChannelResponse { + bytes txid = 1 [json_name = "txid"]; + uint32 outputIndex = 2 [json_name = "output_index"]; +} + + +message CommonListUnspentRequest { +} + +message CommonListUnspentResponse { + repeated UnspentOutput unspentOutputs = 1 [json_name = "unspent_outputs"]; +} + +message UnspentOutput { + bytes txid = 1 [json_name = "txid"]; + uint32 outputIndex = 2 [json_name = "output_index"]; + uint64 amountMsat = 3 [json_name = "amount_msat"]; + bytes scriptPubkey = 4 [json_name = "script_pubkey"]; +} + + +message CommonListPeerChannelsRequest { +} + +message CommonListPeerChannelsResponse { + repeated PeerChannel peerChannels = 1 [json_name = "peer_channels"]; +} + +message PeerChannel { + bytes remoteIdentityPubkey = 1 [json_name = "remote_identity_pubkey"]; + uint64 capacityMsat = 2 [json_name = "capacity_msat"]; + bool announced = 3 [json_name = "announced"]; + bool active = 4 [json_name = "active"]; + bool initiator = 5 [json_name = "initiator"]; + optional uint64 localBalanceMsat = 6 [json_name = "local_balance_msat"]; + optional uint64 remoteBalanceMsat = 7 [json_name = "remote_balance_msat"]; + optional uint64 estimatedSpendableMsat = 8 [json_name = "estimated_spendable_msat"]; + optional uint64 estimatedReceivableMsat = 9 [json_name = "estimated_receivable_msat"]; +} + + +message CommonPayRequest { + string paymentRequest = 1 [json_name = "payment_request"]; + optional uint64 amountMsat = 2 [json_name = "amount_msat"]; + optional int32 timeoutSeconds = 3 [json_name = "timeout_seconds"]; + optional int64 maxFeeMsat = 4 [json_name = "max_fee_msat"]; +} + +message CommonPayResponse { + bytes paymentHash = 1 [json_name = "payment_hash"]; + PaymentStatus status = 2 [json_name = "status"]; + uint64 amountMsat = 3 [json_name = "amount_msat"]; + optional bytes paymentPreimage = 4 [json_name = "payment_preimage"]; + + enum PaymentStatus { + UNKNOWN = 0; + PENDING = 1; + COMPLETE = 2; + FAILED = 3; + } +} + + +message CommonLookupInvoiceRequest { + bytes paymentHash = 1 [json_name = "payment_hash"]; +} + +message CommonLookupInvoiceResponse { + bytes paymentHash = 1 [json_name = "payment_hash"]; + InvoiceStatus status = 2 [json_name = "status"]; + uint64 amountMsat = 3 [json_name = "amount_msat"]; + bytes paymentPreimage = 4 [json_name = "payment_preimage"]; + + enum InvoiceStatus { + UNKNOWN = 0; + PENDING = 1; + COMPLETE = 2; + CANCELLED = 3; + } +} + + +message CommonLookupPaymentRequest { + bytes paymentHash = 1 [json_name = "payment_hash"]; +} + +message CommonLookupPaymentResponse { + bytes paymentHash = 1 [json_name = "payment_hash"]; + CommonPayResponse.PaymentStatus status = 2 [json_name = "status"]; + optional uint64 amountMsat = 3 [json_name = "amount_msat"]; + optional bytes paymentPreimage = 4 [json_name = "payment_preimage"]; +} + + +message CommonQueryRouteRequest { + bytes remoteIdentityPubkey = 1 [json_name = "remote_identity_pubkey"]; + uint64 amountMsat = 2 [json_name = "amount_msat"]; +} + +message CommonQueryRouteResponse { + repeated Route routes = 1 [json_name = "routes"]; + + message Route { + repeated Hop hops = 1 [json_name = "hops"]; + } + + message Hop { + bytes identityPubkey = 1 [json_name = "identity_pubkey"]; + // uint64 shortChannelId = 2 [json_name = "short_channel_id"]; + uint64 amountMsat = 2 [json_name = "amount_msat"]; + } +} diff --git a/lightning-commons/lightning-commons-client-lnd/build.gradle b/lightning-commons/lightning-commons-client-lnd/build.gradle new file mode 100644 index 000000000..9a68a861e --- /dev/null +++ b/lightning-commons/lightning-commons-client-lnd/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' +} + +description = 'ln common client lnd package' + +dependencies { + api project(':lightning-commons:lightning-commons-client-core') + api project(':lnd-grpc-client:lnd-grpc-client-core') +} diff --git a/lightning-commons/lightning-commons-client-lnd/readme.md b/lightning-commons/lightning-commons-client-lnd/readme.md new file mode 100644 index 000000000..aea7ac6ab --- /dev/null +++ b/lightning-commons/lightning-commons-client-lnd/readme.md @@ -0,0 +1,4 @@ + + +## Resources +- https://lightning.engineering/api-docs/api/lnd/ \ No newline at end of file diff --git a/lightning-commons/lightning-commons-client-lnd/src/main/java/org/tbk/lightning/client/common/lnd/LndCommonClient.java b/lightning-commons/lightning-commons-client-lnd/src/main/java/org/tbk/lightning/client/common/lnd/LndCommonClient.java new file mode 100644 index 000000000..39c39f7f9 --- /dev/null +++ b/lightning-commons/lightning-commons-client-lnd/src/main/java/org/tbk/lightning/client/common/lnd/LndCommonClient.java @@ -0,0 +1,391 @@ +package org.tbk.lightning.client.common.lnd; + +import com.google.protobuf.ByteString; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.lightningj.lnd.invoices.proto.InvoicesOuterClass; +import org.lightningj.lnd.proto.LightningApi; +import org.lightningj.lnd.router.proto.RouterOuterClass; +import org.lightningj.lnd.wrapper.ClientSideException; +import org.lightningj.lnd.wrapper.SynchronousLndAPI; +import org.lightningj.lnd.wrapper.invoices.SynchronousInvoicesAPI; +import org.lightningj.lnd.wrapper.invoices.message.LookupInvoiceMsg; +import org.lightningj.lnd.wrapper.message.*; +import org.lightningj.lnd.wrapper.router.SynchronousRouterAPI; +import org.lightningj.lnd.wrapper.router.message.SendPaymentRequest; +import org.lightningj.lnd.wrapper.router.message.TrackPaymentRequest; +import org.lightningj.lnd.wrapper.walletkit.SynchronousWalletKitAPI; +import org.lightningj.lnd.wrapper.walletkit.message.AddrRequest; +import org.lightningj.lnd.wrapper.walletkit.message.AddrResponse; +import org.lightningj.lnd.wrapper.walletkit.message.ListUnspentRequest; +import org.lightningj.lnd.wrapper.walletkit.message.ListUnspentResponse; +import org.tbk.lightning.client.common.core.LightningCommonClient; +import org.tbk.lightning.client.common.core.proto.Chain; +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.Peer; +import org.tbk.lightning.client.common.core.proto.CommonPayResponse.PaymentStatus; +import reactor.core.publisher.Mono; + +import java.util.HexFormat; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +/** + * See: LND API Docs + */ +@Slf4j +@RequiredArgsConstructor +public class LndCommonClient implements LightningCommonClient { + + @NonNull + private final SynchronousLndAPI client; + + @NonNull + private final SynchronousWalletKitAPI walletKitApi; + + @NonNull + private final SynchronousRouterAPI routerApi; + + @NonNull + private final SynchronousInvoicesAPI invoicesApi; + + @Override + public Mono info(CommonInfoRequest request) { + return Mono.fromCallable(() -> { + GetInfoResponse response = client.getInfo(new GetInfoRequest(LightningApi.GetInfoRequest.newBuilder().build())); + return CommonInfoResponse.newBuilder() + .setIdentityPubkey(ByteString.fromHex(response.getIdentityPubkey())) + .setAlias(response.getAlias()) + .setColor(ByteString.fromHex(response.getColor().replace("#", ""))) + .setNumPeers(response.getNumPeers()) + .setNumPendingChannels(response.getNumPendingChannels()) + .setNumActiveChannels(response.getNumActiveChannels()) + .setNumInactiveChannels(response.getNumInactiveChannels()) + .setVersion(response.getVersion()) + .addAllChain(response.getChains().stream() + .map(it -> Chain.newBuilder() + .setChain(it.getChain()) + .setNetwork(it.getNetwork()) + .build()) + .toList()) + .setBlockheight(response.getBlockHeight()) + .setWarningBlockSync(response.getSyncedToChain() ? "" : "Node is not synced to chain") + .build(); + }); + } + + @Override + public Mono connect(CommonConnectRequest request) { + return Mono.fromCallable(() -> { + LightningApi.LightningAddress.Builder addressBuilder = LightningApi.LightningAddress.newBuilder() + .setPubkey(HexFormat.of().formatHex(request.getIdentityPubkey().toByteArray())); + + Optional hostOrEmpty = Optional.of(request.getHost()) + .filter(foo -> request.hasHost()) + .filter(it -> !it.isBlank()) + .map(it -> request.hasPort() ? "%s:%d".formatted(it, request.getPort()) : it); + + hostOrEmpty.ifPresent(addressBuilder::setHost); + + ConnectPeerResponse connectPeerResponse = client.connectPeer(new ConnectPeerRequest(LightningApi.ConnectPeerRequest.newBuilder() + .setAddr(addressBuilder.build()) + .build())); + + log.trace("'connectPeer' returned': {}", connectPeerResponse); + + return CommonConnectResponse.newBuilder().build(); + }); + } + + @Override + public Mono newAddress(CommonNewAddressRequest request) { + return Mono.fromCallable(() -> { + AddrResponse response = walletKitApi.nextAddr(new AddrRequest()); + return CommonNewAddressResponse.newBuilder() + .setAddress(response.getAddr()) + .build(); + }); + } + + @Override + public Mono createInvoice(CommonCreateInvoiceRequest request) { + return Mono.fromCallable(() -> { + LightningApi.Invoice.Builder builder = LightningApi.Invoice.newBuilder(); + + if (request.hasAmountMsat()) { + builder.setValueMsat(request.getAmountMsat()); + } + if (request.hasDescription()) { + builder.setMemo(request.getDescription()); + } + if (request.hasExpiry()) { + builder.setExpiry(request.getExpiry()); + } + + AddInvoiceResponse response = client.addInvoice(new Invoice(builder.build())); + + return CommonCreateInvoiceResponse.newBuilder() + .setPaymentRequest(response.getPaymentRequest()) + .setPaymentHash(ByteString.copyFrom(response.getRHash())) + .build(); + }); + } + + @Override + public Mono listPeers(CommonListPeersRequest request) { + return Mono.fromCallable(() -> { + ListPeersResponse response = client.listPeers(new ListPeersRequest()); + + List peers = response.getPeers().stream() + .map(it -> Peer.newBuilder() + .setIdentityPubkey(ByteString.fromHex(it.getPubKey())) + .addNetworkAddresses(it.getAddress()) + .build()) + .toList(); + + return CommonListPeersResponse.newBuilder() + .addAllPeers(peers) + .build(); + }); + } + + @Override + public Mono openChannel(CommonOpenChannelRequest request) { + return Mono.fromCallable(() -> { + LightningApi.OpenChannelRequest.Builder builder = LightningApi.OpenChannelRequest.newBuilder() + .setNodePubkey(request.getIdentityPubkey()) + .setLocalFundingAmount(request.getAmountMsat() / 1_000) + .setPushSat(request.hasPushMsat() ? request.getPushMsat() / 1_000 : 0) + .setPrivate(request.hasAnnounce() && !request.getAnnounce()); + + if (request.hasSatPerVbyte()) { + builder.setSatPerVbyte(request.getSatPerVbyte()); + } + if (request.hasTargetConf()) { + builder.setTargetConf(request.getTargetConf()); + } + if (request.hasMinUtxoDepth()) { + builder.setMinConfs(request.getMinUtxoDepth()); + } + if (request.hasCloseToAddress()) { + builder.setCloseAddress(request.getCloseToAddress()); + } + + ChannelPoint response = client.openChannelSync(new OpenChannelRequest(builder.build())); + + byte[] txid = Optional.ofNullable(response.getFundingTxidBytes()) + .orElseGet(() -> HexFormat.of().parseHex(response.getFundingTxidStr())); + + return CommonOpenChannelResponse.newBuilder() + .setTxid(ByteString.copyFrom(txid)) + .setOutputIndex(response.getOutputIndex()) + .build(); + }); + } + + @Override + public Mono listUnspent(CommonListUnspentRequest request) { + return Mono.fromCallable(() -> { + ListUnspentResponse response = walletKitApi.listUnspent(new ListUnspentRequest()); + + List unspentOutputs = response.getUtxos().stream() + .map(it -> { + try { + OutPoint outpoint = it.getOutpoint(); + + byte[] txid = Optional.ofNullable(outpoint.getTxidBytes()) + .orElseGet(() -> HexFormat.of().parseHex(outpoint.getTxidStr())); + + return UnspentOutput.newBuilder() + .setTxid(ByteString.copyFrom(txid)) + .setOutputIndex(outpoint.getOutputIndex()) + .setAmountMsat(it.getAmountSat() * 1_000) + .setScriptPubkey(ByteString.fromHex(it.getPkScript())) + .build(); + } catch (ClientSideException e) { + throw new RuntimeException(e); + } + }) + .toList(); + + return CommonListUnspentResponse.newBuilder() + .addAllUnspentOutputs(unspentOutputs) + .build(); + }); + } + + @Override + public Mono listPeerChannels(CommonListPeerChannelsRequest request) { + return Mono.fromCallable(() -> { + ListChannelsResponse response = client.listChannels(new ListChannelsRequest()); + + List outgoingChannels = response.getChannels().stream() + .map(it -> { + try { + long localChannelReserveMsat = it.getLocalConstraints().getChanReserveSat() * 1_000; + long spendable = Math.max(it.getLocalBalance(), localChannelReserveMsat) - localChannelReserveMsat; + + long remoteChannelReserveMsat = it.getRemoteConstraints().getChanReserveSat() * 1_000; + long receivable = Math.max(it.getRemoteBalance(), remoteChannelReserveMsat) - remoteChannelReserveMsat; + + return PeerChannel.newBuilder() + .setRemoteIdentityPubkey(ByteString.fromHex(it.getRemotePubkey())) + .setCapacityMsat(it.getCapacity()) + .setAnnounced(!it.getPrivate()) + .setActive(it.getActive()) + .setInitiator(it.getInitiator()) + .setLocalBalanceMsat(it.getLocalBalance()) + .setRemoteBalanceMsat(it.getRemoteBalance()) + .setEstimatedSpendableMsat(spendable) + .setEstimatedReceivableMsat(receivable) + .build(); + } catch (ClientSideException e) { + throw new RuntimeException(e); + } + }) + .toList(); + + return CommonListPeerChannelsResponse.newBuilder() + .addAllPeerChannels(outgoingChannels) + .build(); + }); + } + + @Override + public Mono pay(CommonPayRequest request) { + return Mono.fromCallable(() -> { + RouterOuterClass.SendPaymentRequest.Builder builder = RouterOuterClass.SendPaymentRequest.newBuilder() + .setPaymentRequest(request.getPaymentRequest()); + + if (request.hasAmountMsat()) { + builder.setAmtMsat(request.getAmountMsat()); + } + if (request.hasTimeoutSeconds()) { + builder.setTimeoutSeconds(request.getTimeoutSeconds()); + } + if (request.hasMaxFeeMsat()) { + builder.setFeeLimitMsat(request.getMaxFeeMsat()); + } + + Iterator paymentIterator = routerApi.sendPaymentV2(new SendPaymentRequest(builder.build())); + Payment payment = last(paymentIterator); + if (payment == null) { + return null; + } + + PaymentStatus status = switch (payment.getStatus()) { + case SUCCEEDED -> PaymentStatus.COMPLETE; + case IN_FLIGHT -> PaymentStatus.PENDING; + case FAILED -> PaymentStatus.FAILED; + case UNKNOWN -> PaymentStatus.UNKNOWN; + }; + + return CommonPayResponse.newBuilder() + .setPaymentHash(ByteString.fromHex(payment.getPaymentHash())) + .setStatus(status) + .setAmountMsat(payment.getValueMsat()) + .setPaymentPreimage(Optional.ofNullable(payment.getPaymentPreimage()) + .map(ByteString::fromHex) + .orElse(ByteString.EMPTY)) + .build(); + }); + } + + @Override + public Mono lookupPayment(CommonLookupPaymentRequest request) { + return Mono.fromCallable(() -> { + + Iterator paymentIterator = routerApi.trackPaymentV2(new TrackPaymentRequest(RouterOuterClass.TrackPaymentRequest.newBuilder() + .setPaymentHash(request.getPaymentHash()) + .build())); + + Payment payment = last(paymentIterator); + if (payment == null) { + return null; + } + + PaymentStatus status = switch (payment.getStatus()) { + case UNKNOWN -> PaymentStatus.UNKNOWN; + case IN_FLIGHT -> PaymentStatus.PENDING; + case SUCCEEDED -> PaymentStatus.COMPLETE; + case FAILED -> PaymentStatus.FAILED; + }; + + CommonLookupPaymentResponse.Builder responseBuilder = CommonLookupPaymentResponse.newBuilder() + .setPaymentHash(ByteString.fromHex(payment.getPaymentHash())) + .setAmountMsat(payment.getValueMsat()) + .setStatus(status); + + Optional.ofNullable(payment.getPaymentPreimage()) + .filter(it -> !it.isBlank()) + .map(ByteString::fromHex) + .ifPresent(responseBuilder::setPaymentPreimage); + + return responseBuilder.build(); + }); + } + + @Override + public Mono queryRoutes(CommonQueryRouteRequest request) { + return Mono.fromCallable(() -> { + LightningApi.QueryRoutesRequest.Builder builder = LightningApi.QueryRoutesRequest.newBuilder() + .setPubKey(HexFormat.of().formatHex(request.getRemoteIdentityPubkey().toByteArray())) + .setAmtMsat(request.getAmountMsat()); + + QueryRoutesResponse queryRoutesResponse = client.queryRoutes(new QueryRoutesRequest(builder.build())); + + return CommonQueryRouteResponse.newBuilder() + .addAllRoutes(queryRoutesResponse.getRoutes().stream() + .map(it -> { + try { + return CommonQueryRouteResponse.Route.newBuilder() + .addAllHops(it.getHops().stream() + .map(hop -> CommonQueryRouteResponse.Hop.newBuilder() + .setIdentityPubkey(ByteString.fromHex(hop.getPubKey())) + .setAmountMsat(hop.getAmtToForwardMsat()) + .build()) + .toList()) + .build(); + } catch (ClientSideException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .build(); + }); + } + + @Override + public Mono lookupInvoice(CommonLookupInvoiceRequest request) { + return Mono.fromCallable(() -> { + Invoice invoice = invoicesApi.lookupInvoiceV2(new LookupInvoiceMsg(InvoicesOuterClass.LookupInvoiceMsg.newBuilder() + .setPaymentHash(request.getPaymentHash()) + .build())); + + InvoiceStatus status = switch (invoice.getState()) { + case OPEN, ACCEPTED -> InvoiceStatus.PENDING; + case SETTLED -> InvoiceStatus.COMPLETE; + case CANCELED -> InvoiceStatus.CANCELLED; + }; + + return CommonLookupInvoiceResponse.newBuilder() + .setPaymentHash(ByteString.copyFrom(invoice.getRHash())) + .setPaymentPreimage(ByteString.copyFrom(invoice.getRPreimage())) + .setAmountMsat(invoice.getValueMsat()) + .setStatus(status) + .build(); + }); + } + + private static T last(Iterator iterator) { + while (true) { + T current = iterator.next(); + if (!iterator.hasNext()) { + return current; + } + } + } +} diff --git a/lightning-regtest/lightning-regtest-core/build.gradle b/lightning-regtest/lightning-regtest-core/build.gradle new file mode 100644 index 000000000..4b591cbc4 --- /dev/null +++ b/lightning-regtest/lightning-regtest-core/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'java' +} + +description = 'lightning regtest core package' + +dependencies { + api "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:${acinqSecp256k1KmpVersion}" + api "fr.acinq.bitcoin:bitcoin-kmp-jvm:${acinqBitcoinKmpVersion}" + api "fr.acinq.lightning:lightning-kmp-jvm:${acinqLightningKmpVersion}" + + api project(':lightning-commons:lightning-commons-client-core') +} diff --git a/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/LightningNetworkConstants.java b/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/LightningNetworkConstants.java new file mode 100644 index 000000000..ec0d9b343 --- /dev/null +++ b/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/LightningNetworkConstants.java @@ -0,0 +1,24 @@ +package org.tbk.lightning.regtest.core; + +import fr.acinq.bitcoin.Satoshi; +import fr.acinq.lightning.MilliSatoshi; + +import java.time.Duration; + +public final class LightningNetworkConstants { + // 16_777_215 sats is the largest size (unless large channels were negotiated with the peer) + // See: https://lightning.readthedocs.io/lightning-fundchannel.7.html + public static final Satoshi LARGEST_CHANNEL_SIZE = new Satoshi(16_777_215L); + public static final MilliSatoshi LARGEST_CHANNEL_SIZE_MSAT = new MilliSatoshi(LARGEST_CHANNEL_SIZE); + + public static final Duration CLN_DEFAULT_INVOICE_EXPIRY = Duration.ofSeconds(604800); + + public static final int CLN_DEFAULT_REGTEST_P2P_PORT = 19_846; + public static final int LND_DEFAULT_REGTEST_P2P_PORT = 9_735; + + public static final int CLN_DEFAULT_CHANNEL_FUNDING_TX_MIN_CONFIRMATIONS = 6; + + private LightningNetworkConstants() { + throw new UnsupportedOperationException(); + } +} diff --git a/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/MoreMilliSatoshi.java b/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/MoreMilliSatoshi.java new file mode 100644 index 000000000..fca903924 --- /dev/null +++ b/lightning-regtest/lightning-regtest-core/src/main/java/org/tbk/lightning/regtest/core/MoreMilliSatoshi.java @@ -0,0 +1,11 @@ +package org.tbk.lightning.regtest.core; + +import fr.acinq.lightning.MilliSatoshi; + +public final class MoreMilliSatoshi { + public static final MilliSatoshi ZERO = new MilliSatoshi(0L); + + private MoreMilliSatoshi() { + throw new UnsupportedOperationException(); + } +} diff --git a/lightning-regtest/lightning-regtest-example-application/build.gradle b/lightning-regtest/lightning-regtest-example-application/build.gradle new file mode 100644 index 000000000..29426b92f --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'org.springframework.boot' + +description = 'lightning regtest example application package' + +dependencies { + implementation project(':lightning-regtest:lightning-regtest-starter') + + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocOpenApiVersion}" + + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + //integTestImplementation 'org.springframework.boot:spring-boot-docker-compose' +} diff --git a/lightning-regtest/lightning-regtest-example-application/readme.md b/lightning-regtest/lightning-regtest-example-application/readme.md new file mode 100644 index 000000000..139896968 --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/readme.md @@ -0,0 +1,9 @@ +lightning-regtest-example-application +=== + +A small demo application of bitcoin and lightning in regtest mode. + +Start application with +```shell +./gradlew -p lightning-regtest/lightning-regtest-example-application bootRun +``` diff --git a/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationTest.java b/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationTest.java new file mode 100644 index 000000000..e27499a6e --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationTest.java @@ -0,0 +1,25 @@ +package org.tbk.lightning.regtest.example; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +@SpringBootTest +@ActiveProfiles("test") +class LightningRegtestExampleApplicationTest { + + @Autowired(required = false) + private ApplicationContext applicationContext; + + @Test + void contextLoads() { + assertThat(applicationContext, is(notNullValue())); + } + +} diff --git a/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApiTest.java b/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApiTest.java new file mode 100644 index 000000000..b8f74d283 --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/integTest/java/org/tbk/lightning/regtest/example/api/LocalTestUserAliceClnNodeApiTest.java @@ -0,0 +1,43 @@ +package org.tbk.lightning.regtest.example.api; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(print = MockMvcPrint.LOG_DEBUG, printOnlyOnFailure = false) +@ActiveProfiles("test") +class LocalTestUserAliceClnNodeApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private LocalTestUserAliceClnNodeApi localTestUserAliceClnNodeApi; + + @Test + void itShouldFetchStatusSuccessfully() throws Exception { + mockMvc.perform(get("/api/v1/regtest/test-user-node/alice/balance-info") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.channelCount", is(1))) + .andExpect(jsonPath("$.totalCapacityMsat", is(notNullValue()))) + .andExpect(jsonPath("$.outboundMsat", is(notNullValue()))) + .andExpect(jsonPath("$.inboundMsat", is(notNullValue()))) + .andExpect(jsonPath("$.utxoCount", is(notNullValue()))) + .andExpect(jsonPath("$.onchainMsat", is(notNullValue()))); + } +} \ No newline at end of file diff --git a/lightning-regtest/lightning-regtest-example-application/src/integTest/resources/application-test.yml b/lightning-regtest/lightning-regtest-example-application/src/integTest/resources/application-test.yml new file mode 100644 index 000000000..05784ad9a --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/integTest/resources/application-test.yml @@ -0,0 +1,32 @@ +app.name: tbk-bitcoin-spring-boot-starter-demo (test) +app.description: A spring boot bitcoin demo application + +spring.application.name: 'tbk-bitcoin-spring-boot-starter-demo-test' +spring.http.log-request-details: true + +logging.level.org.tbk: 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 + skip.in-tests: false + +org.tbk.bitcoin.jsonrpc: + network: regtest + rpchost: http://localhost + rpcport: 18443 + rpcuser: regtest + rpcpassword: regtest + +org.tbk.bitcoin.zeromq: + network: regtest + zmqpubrawblock: tcp://127.0.0.1:28332 + zmqpubrawtx: tcp://127.0.0.1:28333 + zmqpubhashblock: tcp://127.0.0.1:28334 + zmqpubhashtx: tcp://127.0.0.1:28335 + +org.tbk.bitcoin.regtest: + enabled: true + mining: + enabled: false # disable miner as no services will run in test profile diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplication.java b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplication.java new file mode 100644 index 000000000..349097fa8 --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplication.java @@ -0,0 +1,37 @@ +package org.tbk.lightning.regtest.example; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.ApplicationPidFileWriter; +import org.springframework.boot.web.context.WebServerPortFileWriter; +import org.springframework.context.ApplicationListener; + +import java.util.Locale; +import java.util.TimeZone; + +@Slf4j +@SpringBootApplication(proxyBeanMethods = false) +public class LightningRegtestExampleApplication { + static { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + Locale.setDefault(Locale.ENGLISH); + } + + public static void main(String[] args) { + new SpringApplicationBuilder() + .sources(LightningRegtestExampleApplication.class) + .listeners(applicationPidFileWriter(), webServerPortFileWriter()) + .web(WebApplicationType.SERVLET) + .run(args); + } + + private static ApplicationListener applicationPidFileWriter() { + return new ApplicationPidFileWriter("application.pid"); + } + + private static ApplicationListener webServerPortFileWriter() { + return new WebServerPortFileWriter("application.port"); + } +} diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationConfig.java b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationConfig.java new file mode 100644 index 000000000..802f70e34 --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/LightningRegtestExampleApplicationConfig.java @@ -0,0 +1,13 @@ +package org.tbk.lightning.regtest.example; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.tbk.lightning.regtest.setup.devel.impl.LocalRegtestLightningNetworkSetupConfig; + +@Slf4j +@Configuration(proxyBeanMethods = false) +@Import(LocalRegtestLightningNetworkSetupConfig.class) +class LightningRegtestExampleApplicationConfig { + +} diff --git a/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/AbstractClnNodeApi.java b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/AbstractClnNodeApi.java new file mode 100644 index 000000000..6e999939e --- /dev/null +++ b/lightning-regtest/lightning-regtest-example-application/src/main/java/org/tbk/lightning/regtest/example/api/AbstractClnNodeApi.java @@ -0,0 +1,149 @@ +package org.tbk.lightning.regtest.example.api; + +import fr.acinq.lightning.MilliSatoshi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.tags.Tags; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.tbk.lightning.cln.grpc.client.*; +import org.tbk.lightning.regtest.core.MoreMilliSatoshi; + +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; + +import static java.util.Objects.requireNonNull; +import static org.tbk.lightning.regtest.core.LightningNetworkConstants.CLN_DEFAULT_INVOICE_EXPIRY; +import static org.tbk.lightning.regtest.core.LightningNetworkConstants.LARGEST_CHANNEL_SIZE_MSAT; + +@Tags({ + @Tag(name = "regtest-node") +}) +// hint: must be public in order for the controllers to show up in swagger-ui! +public abstract class AbstractClnNodeApi { + + private final NodeGrpc.NodeBlockingStub node; + + public AbstractClnNodeApi(NodeGrpc.NodeBlockingStub node) { + this.node = requireNonNull(node); + } + + @Operation( + summary = "Create an invoice on user node" + ) + @PostMapping("/invoice") + public ResponseEntity invoice(@RequestParam("millisats") long millisats, + @RequestParam(name = "expiryInSeconds", required = false) Long expiryInSecondsOrNull) { + Duration expiry = expiryInSecondsOrNull != null ? Duration.ofSeconds(expiryInSecondsOrNull) : CLN_DEFAULT_INVOICE_EXPIRY; + + InvoiceResponse invoice = node.invoice(InvoiceRequest.newBuilder() + .setLabel(RandomStringUtils.randomAlphanumeric(32)) + .setAmountMsat(AmountOrAny.newBuilder() + .setAmount(Amount.newBuilder() + .setMsat(millisats) + .build()) + .build()) + .setExpiry(expiry.toSeconds()) + .build()); + + return ResponseEntity.ok(CreateInvoiceResponse.builder() + .bolt11(invoice.getBolt11()) + .paymentHash(HexFormat.of().formatHex(invoice.getPaymentHash().toByteArray())) + .expiresAt(Instant.ofEpochSecond(invoice.getExpiresAt())) + .amountMsat(millisats) + .build()); + } + + @Operation( + summary = "Create an invoice on user node with an 'non-payable' amount in the local regtest setup" + ) + @PostMapping("/non-payable-invoice") + public ResponseEntity nonPayableInvoice(@RequestParam(name = "expiryInSeconds", required = false) Long expiryInSecondsOrNull) { + return invoice(LARGEST_CHANNEL_SIZE_MSAT.plus(new MilliSatoshi(1)).getMsat(), expiryInSecondsOrNull); + } + + @Operation( + summary = "Get balance information on user node" + ) + @GetMapping("/balance-info") + public ResponseEntity 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); - } - }); - } -}