diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index eebb0b5e..24422104 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -29,6 +29,9 @@ 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.crypto.KeyManager +import fr.acinq.lightning.crypto.div import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.WrappedChannelCommand @@ -52,7 +55,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 @@ -135,7 +143,10 @@ class Api( .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()) @@ -210,6 +221,15 @@ class Api( 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() @@ -272,6 +292,94 @@ class Api( 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 + } + 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("lnurlpay") { val formParameters = call.receiveParameters() val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() @@ -354,6 +462,44 @@ class Api( 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 07bab792..49290ade 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -38,6 +38,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 @@ -49,6 +51,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 @@ -276,9 +279,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 @@ -308,7 +328,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 cf74621b..a51b1bfe 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -71,7 +71,23 @@ 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 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 bddbf71b..113faf9f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -52,6 +52,7 @@ fun main(args: Array) = ListOutgoingPayments(), GetIncomingPayment(), ListIncomingPayments(), + DeleteIncomingPayment(), CreateInvoice(), GetOffer(), GetLnAddress(), @@ -64,7 +65,15 @@ fun main(args: Array) = LnurlWithdraw(), LnurlAuth(), SendToAddress(), - CloseChannel() + CloseChannel(), + GetFinalAddress(), + GetSwapInAddress(), + GetFinalWalletBalance(), + GetSwapInWalletBalance(), + GetSwapInTransactions(), + GetFinalWalletInfo(), + GetSwapInWalletInfo(), + ManualSpliceIn() ) .main(args) @@ -199,6 +208,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( @@ -304,6 +320,49 @@ 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 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 LnurlPay : PhoenixCliCommand(name = "lnurlpay", help = "Pay a LNURL", printHelpOnEmptyArgs = true) { private val amountSat by option("--amountSat").long() private val lnurl by option("--lnurl").required().check { @@ -368,6 +427,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 }