diff --git a/contrib/eclair-cli_autocomplete.sh b/contrib/eclair-cli_autocomplete.sh index f06329f..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" + 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" @@ -44,6 +44,12 @@ _eclair_cli() { local getinvoice_opts="--paymentHash" local listinvoices_opts="--from --to --count --skip" local listpendinginvoices_opts="--from --to --count --skip" + local findroute_opts="--invoice --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" + local findroutetonode_opts="--nodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" + local findroutebetweennodes_opts="--sourceNodeId --targetNodeId --amountMsat --ignoreNodeIds --ignoreShortChannelIds --format --maxFeeMsat --includeLocalChannelCost --pathFindingExperimentName" + 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="" @@ -126,6 +132,24 @@ _eclair_cli() { listpendinginvoices) COMPREPLY=( $(compgen -W "${listpendinginvoices_opts} ${common_opts}" -- ${cur}) ) ;; + findroute) + COMPREPLY=( $(compgen -W "${findroute_opts} ${common_opts}" -- ${cur}) ) + ;; + findroutetonode) + COMPREPLY=( $(compgen -W "${findroutetonode_opts} ${common_opts}" -- ${cur}) ) + ;; + findroutebetweennodes) + COMPREPLY=( $(compgen -W "${findroutebetweennodes_opts} ${common_opts}" -- ${cur}) ) + ;; + sendonchain) + COMPREPLY=( $(compgen -W "${sendonchain_opts} ${common_opts}" -- ${cur}) ) + ;; + 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 667ac0b..4a14f1d 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,15 @@ fun main(args: Array) { ListReceivedPaymentsCommand(resultWriter, apiClientBuilder), GetInvoiceCommand(resultWriter, apiClientBuilder), ListInvoicesCommand(resultWriter, apiClientBuilder), - ListPendingInvoicesCommand(resultWriter, apiClientBuilder) + ListPendingInvoicesCommand(resultWriter, apiClientBuilder), + FindRouteCommand(resultWriter, apiClientBuilder), + FindRouteToNodeCommand(resultWriter, apiClientBuilder), + FindRouteBetweenNodesCommand(resultWriter, apiClientBuilder), + GetNewAddressCommand(resultWriter, apiClientBuilder), + SendOnChainCommand(resultWriter, apiClientBuilder), + OnChainBalanceCommand(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 ead7d0d..f432447 100644 --- a/src/nativeMain/kotlin/api/EclairClient.kt +++ b/src/nativeMain/kotlin/api/EclairClient.kt @@ -153,6 +153,57 @@ interface IEclairClient { count: Int?, skip: Int? ): Either + + suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either + + + suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either + + suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either + + suspend fun getnewaddress(): Either + + suspend fun sendonchain(address: String, amountSatoshis: Int, confirmationTarget: Int): Either + + 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 { @@ -748,4 +799,197 @@ class EclairClient(private val apiHost: String, private val apiPassword: String) Either.Left(ApiError(0, e.message ?: "Unknown exception")) } } + + override suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroute", + formParameters = Parameters.build { + append("invoice", invoice) + amountMsat?.let { append("amountMsat", it.toString()) } + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } + + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroutetonode", + formParameters = Parameters.build { + append("nodeId", nodeId) + append("amountMsat", amountMsat.toString()) + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } + + override suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return try { + val response: HttpResponse = httpClient.submitForm( + url = "$apiHost/findroutebetweennodes", + formParameters = Parameters.build { + append("sourceNodeId", sourceNodeId) + append("targetNodeId", targetNodeId) + append("amountMsat", amountMsat.toString()) + ignoreNodeIds?.let { append("ignoreNodeIds", it.joinToString(",")) } + ignoreShortChannelIds?.let { append("ignoreShortChannelIds", it.joinToString(",")) } + format?.let { append("format", it) } + maxFeeMsat?.let { append("maxFeeMsat", it.toString()) } + includeLocalChannelCost?.let { append("includeLocalChannelCost", it.toString()) } + pathFindingExperimentName?.let { append("pathFindingExperimentName", it) } + } + ) + when (response.status) { + HttpStatusCode.OK -> Either.Right((response.bodyAsText())) + else -> Either.Left(convertHttpError(response.status)) + } + } catch (e: Throwable) { + Either.Left(ApiError(0, e.message ?: "Unknown exception")) + } + } + + 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")) + } + } + + 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")) + } + } + + 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")) + } + } + + 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")) + } + } + + 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/FindRoute.kt b/src/nativeMain/kotlin/commands/FindRoute.kt new file mode 100644 index 0000000..a66868a --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRoute.kt @@ -0,0 +1,66 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroute", + "Finds a route to the node specified by the invoice. The formats currently supported are nodeId, shortChannelId or full" +) { + private val invoice by option( + ArgType.String, + description = "The invoice containing the destination" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroute( + invoice!!, + amountMsat, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt b/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt new file mode 100644 index 0000000..8004130 --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRouteBetweenNodes.kt @@ -0,0 +1,71 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteBetweenNodesCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroutebetweennodes", + "Finds a route between two nodes." +) { + private val sourceNodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val targetNodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroutebetweennodes( + sourceNodeId!!, + targetNodeId!!, + amountMsat!!, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/commands/FindRouteToNode.kt b/src/nativeMain/kotlin/commands/FindRouteToNode.kt new file mode 100644 index 0000000..99b7828 --- /dev/null +++ b/src/nativeMain/kotlin/commands/FindRouteToNode.kt @@ -0,0 +1,66 @@ +package commands + +import IResultWriter +import api.IEclairClientBuilder +import arrow.core.flatMap +import kotlinx.cli.ArgType +import kotlinx.coroutines.runBlocking +import types.FindRouteResponse +import types.Serialization + +class FindRouteToNodeCommand( + private val resultWriter: IResultWriter, + private val eclairClientBuilder: IEclairClientBuilder +) : BaseCommand( + "findroutetonode", + "Finds a route to the given node." +) { + private val nodeId by option( + ArgType.String, + description = "The destination of the route" + ) + private val amountMsat by option( + ArgType.Int, + description = "The amount that should go through the route" + ) + private val ignoreNodeIds by option( + ArgType.String, + description = "A list of nodes to exclude from path-finding" + ) + private val ignoreShortChannelIds by option( + ArgType.String, + description = "A list of channels to exclude from path-finding" + ) + private val format by option( + ArgType.String, + description = "Format that will be used for the resulting route" + ) + private val maxFeeMsat by option( + ArgType.Int, + description = "Maximum fee allowed for this payment" + ) + private val includeLocalChannelCost by option( + ArgType.Boolean, + description = "If true, the relay fees of local channels will be counted" + ) + private val pathFindingExperimentName by option( + ArgType.String, + description = "Name of the path-finding configuration that should be used" + ) + + override fun execute() = runBlocking { + val eclairClient = eclairClientBuilder.build(host, password) + val result = eclairClient.findroutetonode( + nodeId!!, + amountMsat!!, + ignoreNodeIds?.split(","), + ignoreShortChannelIds?.split(","), + format, + maxFeeMsat, + includeLocalChannelCost, + pathFindingExperimentName + ).flatMap { apiResponse -> Serialization.decode(apiResponse) } + .map { decoded -> Serialization.encode(decoded) } + resultWriter.write(result) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/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/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/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/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/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 d663ead..bf31e18 100644 --- a/src/nativeMain/kotlin/types/EclairApiTypes.kt +++ b/src/nativeMain/kotlin/types/EclairApiTypes.kt @@ -164,3 +164,70 @@ 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 +) + +@Serializable +data class GetNewAddressResult( + val success: Boolean, + val message: String +): EclairApiType() + +@Serializable +data class SendOnChainResult( + val success: Boolean, + val message: String +): EclairApiType() + +@Serializable +data class OnChainBalance( + val confirmed: Long, + val unconfirmed: Long +): 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 +) + +@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/FindRouteBetweenNodesTest.kt b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt new file mode 100644 index 0000000..30f0a9b --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteBetweenNodesTest.kt @@ -0,0 +1,108 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteBetweenNodesCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteBetweenNodesCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + val arguments = mutableListOf( + "findroutebetweennodes", + "-p", + "password", + "--sourceNodeId", + "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "--targetNodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" + ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) + return resultWriter + } + + @Test + fun `successful request via nodeId`() { + val resultWriter = + runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString( + FindRouteResponse.serializer(), + DummyEclairClient.validRouteResponseNodeId + ), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!) + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/FindRouteTest.kt b/src/nativeTest/kotlin/commands/FindRouteTest.kt new file mode 100644 index 0000000..c30967e --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteTest.kt @@ -0,0 +1,100 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + val arguments = mutableListOf( + "findroute", + "-p", + "password", + "--invoice", + "lnbcrt10n1pjduajwpp5s6dsm9vk3q0ntxeq2zd6d4jz8mv8wau75dugud5puc3lltwp68esdqsd3shgetnw33k7er9sp5rye5z7eccrg7kx9jj6u24q2aumgl09e0e894w6hdceyk60g7a2hsmqz9gxqrrsscqp79q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgq9fktzq8fpyey9js0x85t6s5mtcwqzmmd4ql9cjq04f4tunlysje894mdcmhjwkewrk5wn2ylv3da64pda7tj04s3m90en5t6p7yyglgpue2lzz" + ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) + return resultWriter + } + + @Test + fun `successful request via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseNodeId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt new file mode 100644 index 0000000..1121bf9 --- /dev/null +++ b/src/nativeTest/kotlin/commands/FindRouteToNodeTest.kt @@ -0,0 +1,102 @@ +package commands + +import api.IEclairClientBuilder +import kotlinx.cli.ArgParser +import kotlinx.cli.ExperimentalCli +import kotlinx.serialization.json.Json +import mocks.DummyEclairClient +import mocks.DummyResultWriter +import mocks.FailingEclairClient +import types.ApiError +import types.FindRouteResponse +import kotlin.test.* + +@OptIn(ExperimentalCli::class) +class FindRouteToNodeTestCommandTest { + private fun runTest(eclairClient: IEclairClientBuilder, format: String? = null): DummyResultWriter { + val resultWriter = DummyResultWriter() + val command = FindRouteToNodeCommand(resultWriter, eclairClient) + val parser = ArgParser("test") + parser.subcommands(command) + val arguments = mutableListOf( + "findroutetonode", + "-p", + "password", + "--nodeId", + "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "--amountMsat", + "1000" + ) + format?.let { arguments.addAll(listOf("--format", it)) } + parser.parse(arguments.toTypedArray()) + return resultWriter + } + + @Test + fun `successful request via nodeId`() { + val resultWriter = runTest(DummyEclairClient(), "nodeId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseNodeId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `successful request via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(), "shortChannelId") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseShortChannelId), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!) + ) + } + + @Test + fun `successful request via full`() { + val resultWriter = runTest(DummyEclairClient(), "full") + assertNull(resultWriter.lastError) + assertNotNull(resultWriter.lastResult) + val format = Json { ignoreUnknownKeys = true } + assertEquals( + format.decodeFromString(FindRouteResponse.serializer(), DummyEclairClient.validRouteResponseFull), + format.decodeFromString(FindRouteResponse.serializer(), resultWriter.lastResult!!), + ) + } + + @Test + fun `api error`() { + val error = ApiError(42, "test failure message") + val resultWriter = runTest(FailingEclairClient(error)) + assertNull(resultWriter.lastResult) + assertEquals(error, resultWriter.lastError) + } + + @Test + fun `serialization error via nodeId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseNodeId = "{invalidJson}"), "nodeId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via shortChannelId`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseShortChannelId = "{invalidJson}"), "shortChannelId") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } + + @Test + fun `serialization error via full`() { + val resultWriter = runTest(DummyEclairClient(findrouteResponseFull = "{invalidJson}"), "full") + assertNull(resultWriter.lastResult) + assertNotNull(resultWriter.lastError) + assertTrue(resultWriter.lastError!!.message.contains("api response could not be parsed")) + } +} \ No newline at end of file diff --git a/src/nativeTest/kotlin/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/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/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/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/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 28bd18a..e7ef712 100644 --- a/src/nativeTest/kotlin/mocks/EclairClientMocks.kt +++ b/src/nativeTest/kotlin/mocks/EclairClientMocks.kt @@ -32,11 +32,21 @@ class DummyEclairClient( private val listreceivedpaymentsResponse: String = validListReceivedPaymentsResponse, private val getinvoiceResponse: String = validGetInvoiceResponse, private val listinvoicesResponse: String = validListInvoicesResponse, - private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse + private val listpendinginvoicesResponse: String = validListPendingInvoicesResponse, + private val findrouteResponseNodeId: String = validRouteResponseNodeId, + private val findrouteResponseShortChannelId: String = validRouteResponseShortChannelId, + private val findrouteResponseFull: String = validRouteResponseFull, + private val getnewaddressResponse: String = validGetNewAddressResponse, + 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) - override suspend fun connect(target: ConnectionTarget): Either = Either.Right(validConnectResponse) + override suspend fun connect(target: ConnectionTarget): Either = + Either.Right(validConnectResponse) + override suspend fun rbfopen( channelId: String, targetFeerateSatByte: Int, @@ -101,7 +111,8 @@ class DummyEclairClient( paymentPreimage: String? ): Either = Either.Right(createInvoiceResponse) - override suspend fun deleteinvoice(paymentHash: String): Either = Either.Right(deleteInvoiceResponse) + override suspend fun deleteinvoice(paymentHash: String): Either = + Either.Right(deleteInvoiceResponse) override suspend fun parseinvoice(invoice: String): Either = Either.Right(parseInvoiceResponse) @@ -169,6 +180,82 @@ class DummyEclairClient( skip: Int? ): Either = Either.Right(listpendinginvoicesResponse) + override suspend fun findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } + + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } + + override suspend fun findroutebetweennodes( + sourceNodeId: String, + targetNodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either { + return when (format) { + "nodeId" -> Either.Right(findrouteResponseNodeId) + "shortChannelId" -> Either.Right(findrouteResponseShortChannelId) + "full" -> Either.Right(findrouteResponseFull) + else -> Either.Right(findrouteResponseNodeId) + } + } + + override suspend fun getnewaddress(): Either = Either.Right(getnewaddressResponse) + + override suspend fun sendonchain( + address: String, + amountSatoshis: Int, + confirmationTarget: Int + ): Either = Either.Right(sendonchainResponse) + + override suspend fun onchainbalance(): Either = Either.Right(onchainbalanceResponse) + + 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"}""" @@ -385,7 +472,8 @@ class DummyEclairClient( }, "routingInfo": [] }""" - val validDeleteInvoiceResponse = "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" + val validDeleteInvoiceResponse = + "deleted invoice 6f0864735283ca95eaf9c50ef77893f55ee3dd11cb90710cbbfb73f018798a68" val validParseInvoiceResponse = """{ "prefix": "lnbcrt", "timestamp": 1643718891, @@ -756,6 +844,176 @@ class DummyEclairClient( "routingInfo": [] } ]""" + val validRouteResponseNodeId = """{ + "routes": [ + { + "amount": 5000, + "nodeIds": [ + "036d65409c41ab7380a43448f257809e7496b52bf92057c09c4f300cbd61c50d96", + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f", + "03d06758583bb5154774a6eb221b1276c9e82d65bbaceca806d90e20c108f4b1c7" + ] + } + ] +}""" + val validRouteResponseShortChannelId = """{ + "routes": [ + { + "amount": 5000, + "shortChannelIds": [ + "11203x1x0", + "11203x7x5", + "11205x3x3" + ] + } + ] +}""" + val validRouteResponseFull = """{ + "type": "types.FindRouteResponse", + "routes": [ + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "5d0b0155259727236f77947c87b30849ad7209dd17c6cd3ef5e53783df4ca9da4f53c2f9b687c6fc99f4c4bc8bfd7d2c719003c0fbd9b475b0e5978155716878", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "354x1x1", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + }, + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "4d9a50fdfb3d76ce47e26f75440295e1ecde91c1a67e14930bf657c5084e07b6403b0b8047d68c2b2bd765b27a3dc71fd434431881b7d863cf3283496f80bf24", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "252x2x1", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + }, + { + "amount": 1000, + "hops": [ + { + "nodeId": "03c5b161c16e9f8ef3f3bccfb74a6e9a3b423dd41fe2848174b7209f1c2ea25dad", + "nextNodeId": "02f666711319435b7905dd77d10c269d8d50c02668b975f526577167d370b50a3e", + "source": { + "type": "announcement", + "channelUpdate": { + "signature": "615fab66837d37f0fe9a949b97a6fadd37d42dcfd9adf325a7820880ca195666485d811dc00cdc2f11f98691500a88f77d4eb5179e35f594e7e68e3be71dc8ac", + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "shortChannelId": "151x3x0", + "timestamp": { + "iso": "2023-08-12T12:16:57Z", + "unix": 1691842617 + }, + "messageFlags": { + "dontForward": false + }, + "channelFlags": { + "isEnabled": true, + "isNode1": false + }, + "cltvExpiryDelta": 144, + "htlcMinimumMsat": 1, + "feeBaseMsat": 7856, + "feeProportionalMillionths": 5679, + "htlcMaximumMsat": 45000000, + "tlvStream": { + } + } + } + } + ] + } + ] +} +""" + val validGetNewAddressResponse = "bcrt1qaq9azfugal9usaffv3cj89gpeq36xst9ms53xl" + val validSendOnChainResponse = "d19c45509b2e39c92f2f84a6e07fab95509f5c1959e98f3085c66dc148582751" + val validOnChainBalanceResponse = """{ + "confirmed": 1304986456540, + "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 + } +]""" + val validSendOnionMessageWithReplyPath = """{ + "sent": true, + "response": { + "unknownTlvs": { + "211": "deadbeef" + } + } +}""" } } @@ -865,15 +1123,73 @@ 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 findroute( + invoice: String, + amountMsat: Int?, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Left(error) + + override suspend fun findroutetonode( + nodeId: String, + amountMsat: Int, + ignoreNodeIds: List?, + ignoreShortChannelIds: List?, + format: String?, + maxFeeMsat: Int?, + includeLocalChannelCost: Boolean?, + pathFindingExperimentName: String? + ): Either = Either.Left(error) - override suspend fun listpendinginvoices(from: Int?, to: Int?, count: Int?, skip: Int?): 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) + + override suspend fun getnewaddress(): Either = Either.Left(error) + + override suspend fun sendonchain( + address: String, + amountSatoshis: Int, + confirmationTarget: Int + ): Either = Either.Left(error) + + 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