diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index f06329f..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" + 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" @@ -44,6 +44,9 @@ _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" + 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="" @@ -126,6 +129,15 @@ _eclair_cli() { listpendinginvoices) COMPREPLY=( $(compgen -W "${listpendinginvoices_opts} ${common_opts}" -- ${cur}) ) ;; + findroute) + COMPREPLY=( $(compgen -W "${findroute_opts} ${common_opts}" -- ${cur}) ) + ;; + findroutetonode) + 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 667ac0b..2a5661d 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,10 @@ fun main(args: Array) { ListReceivedPaymentsCommand(resultWriter, apiClientBuilder), GetInvoiceCommand(resultWriter, apiClientBuilder), ListInvoicesCommand(resultWriter, apiClientBuilder), - ListPendingInvoicesCommand(resultWriter, apiClientBuilder) + 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 ead7d0d..45a5262 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -153,6 +153,41 @@ 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 + + + suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + 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 { @@ -748,4 +783,105 @@ 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")) + } + } + + 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")) + } + } + + 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/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/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/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/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/FindRouteBetweenNodesTest.kt b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt new file mode 100644 index 0000000..30f0a9b --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt @@ -0,0 +1,108 @@ +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, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteBetweenNodesCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + 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 via nodeId`() { + val resultWriter = + runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + 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!!), + ) + } + + @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 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")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/FindRouteTest.kt b/src/nativeTest/kotlin/commands/FindRouteTest.kt new file mode 100644 index 0000000..c30967e --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteTest.kt @@ -0,0 +1,100 @@ +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, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + 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 via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + 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!!), + ) + } + + @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 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")) + } +} \ 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..1121bf9 --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt @@ -0,0 +1,102 @@ +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, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteToNodeCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + 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 via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + 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!!), + ) + } + + @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 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")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 28bd18a..7056312 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -32,11 +32,22 @@ 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 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, @@ -101,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) @@ -169,6 +181,61 @@ 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 { + 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, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): 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, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } + 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"}""" @@ -385,7 +452,8 @@ class DummyEclairClient( }, "routingInfo": [] }""" - val validDeleteInvoiceResponse = "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" + val validDeleteInvoiceResponse = + "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" val validParseInvoiceResponse = """{ "prefix": "lnbcrt", "timestamp": 1643718891, @@ -756,6 +824,141 @@ class DummyEclairClient( "routingInfo": [] } ]""" + val validRouteResponseNodeId = """{ + "routes": [ + { + "amount": 5000, + "nodeIds": [ + "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", + "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" + ] + } + ] +}""" + val validRouteResponseShortChannelId = """{ + "routes": [ + { + "amount": 5000, + "shortChannelIds": [ + "11203x1x0", + "11203x7x5", + "11205x3x3" + ] + } + ] +}""" + val validRouteResponseFull = """{ + "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": { + } + } + } + } + ] + } + ] +} +""" } } @@ -865,15 +1068,53 @@ 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, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + 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) + + 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