From bc20c3dc9bac2293d18e2a85d116348307c0cf98 Mon Sep 17 00:00:00 2001 From: Mohit Kumar Date: Fri, 18 Aug 2023 01:41:15 +0530 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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, From 6ebef359e89784a7ca9a4046a088f99c34c08d97 Mon Sep 17 00:00:00 2001 From: claddyk Date: Tue, 5 Sep 2023 10:31:53 +0530 Subject: [PATCH 5/9] Implement the `getnewaddress` command --- contrib/eclair-cli_autocomplete.sh | 2 +- src/nativeMain/kotlin/Main.kt | 1 + src/nativeMain/kotlin/api/EclairClient.kt | 14 ++++++ .../kotlin/commands/GetNewAddress.kt | 40 +++++++++++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 6 +++ .../kotlin/commands/GetNewAddressTest.kt | 45 +++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 12 ++--- 7 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/GetNewAddress.kt create mode 100644 src/nativeTest/kotlin/commands/GetNewAddressTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index bd4bfed..d8b2110 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 findroutebetweennodes" + 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 getnewaddress" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" diff --git a/src/nativeMain/kotlin/Main.kt b/src/nativeMain/kotlin/Main.kt index 2a5661d..3ba6dc6 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -38,6 +38,7 @@ fun main(args: Array) { FindRouteCommand(resultWriter, apiClientBuilder), FindRouteToNodeCommand(resultWriter, apiClientBuilder), FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), + GetNewAddressCommand(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 45a5262..3e97ebb 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -188,6 +188,8 @@ interface IEclairClient { includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either + + suspend fun getnewaddress(): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -884,4 +886,16 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun getnewaddress(): Either { + return try { + val response: HttpResponse = httpClient.post("$apiHost/getnewaddress") + 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/GetNewAddress.kt b/src/nativeMain/kotlin/commands/GetNewAddress.kt new file mode 100644 index 0000000..85ac6ff --- /dev/null +++ b/src/nativeMain/kotlin/commands/GetNewAddress.kt @@ -0,0 +1,40 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.Either +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import types.ApiError +import types.GetNewAddressResult +import types.Serialization + +class GetNewAddressCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "getnewaddress", + "Get a new on-chain address from the wallet. This can be used to deposit funds that will later be used to fund channels." +) { + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val format = Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } + val result: Either = when (val response = eclairClient.getnewaddress()) { + is Either.Left -> Either.Left(response.value) + is Either.Right -> { + try { + val decoded = format.decodeFromString(response.value) + Either.Right(Serialization.encode(GetNewAddressResult(true, decoded))) + } catch (e: SerializationException) { + Either.Left(ApiError(1, "API response could not be parsed: ${response.value}")) + } + } + } + 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 737528c..1361090 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -190,3 +190,9 @@ data class Source( val type: String, val channelUpdate: AllUpdates ) + +@Serializable +data class GetNewAddressResult( + val success: Boolean, + val message: String +): EclairApiType() \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/GetNewAddressTest.kt b/src/nativeTest/kotlin/commands/GetNewAddressTest.kt new file mode 100644 index 0000000..fe5c9a7 --- /dev/null +++ b/src/nativeTest/kotlin/commands/GetNewAddressTest.kt @@ -0,0 +1,45 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.GetNewAddressResult +import types.Serialization +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class GetNewAddressCommandTest { + @OptIn(ExperimentalCli::class) + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = GetNewAddressCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse(arrayOf("getnewaddress", "-p", "password")) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = runTest(DummyEclairClient()) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val expectedOutput = + Serialization.encode(GetNewAddressResult(true, DummyEclairClient.validGetNewAddressResponse)) + assertEquals(expectedOutput, 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) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index 7056312..ba2f270 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -36,12 +36,7 @@ class DummyEclairClient( 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, + private val getnewaddressResponse: String = validGetNewAddressResponse, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -236,6 +231,8 @@ class DummyEclairClient( } } + override suspend fun getnewaddress(): Either = Either.Right(getnewaddressResponse) + 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"}""" @@ -959,6 +956,7 @@ class DummyEclairClient( ] } """ + val validGetNewAddressResponse = "bcrt1qaq9azfugal9usaffv3cj89gpeq36xst9ms53xl" } } @@ -1117,4 +1115,6 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC includeLocalChannelCost: Boolean?, pathFindingExperimentName: String? ): Either = Either.Left(error) + + override suspend fun getnewaddress(): Either = Either.Left(error) } \ No newline at end of file From 11dcbc9455f808e88f2133b2511956461e5f1032 Mon Sep 17 00:00:00 2001 From: claddyk Date: Thu, 7 Sep 2023 23:36:33 +0530 Subject: [PATCH 6/9] Implement the ` sendonchain` command --- contrib/eclair-cli_autocomplete.sh | 5 +- src/nativeMain/kotlin/Main.kt | 1 + src/nativeMain/kotlin/api/EclairClient.kt | 27 ++++++++- src/nativeMain/kotlin/commands/SendOnChain.kt | 58 +++++++++++++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 6 ++ .../kotlin/commands/SendOnChainTest.kt | 56 ++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 14 +++++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/SendOnChain.kt create mode 100644 src/nativeTest/kotlin/commands/SendOnChainTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index d8b2110..4fce34e 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 findroutebetweennodes getnewaddress" + 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 getnewaddress sendonchain" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -47,6 +47,7 @@ _eclair_cli() { 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" + local sendonchain_opts="--address --amountSatoshis --confirmationTarget" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -138,6 +139,8 @@ _eclair_cli() { findroutebetweennodes) COMPREPLY=( $(compgen -W "${findroutebetweennodes_opts} ${common_opts}" -- ${cur}) ) ;; + sendonchain) + COMPREPLY=( $(compgen -W "${sendonchain_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 3ba6dc6..5c6fa4d 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -39,6 +39,7 @@ fun main(args: Array) { FindRouteToNodeCommand(resultWriter, apiClientBuilder), FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), GetNewAddressCommand(resultWriter, apiClientBuilder), + SendOnChainCommand(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 3e97ebb..880f4d3 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -190,6 +190,8 @@ interface IEclairClient { ): Either suspend fun getnewaddress(): Either + + suspend fun sendonchain(address: String, amountSatoshis: Int, confirmationTarget: Int): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -887,7 +889,7 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) } } - override suspend fun getnewaddress(): Either { + override suspend fun getnewaddress(): Either { return try { val response: HttpResponse = httpClient.post("$apiHost/getnewaddress") when (response.status) { @@ -898,4 +900,27 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "unknown exception")) } } + + override suspend fun sendonchain( + address: String, + amountSatoshis: Int, + confirmationTarget: Int + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/sendonchain", + formParameters = Parameters.build { + append("address", address) + append("amountSatoshis", amountSatoshis.toString()) + append("confirmationTarget", confirmationTarget.toString()) + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right(Json.decodeFromString(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/SendOnChain.kt b/src/nativeMain/kotlin/commands/SendOnChain.kt new file mode 100644 index 0000000..c22f7d4 --- /dev/null +++ b/src/nativeMain/kotlin/commands/SendOnChain.kt @@ -0,0 +1,58 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.Either +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import types.ApiError +import types.SendOnChainResult +import types.Serialization + +class SendOnChainCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "sendonchain", + "Send an on-chain transaction to the given address. The API is only available with the bitcoin-core watcher type. The API returns the txid of the bitcoin transaction sent." +) { + private val address by option( + ArgType.String, + description = "The bitcoin address of the recipient" + ) + private val amountSatoshis by option( + ArgType.Int, + description = "The amount that should be sent" + ) + private val confirmationTarget by option( + ArgType.Int, + description = "The confirmation target(blocks)" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val format = Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } + val result: Either = when (val response = eclairClient.sendonchain( + address = address!!, + amountSatoshis = amountSatoshis!!, + confirmationTarget = confirmationTarget!! + )) { + is Either.Left -> Either.Left(response.value) + is Either.Right -> { + try { + val decoded = format.decodeFromString(response.value) + Either.Right(Serialization.encode(SendOnChainResult(true, decoded))) + } catch (e: SerializationException) { + Either.Left(ApiError(1, "API response could not be parsed: ${response.value}")) + } + } + } + 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 1361090..63c9272 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -195,4 +195,10 @@ data class Source( data class GetNewAddressResult( val success: Boolean, val message: String +): EclairApiType() + +@Serializable +data class SendOnChainResult( + val success: Boolean, + val message: String ): EclairApiType() \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/SendOnChainTest.kt b/src/nativeTest/kotlin/commands/SendOnChainTest.kt new file mode 100644 index 0000000..69ca6f8 --- /dev/null +++ b/src/nativeTest/kotlin/commands/SendOnChainTest.kt @@ -0,0 +1,56 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.SendOnChainResult +import types.Serialization +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCli::class) +class SendOnChainCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = SendOnChainCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse( + arrayOf( + "sendonchain", + "-p", + "password", + "--address", + "bcrt1qassq4a3xayeza0w6vv47uvnjqq074avqje03v8", + "--amountSatoshis", + "1000", + "--confirmationTarget", + "1000" + ) + ) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = runTest(DummyEclairClient()) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val expectedOutput = Serialization.encode(SendOnChainResult(true, DummyEclairClient.validSendOnChainResponse)) + assertEquals(expectedOutput, 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) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt index ba2f270..e5d1b1b 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -37,6 +37,7 @@ class DummyEclairClient( private val findrouteResponseShortChannelId: String = validRouteResponseShortChannelId, private val findrouteResponseFull: String = validRouteResponseFull, private val getnewaddressResponse: String = validGetNewAddressResponse, + private val sendonchainResponse: String = validSendOnChainResponse ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -233,6 +234,12 @@ class DummyEclairClient( override suspend fun getnewaddress(): Either = Either.Right(getnewaddressResponse) + override suspend fun sendonchain( + address: String, + amountSatoshis: Int, + confirmationTarget: Int + ): Either = Either.Right(sendonchainResponse) + 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"}""" @@ -957,6 +964,7 @@ class DummyEclairClient( } """ val validGetNewAddressResponse = "bcrt1qaq9azfugal9usaffv3cj89gpeq36xst9ms53xl" + val validSendOnChainResponse = "d19c45509b2e39c92f2f84a6e07fab95509f5c1959e98f3085c66dc148582751" } } @@ -1117,4 +1125,10 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC ): Either = Either.Left(error) override suspend fun getnewaddress(): Either = Either.Left(error) + + override suspend fun sendonchain( + address: String, + amountSatoshis: Int, + confirmationTarget: Int + ): Either = Either.Left(error) } \ No newline at end of file From d559b9cea2cef44566e7318951214bab4339a5a2 Mon Sep 17 00:00:00 2001 From: claddyk Date: Fri, 8 Sep 2023 00:06:05 +0530 Subject: [PATCH 7/9] Implement the `onchainbalance` command --- contrib/eclair-cli_autocomplete.sh | 2 +- src/nativeMain/kotlin/Main.kt | 1 + src/nativeMain/kotlin/api/EclairClient.kt | 14 +++++ .../kotlin/commands/OnChainBalance.kt | 24 +++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 6 +++ .../kotlin/commands/OnChainBalanceTest.kt | 53 +++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 12 ++++- 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/OnChainBalance.kt create mode 100644 src/nativeTest/kotlin/commands/OnChainBalanceTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index 4fce34e..24d630a 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 findroutebetweennodes getnewaddress sendonchain" + 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 getnewaddress sendonchain onchainbalance" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" diff --git a/src/nativeMain/kotlin/Main.kt b/src/nativeMain/kotlin/Main.kt index 5c6fa4d..924e38a 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -40,6 +40,7 @@ fun main(args: Array) { FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), GetNewAddressCommand(resultWriter, apiClientBuilder), SendOnChainCommand(resultWriter, apiClientBuilder), + OnChainBalanceCommand(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 880f4d3..2408813 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -192,6 +192,8 @@ interface IEclairClient { suspend fun getnewaddress(): Either suspend fun sendonchain(address: String, amountSatoshis: Int, confirmationTarget: Int): Either + + suspend fun onchainbalance(): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -923,4 +925,16 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "unknown exception")) } } + + override suspend fun onchainbalance(): Either { + return try { + val response: HttpResponse = httpClient.post("$apiHost/onchainbalance") + 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/OnChainBalance.kt b/src/nativeMain/kotlin/commands/OnChainBalance.kt new file mode 100644 index 0000000..6105e1c --- /dev/null +++ b/src/nativeMain/kotlin/commands/OnChainBalance.kt @@ -0,0 +1,24 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.coroutines.runBlocking +import types.OnChainBalance +import types.Serialization + +class OnChainBalanceCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "onchainbalance", + "Retrieves information about the available on-chain bitcoin balance (amounts are in satoshis). Unconfirmed balance refers to incoming transactions seen in the mempool." +) { + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.onchainbalance() + .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 63c9272..4e44382 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -201,4 +201,10 @@ data class GetNewAddressResult( data class SendOnChainResult( val success: Boolean, val message: String +): EclairApiType() + +@Serializable +data class OnChainBalance( + val confirmed: Long, + val unconfirmed: Long ): EclairApiType() \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/OnChainBalanceTest.kt b/src/nativeTest/kotlin/commands/OnChainBalanceTest.kt new file mode 100644 index 0000000..328c4d0 --- /dev/null +++ b/src/nativeTest/kotlin/commands/OnChainBalanceTest.kt @@ -0,0 +1,53 @@ +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.OnChainBalance +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class OnChainBalanceCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = OnChainBalanceCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse(arrayOf("onchainbalance", "-p", "password")) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = + runTest(DummyEclairClient(onchainbalanceResponse = DummyEclairClient.validOnChainBalanceResponse)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(OnChainBalance.serializer(), DummyEclairClient.validOnChainBalanceResponse), + format.decodeFromString(OnChainBalance.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(onchainbalanceResponse = "{invalid JSON}")) + 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 e5d1b1b..e66744d 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -37,7 +37,8 @@ class DummyEclairClient( private val findrouteResponseShortChannelId: String = validRouteResponseShortChannelId, private val findrouteResponseFull: String = validRouteResponseFull, private val getnewaddressResponse: String = validGetNewAddressResponse, - private val sendonchainResponse: String = validSendOnChainResponse + private val sendonchainResponse: String = validSendOnChainResponse, + private val onchainbalanceResponse: String = validOnChainBalanceResponse ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -240,6 +241,8 @@ class DummyEclairClient( confirmationTarget: Int ): Either = Either.Right(sendonchainResponse) + override suspend fun onchainbalance(): Either = Either.Right(onchainbalanceResponse) + 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"}""" @@ -965,6 +968,11 @@ class DummyEclairClient( """ val validGetNewAddressResponse = "bcrt1qaq9azfugal9usaffv3cj89gpeq36xst9ms53xl" val validSendOnChainResponse = "d19c45509b2e39c92f2f84a6e07fab95509f5c1959e98f3085c66dc148582751" + val validOnChainBalanceResponse = """{ + "confirmed": 1304986456540, + "unconfirmed": 0 +} +""" } } @@ -1131,4 +1139,6 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC amountSatoshis: Int, confirmationTarget: Int ): Either = Either.Left(error) + + override suspend fun onchainbalance(): Either = Either.Left(error) } \ No newline at end of file From 3ffb1ca51682ac85540b112fb9e2b084a1be057a Mon Sep 17 00:00:00 2001 From: claddyk Date: Fri, 8 Sep 2023 00:54:11 +0530 Subject: [PATCH 8/9] Implement the `onchaintransactions` command --- contrib/eclair-cli_autocomplete.sh | 7 ++- src/nativeMain/kotlin/Main.kt | 3 +- src/nativeMain/kotlin/api/EclairClient.kt | 20 +++++++ .../kotlin/commands/OnChainTransactions.kt | 53 ++++++++++++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 13 ++++- .../commands/OnChainTransactionsTest.kt | 56 +++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 28 +++++++++- 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/OnChainTransactions.kt create mode 100644 src/nativeTest/kotlin/commands/OnChainTransactionsTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index 24d630a..6a2823a 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 findroutebetweennodes getnewaddress sendonchain onchainbalance" + 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 getnewaddress sendonchain onchainbalance onchaintransactions" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -48,6 +48,7 @@ _eclair_cli() { local findroutetonode_opts="--nodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" local findroutebetweennodes_opts="--sourceNodeId --targetNodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" local sendonchain_opts="--address --amountSatoshis --confirmationTarget" + local onchaintransactions_opts="--count --skip" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -141,6 +142,10 @@ _eclair_cli() { ;; sendonchain) COMPREPLY=( $(compgen -W "${sendonchain_opts} ${common_opts}" -- ${cur}) ) + ;; + onchaintransactions) + COMPREPLY=( $(compgen -W "${onchaintransactions_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 924e38a..f9c29c3 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -40,7 +40,8 @@ fun main(args: Array) { FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), GetNewAddressCommand(resultWriter, apiClientBuilder), SendOnChainCommand(resultWriter, apiClientBuilder), - OnChainBalanceCommand(resultWriter, apiClientBuilder) + OnChainBalanceCommand(resultWriter, apiClientBuilder), + OnChainTransactionsCommand(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 2408813..f8fbf63 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -194,6 +194,8 @@ interface IEclairClient { suspend fun sendonchain(address: String, amountSatoshis: Int, confirmationTarget: Int): Either suspend fun onchainbalance(): Either + + suspend fun onchaintransactions(count: Int, skip: Int): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -937,4 +939,22 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "unknown exception")) } } + + override suspend fun onchaintransactions(count: Int, skip: Int): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/onchaintransactions", + formParameters = Parameters.build { + append("count", count.toString()) + append("skip", skip.toString()) + } + ) + 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/OnChainTransactions.kt b/src/nativeMain/kotlin/commands/OnChainTransactions.kt new file mode 100644 index 0000000..9ea0c13 --- /dev/null +++ b/src/nativeMain/kotlin/commands/OnChainTransactions.kt @@ -0,0 +1,53 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.Either +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import types.ApiError +import types.OnChainTransaction + +class OnChainTransactionsCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "onchaintransactions", + "Retrieves information about the latest on-chain transactions made by our Bitcoin wallet(most recent transactions first." +) { + private val count by option( + ArgType.Int, + description = "Number of transactions to return" + ) + private val skip by option( + ArgType.Int, + description = "Number of transactions to skip" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val format = Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } + val result = eclairClient.onchaintransactions( + count = count!!, + skip = skip!! + ) + .flatMap { apiResponse -> + try { + Either.Right(format.decodeFromString>(apiResponse)) + } catch (e: Throwable) { + Either.Left(ApiError(1, "api response could not be parsed: $apiResponse")) + } + } + .map { decoded -> + format.encodeToString(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 4e44382..da6788c 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -207,4 +207,15 @@ data class SendOnChainResult( data class OnChainBalance( val confirmed: Long, val unconfirmed: Long -): EclairApiType() \ No newline at end of file +): EclairApiType() + +@Serializable +data class OnChainTransaction( + val address: String, + val amount: Int, + val fees: Int, + val blockHash: String, + val confirmations: Int, + val txid: String, + val timestamp: Long +) \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/OnChainTransactionsTest.kt b/src/nativeTest/kotlin/commands/OnChainTransactionsTest.kt new file mode 100644 index 0000000..58ea88c --- /dev/null +++ b/src/nativeTest/kotlin/commands/OnChainTransactionsTest.kt @@ -0,0 +1,56 @@ +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 kotlin.test.* + +class OnChainTransactionsCommandTest { + @OptIn(ExperimentalCli::class) + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = OnChainTransactionsCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse(arrayOf("onchaintransactions", "-p", "password", "--count", "2", "--skip", "0")) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = + runTest(DummyEclairClient(onchaintransactionsResponse = DummyEclairClient.validOnChainTransactionsResponse)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } + assertEquals( + format.parseToJsonElement(DummyEclairClient.validOnChainTransactionsResponse), + format.decodeFromString(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(onchaintransactionsResponse = "{invalidJson}")) + 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 e66744d..81229e5 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -38,7 +38,8 @@ class DummyEclairClient( private val findrouteResponseFull: String = validRouteResponseFull, private val getnewaddressResponse: String = validGetNewAddressResponse, private val sendonchainResponse: String = validSendOnChainResponse, - private val onchainbalanceResponse: String = validOnChainBalanceResponse + private val onchainbalanceResponse: String = validOnChainBalanceResponse, + private val onchaintransactionsResponse: String = validOnChainTransactionsResponse, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -243,6 +244,9 @@ class DummyEclairClient( override suspend fun onchainbalance(): Either = Either.Right(onchainbalanceResponse) + override suspend fun onchaintransactions(count: Int, skip: Int): Either = + Either.Right(onchaintransactionsResponse) + 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"}""" @@ -973,6 +977,26 @@ class DummyEclairClient( "unconfirmed": 0 } """ + val validOnChainTransactionsResponse = """[ + { + "address": "2NEDjKwa56LFcFVjPefuwkN3pyABkMrqpJn", + "amount": 25000, + "fees": 0, + "blockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "confirmations": 0, + "txid": "d19c45509b2e39c92f2f84a6e07fab95509f5c1959e98f3085c66dc148582751", + "timestamp": 1593700112 + }, + { + "address": "2NEDjKwa56LFcFVjPefuwkN3pyABkMrqpJn", + "amount": 625000000, + "fees": 0, + "blockHash": "3f66e75bb70c1bc28edda9456fcf96ac68f10053020bee39f4cd45c240a1f05d", + "confirmations": 1, + "txid": "467e0f4c1fed9db56760e7bdcedb335c6b649fdaa82f51da80481a1101a98329", + "timestamp": 1593698170 + } +]""" } } @@ -1141,4 +1165,6 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC ): Either = Either.Left(error) override suspend fun onchainbalance(): Either = Either.Left(error) + + override suspend fun onchaintransactions(count: Int, skip: Int): Either = Either.Left(error) } \ No newline at end of file From 041c17655b2984a0e70a7b0ddc433fd91072c9a5 Mon Sep 17 00:00:00 2001 From: claddyk Date: Fri, 8 Sep 2023 19:51:50 +0530 Subject: [PATCH 9/9] Implement the `sendonionmessage` command --- contrib/eclair-cli_autocomplete.sh | 6 +- src/nativeMain/kotlin/Main.kt | 3 +- src/nativeMain/kotlin/api/EclairClient.kt | 35 ++++++++++ .../kotlin/commands/SendOnionMessage.kt | 51 ++++++++++++++ src/nativeMain/kotlin/types/EclairApiTypes.kt | 12 ++++ .../kotlin/commands/SendOnionMessageTest.kt | 66 +++++++++++++++++++ .../kotlin/mocks/EclairClientMocks.kt | 25 +++++++ 7 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/nativeMain/kotlin/commands/SendOnionMessage.kt create mode 100644 src/nativeTest/kotlin/commands/SendOnionMessageTest.kt diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index 6a2823a..b4503ab 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 findroutebetweennodes getnewaddress sendonchain onchainbalance onchaintransactions" + 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 getnewaddress sendonchain onchainbalance onchaintransactions sendonionmessage" local common_opts="-p --host" local connect_opts="--uri --nodeId --address --port" local disconnect_opts="--nodeId" @@ -49,6 +49,7 @@ _eclair_cli() { local findroutebetweennodes_opts="--sourceNodeId --targetNodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" local sendonchain_opts="--address --amountSatoshis --confirmationTarget" local onchaintransactions_opts="--count --skip" + local sendonionmessage_opts="--content --recipientNode --recipientBlindedRoute --intermediateNodes --replyPath" # If the current word starts with a dash (-), it's an option rather than a command if [[ ${cur} == -* ]]; then local cmd="" @@ -146,6 +147,9 @@ _eclair_cli() { onchaintransactions) COMPREPLY=( $(compgen -W "${onchaintransactions_opts} ${common_opts}" -- ${cur}) ) ;; + sendonionmessage) + COMPREPLY=( $(compgen -W "${sendonionmessage_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 f9c29c3..4a14f1d 100644 --- a/src/nativeMain/kotlin/Main.kt +++ b/src/nativeMain/kotlin/Main.kt @@ -41,7 +41,8 @@ fun main(args: Array) { GetNewAddressCommand(resultWriter, apiClientBuilder), SendOnChainCommand(resultWriter, apiClientBuilder), OnChainBalanceCommand(resultWriter, apiClientBuilder), - OnChainTransactionsCommand(resultWriter, apiClientBuilder) + OnChainTransactionsCommand(resultWriter, apiClientBuilder), + SendOnionMessageCommand(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 f8fbf63..f432447 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -196,6 +196,14 @@ interface IEclairClient { suspend fun onchainbalance(): Either suspend fun onchaintransactions(count: Int, skip: Int): Either + + suspend fun sendonionmessage( + content: String, + recipientNode: String?, + recipientBlindedRoute: String?, + intermediateNodes: List?, + replyPath: List? + ): Either } class EclairClient(private val apiHost: String, private val apiPassword: String) : IEclairClient { @@ -957,4 +965,31 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun sendonionmessage( + content: String, + recipientNode: String?, + recipientBlindedRoute: String?, + intermediateNodes: List?, + replyPath: List? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/sendonionmessage", + formParameters = Parameters.build { + append("content", content) + recipientNode?.let { append("recipientNode", it) } + recipientBlindedRoute?.let { append("recipientBlindedRoute", it) } + intermediateNodes?.let { append("intermediateNodes", it.joinToString(",")) } + replyPath?.let { append("replyPath", it.joinToString(",")) } + } + ) + 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/SendOnionMessage.kt b/src/nativeMain/kotlin/commands/SendOnionMessage.kt new file mode 100644 index 0000000..83b8c01 --- /dev/null +++ b/src/nativeMain/kotlin/commands/SendOnionMessage.kt @@ -0,0 +1,51 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.SendOnionMessageResult +import types.Serialization + +class SendOnionMessageCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "sendonionmessage", + "Send an onion message to a remote recipient." +) { + private val content by option( + ArgType.String, + description = "Message sent to the recipient(encoded as a tlv stream)" + ) + private val recipientNode by option( + ArgType.String, + description = "NodeId of the recipient, if known" + ) + private val recipientBlindedRoute by option( + ArgType.String, + description = "Blinded route provided by the recipient(encoded as a tlv)" + ) + private val intermediateNodes by option( + ArgType.String, + description = "Intermediate nodes to insert before the recipient" + ) + private val replyPath by option( + ArgType.String, + description = "Reply path that must be used if a response is expected" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.sendonionmessage( + content = content!!, + recipientNode = recipientNode, + recipientBlindedRoute = recipientBlindedRoute, + intermediateNodes = intermediateNodes?.split(","), + replyPath = replyPath?.split(",") + ).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 da6788c..bf31e18 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -218,4 +218,16 @@ data class OnChainTransaction( val confirmations: Int, val txid: String, val timestamp: Long +) + +@Serializable +data class SendOnionMessageResult( + val sent: Boolean, + val response: Response? = null, + val failureMessage: String? = null +): EclairApiType() + +@Serializable +data class Response( + val unknownTlvs: Map ) \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/SendOnionMessageTest.kt b/src/nativeTest/kotlin/commands/SendOnionMessageTest.kt new file mode 100644 index 0000000..c5fa087 --- /dev/null +++ b/src/nativeTest/kotlin/commands/SendOnionMessageTest.kt @@ -0,0 +1,66 @@ +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.SendOnionMessageResult +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class SendOnionMessageCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = SendOnionMessageCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + parser.parse( + arrayOf( + "sendonionmessage", + "-p", + "password", + "--content", + "2b03ffffff", + "--recipientNode", + "02e33c55738832506284c40d60cecc4e7f7a7f32de97fc0def1ba2ac8f29d27917" + ) + ) + return resultWriter + } + + @Test + fun `successful request`() { + val resultWriter = + runTest(DummyEclairClient(sendonionmessageSuccessWithReplyPath = DummyEclairClient.validSendOnionMessageWithReplyPath)) + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString( + SendOnionMessageResult.serializer(), + DummyEclairClient.validSendOnionMessageWithReplyPath + ), + format.decodeFromString(SendOnionMessageResult.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(sendonionmessageSuccessWithReplyPath = "{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 81229e5..e7ef712 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -40,6 +40,7 @@ class DummyEclairClient( private val sendonchainResponse: String = validSendOnChainResponse, private val onchainbalanceResponse: String = validOnChainBalanceResponse, private val onchaintransactionsResponse: String = validOnChainTransactionsResponse, + private val sendonionmessageSuccessWithReplyPath: String = validSendOnionMessageWithReplyPath, ) : IEclairClient, IEclairClientBuilder { override fun build(apiHost: String, apiPassword: String): IEclairClient = this override suspend fun getInfo(): Either = Either.Right(getInfoResponse) @@ -247,6 +248,14 @@ class DummyEclairClient( override suspend fun onchaintransactions(count: Int, skip: Int): Either = Either.Right(onchaintransactionsResponse) + override suspend fun sendonionmessage( + content: String, + recipientNode: String?, + recipientBlindedRoute: String?, + intermediateNodes: List?, + replyPath: List? + ): Either = Either.Right(sendonionmessageSuccessWithReplyPath) + 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"}""" @@ -997,6 +1006,14 @@ class DummyEclairClient( "timestamp": 1593698170 } ]""" + val validSendOnionMessageWithReplyPath = """{ + "sent": true, + "response": { + "unknownTlvs": { + "211": "deadbeef" + } + } +}""" } } @@ -1167,4 +1184,12 @@ class FailingEclairClient(private val error: ApiError) : IEclairClient, IEclairC override suspend fun onchainbalance(): Either = Either.Left(error) override suspend fun onchaintransactions(count: Int, skip: Int): Either = Either.Left(error) + + override suspend fun sendonionmessage( + content: String, + recipientNode: String?, + recipientBlindedRoute: String?, + intermediateNodes: List?, + replyPath: List? + ): Either = Either.Left(error) } \ No newline at end of file