From 6035dfc3e4ecb1b062b25411820f380d57bf4a7d Mon Sep 17 00:00:00 2001 From: matthiasdebernardini Date: Mon, 9 Mar 2026 16:37:56 -0500 Subject: [PATCH] add swap-in support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/fr/acinq/phoenixd/Api.kt | 27 +++++++- .../kotlin/fr/acinq/phoenixd/Phoenixd.kt | 27 +++++++- .../fr/acinq/phoenixd/cli/PhoenixCli.kt | 7 +++ .../fr/acinq/phoenixd/json/JsonSerializers.kt | 63 ++++++++++++++++--- 4 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt index decbae3..a807f8d 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/Api.kt @@ -7,6 +7,8 @@ import fr.acinq.bitcoin.utils.toEither import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.blockchain.electrum.SwapInManager +import fr.acinq.lightning.blockchain.electrum.balance import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCloseResponse @@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.io.files.Path import kotlinx.serialization.json.Json +import kotlin.collections.filterIsInstance import kotlin.time.Duration.Companion.seconds class Api( @@ -154,12 +157,27 @@ class Api( call.respond(info) } get("getbalance") { - val balance = peer.channels.values + val channels = peer.channels.values + val balance = channels .filterIsInstance() .filterNot { it is Closing || it is Closed } .map { it.commitments.active.first().availableBalanceForSend(it.commitments.channelParams, it.commitments.changes) } .sum().truncateToSatoshi() - call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi())) + val swapInBalance = peer.swapInWallet?.wallet?.walletStateFlow?.value?.let { walletState -> + val reservedInputs = SwapInManager.reservedWalletInputs(channels.filterIsInstance()) + val walletWithoutReserved = walletState.withoutReservedUtxos(reservedInputs) + val swapInWallet = walletWithoutReserved.withConfirmations( + currentBlockHeight = peer.currentTipFlow.value ?: 0, + swapInParams = peer.walletParams.swapInParams + ) + SwapInBalance( + unconfirmedBalance = swapInWallet.unconfirmed.balance, + weaklyConfirmedBalance = swapInWallet.weaklyConfirmed.balance, + deeplyConfirmedBalance = swapInWallet.deeplyConfirmed.balance + ) + } + + call.respond(Balance(balance, peer.feeCreditFlow.value.truncateToSatoshi(), swapInBalance)) } get("estimateliquidityfees") { val amount = call.parameters.getLong("amountSat").sat @@ -219,6 +237,11 @@ class Api( call.respond("₿$address") } } + get("getswapinaddress") { + val swapInWallet = peer.swapInWallet ?: badRequest("swap-in wallet unavailable") + val (address, index) = swapInWallet.swapInAddressFlow.filterNotNull().first() + call.respond(SwapInAddress(address, index)) + } get("payments/incoming") { val payments: List = paymentDb.listIncomingPayments( from = call.parameters.getOptionalLong("from") ?: 0L, diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt index 648894e..14601eb 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/Phoenixd.kt @@ -30,6 +30,7 @@ import fr.acinq.lightning.PaymentEvents import fr.acinq.lightning.blockchain.electrum.* import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient import fr.acinq.lightning.blockchain.mempool.MempoolSpaceWatcher +import fr.acinq.lightning.channel.states.PersistedChannelState import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.db.* import fr.acinq.lightning.io.Peer @@ -304,7 +305,7 @@ class Phoenixd : CliktCommand() { val channelsDb = SqliteChannelsDb(driver, database) val paymentsDb = SqlitePaymentsDb(database) - val (blockchainClient, blockchainWatcher) = when(val mempoolUrl = mempoolSpaceUrl) { + val (blockchainClient, blockchainWatcher) = when (val mempoolUrl = mempoolSpaceUrl) { null -> { consoleLog(cyan("using electrum")) val client = ElectrumClient(scope, loggerFactory) @@ -373,6 +374,24 @@ class Phoenixd : CliktCommand() { } } } + val swapInWallet = peer.swapInWallet + if (swapInWallet != null) { + launch { + combine(swapInWallet.wallet.walletStateFlow, peer.currentTipFlow.filterNotNull(), peer.channelsFlow) { walletState, currentBlockHeight, channels -> + val reservedInputs = SwapInManager.reservedWalletInputs(channels.values.filterIsInstance()) + val walletWithoutReserved = walletState.withoutReservedUtxos(reservedInputs) + walletWithoutReserved.withConfirmations( + currentBlockHeight = currentBlockHeight, + swapInParams = peer.walletParams.swapInParams + ) + }.drop(1) + .map { wallet -> Triple(wallet.unconfirmed.balance, wallet.weaklyConfirmed.balance, wallet.deeplyConfirmed.balance) } + .distinctUntilChanged() + .collect { (unconfirmedBalance, weaklyConfirmedBalance, deeplyConfirmedBalance) -> consoleLog("swap-in wallet: unconfirmed=$unconfirmedBalance weaklyConfirmed=$weaklyConfirmedBalance deeplyConfirmed=$deeplyConfirmedBalance") } + + } + + } launch { nodeParams.nodeEvents .filterIsInstance() @@ -384,6 +403,9 @@ class Phoenixd : CliktCommand() { val type = payment.parts.joinToString { part -> part::class.simpleName.toString().lowercase() } consoleLog("received lightning payment: ${payment.amount.truncateToSatoshi()} ($type${if (fee > 0.sat) " fee=$fee" else ""})") } + is OnChainIncomingPayment -> { + consoleLog("received on-chain payment: ${payment.amount.truncateToSatoshi()} (fee=${payment.fees})") + } else -> {} } is PaymentEvents.PaymentSent -> @@ -461,6 +483,9 @@ class Phoenixd : CliktCommand() { peer.connectionState.first { it == Connection.ESTABLISHED } } + // Start monitoring swap-in wallet after both electrum and peer are connected + scope.launch { peer.startWatchSwapInWallet() } + val server = embeddedServer( CIO, environment = applicationEnvironment { diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt index 65976a2..7e737df 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/cli/PhoenixCli.kt @@ -54,6 +54,7 @@ fun main(args: Array): kotlin.Unit = CreateOffer(), GetOffer(), GetLnAddress(), + GetSwapInAddress(), PayInvoice(), PayOffer(), PayLnAddress(), @@ -405,6 +406,12 @@ class LnurlAuth : PhoenixCliCommand(name = "lnurlauth", help = "Authenticate on } } +class GetSwapInAddress : PhoenixCliCommand(name = "getswapinaddress", help = "Get swap-in wallet address") { + override suspend fun httpRequest() = commonOptions.httpClient.use { + it.get(url = commonOptions.baseUrl / "getswapinaddress") + } +} + 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 } diff --git a/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt index ec28cde..1e376bd 100644 --- a/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/phoenixd/json/JsonSerializers.kt @@ -24,7 +24,6 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.db.* import fr.acinq.lightning.json.JsonSerializers import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.lightning.payment.OfferPaymentMetadata import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.wire.LiquidityAds @@ -78,7 +77,15 @@ sealed class ApiType { ) @Serializable - data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi) : ApiType() + data class SwapInBalance( + @SerialName("unconfirmedBalanceSat") val unconfirmedBalance: Satoshi, + @SerialName("weaklyConfirmedBalanceSat") val weaklyConfirmedBalance: Satoshi, + @SerialName("deeplyConfirmedBalanceSat") val deeplyConfirmedBalance: Satoshi + ) : ApiType() + + + @Serializable + data class Balance(@SerialName("balanceSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi, val swapIn: SwapInBalance?) : ApiType() @Serializable data class LiquidityFees(@SerialName("miningFeeSat") val miningFee: Satoshi, @SerialName("serviceFeeSat") val serviceFee: Satoshi) : ApiType() { @@ -109,7 +116,13 @@ sealed class ApiType { @Serializable @SerialName("payment_sent") - data class PaymentSent(@SerialName("recipientAmountSat") val recipientAmount: Satoshi, @SerialName("routingFeeSat") val routingFee: Satoshi, @SerialName("paymentId") val uuid: UUID, val paymentHash: ByteVector32, val paymentPreimage: ByteVector32) : ApiType() { + data class PaymentSent( + @SerialName("recipientAmountSat") val recipientAmount: Satoshi, + @SerialName("routingFeeSat") val routingFee: Satoshi, + @SerialName("paymentId") val uuid: UUID, + val paymentHash: ByteVector32, + val paymentPreimage: ByteVector32 + ) : ApiType() { constructor(event: fr.acinq.lightning.io.PaymentSent) : this( event.payment.recipientAmount.truncateToSatoshi(), event.payment.routingFee.truncateToSatoshi(), @@ -128,8 +141,25 @@ sealed class ApiType { @Serializable @SerialName("incoming_payment") - data class IncomingPayment(val subType: String, val paymentHash: ByteVector32, val preimage: ByteVector32, val externalId: String?, val description: String?, val invoice: String?, val isPaid: Boolean, val isExpired: Boolean, val requestedSat: Satoshi?, val receivedSat: Satoshi, val fees: MilliSatoshi, val payerNote: String?, val payerKey: PublicKey?, val expiresAt: Long?, val completedAt: Long?, val createdAt: Long): ApiType() { - constructor(payment: LightningIncomingPayment, externalId: String?) : this ( + data class IncomingPayment( + val subType: String, + val paymentHash: ByteVector32, + val preimage: ByteVector32, + val externalId: String?, + val description: String?, + val invoice: String?, + val isPaid: Boolean, + val isExpired: Boolean, + val requestedSat: Satoshi?, + val receivedSat: Satoshi, + val fees: MilliSatoshi, + val payerNote: String?, + val payerKey: PublicKey?, + val expiresAt: Long?, + val completedAt: Long?, + val createdAt: Long + ) : ApiType() { + constructor(payment: LightningIncomingPayment, externalId: String?) : this( subType = "lightning", paymentHash = payment.paymentHash, preimage = payment.paymentPreimage, @@ -147,8 +177,9 @@ sealed class ApiType { completedAt = payment.completedAt, createdAt = payment.createdAt, ) + @Suppress("DEPRECATION") - constructor(payment: LegacyPayToOpenIncomingPayment, externalId: String?) : this ( + constructor(payment: LegacyPayToOpenIncomingPayment, externalId: String?) : this( subType = "lightning", paymentHash = payment.paymentHash, preimage = payment.paymentPreimage, @@ -170,7 +201,19 @@ sealed class ApiType { @Serializable @SerialName("outgoing_payment") - data class OutgoingPayment(val subType: String, val paymentId: String, val paymentHash: ByteVector32?, val preimage: ByteVector32?, val txId: TxId?, val isPaid: Boolean, val sent: Satoshi, val fees: MilliSatoshi, val invoice: String?, val completedAt: Long?, val createdAt: Long): ApiType() { + data class OutgoingPayment( + val subType: String, + val paymentId: String, + val paymentHash: ByteVector32?, + val preimage: ByteVector32?, + val txId: TxId?, + val isPaid: Boolean, + val sent: Satoshi, + val fees: MilliSatoshi, + val invoice: String?, + val completedAt: Long?, + val createdAt: Long + ) : ApiType() { constructor(payment: LightningOutgoingPayment) : this( subType = "lightning", paymentId = payment.id.toString(), @@ -184,8 +227,9 @@ sealed class ApiType { completedAt = payment.completedAt, createdAt = payment.createdAt, ) + constructor(payment: OnChainOutgoingPayment) : this( - subType = when(payment) { + subType = when (payment) { is AutomaticLiquidityPurchasePayment -> "auto_liquidity" is ManualLiquidityPurchasePayment -> "manual_liquidity" is SpliceOutgoingPayment -> "splice_out" @@ -205,6 +249,9 @@ sealed class ApiType { ) } + @Serializable + data class SwapInAddress(val address: String, val index: Int) : ApiType() + @Serializable @SerialName("lnurl_request") data class LnurlRequest(val url: String, val tag: String?) {