From bc20c3dc9bac2293d18e2a85d116348307c0cf98 Mon Sep 17 00:00:00 2001 From: Mohit Kumar Date: Fri, 18 Aug 2023 01:41:15 +0530 Subject: [PATCH 1/4] Implement the `findroute` command --- contrib/eclair-cli_autocomplete.sh | 6 +- src/nativeMain/kotlin/Main.kt | 4 +- src/nativeMain/kotlin/api/EclairClient.kt | 44 ++++++ src/nativeMain/kotlin/commands/FindRoute.kt | 66 +++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 26 ++++ .../kotlin/commands/FindRouteTest.kt | 62 ++++++++ .../kotlin/mocks/EclairClientMocks.kt | 136 +++++++++++++++++- 7 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/FindRoute.kt create mode 100644 src/nativeTest/kotlin/commands/FindRouteTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index f06329f..aa3408e 100644 --- a/contrib/eclair-cli_autocomplete.sh +++ b/contrib/eclair-cli_autocomplete.sh @@ -19,7 +19,7 @@ _eclair_cli() { # `_init_completion` is a helper function provided by the Bash-completion package. _init_completion || return - local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices" + local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices findroute" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -44,6 +44,7 @@ _eclair_cli() { local getinvoice_opts="--paymentHash" local listinvoices_opts="--from --to --count --skip" local listpendinginvoices_opts="--from --to --count --skip" + local findroute_opts="--invoice --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -126,6 +127,9 @@ _eclair_cli() { listpendinginvoices) COMPREPLY=( $(compgen -W "${listpendinginvoices_opts} ${common_opts}" -- ${cur}) ) ;; + findroute) + COMPREPLY=( $(compgen -W "${findroute_opts} ${common_opts}" -- ${cur}) ) + ;; *) COMPREPLY=( $(compgen -W "${common_opts}" -- ${cur}) ) ;; diff --git a/src/nativeMain/kotlin/Main.kt b/src/nativeMain/kotlin/Main.kt index 667ac0b..430d2e9 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -19,7 +19,6 @@ fun main(args: Array) { ForceCloseCommand(resultWriter, apiClientBuilder), UpdateRelayFeeCommand(resultWriter, apiClientBuilder), PeersCommand(resultWriter, apiClientBuilder), - UpdateRelayFeeCommand(resultWriter, apiClientBuilder), NodesCommand(resultWriter, apiClientBuilder), NodeCommand(resultWriter, apiClientBuilder), AllChannelsCommand(resultWriter, apiClientBuilder), @@ -35,7 +34,8 @@ fun main(args: Array) { ListReceivedPaymentsCommand(resultWriter, apiClientBuilder), GetInvoiceCommand(resultWriter, apiClientBuilder), ListInvoicesCommand(resultWriter, apiClientBuilder), - ListPendingInvoicesCommand(resultWriter, apiClientBuilder) + ListPendingInvoicesCommand(resultWriter, apiClientBuilder), + FindRouteCommand(resultWriter, apiClientBuilder), ) parser.parse(args) } \ No newline at end of file diff --git a/src/nativeMain/kotlin/api/EclairClient.kt b/src/nativeMain/kotlin/api/EclairClient.kt index ead7d0d..a583705 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -153,6 +153,17 @@ interface IEclairClient { count: Int?, skip: Int? ): Either + + suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -748,4 +759,37 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroute", + formParameters = Parameters.build { + append("invoice", invoice) + amountMsat?.let { append("amountMsat", it.toString()) } + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } } diff --git a/src/nativeMain/kotlin/commands/FindRoute.kt b/src/nativeMain/kotlin/commands/FindRoute.kt new file mode 100644 index 0000000..a66868a --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRoute.kt @@ -0,0 +1,66 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroute", + "Finds a route to the node specified by the invoice. The formats currently supported are nodeId, shortChannelId or full" +) { + private val invoice by option( + ArgType.String, + description = "The invoice containing the destination" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroute( + invoice!!, + amountMsat, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/types/EclairApiTypes.kt b/src/nativeMain/kotlin/types/EclairApiTypes.kt index d663ead..737528c 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -164,3 +164,29 @@ data class ReceivePaymentStatus( val amount: Long? = null, val receivedAt: Timestamp? = null ) + +@Serializable +data class FindRouteResponse( + val routes: List +): EclairApiType() + +@Serializable +data class Routes( + val amount: Int, + val nodeIds: List? = null, + val shortChannelIds: List? = null, + val hops: List?=null +) + +@Serializable +data class Hops( + val nodeId: String, + val nextNodeId: String, + val source: Source, +) + +@Serializable +data class Source( + val type: String, + val channelUpdate: AllUpdates +) diff --git a/src/nativeTest/kotlin/commands/FindRouteTest.kt b/src/nativeTest/kotlin/commands/FindRouteTest.kt new file mode 100644 index 0000000..b94911b --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteTest.kt @@ -0,0 +1,62 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse( + arrayOf( + "findroute", + "-p", + "password", + "--invoice", + "lnbcrt10n1pjduajwpp5s6dsm9vk3q0ntxeq2zd6d4jz8mv8wau75dugud5puc3lltwp68esdqsd3shgetnw33k7er9sp5rye5z7eccrg7kx9jj6u24q2aumgl09e0e894w6hdceyk60g7a2hsmqz9gxqrrsscqp79q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgq9fktzq8fpyey9js0x85t6s5mtcwqzmmd4ql9cjq04f4tunlysje894mdcmhjwkewrk5wn2ylv3da64pda7tj04s3m90en5t6p7yyglgpue2lzz", + "--format", + "full" + ) + ) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponse = DummyEclairClient.validFindRouteResponse)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validFindRouteResponse), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponse = "{invalidJson}")) + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 28bd18a..2d4fb42 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -32,7 +32,8 @@ class DummyEclairClient( private val listreceivedpaymentsResponse: String = validListReceivedPaymentsResponse, private val getinvoiceResponse: String = validGetInvoiceResponse, private val listinvoicesResponse: String = validListInvoicesResponse, - private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse + private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse, + private val findrouteResponse: String = validFindRouteResponse, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -169,6 +170,17 @@ class DummyEclairClient( skip: Int? ): Either = Either.Right(listpendinginvoicesResponse) + override suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Right(findrouteResponse) + companion object { val validGetInfoResponse = """{"version":"0.9.0","nodeId":"03e319aa4ecc7a89fb8b3feb6efe9075864b91048bff5bef14efd55a69760ddf17","alias":"alice","color":"#49daaa","features":{"activated":{"var_onion_optin":"mandatory","option_static_remotekey":"optional"},"unknown":[151,178]},"chainHash":"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f","network":"regtest","blockHeight":107,"publicAddresses":[],"instanceId":"be74bd9a-fc54-4f24-bc41-0477c9ce2fb4"}""" @@ -756,6 +768,117 @@ class DummyEclairClient( "routingInfo": [] } ]""" + val validFindRouteResponse = """{ + "type": "types.FindRouteResponse", + "routes": [ + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "5d0b0155259727236f77947c87b30849ad7209dd17c6cd3ef5e53783df4ca9da4f53c2f9b687c6fc99f4c4bc8bfd7d2c719003c0fbd9b475b0e5978155716878", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "354x1x1", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + }, + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "4d9a50fdfb3d76ce47e26f75440295e1ecde91c1a67e14930bf657c5084e07b6403b0b8047d68c2b2bd765b27a3dc71fd434431881b7d863cf3283496f80bf24", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "252x2x1", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + }, + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "615fab66837d37f0fe9a949b97a6fadd37d42dcfd9adf325a7820880ca195666485d811dc00cdc2f11f98691500a88f77d4eb5179e35f594e7e68e3be71dc8ac", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "151x3x0", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + } + ] +} +""" } } @@ -876,4 +999,15 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC override suspend fun listinvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = Either.Left(error) override suspend fun listpendinginvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = Either.Left(error) + + override suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Left(error) } \ No newline at end of file From c860eaaeea094eaa56770ec925fd64a5cc5beaae Mon Sep 17 00:00:00 2001 From: Mohit Kumar Date: Fri, 18 Aug 2023 03:00:25 +0530 Subject: [PATCH 2/4] Implement the `findroutetonode` command --- contrib/eclair-cli_autocomplete.sh | 6 +- src/nativeMain/kotlin/Main.kt | 1 + src/nativeMain/kotlin/api/EclairClient.kt | 45 +++++++++++++ .../kotlin/commands/FindRouteToNode.kt | 66 +++++++++++++++++++ .../kotlin/commands/FindRouteToNodeTest.kt | 63 ++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 35 ++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/nativeMain/kotlin/commands/FindRouteToNode.kt create mode 100644 src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index aa3408e..5db4828 100644 --- a/contrib/eclair-cli_autocomplete.sh +++ b/contrib/eclair-cli_autocomplete.sh @@ -19,7 +19,7 @@ _eclair_cli() { # `_init_completion` is a helper function provided by the Bash-completion package. _init_completion || return - local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices findroute" + local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices findroute findroutetonode" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -45,6 +45,7 @@ _eclair_cli() { local listinvoices_opts="--from --to --count --skip" local listpendinginvoices_opts="--from --to --count --skip" local findroute_opts="--invoice --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" + local findroutetonode_opts="--nodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -130,6 +131,9 @@ _eclair_cli() { findroute) COMPREPLY=( $(compgen -W "${findroute_opts} ${common_opts}" -- ${cur}) ) ;; + findroutetonode) + COMPREPLY=( $(compgen -W "${findroutetonode_opts} ${common_opts}" -- ${cur}) ) + ;; *) COMPREPLY=( $(compgen -W "${common_opts}" -- ${cur}) ) ;; diff --git a/src/nativeMain/kotlin/Main.kt b/src/nativeMain/kotlin/Main.kt index 430d2e9..f7d7b77 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -36,6 +36,7 @@ fun main(args: Array) { ListInvoicesCommand(resultWriter, apiClientBuilder), ListPendingInvoicesCommand(resultWriter, apiClientBuilder), FindRouteCommand(resultWriter, apiClientBuilder), + FindRouteToNodeCommand(resultWriter, apiClientBuilder), ) parser.parse(args) } \ No newline at end of file diff --git a/src/nativeMain/kotlin/api/EclairClient.kt b/src/nativeMain/kotlin/api/EclairClient.kt index a583705..b1c1810 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -164,6 +164,18 @@ interface IEclairClient { includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either + + + suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -792,4 +804,37 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroutetonode", + formParameters = Parameters.build { + append("nodeId", nodeId) + append("amountMsat", amountMsat.toString()) + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } } diff --git a/src/nativeMain/kotlin/commands/FindRouteToNode.kt b/src/nativeMain/kotlin/commands/FindRouteToNode.kt new file mode 100644 index 0000000..99b7828 --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRouteToNode.kt @@ -0,0 +1,66 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteToNodeCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroutetonode", + "Finds a route to the given node." +) { + private val nodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroutetonode( + nodeId!!, + amountMsat!!, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt new file mode 100644 index 0000000..5ce9ea6 --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt @@ -0,0 +1,63 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteToNodeTestCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteToNodeCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse( + arrayOf( + "findroutetonode", + "-p", + "password", + "--nodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" + ) + ) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = + runTest(DummyEclairClient(findroutetonodeResponse = DummyEclairClient.validFindRouteToNodeResponse)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validFindRouteToNodeResponse), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error`() { + val resultWriter = runTest(DummyEclairClient(findroutetonodeResponse = "{invalidJson}")) + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 2d4fb42..8abe763 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -34,6 +34,7 @@ class DummyEclairClient( private val listinvoicesResponse: String = validListInvoicesResponse, private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse, private val findrouteResponse: String = validFindRouteResponse, + private val findroutetonodeResponse: String = validFindRouteToNodeResponse, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -181,6 +182,17 @@ class DummyEclairClient( pathFindingExperimentName: String? ): Either = Either.Right(findrouteResponse) + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Right(findroutetonodeResponse) + companion object { val validGetInfoResponse = """{"version":"0.9.0","nodeId":"03e319aa4ecc7a89fb8b3feb6efe9075864b91048bff5bef14efd55a69760ddf17","alias":"alice","color":"#49daaa","features":{"activated":{"var_onion_optin":"mandatory","option_static_remotekey":"optional"},"unknown":[151,178]},"chainHash":"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f","network":"regtest","blockHeight":107,"publicAddresses":[],"instanceId":"be74bd9a-fc54-4f24-bc41-0477c9ce2fb4"}""" @@ -879,6 +891,18 @@ class DummyEclairClient( ] } """ + val validFindRouteToNodeResponse = """{ + "routes": [ + { + "amount": 5000, + "nodeIds": [ + "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", + "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" + ] + } + ] +}""" } } @@ -1010,4 +1034,15 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either = Either.Left(error) + + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Left(error) } \ No newline at end of file From 6f6807cce8e62b2771a1800c75ac3a5f42882f28 Mon Sep 17 00:00:00 2001 From: Mohit Kumar Date: Fri, 18 Aug 2023 14:27:53 +0530 Subject: [PATCH 3/4] Implement the `findroutebetweennodes` command --- contrib/eclair-cli_autocomplete.sh | 8 ++- src/nativeMain/kotlin/Main.kt | 1 + src/nativeMain/kotlin/api/EclairClient.kt | 47 ++++++++++++ .../kotlin/commands/FindRouteBetweenNodes.kt | 71 +++++++++++++++++++ .../commands/FindRouteBetweenNodesTest.kt | 68 ++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 37 ++++++++++ 6 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt create mode 100644 src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index 5db4828..bd4bfed 100644 --- a/contrib/eclair-cli_autocomplete.sh +++ b/contrib/eclair-cli_autocomplete.sh @@ -19,7 +19,7 @@ _eclair_cli() { # `_init_completion` is a helper function provided by the Bash-completion package. _init_completion || return - local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices findroute findroutetonode" + local commands="getinfo connect disconnect open rbfopen cpfpbumpfees close forceclose updaterelayfee peers nodes node allchannels allupdates createinvoice deleteinvoice parseinvoice payinvoice sendtonode sendtoroute getsentinfo getreceivedinfo listreceivedpayments getinvoice listinvoices listpendinginvoices findroute findroutetonode findroutebetweennodes" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -46,6 +46,7 @@ _eclair_cli() { local listpendinginvoices_opts="--from --to --count --skip" local findroute_opts="--invoice --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" local findroutetonode_opts="--nodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" + local findroutebetweennodes_opts="--sourceNodeId --targetNodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -132,7 +133,10 @@ _eclair_cli() { COMPREPLY=( $(compgen -W "${findroute_opts} ${common_opts}" -- ${cur}) ) ;; findroutetonode) - COMPREPLY=( $(compgen -W "${findroutetonode_opts} ${common_opts}" -- ${cur}) ) + COMPREPLY=( $(compgen -W "${findroutetonode_opts} ${common_opts}" -- ${cur}) ) + ;; + findroutebetweennodes) + COMPREPLY=( $(compgen -W "${findroutebetweennodes_opts} ${common_opts}" -- ${cur}) ) ;; *) COMPREPLY=( $(compgen -W "${common_opts}" -- ${cur}) ) diff --git a/src/nativeMain/kotlin/Main.kt b/src/nativeMain/kotlin/Main.kt index f7d7b77..2a5661d 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -37,6 +37,7 @@ fun main(args: Array) { ListPendingInvoicesCommand(resultWriter, apiClientBuilder), FindRouteCommand(resultWriter, apiClientBuilder), FindRouteToNodeCommand(resultWriter, apiClientBuilder), + FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), ) parser.parse(args) } \ No newline at end of file diff --git a/src/nativeMain/kotlin/api/EclairClient.kt b/src/nativeMain/kotlin/api/EclairClient.kt index b1c1810..45a5262 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -176,6 +176,18 @@ interface IEclairClient { includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either + + suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -837,4 +849,39 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroutebetweennodes", + formParameters = Parameters.build { + append("sourceNodeId", sourceNodeId) + append("targetNodeId", targetNodeId) + append("amountMsat", amountMsat.toString()) + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } } diff --git a/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt b/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt new file mode 100644 index 0000000..8004130 --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt @@ -0,0 +1,71 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteBetweenNodesCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroutebetweennodes", + "Finds a route between two nodes." +) { + private val sourceNodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val targetNodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroutebetweennodes( + sourceNodeId!!, + targetNodeId!!, + amountMsat!!, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt new file mode 100644 index 0000000..ebeb4be --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt @@ -0,0 +1,68 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteBetweenNodesCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteBetweenNodesCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse( + arrayOf( + "findroutebetweennodes", + "-p", + "password", + "--sourceNodeId", + "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "--targetNodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" + ) + ) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = + runTest(DummyEclairClient(findroutebetweennodesResponse = DummyEclairClient.validFindRouteBetweenNodesResponse)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString( + FindRouteResponse.serializer(), + DummyEclairClient.validFindRouteBetweenNodesResponse + ), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error`() { + val resultWriter = runTest(DummyEclairClient(findroutebetweennodesResponse = "{invalidJson}")) + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 8abe763..87db926 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -35,6 +35,7 @@ class DummyEclairClient( private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse, private val findrouteResponse: String = validFindRouteResponse, private val findroutetonodeResponse: String = validFindRouteToNodeResponse, + private val findroutebetweennodesResponse: String = validFindRouteBetweenNodesResponse, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -193,6 +194,18 @@ class DummyEclairClient( pathFindingExperimentName: String? ): Either = Either.Right(findroutetonodeResponse) + override suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Right(findroutebetweennodesResponse) + companion object { val validGetInfoResponse = """{"version":"0.9.0","nodeId":"03e319aa4ecc7a89fb8b3feb6efe9075864b91048bff5bef14efd55a69760ddf17","alias":"alice","color":"#49daaa","features":{"activated":{"var_onion_optin":"mandatory","option_static_remotekey":"optional"},"unknown":[151,178]},"chainHash":"06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f","network":"regtest","blockHeight":107,"publicAddresses":[],"instanceId":"be74bd9a-fc54-4f24-bc41-0477c9ce2fb4"}""" @@ -902,6 +915,18 @@ class DummyEclairClient( ] } ] +}""" + val validFindRouteBetweenNodesResponse = """{ + "routes": [ + { + "amount": 5000, + "nodeIds": [ + "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", + "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" + ] + } + ] }""" } } @@ -1045,4 +1070,16 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either = Either.Left(error) + + override suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Left(error) } \ No newline at end of file From c60dda42856def19dd4f6ce04d5314c7c4eb65c7 Mon Sep 17 00:00:00 2001 From: Mohit Kumar Date: Sat, 19 Aug 2023 17:24:37 +0530 Subject: [PATCH 4/4] Add test for format=nodeId and shortChannelId --- .../commands/FindRouteBetweenNodesTest.kt | 76 +++++++++--- .../kotlin/commands/FindRouteTest.kt | 70 ++++++++--- .../kotlin/commands/FindRouteToNodeTest.kt | 73 ++++++++--- .../kotlin/mocks/EclairClientMocks.kt | 113 ++++++++++++------ 4 files changed, 242 insertions(+), 90 deletions(-) diff --git a/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt index ebeb4be..30f0a9b 100644 --- a/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt +++ b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt @@ -13,43 +13,67 @@ import kotlin.test.* @OptIn(ExperimentalCli::class) class FindRouteBetweenNodesCommandTest { - private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { val resultWriter = DummyResultWriter() val command = FindRouteBetweenNodesCommand(resultWriter, eclairClient) val parser = ArgParser("test") parser.subcommands(command) - parser.parse( - arrayOf( - "findroutebetweennodes", - "-p", - "password", - "--sourceNodeId", - "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", - "--targetNodeId", - "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", - "--amountMsat", - "1000" - ) + val arguments = mutableListOf( + "findroutebetweennodes", + "-p", + "password", + "--sourceNodeId", + "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "--targetNodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) return resultWriter } @Test - fun `successful request`() { + fun `successful request via nodeId`() { val resultWriter = - runTest(DummyEclairClient(findroutebetweennodesResponse = DummyEclairClient.validFindRouteBetweenNodesResponse)) + runTest(DummyEclairClient(), "nodeId") assertNull(resultWriter.lastError) assertNotNull(resultWriter.lastResult) val format = Json { ignoreUnknownKeys = true } assertEquals( format.decodeFromString( FindRouteResponse.serializer(), - DummyEclairClient.validFindRouteBetweenNodesResponse + DummyEclairClient.validRouteResponseNodeId ), format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), ) } + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!) + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + @Test fun `api error`() { val error = ApiError(42, "test failure message") @@ -59,8 +83,24 @@ class FindRouteBetweenNodesCommandTest { } @Test - fun `serialization error`() { - val resultWriter = runTest(DummyEclairClient(findroutebetweennodesResponse = "{invalidJson}")) + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") assertNull(resultWriter.lastResult) assertNotNull(resultWriter.lastError) assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) diff --git a/src/nativeTest/kotlin/commands/FindRouteTest.kt b/src/nativeTest/kotlin/commands/FindRouteTest.kt index b94911b..c30967e 100644 --- a/src/nativeTest/kotlin/commands/FindRouteTest.kt +++ b/src/nativeTest/kotlin/commands/FindRouteTest.kt @@ -13,33 +13,55 @@ import kotlin.test.* @OptIn(ExperimentalCli::class) class FindRouteCommandTest { - private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { val resultWriter = DummyResultWriter() val command = FindRouteCommand(resultWriter, eclairClient) val parser = ArgParser("test") parser.subcommands(command) - parser.parse( - arrayOf( - "findroute", - "-p", - "password", - "--invoice", - "lnbcrt10n1pjduajwpp5s6dsm9vk3q0ntxeq2zd6d4jz8mv8wau75dugud5puc3lltwp68esdqsd3shgetnw33k7er9sp5rye5z7eccrg7kx9jj6u24q2aumgl09e0e894w6hdceyk60g7a2hsmqz9gxqrrsscqp79q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgq9fktzq8fpyey9js0x85t6s5mtcwqzmmd4ql9cjq04f4tunlysje894mdcmhjwkewrk5wn2ylv3da64pda7tj04s3m90en5t6p7yyglgpue2lzz", - "--format", - "full" - ) + val arguments = mutableListOf( + "findroute", + "-p", + "password", + "--invoice", + "lnbcrt10n1pjduajwpp5s6dsm9vk3q0ntxeq2zd6d4jz8mv8wau75dugud5puc3lltwp68esdqsd3shgetnw33k7er9sp5rye5z7eccrg7kx9jj6u24q2aumgl09e0e894w6hdceyk60g7a2hsmqz9gxqrrsscqp79q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgq9fktzq8fpyey9js0x85t6s5mtcwqzmmd4ql9cjq04f4tunlysje894mdcmhjwkewrk5wn2ylv3da64pda7tj04s3m90en5t6p7yyglgpue2lzz" ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) return resultWriter } @Test - fun `successful request`() { - val resultWriter = runTest(DummyEclairClient(findrouteResponse = DummyEclairClient.validFindRouteResponse)) + fun `successful request via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") assertNull(resultWriter.lastError) assertNotNull(resultWriter.lastResult) val format = Json { ignoreUnknownKeys = true } assertEquals( - format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validFindRouteResponse), + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseNodeId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), ) } @@ -53,8 +75,24 @@ class FindRouteCommandTest { } @Test - fun `serialization error`() { - val resultWriter = runTest(DummyEclairClient(findrouteResponse = "{invalidJson}")) + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") assertNull(resultWriter.lastResult) assertNotNull(resultWriter.lastError) assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) diff --git a/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt index 5ce9ea6..1121bf9 100644 --- a/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt +++ b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt @@ -13,34 +13,57 @@ import kotlin.test.* @OptIn(ExperimentalCli::class) class FindRouteToNodeTestCommandTest { - private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { val resultWriter = DummyResultWriter() val command = FindRouteToNodeCommand(resultWriter, eclairClient) val parser = ArgParser("test") parser.subcommands(command) - parser.parse( - arrayOf( - "findroutetonode", - "-p", - "password", - "--nodeId", - "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", - "--amountMsat", - "1000" - ) + val arguments = mutableListOf( + "findroutetonode", + "-p", + "password", + "--nodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) return resultWriter } @Test - fun `successful request`() { - val resultWriter = - runTest(DummyEclairClient(findroutetonodeResponse = DummyEclairClient.validFindRouteToNodeResponse)) + fun `successful request via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") assertNull(resultWriter.lastError) assertNotNull(resultWriter.lastResult) val format = Json { ignoreUnknownKeys = true } assertEquals( - format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validFindRouteToNodeResponse), + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseNodeId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!) + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), ) } @@ -54,8 +77,24 @@ class FindRouteToNodeTestCommandTest { } @Test - fun `serialization error`() { - val resultWriter = runTest(DummyEclairClient(findroutetonodeResponse = "{invalidJson}")) + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") assertNull(resultWriter.lastResult) assertNotNull(resultWriter.lastError) assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 87db926..7056312 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -33,13 +33,21 @@ class DummyEclairClient( private val getinvoiceResponse: String = validGetInvoiceResponse, private val listinvoicesResponse: String = validListInvoicesResponse, private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse, - private val findrouteResponse: String = validFindRouteResponse, - private val findroutetonodeResponse: String = validFindRouteToNodeResponse, - private val findroutebetweennodesResponse: String = validFindRouteBetweenNodesResponse, + private val findrouteResponseNodeId: String = validRouteResponseNodeId, + private val findrouteResponseShortChannelId: String = validRouteResponseShortChannelId, + private val findrouteResponseFull: String = validRouteResponseFull, + private val findroutetonodeResponseNodeId: String = validRouteResponseNodeId, + private val findroutetonodeResponseShortChannelId: String = validRouteResponseShortChannelId, + private val findroutetonodeResponseFull: String = validRouteResponseFull, + private val findroutebetweennodesResponseNodeId: String = validRouteResponseNodeId, + private val findroutebetweennodesResponseShortChannelId: String = validRouteResponseShortChannelId, + private val findroutebetweennodesResponseFull: String = validRouteResponseFull, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) - override suspend fun connect(target: ConnectionTarget): Either = Either.Right(validConnectResponse) + override suspend fun connect(target: ConnectionTarget): Either = + Either.Right(validConnectResponse) + override suspend fun rbfopen( channelId: String, targetFeerateSatByte: Int, @@ -104,7 +112,8 @@ class DummyEclairClient( paymentPreimage: String? ): Either = Either.Right(createInvoiceResponse) - override suspend fun deleteinvoice(paymentHash: String): Either = Either.Right(deleteInvoiceResponse) + override suspend fun deleteinvoice(paymentHash: String): Either = + Either.Right(deleteInvoiceResponse) override suspend fun parseinvoice(invoice: String): Either = Either.Right(parseInvoiceResponse) @@ -181,7 +190,14 @@ class DummyEclairClient( maxFeeMsat: Int?, includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? - ): Either = Either.Right(findrouteResponse) + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } override suspend fun findroutetonode( nodeId: String, @@ -192,7 +208,14 @@ class DummyEclairClient( maxFeeMsat: Int?, includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? - ): Either = Either.Right(findroutetonodeResponse) + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } override suspend fun findroutebetweennodes( sourceNodeId: String, @@ -204,7 +227,14 @@ class DummyEclairClient( maxFeeMsat: Int?, includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? - ): Either = Either.Right(findroutebetweennodesResponse) + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } companion object { val validGetInfoResponse = @@ -422,7 +452,8 @@ class DummyEclairClient( }, "routingInfo": [] }""" - val validDeleteInvoiceResponse = "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" + val validDeleteInvoiceResponse = + "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" val validParseInvoiceResponse = """{ "prefix": "lnbcrt", "timestamp": 1643718891, @@ -793,7 +824,31 @@ class DummyEclairClient( "routingInfo": [] } ]""" - val validFindRouteResponse = """{ + val validRouteResponseNodeId = """{ + "routes": [ + { + "amount": 5000, + "nodeIds": [ + "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", + "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" + ] + } + ] +}""" + val validRouteResponseShortChannelId = """{ + "routes": [ + { + "amount": 5000, + "shortChannelIds": [ + "11203x1x0", + "11203x7x5", + "11205x3x3" + ] + } + ] +}""" + val validRouteResponseFull = """{ "type": "types.FindRouteResponse", "routes": [ { @@ -904,30 +959,6 @@ class DummyEclairClient( ] } """ - val validFindRouteToNodeResponse = """{ - "routes": [ - { - "amount": 5000, - "nodeIds": [ - "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", - "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", - "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" - ] - } - ] -}""" - val validFindRouteBetweenNodesResponse = """{ - "routes": [ - { - "amount": 5000, - "nodeIds": [ - "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", - "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", - "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" - ] - } - ] -}""" } } @@ -1037,17 +1068,21 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC externalId: String? ): Either = Either.Left(error) - override suspend fun getsentinfo(paymentHash: String, id: String?): Either = Either.Left(error) + override suspend fun getsentinfo(paymentHash: String, id: String?): Either = Either.Left(error) - override suspend fun getreceivedinfo(paymentHash: String?, invoice: String?): Either = Either.Left(error) + override suspend fun getreceivedinfo(paymentHash: String?, invoice: String?): Either = + Either.Left(error) - override suspend fun listreceivedpayments(from: Int?, to: Int?, count: Int?, skip: Int?): Either = Either.Left(error) + override suspend fun listreceivedpayments(from: Int?, to: Int?, count: Int?, skip: Int?): Either = + Either.Left(error) override suspend fun getinvoice(paymentHash: String): Either = Either.Left(error) - override suspend fun listinvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = Either.Left(error) + override suspend fun listinvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = + Either.Left(error) - override suspend fun listpendinginvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = Either.Left(error) + override suspend fun listpendinginvoices(from: Int?, to: Int?, count: Int?, skip: Int?): Either = + Either.Left(error) override suspend fun findroute( invoice: String,