Swap-in wallet functionalities

This commit is contained in:
Raymond Sebetoa 2024-07-09 11:53:16 +02:00
parent 2e22c0b80c
commit d7333c87ad
4 changed files with 231 additions and 7 deletions

View File

@ -8,7 +8,6 @@ import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.toEither import fr.acinq.bitcoin.utils.toEither
import fr.acinq.lightning.BuildVersions import fr.acinq.lightning.BuildVersions
import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.Lightning.randomKey
import fr.acinq.lightning.NodeParams import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.bin.db.SqlitePaymentsDb import fr.acinq.lightning.bin.db.SqlitePaymentsDb
import fr.acinq.lightning.bin.db.WalletPaymentId 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.Closed
import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.Closing
import fr.acinq.lightning.channel.states.ClosingFeerates 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.Peer
import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.io.WrappedChannelCommand
import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.Bolt11Invoice
@ -43,7 +43,12 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.SharedFlow 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.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json 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 } .filterNot { it is Closing || it is Closed }
.map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) } .map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) }
.sum().truncateToSatoshi() .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") { get("listchannels") {
call.respond(peer.channels.values.toList()) 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(OutgoingPayment(it))
} ?: call.respond(HttpStatusCode.NotFound) } ?: 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") { post("payinvoice") {
val formParameters = call.receiveParameters() val formParameters = call.receiveParameters()
val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() 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") val offer = formParameters.getOffer("offer")
call.respond(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") { post("sendtoaddress") {
val res = kotlin.runCatching { val res = kotlin.runCatching {
val formParameters = call.receiveParameters() 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()) 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<Normal>()
.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") { post("closechannel") {
val formParameters = call.receiveParameters() val formParameters = call.receiveParameters()
val channelId = formParameters.getByteVector32("channelId") val channelId = formParameters.getByteVector32("channelId")

View File

@ -37,6 +37,8 @@ import fr.acinq.lightning.bin.json.ApiType
import fr.acinq.lightning.bin.logs.FileLogWriter import fr.acinq.lightning.bin.logs.FileLogWriter
import fr.acinq.lightning.bin.logs.TimestampFormatter import fr.acinq.lightning.bin.logs.TimestampFormatter
import fr.acinq.lightning.bin.logs.stringTimestamp 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.MempoolSpaceClient
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher
import fr.acinq.lightning.crypto.LocalKeyManager 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.logging.LoggerFactory
import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.ServerAddress
import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.utils.toByteVector
@ -270,9 +273,26 @@ class Phoenixd : CliktCommand() {
val paymentsDb = SqlitePaymentsDb(database) val paymentsDb = SqlitePaymentsDb(database)
val mempoolSpace = MempoolSpaceClient(mempoolSpaceUrl, loggerFactory) 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( 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 channels: ChannelsDb get() = channelsDb
override val payments: PaymentsDb get() = paymentsDb override val payments: PaymentsDb get() = paymentsDb
}, socketBuilder = TcpSocket.Builder(), scope }, socketBuilder = TcpSocket.Builder(), scope
@ -302,7 +322,10 @@ class Phoenixd : CliktCommand() {
peer.connectionState.dropWhile { it is Connection.CLOSED }.collect { peer.connectionState.dropWhile { it is Connection.CLOSED }.collect {
when (it) { when (it) {
Connection.ESTABLISHING -> consoleLog(yellow("connecting to lightning peer...")) 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")) is Connection.CLOSED -> consoleLog(yellow("disconnected from lightning peer"))
} }
} }

View File

@ -66,8 +66,17 @@ sealed class ApiType {
) )
@Serializable @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 @Serializable
data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType() data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType()

View File

@ -48,6 +48,7 @@ fun main(args: Array<String>) =
ListOutgoingPayments(), ListOutgoingPayments(),
GetIncomingPayment(), GetIncomingPayment(),
ListIncomingPayments(), ListIncomingPayments(),
DeleteIncomingPayment(),
CreateInvoice(), CreateInvoice(),
GetOffer(), GetOffer(),
PayInvoice(), PayInvoice(),
@ -55,7 +56,13 @@ fun main(args: Array<String>) =
DecodeInvoice(), DecodeInvoice(),
DecodeOffer(), DecodeOffer(),
SendToAddress(), SendToAddress(),
CloseChannel() CloseChannel(),
GetFinalAddress(),
GetSwapInAddress(),
GetFinalWalletBalance(),
GetSwapInWalletBalance(),
GetSwapInTransactions(),
ManualSpliceIn()
) )
.main(args) .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) { class CreateInvoice : PhoenixCliCommand(name = "createinvoice", help = "Create a Lightning invoice", printHelpOnEmptyArgs = true) {
private val amountSat by option("--amountSat").long() private val amountSat by option("--amountSat").long()
private val description by mutuallyExclusiveOptions( 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) { class SendToAddress : PhoenixCliCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) {
private val amountSat by option("--amountSat").long().required() 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 } 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) { class CloseChannel : PhoenixCliCommand(name = "closechannel", help = "Close channel", printHelpOnEmptyArgs = true) {
private val channelId by option("--channelId").convert { it.toByteVector32() }.required() 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 } private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess }