From d7333c87adb4ace13aa98960b64dd649f6ea187a Mon Sep 17 00:00:00 2001 From: Raymond Sebetoa Date: Tue, 9 Jul 2024 11:53:16 +0200 Subject: [PATCH 1/2] Swap-in wallet functionalities --- .../kotlin/fr/acinq/lightning/bin/Api.kt | 137 +++++++++++++++++- .../kotlin/fr/acinq/lightning/bin/Main.kt | 29 +++- .../lightning/bin/json/JsonSerializers.kt | 11 +- .../fr/acinq/lightning/cli/PhoenixCli.kt | 61 +++++++- 4 files changed, 231 insertions(+), 7 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index 2f9c9d0..440c94b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -8,7 +8,6 @@ import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.toEither import fr.acinq.lightning.BuildVersions import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.NodeParams import fr.acinq.lightning.bin.db.SqlitePaymentsDb import fr.acinq.lightning.bin.db.WalletPaymentId @@ -22,6 +21,7 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.ClosingFeerates +import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.payment.Bolt11Invoice @@ -43,7 +43,12 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -114,7 +119,10 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va .filterNot { it is Closing || it is Closed } .map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) } .sum().truncateToSatoshi() - call.respond(Balance(balance, nodeParams.feeCredit.value)) + val swapInBalance = peer.swapInWallet.wallet.walletStateFlow + .map { it.totalBalance } + .distinctUntilChanged().first() + call.respond(Balance(balance, nodeParams.feeCredit.value, swapInBalance)) } get("listchannels") { call.respond(peer.channels.values.toList()) @@ -181,6 +189,15 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va call.respond(OutgoingPayment(it)) } ?: call.respond(HttpStatusCode.NotFound) } + delete("payments/incoming/{paymentHash}") { + val paymentHash = call.parameters.getByteVector32("paymentHash") + val success = paymentDb.removeIncomingPayment(paymentHash) + if (success) { + call.respondText("Payment successfully deleted", status = HttpStatusCode.OK) + } else { + call.respondText("Payment not found or failed to delete", status = HttpStatusCode.NotFound) + } + } post("payinvoice") { val formParameters = call.receiveParameters() val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() @@ -214,6 +231,84 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va val offer = formParameters.getOffer("offer") call.respond(offer) } + get("/getfinaladdress"){ + val finalAddress = peer.finalWallet.finalAddress + call.respond(finalAddress) + } + get("/getswapinaddress") { + try { + val swapInWalletStateFlow = peer.swapInWallet.wallet.walletStateFlow + swapInWalletStateFlow + .map { it.lastDerivedAddress } + .filterNotNull() + .distinctUntilChanged() + .collect { (address, derived) -> + call.respond(SwapInAddress(address, derived.index)) + } + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Error fetching swap-in address") + } + } + get("/finalwalletbalance") { + try { + val currentBlockHeight: Long = peer.currentTipFlow.filterNotNull().first().toLong() + val walletStateFlow = peer.finalWallet.wallet.walletStateFlow + val utxosFlow = walletStateFlow.map { walletState -> + walletState.utxos.groupBy { utxo -> + val confirmations = currentBlockHeight - utxo.blockHeight + 1 + when { + confirmations < 1 -> "unconfirmed" + confirmations < 3 -> "weaklyConfirmed" + else -> "deeplyConfirmed" + } + }.mapValues { entry -> + entry.value.sumOf { it.amount.toLong() } + } + }.distinctUntilChanged() + val balancesByConfirmation = utxosFlow.first() + val response = WalletBalance( + unconfirmed = balancesByConfirmation["unconfirmed"] ?: 0L, + weaklyConfirmed = balancesByConfirmation["weaklyConfirmed"] ?: 0L, + deeplyConfirmed = balancesByConfirmation["deeplyConfirmed"] ?: 0L + ) + + call.respond(response) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Error fetching Final wallet balance") + } + } + get("/swapinwalletbalance") { + try { + val currentBlockHeight: Long = peer.currentTipFlow.filterNotNull().first().toLong() + val walletStateFlow = peer.swapInWallet.wallet.walletStateFlow + val utxosFlow = walletStateFlow.map { walletState -> + walletState.utxos.groupBy { utxo -> + val confirmations = currentBlockHeight - utxo.blockHeight + 1 + when { + confirmations < 1 -> "unconfirmed" + confirmations < 3 -> "weaklyConfirmed" + else -> "deeplyConfirmed" + } + }.mapValues { entry -> + entry.value.sumOf { it.amount.toLong() } + } + }.distinctUntilChanged() + val balancesByConfirmation = utxosFlow.first() + val response = WalletBalance( + unconfirmed = balancesByConfirmation["unconfirmed"] ?: 0L, + weaklyConfirmed = balancesByConfirmation["weaklyConfirmed"] ?: 0L, + deeplyConfirmed = balancesByConfirmation["deeplyConfirmed"] ?: 0L + ) + call.respond(response) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Error fetching Swapin wallet balance") + } + } + get("/swapintransactions") { + val wallet = peer.swapInWallet.wallet + val walletState = wallet.walletStateFlow.value + call.respond(walletState.utxos.toString()) //no serializable json structure for this + } post("sendtoaddress") { val res = kotlin.runCatching { val formParameters = call.receiveParameters() @@ -231,6 +326,44 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va is Either.Left -> call.respondText(res.value.message.toString()) } } + post("/splicein") {//Manual splice-in + val formParameters = call.receiveParameters() + val amountSat = formParameters.getLong("amountSat").msat //the splice in command will send all the balance in wallet + val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat)) + val walletInputs = peer.swapInWallet.wallet.walletStateFlow.value.utxos + + val suitableChannel = peer.channels.values + .filterIsInstance() + .firstOrNull { it.commitments.availableBalanceForReceive() > amountSat } + ?: return@post call.respond(HttpStatusCode.BadRequest, "No suitable channel available for splice-in") + + if (walletInputs.isEmpty()) { + return@post call.respond(HttpStatusCode.BadRequest, "No wallet inputs available for splice-in,swap-in wallet balance too low") + } + + try { + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = walletInputs.let { it1 -> + ChannelCommand.Commitment.Splice.Request.SpliceIn( + it1, amountSat) + }, + spliceOut = null, + requestRemoteFunding = null, + feerate = feerate + ) + + peer.send(WrappedChannelCommand(suitableChannel.channelId, spliceCommand)) + + when (val response = spliceCommand.replyTo.await()) { + is ChannelCommand.Commitment.Splice.Response.Created -> call.respondText("Splice-in successful: transaction ID ${response.fundingTxId}", status = HttpStatusCode.OK) + is ChannelCommand.Commitment.Splice.Response.Failure -> call.respondText("Splice-in failed: $response", status = HttpStatusCode.BadRequest) + else -> call.respondText("Splice-in failed: unexpected response type", status = HttpStatusCode.InternalServerError) + } + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to process splice-in: ${e.localizedMessage}") + } + } post("closechannel") { val formParameters = call.receiveParameters() val channelId = formParameters.getByteVector32("channelId") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt index 5353dd0..aa54145 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -37,6 +37,8 @@ import fr.acinq.lightning.bin.json.ApiType import fr.acinq.lightning.bin.logs.FileLogWriter import fr.acinq.lightning.bin.logs.TimestampFormatter import fr.acinq.lightning.bin.logs.stringTimestamp +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher import fr.acinq.lightning.crypto.LocalKeyManager @@ -48,6 +50,7 @@ import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.ServerAddress import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector @@ -270,9 +273,26 @@ class Phoenixd : CliktCommand() { val paymentsDb = SqlitePaymentsDb(database) val mempoolSpace = MempoolSpaceClient(mempoolSpaceUrl, loggerFactory) - val watcher = MempoolSpaceWatcher(mempoolSpace, scope, loggerFactory, pollingInterval = mempoolPollingInterval) + //val watcher = MempoolSpaceWatcher(mempoolSpace, scope, loggerFactory, pollingInterval = mempoolPollingInterval) + + val electrumClient = ElectrumClient(scope, nodeParams.loggerFactory) + val serverAddress = ServerAddress("electrum.acinq.co", 50002, TcpSocket.TLS.UNSAFE_CERTIFICATES) + val socketBuilder = TcpSocket.Builder() + + runBlocking { + val connected = electrumClient.connect(serverAddress, socketBuilder) + if (!connected) { + consoleLog(yellow("Failed to connect to Electrum server")) + return@runBlocking + } + else{ + consoleLog(yellow("Successfully Connected to Electrum Server")) + } + } + + val electrumWatcher = ElectrumWatcher(electrumClient, scope, nodeParams.loggerFactory) val peer = Peer( - nodeParams = nodeParams, walletParams = lsp.walletParams, client = mempoolSpace, watcher = watcher, db = object : Databases { + nodeParams = nodeParams, walletParams = lsp.walletParams, client = mempoolSpace, watcher = electrumWatcher, db = object : Databases { override val channels: ChannelsDb get() = channelsDb override val payments: PaymentsDb get() = paymentsDb }, socketBuilder = TcpSocket.Builder(), scope @@ -302,7 +322,10 @@ class Phoenixd : CliktCommand() { peer.connectionState.dropWhile { it is Connection.CLOSED }.collect { when (it) { Connection.ESTABLISHING -> consoleLog(yellow("connecting to lightning peer...")) - Connection.ESTABLISHED -> consoleLog(yellow("connected to lightning peer")) + Connection.ESTABLISHED -> { + consoleLog(yellow("connected to lightning peer")) + peer.startWatchSwapInWallet() + } is Connection.CLOSED -> consoleLog(yellow("disconnected from lightning peer")) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt index 6dde917..ed58a8b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -66,8 +66,17 @@ sealed class ApiType { ) @Serializable - data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi) : ApiType() + data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi, @SerialName("swapInSat") val swapInBalance: Satoshi?) : ApiType() + @Serializable + data class SwapInAddress(@SerialName("address") val address: String, @SerialName("index") val index: Int) : ApiType() + + @Serializable + data class WalletBalance( + val unconfirmed: Long, + val weaklyConfirmed: Long, + val deeplyConfirmed: Long + ) : ApiType() @Serializable data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt index 39c1c09..632fb35 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -48,6 +48,7 @@ fun main(args: Array) = ListOutgoingPayments(), GetIncomingPayment(), ListIncomingPayments(), + DeleteIncomingPayment(), CreateInvoice(), GetOffer(), PayInvoice(), @@ -55,7 +56,13 @@ fun main(args: Array) = DecodeInvoice(), DecodeOffer(), SendToAddress(), - CloseChannel() + CloseChannel(), + GetFinalAddress(), + GetSwapInAddress(), + GetFinalWalletBalance(), + GetSwapInWalletBalance(), + GetSwapInTransactions(), + ManualSpliceIn() ) .main(args) @@ -190,6 +197,13 @@ class ListIncomingPayments : PhoenixCliCommand(name = "listincomingpayments", he } } +class DeleteIncomingPayment : PhoenixCliCommand(name = "deleteincomingpayment", help = "Delete an incoming payment") { + private val paymentHash by option("--paymentHash", "--h").convert { ByteVector32.fromValidHex(it) }.required() + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.delete(url = commonOptions.baseUrl / "payments/incoming/$paymentHash") + } +} + class CreateInvoice : PhoenixCliCommand(name = "createinvoice", help = "Create a Lightning invoice", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long() private val description by mutuallyExclusiveOptions( @@ -273,6 +287,36 @@ class DecodeOffer : PhoenixCliCommand(name = "decodeoffer", help = "Decode a Lig } } +class GetFinalAddress : PhoenixCliCommand(name = "getfinaladdress", help = "Retrieve the final wallet address", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getfinaladdress") + } +} + +class GetSwapInAddress : PhoenixCliCommand(name = "getswapinaddress", help = "Retrieve the current swap-in address from the wallet", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getswapinaddress") + } +} + +class GetFinalWalletBalance : PhoenixCliCommand(name = "getfinalwalletbalance", help = "Retrieve the final wallet balance", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "finalwalletbalance") + } +} + +class GetSwapInWalletBalance : PhoenixCliCommand(name = "getswapinwalletbalance", help = "Retrieve the swap-in wallet balance", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "swapinwalletbalance") + } +} + +class GetSwapInTransactions : PhoenixCliCommand(name = "getswapintransactions", help = "List transactions for the swap-in wallet", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "swapintransactions") + } +} + class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long().required() private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess } @@ -289,6 +333,21 @@ class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to } } +class ManualSpliceIn : PhoenixCliCommand(name = "splicein", help = "Splice in funds to a channel using all available balance in the wallet", printHelpOnEmptyArgs = true) { + private val amountSat by option("--amountSat").long().required() //not necessarily required, come back to it + private val feerateSatByte by option("--feerateSatByte").int().required() + + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.submitForm( + url = (commonOptions.baseUrl / "splicein").toString(), + formParameters = parameters { + append("amountSat", amountSat.toString()) + append("feerateSatByte", feerateSatByte.toString()) + } + ) + } +} + class CloseChannel : PhoenixCliCommand(name = "closechannel", help = "Close channel", printHelpOnEmptyArgs = true) { private val channelId by option("--channelId").convert { it.toByteVector32() }.required() private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess } From 26bfc7d3f9546a34025a8c72f883bd71994281f5 Mon Sep 17 00:00:00 2001 From: Raymond Sebetoa Date: Thu, 11 Jul 2024 10:10:10 +0200 Subject: [PATCH 2/2] Onchain wallets information endpoints --- .../kotlin/fr/acinq/lightning/bin/Api.kt | 11 +++++++++++ .../acinq/lightning/bin/json/JsonSerializers.kt | 7 +++++++ .../kotlin/fr/acinq/lightning/cli/PhoenixCli.kt | 15 +++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index 440c94b..ee0db36 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -22,6 +22,8 @@ import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.ClosingFeerates import fr.acinq.lightning.channel.states.Normal +import fr.acinq.lightning.crypto.KeyManager +import fr.acinq.lightning.crypto.div import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.payment.Bolt11Invoice @@ -309,6 +311,15 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va val walletState = wallet.walletStateFlow.value call.respond(walletState.utxos.toString()) //no serializable json structure for this } + get("/getfinalwalletinfo"){ + val finalOnChainWallet = nodeParams.keyManager.finalOnChainWallet + val path = (KeyManager.Bip84OnChainKeys.bip84BasePath(nodeParams.chain) / finalOnChainWallet.account).toString() + call.respond(FinalWalletInfo(path, finalOnChainWallet.xpub)) + } + get("/getswapinwalletinfo"){ + val swapInOnChainWallet = nodeParams.keyManager.swapInOnChainWallet + call.respond(SwapInWalletInfo(swapInOnChainWallet.legacyDescriptor, swapInOnChainWallet.publicDescriptor, swapInOnChainWallet.userPublicKey.toHex())) + } post("sendtoaddress") { val res = kotlin.runCatching { val formParameters = call.receiveParameters() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt index ed58a8b..24988b2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -77,6 +77,13 @@ sealed class ApiType { val weaklyConfirmed: Long, val deeplyConfirmed: Long ) : ApiType() + + @Serializable + data class FinalWalletInfo(@SerialName("path") val path: String, @SerialName("xpub") val xpub: String) : ApiType() + + @Serializable + data class SwapInWalletInfo(@SerialName("legacyDescriptor") val legacyDescriptor: String, @SerialName("publicDescriptor") val publicDescriptor: String, @SerialName("userPublicKey") val userPublicKey: String) : ApiType() + @Serializable data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt index 632fb35..f39cb11 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -62,6 +62,8 @@ fun main(args: Array) = GetFinalWalletBalance(), GetSwapInWalletBalance(), GetSwapInTransactions(), + GetFinalWalletInfo(), + GetSwapInWalletInfo(), ManualSpliceIn() ) .main(args) @@ -317,6 +319,19 @@ class GetSwapInTransactions : PhoenixCliCommand(name = "getswapintransactions", } } +class GetFinalWalletInfo : PhoenixCliCommand(name = "getfinalwalletinfo", help = "Get the final wallet information", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getfinalwalletinfo") + } +} + +class GetSwapInWalletInfo : PhoenixCliCommand(name = "getswapinwalletinfo", help = "Get the swap-in wallet information", printHelpOnEmptyArgs = true) { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getswapinwalletinfo") + } +} + + class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long().required() private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess }