All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.fr.acinq.lightning.io.Peer.kt Maven / Gradle / Ivy

There is a newer version: 1.8.4
Show newest version
package fr.acinq.lightning.io

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.*
import fr.acinq.lightning.blockchain.IClient
import fr.acinq.lightning.blockchain.IWatcher
import fr.acinq.lightning.blockchain.WatchEvent
import fr.acinq.lightning.blockchain.computeSpliceCpfpFeerate
import fr.acinq.lightning.blockchain.electrum.*
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.blockchain.fee.OnChainFeerates
import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient
import fr.acinq.lightning.channel.*
import fr.acinq.lightning.channel.states.*
import fr.acinq.lightning.crypto.noise.*
import fr.acinq.lightning.db.*
import fr.acinq.lightning.logging.MDCLogger
import fr.acinq.lightning.logging.mdc
import fr.acinq.lightning.logging.withMDC
import fr.acinq.lightning.payment.*
import fr.acinq.lightning.serialization.Encryption.from
import fr.acinq.lightning.serialization.Serialization.DeserializationResult
import fr.acinq.lightning.transactions.Scripts
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.*
import fr.acinq.lightning.wire.Ping
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

sealed class PeerCommand

/** Open a channel, consuming all the spendable utxos in the wallet state provided. */
data class OpenChannel(
    val fundingAmount: Satoshi,
    val pushAmount: MilliSatoshi,
    val walletInputs: List,
    val commitTxFeerate: FeeratePerKw,
    val fundingTxFeerate: FeeratePerKw,
    val channelType: ChannelType.SupportedChannelType
) : PeerCommand()

/** Consume all the spendable utxos in the wallet state provided to open a channel or splice into an existing channel. */
data class AddWalletInputsToChannel(val walletInputs: List) : PeerCommand() {
    val totalAmount: Satoshi = walletInputs.map { it.amount }.sum()
}

/**
 * Initiate a channel open or a splice to allow receiving an off-chain payment.
 *
 * @param paymentAmount total amount of the off-chain payment (before fees are paid).
 * @param requestedAmount requested inbound liquidity, which will allow receiving the off-chain payment.
 * @param fundingRate funding rate applied by our peer for this amount.
 * @param preimage preimage of the off-chain payment.
 * @param willAddHtlcs HTLCs that will be relayed to us once additional liquidity is available.
 */
data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val requestedAmount: Satoshi, val fundingRate: LiquidityAds.FundingRate, val preimage: ByteVector32, val willAddHtlcs: List) : PeerCommand() {
    val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32()

    fun fees(fundingFeerate: FeeratePerKw, isChannelCreation: Boolean): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount, isChannelCreation)

    companion object {
        /**
         * When we open a new channel without contributing any input, we won't be able to pay on-chain fees for our
         * weight of the funding transaction. If we don't do anything, the resulting transaction will thus have a
         * lower feerate than requested and may not confirm.
         *
         * To avoid that, we ask our peer to target a higher feerate than the one we actually want. They will pay
         * more mining fees to satisfy that feerate, while we won't pay any mining fees. We should be paying for the
         * shared output, which doesn't add too much weight, so we add 25%. This is hacky but should result in an
         * effective feerate that is somewhat close to the initial feerate we wanted. Note that we will pay liquidity
         * fees based on this inflated feerate, which will refund our peer for this hack.
         */
        const val ChannelOpenFeerateRatio = 1.25

        /**
         * When our balance is low, we won't be able to pay the mining fees for our weight of the splice transaction.
         * If we don't do anything, the resulting transaction will thus have a lower feerate than requested and may
         * not confirm.
         *
         * To avoid that, we ask our peer to target a higher feerate than the one we actually want. They will pay more
         * mining fees to satisfy that feerate, while we'll pay whatever we can from our current balance. We should be
         * paying for the shared input and shared output, which is a lot of weight, so we add 50%. This is hacky but
         * should result in an effective feerate that is somewhat close to the initial feerate we wanted. Note that we
         * will pay liquidity fees based on the inflated feerate, which will refund our peer for this hack.
         */
        const val SpliceWithNoBalanceFeerateRatio = 1.5
    }
}

data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) {
    fun send(msg: LightningMessage) {
        // We can safely use trySend because we use unlimited channel buffers.
        // If the connection was closed, the message will automatically be dropped.
        val result = output.trySend(msg)
        result.onFailure { failure ->
            when (msg) {
                is Ping -> logger.warning { "cannot send $msg: ${failure?.message}" } // no need to display the full stack trace for pings, they will spam the logs when user is disconnected
                else -> logger.warning(failure) { "cannot send $msg" }
            }
        }
    }
}

data class Connected(val peerConnection: PeerConnection) : PeerCommand()
data class MessageReceived(val connectionId: Long, val msg: LightningMessage) : PeerCommand()
data class WatchReceived(val watch: WatchEvent) : PeerCommand()
data class WrappedChannelCommand(val channelId: ByteVector32, val channelCommand: ChannelCommand) : PeerCommand()
data object Disconnected : PeerCommand()

sealed class PaymentCommand : PeerCommand()
private data object CheckPaymentsTimeout : PaymentCommand()
private data class CheckInvoiceRequestTimeout(val pathId: ByteVector32, val payOffer: PayOffer) : PaymentCommand()

// @formatter:off
sealed class SendPayment : PaymentCommand() {
    abstract val paymentId: UUID
    abstract val amount: MilliSatoshi
}
data class PayInvoice(override val paymentId: UUID, override val amount: MilliSatoshi, val paymentDetails: LightningOutgoingPayment.Details, val trampolineFeesOverride: List? = null) : SendPayment() {
    val paymentHash: ByteVector32 = paymentDetails.paymentHash
    val recipient: PublicKey = paymentDetails.paymentRequest.nodeId
}
data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List? = null) : SendPayment()
// @formatter:on

data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand()

data class SendOnionMessage(val message: OnionMessage) : PeerCommand()
data class SendOnTheFlyFundingMessage(val message: OnTheFlyFundingMessage) : PeerCommand()

sealed class PeerEvent

@Deprecated("Replaced by NodeEvents", replaceWith = ReplaceWith("PaymentEvents.PaymentReceived", "fr.acinq.lightning.PaymentEvents"))
data class PaymentReceived(val incomingPayment: IncomingPayment, val received: IncomingPayment.Received) : PeerEvent()
data class PaymentProgress(val request: SendPayment, val fees: MilliSatoshi) : PeerEvent()
sealed class SendPaymentResult : PeerEvent() {
    abstract val request: SendPayment
}
/** We couldn't obtain a valid invoice to pay the corresponding offer. */
data class OfferNotPaid(override val request: PayOffer, val reason: Bolt12InvoiceRequestFailure) : SendPaymentResult()
/** We couldn't pay the corresponding invoice. */
data class PaymentNotSent(override val request: PayInvoice, val reason: OutgoingPaymentFailure) : SendPaymentResult()
/** We successfully paid the corresponding request. */
data class PaymentSent(override val request: PayInvoice, val payment: LightningOutgoingPayment) : SendPaymentResult()

data class OfferInvoiceReceived(val request: PayOffer, val invoice: Bolt12Invoice) : PeerEvent()
data class ChannelClosing(val channelId: ByteVector32) : PeerEvent()

/**
 * Useful to handle transparent migration on Phoenix Android between eclair-core and lightning-kmp.
 */
data class PhoenixAndroidLegacyInfoEvent(val info: PhoenixAndroidLegacyInfo) : PeerEvent()

data class AddressAssigned(val address: String) : PeerEvent()

/**
 * The peer we establish a connection to. This object contains the TCP socket, a flow of the channels with that peer, and watches
 * the events on those channels and processes the relevant actions. The dialogue with the peer is done in coroutines.
 *
 * @param nodeParams Low level, Lightning related parameters that our node will use in relation to this Peer.
 * @param walletParams High level parameters for our node. It especially contains the Peer's [NodeUri].
 * @param watcher Watches events from the Electrum client and publishes transactions and events.
 * @param db Wraps the various databases persisting the channels and payments data related to the Peer.
 * @param socketBuilder Builds the TCP socket used to connect to the Peer.
 * @param initTlvStream Optional stream of TLV for the [Init] message we send to this Peer after connection. Empty by default.
 */
@OptIn(ExperimentalStdlibApi::class)
class Peer(
    val nodeParams: NodeParams,
    val walletParams: WalletParams,
    val client: IClient,
    val watcher: IWatcher,
    val db: Databases,
    socketBuilder: TcpSocket.Builder?,
    scope: CoroutineScope,
    private val initTlvStream: TlvStream = TlvStream.empty()
) : CoroutineScope by scope {
    companion object {
        private const val prefix: Byte = 0x00
        private val prologue = "lightning".encodeToByteArray()
    }

    var socketBuilder: TcpSocket.Builder? = socketBuilder
        set(value) {
            logger.debug { "swap socket builder=$value" }
            field = value
        }

    val remoteNodeId: PublicKey = walletParams.trampolineNode.id

    // We use unlimited buffers, otherwise we may end up in a deadlock since we're both
    // receiving *and* sending to those channels in the same coroutine.
    private val input = Channel(UNLIMITED)

    private val swapInCommands = Channel(UNLIMITED)

    private val logger = MDCLogger(logger = nodeParams.loggerFactory.newLogger(this::class), staticMdc = mapOf("remoteNodeId" to remoteNodeId))

    // The channels map, as initially loaded from the database at "boot" (on Peer.init).
    // As the channelsFlow is unavailable until the electrum connection is up-and-running,
    // this may provide useful information for the UI.
    private val _bootChannelsFlow = MutableStateFlow?>(null)
    val bootChannelsFlow: StateFlow?> get() = _bootChannelsFlow

    // channels map, indexed by channel id
    // note that a channel starts with a temporary id then switches to its final id once accepted
    private val _channelsFlow = MutableStateFlow>(HashMap())
    val channelsFlow: StateFlow> get() = _channelsFlow

    private var _channels by _channelsFlow
    val channels: Map get() = _channelsFlow.value

    private val _connectionState = MutableStateFlow(Connection.CLOSED(null))
    val connectionState: StateFlow get() = _connectionState

    private val _eventsFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
    val eventsFlow: SharedFlow get() = _eventsFlow.asSharedFlow()

    // encapsulates logic for validating incoming payments
    private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments)

    // encapsulates logic for sending payments
    private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments)

    private val features = nodeParams.features

    private val ourInit = Init(features.initFeatures(), initTlvStream)
    private var theirInit: Init? = null

    val currentTipFlow = MutableStateFlow(null)
    val onChainFeeratesFlow = MutableStateFlow(null)
    val peerFeeratesFlow = MutableStateFlow(null)
    val remoteFundingRates = MutableStateFlow(null)
    val feeCreditFlow = MutableStateFlow(0.msat)

    private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class)
    private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> {
        val state = this
        val ctx = ChannelContext(
            StaticParams(nodeParams, remoteNodeId),
            currentTipFlow.filterNotNull().first(),
            onChainFeeratesFlow,
            logger = MDCLogger(
                logger = _channelLogger,
                staticMdc = mapOf("remoteNodeId" to remoteNodeId) + state.mdc()
            )
        )
        return state.run { ctx.process(cmd) }
            .also { (state1, _) ->
                if (state1::class != state::class) {
                    ctx.logger.info { "${state.stateName} -> ${state1.stateName}" }
                }
            }
    }

    val finalWallet = (client as? IElectrumClient)?.let { electrumClient -> FinalWallet(nodeParams.chain, nodeParams.keyManager.finalOnChainWallet, electrumClient, scope, nodeParams.loggerFactory) }
    val swapInWallet = (client as? IElectrumClient)?.let { electrumClient -> SwapInWallet(nodeParams.chain, nodeParams.keyManager.swapInOnChainWallet, electrumClient, scope, nodeParams.loggerFactory) }

    private var swapInJob: Job? = null

    private val offerManager = OfferManager(nodeParams, walletParams, _eventsFlow, logger)

    init {
        logger.info { "initializing peer" }
        launch {
            when (client) {
                is IElectrumClient -> client.notifications.filterIsInstance()
                    .collect { msg ->
                        currentTipFlow.value = msg.blockHeight
                    }
                is MempoolSpaceClient -> while (true) {
                    runCatching {
                        client.getBlockTipHeight()?.let { currentBlockHeight ->
                            logger.debug { "current block height is $currentBlockHeight" }
                            currentTipFlow.value = currentBlockHeight
                        }
                    }
                    delay(1.minutes)
                }
            }

        }
        launch {
            suspend fun updateFeerates() {
                client.getFeerates()?.let { feerates ->
                    logger.info { "on-chain fees: $feerates" }
                    onChainFeeratesFlow.value = OnChainFeerates(feerates)
                } ?: logger.error { "cannot retrieve feerates!" }
            }

            when (client) {
                is IElectrumClient -> client.connectionStatus.filter { it is ElectrumConnectionStatus.Connected }.collect {
                    // onchain fees are retrieved punctually, when electrum status moves to Connection.ESTABLISHED
                    // since the application is not running most of the time, and when it is, it will be only for a few minutes, this is good enough.
                    // (for a node that is online most of the time things would be different and we would need to re-evaluate onchain fee estimates on a regular basis)
                    updateFeerates()
                }
                is MempoolSpaceClient -> while (true) {
                    updateFeerates()
                    delay(3.minutes)
                }
            }
        }
        launch {
            watcher.openWatchNotificationsFlow().collect {
                logger.debug { "notification: $it" }
                input.send(WrappedChannelCommand(it.channelId, ChannelCommand.WatchReceived(it)))
            }
        }
        launch {
            // we don't restore closed channels
            val bootChannels = db.channels.listLocalChannels().filterNot { it is Closed || it is LegacyWaitForFundingConfirmed }
            _bootChannelsFlow.value = bootChannels.associateBy { it.channelId }
            val channelIds = bootChannels.map {
                logger.info { "restoring channel ${it.channelId} from local storage" }
                val state = WaitForInit
                val (state1, actions) = state.process(ChannelCommand.Init.Restore(it))
                processActions(it.channelId, peerConnection, actions)
                _channels = _channels + (it.channelId to state1)
                it.channelId
            }
            logger.info { "restored ${channelIds.size} channels" }
            launch {
                // the swap-in manager executes commands, but will not do anything until startWatchSwapInWallet() is called
                val swapInManager = SwapInManager(bootChannels, logger)
                processSwapInCommands(swapInManager)
            }
            launch {
                // If we have some htlcs that have timed out, we may need to close channels to ensure we don't lose funds.
                // But maybe we were offline for too long and it is why our peer couldn't settle these htlcs in time.
                // We give them a bit of time after we reconnect to send us their latest htlc updates.
                delay(nodeParams.checkHtlcTimeoutAfterStartupDelay)
                logger.info { "checking for timed out htlcs for channels: ${channelIds.joinToString(", ")}" }
                channelIds.forEach { input.send(WrappedChannelCommand(it, ChannelCommand.Commitment.CheckHtlcTimeout)) }
            }
            run()
        }
        launch {
            var previousState = connectionState.value
            connectionState.filter { it != previousState }.collect {
                logger.info { "connection state changed: ${it::class.simpleName}" }
                previousState = it
            }
        }
    }

    data class ConnectionJob(val job: Job, val socket: TcpSocket) {
        fun cancel() {
            job.cancel()
            socket.close()
        }
    }

    private var connectionJob: ConnectionJob? = null

    suspend fun connect(connectTimeout: Duration, handshakeTimeout: Duration): Boolean {
        return if (connectionState.value is Connection.CLOSED) {
            // Clean up previous connection state: we do this here to ensure that it is handled before the Connected event for the new connection.
            // That means we're not sending this event if we don't reconnect. It's ok, since that has the same effect as not detecting a disconnection and closing the app.
            input.send(Disconnected)
            _connectionState.value = Connection.ESTABLISHING

            val connectionId = currentTimestampMillis()
            val logger = MDCLogger(logger = nodeParams.loggerFactory.newLogger(this::class), staticMdc = mapOf("remoteNodeId" to remoteNodeId, "connectionId" to connectionId))
            logger.info { "connecting to ${walletParams.trampolineNode.host}" }
            val socket = openSocket(connectTimeout) ?: return false

            val priv = nodeParams.nodePrivateKey
            val pub = priv.publicKey()
            val keyPair = Pair(pub.value.toByteArray(), priv.value.toByteArray())
            val (enc, dec, ck) = try {
                withTimeout(handshakeTimeout) {
                    handshake(
                        keyPair,
                        remoteNodeId.value.toByteArray(),
                        { s -> socket.receiveFully(s) },
                        { b -> socket.send(b) }
                    )
                }
            } catch (ex: Throwable) {
                logger.warning(ex) { "Noise handshake: ${ex.message}: " }
                socket.close()
                _connectionState.value = Connection.CLOSED(ex as? TcpSocket.IOException)
                return false
            }

            val session = LightningSession(enc, dec, ck)
            _connectionState.value = Connection.ESTABLISHED
            // TODO use atomic counter instead
            val peerConnection = PeerConnection(connectionId, Channel(UNLIMITED), logger)
            // Inform the peer about the new connection.
            input.send(Connected(peerConnection))
            connectionJob = connectionLoop(socket, session, peerConnection, logger)
            true
        } else {
            logger.warning { "Peer is already connecting / connected" }
            false
        }
    }

    fun disconnect() {
        connectionJob?.cancel()
        connectionJob = null
        _connectionState.value = Connection.CLOSED(null)
    }

    private suspend fun openSocket(timeout: Duration): TcpSocket? {
        var socket: TcpSocket? = null
        return try {
            withTimeout(timeout) {
                socket = socketBuilder?.connect(
                    host = walletParams.trampolineNode.host,
                    port = walletParams.trampolineNode.port,
                    tls = TcpSocket.TLS.DISABLED,
                    loggerFactory = nodeParams.loggerFactory
                ) ?: error("socket builder is null.")
                socket
            }
        } catch (ex: Throwable) {
            logger.warning(ex) { "TCP connect: ${ex.message}: " }
            val ioException = when (ex) {
                is TcpSocket.IOException -> ex
                else -> TcpSocket.IOException.ConnectionRefused(ex)
            }
            socket?.close()
            _connectionState.value = Connection.CLOSED(ioException)
            null
        }
    }

    private fun connectionLoop(socket: TcpSocket, session: LightningSession, peerConnection: PeerConnection, logger: MDCLogger): ConnectionJob {
        val job = launch {
            fun closeSocket(ex: TcpSocket.IOException?) {
                if (_connectionState.value is Connection.CLOSED) return
                logger.warning { "closing TCP socket: ${ex?.message}" }
                socket.close()
                _connectionState.value = Connection.CLOSED(ex)
                cancel()
            }

            suspend fun doPing() {
                val ping = Ping(10, ByteVector("deadbeef"))
                while (isActive) {
                    delay(nodeParams.pingInterval)
                    peerConnection.send(ping)
                }
            }

            suspend fun checkPaymentsTimeout() {
                while (isActive) {
                    delay(nodeParams.checkHtlcTimeoutInterval)
                    input.send(CheckPaymentsTimeout)
                }
            }

            suspend fun receiveLoop() {
                try {
                    while (isActive) {
                        val received = session.receive { size -> socket.receiveFully(size) }
                        try {
                            val msg = LightningMessage.decode(received)
                            input.send(MessageReceived(peerConnection.id, msg))
                        } catch (e: Throwable) {
                            logger.warning { "cannot deserialize message: ${received.byteVector().toHex()}" }
                        }
                    }
                    closeSocket(null)
                } catch (ex: Throwable) {
                    logger.warning { "TCP receive: ${ex.message}" }
                    closeSocket(ex as? TcpSocket.IOException)
                } finally {
                    peerConnection.output.close()
                }
            }

            suspend fun sendLoop() {
                try {
                    for (msg in peerConnection.output) {
                        when (msg) {
                            is Ping -> {} // Avoids polluting the logs with pings/pongs
                            is Pong -> {}
                            is OnionMessage -> logger.info { "sending ${msg.copy(onionRoutingPacket = msg.onionRoutingPacket.copy(payload = ByteVector.empty))} (truncated payload)" } // Not printing the payload, which can be very large
                            else -> logger.info { "sending $msg" }
                        }
                        val encoded = LightningMessage.encode(msg)
                        session.send(encoded) { data, flush -> socket.send(data, flush) }
                    }
                } catch (ex: Throwable) {
                    logger.warning { "TCP send: ${ex.message}" }
                    closeSocket(ex as? TcpSocket.IOException)
                } finally {
                    peerConnection.output.close()
                }
            }

            launch(CoroutineName("keep-alive")) { doPing() }
            launch(CoroutineName("check-payments-timeout")) { checkPaymentsTimeout() }
            launch(CoroutineName("send-loop")) { sendLoop() }
            val receiveJob = launch(CoroutineName("receive-loop")) { receiveLoop() }
            // Suspend until the coroutine is cancelled or the socket is closed.
            receiveJob.join()
        }

        return ConnectionJob(job, socket)
    }

    /** We try swapping funds in whenever one of those fields is updated. */
    data class TrySwapInFlow(val currentBlockHeight: Int, val walletState: WalletState, val feerate: FeeratePerKw, val liquidityPolicy: LiquidityPolicy)

    /**
     * This function needs to be called after [Peer] is initialized, to start watching the swap-in wallet
     * and trigger swap-ins.
     * Warning: not thread-safe!
     */
    suspend fun startWatchSwapInWallet() {
        when {
            swapInWallet == null -> logger.warning { "swap-in wallet unavailable" }
            else -> {
                logger.info { "starting swap-in watch job" }
                if (swapInJob != null) {
                    logger.info { "swap-in watch job already started" }
                    return
                }
                logger.info { "waiting for peer to be ready" }
                waitForPeerReady()
                swapInJob = launch {
                    swapInWallet.wallet.walletStateFlow
                        .combine(currentTipFlow.filterNotNull()) { walletState, currentTip -> Pair(walletState, currentTip) }
                        .combine(peerFeeratesFlow.filterNotNull()) { (walletState, currentTip), feerates -> Triple(walletState, currentTip, feerates.fundingFeerate) }
                        .combine(nodeParams.liquidityPolicy) { (walletState, currentTip, feerate), policy -> TrySwapInFlow(currentTip, walletState, feerate, policy) }
                        .collect { w -> swapInCommands.send(SwapInCommand.TrySwapIn(w.currentBlockHeight, w.walletState, walletParams.swapInParams)) }
                }
            }
        }
    }

    suspend fun stopWatchSwapInWallet() {
        logger.info { "stopping swap-in watch job" }
        swapInJob?.cancelAndJoin()
        swapInJob = null
    }

    private suspend fun processSwapInCommands(swapInManager: SwapInManager) {
        for (command in swapInCommands) {
            swapInManager.process(command)?.let { requestChannelOpen -> input.send(requestChannelOpen) }
        }
    }

    suspend fun send(cmd: PeerCommand) {
        input.send(cmd)
    }

    /**
     * This function blocks until the peer is connected and existing channels have been fully reestablished.
     */
    private suspend fun waitForPeerReady() {
        // In theory we would only need to verify that no channel is in state Offline/Syncing, but there is a corner
        // case where a channel permanently stays in Syncing, because it is only present locally, and the peer will
        // never send a channel_reestablish (this happens e.g. due to an error at funding). That is why we consider
        // the peer ready if "all channels are synced" OR "peer has been connected for 10s".
        _peerConnection.filterNotNull().first()
        val result = withTimeoutOrNull(10.seconds) {
            channelsFlow.first { it.values.all { channel -> channel !is Offline && channel !is Syncing } }
        }
        if (result == null) {
            logger.info { "peer ready timeout elapsed, not all channels are synced but proceeding anyway" }
        }
    }

    /**
     * Estimate the actual feerate to use (and corresponding fee to pay) in order to reach the target feerate
     * for a splice out, taking into account potential unconfirmed parent splices.
     */
    suspend fun estimateFeeForSpliceOut(amount: Satoshi, scriptPubKey: ByteVector, targetFeerate: FeeratePerKw): Pair? {
        return channels.values
            .filterIsInstance()
            .firstOrNull { it.commitments.availableBalanceForSend() > amount }
            ?.let { channel ->
                val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = listOf(TxOut(amount, scriptPubKey)))
                val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger)
                Pair(actualFeerate, ChannelManagementFees(miningFee, 0.sat))
            }
    }

    /**
     * Estimate the actual feerate to use (and corresponding fee to pay) in order to reach the target feerate
     * for a cpfp splice.
     * @return The adjusted feerate to use in [spliceCpfp], such that the whole transaction chain has a feerate equivalent
     *         to [targetFeerate].
     *         NB: if the output feerate is equal to the input feerate then the cpfp is useless and
     *         should not be attempted.
     */
    suspend fun estimateFeeForSpliceCpfp(channelId: ByteVector32, targetFeerate: FeeratePerKw): Pair? {
        return channels.values
            .filterIsInstance()
            .find { it.channelId == channelId }
            ?.let { channel ->
                val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList())
                val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger)
                Pair(actualFeerate, ChannelManagementFees(miningFee, 0.sat))
            }
    }

    /**
     * Estimate the actual feerate to use (and corresponding fee to pay) to purchase inbound liquidity with a splice
     * that reaches the target feerate.
     */
    suspend fun estimateFeeForInboundLiquidity(amount: Satoshi, targetFeerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate): Pair? {
        return channels.values
            .filterIsInstance()
            .firstOrNull()
            ?.let { channel ->
                val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) + fundingRate.fundingWeight
                // The mining fee below pays for the entirety of the splice transaction, including inputs and outputs from the liquidity provider.
                val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger)
                // The mining fee below only covers the remote node's inputs and outputs, which are already included in the mining fee above.
                val fundingFees = fundingRate.fees(actualFeerate, amount, amount, isChannelCreation = false)
                Pair(actualFeerate, ChannelManagementFees(miningFee, fundingFees.serviceFee))
            }
    }

    /**
     * Do a splice out using any suitable channel.
     *
     * @return [ChannelFundingResponse] if a splice was attempted, or {null} if no suitable channel was found
     */
    suspend fun spliceOut(amount: Satoshi, scriptPubKey: ByteVector, feerate: FeeratePerKw): ChannelFundingResponse? {
        return channels.values
            .filterIsInstance()
            .firstOrNull { it.commitments.availableBalanceForSend() > amount }
            ?.let { channel ->
                val spliceCommand = ChannelCommand.Commitment.Splice.Request(
                    replyTo = CompletableDeferred(),
                    spliceIn = null,
                    spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, scriptPubKey),
                    requestRemoteFunding = null,
                    currentFeeCredit = feeCreditFlow.value,
                    feerate = feerate,
                    origins = listOf(),
                )
                send(WrappedChannelCommand(channel.channelId, spliceCommand))
                spliceCommand.replyTo.await()
            }
    }

    suspend fun spliceCpfp(channelId: ByteVector32, feerate: FeeratePerKw): ChannelFundingResponse? {
        return channels.values
            .filterIsInstance()
            .find { it.channelId == channelId }
            ?.let { channel ->
                val spliceCommand = ChannelCommand.Commitment.Splice.Request(
                    replyTo = CompletableDeferred(),
                    // no additional inputs or outputs, the splice is only meant to bump fees
                    spliceIn = null,
                    spliceOut = null,
                    requestRemoteFunding = null,
                    currentFeeCredit = feeCreditFlow.value,
                    feerate = feerate,
                    origins = listOf(),
                )
                send(WrappedChannelCommand(channel.channelId, spliceCommand))
                spliceCommand.replyTo.await()
            }
    }

    suspend fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate): ChannelFundingResponse? {
        return channels.values
            .filterIsInstance()
            .firstOrNull()
            ?.let { channel ->
                val spliceCommand = ChannelCommand.Commitment.Splice.Request(
                    replyTo = CompletableDeferred(),
                    spliceIn = null,
                    spliceOut = null,
                    requestRemoteFunding = LiquidityAds.RequestFunding(amount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance),
                    currentFeeCredit = feeCreditFlow.value,
                    feerate = feerate,
                    origins = listOf(),
                )
                send(WrappedChannelCommand(channel.channelId, spliceCommand))
                spliceCommand.replyTo.await()
            }
    }

    suspend fun payInvoice(amount: MilliSatoshi, paymentRequest: Bolt11Invoice): SendPaymentResult {
        val res = CompletableDeferred()
        val paymentId = UUID.randomUUID()
        this.launch {
            res.complete(eventsFlow
                .filterIsInstance()
                .filter { it.request.paymentId == paymentId }
                .first()
            )
        }
        send(PayInvoice(paymentId, amount, LightningOutgoingPayment.Details.Normal(paymentRequest)))
        return res.await()
    }

    suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeout: Duration): SendPaymentResult {
        val res = CompletableDeferred()
        val paymentId = UUID.randomUUID()
        this.launch {
            res.complete(eventsFlow
                .filterIsInstance()
                .filter { it.request.paymentId == paymentId }
                .first()
            )
        }
        send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout))
        return res.await()
    }

    suspend fun createInvoice(paymentPreimage: ByteVector32, amount: MilliSatoshi?, description: Either, expirySeconds: Long? = null): Bolt11Invoice {
        // we add one extra hop which uses a virtual channel with a "peer id", using the highest remote fees and expiry across all
        // channels to maximize the likelihood of success on the first payment attempt
        val remoteChannelUpdates = _channels.values.mapNotNull { channelState ->
            when (channelState) {
                is Normal -> channelState.remoteChannelUpdate
                is Offline -> (channelState.state as? Normal)?.remoteChannelUpdate
                is Syncing -> (channelState.state as? Normal)?.remoteChannelUpdate
                else -> null
            }
        }
        val extraHops = listOf(
            listOf(
                Bolt11Invoice.TaggedField.ExtraHop(
                    nodeId = walletParams.trampolineNode.id,
                    shortChannelId = ShortChannelId.peerId(nodeParams.nodeId),
                    feeBase = remoteChannelUpdates.maxOfOrNull { it.feeBaseMsat } ?: walletParams.invoiceDefaultRoutingFees.feeBase,
                    feeProportionalMillionths = remoteChannelUpdates.maxOfOrNull { it.feeProportionalMillionths } ?: walletParams.invoiceDefaultRoutingFees.feeProportional,
                    cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta
                )
            )
        )
        return incomingPaymentHandler.createInvoice(paymentPreimage, amount, description, extraHops, expirySeconds)
    }

    // The (node_id, fcm_token) tuple only needs to be registered once.
    // And after that, only if the tuple changes (e.g. different fcm_token).
    fun registerFcmToken(token: String?) {
        val message = if (token == null) UnsetFCMToken else FCMToken(token)
        peerConnection?.send(message)
    }

    /**
     * Request a BIP-353's compliant DNS address from our peer.
     *
     * This will only return if there are existing channels with the peer, otherwise it will hang. This should be handled by the caller.
     *
     * @param languageSubtag IETF BCP 47 language tag (en, fr, de, es, ...) to indicate preference for the words that make up the address
     */
    suspend fun requestAddress(languageSubtag: String): String {
        val replyTo = CompletableDeferred()
        this.launch {
            eventsFlow
                .filterIsInstance()
                .first()
                .let { event -> replyTo.complete(event.address) }
        }
        peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag))
        return replyTo.await()
    }

    sealed class SelectChannelResult {
        /** We have a channel that is available for payments and splicing. */
        data class Available(val channel: Normal) : SelectChannelResult()
        /** We have a channel, or will have one soon, but it is not ready yet. */
        data object NotReady : SelectChannelResult()
        /** We don't have any channel, or our channel is closing, so we need a new one. */
        data object None : SelectChannelResult()
    }

    private fun selectChannelForSplicing(): SelectChannelResult = when (val channel = channels.values.firstOrNull { it is Normal }) {
        is Normal -> when {
            channel.spliceStatus == SpliceStatus.None -> SelectChannelResult.Available(channel)
            else -> SelectChannelResult.NotReady
        }
        else -> when {
            // We haven't finished reconnecting to our peer.
            channels.values.any { it is Offline || it is Syncing } -> SelectChannelResult.NotReady
            // We are currently funding a new channel, which should be available soon.
            channels.values.any { it is WaitForOpenChannel || it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned } -> SelectChannelResult.NotReady
            // We have funded a channel and are waiting for it to be ready.
            channels.values.any { it is WaitForFundingConfirmed || it is WaitForChannelReady } -> SelectChannelResult.NotReady
            // All channels are closing, we need a new one.
            channels.values.all { it is ShuttingDown || it is Negotiating || it is Closing || it is Closed || it is Aborted } -> SelectChannelResult.None
            // Otherwise we need a new channel.
            else -> SelectChannelResult.None
        }
    }

    private suspend fun processActions(channelId: ByteVector32, peerConnection: PeerConnection?, actions: List) {
        // we peek into the actions to see if the id of the channel is going to change, but we're not processing it yet
        val actualChannelId = actions.filterIsInstance().firstOrNull()?.channelId ?: channelId
        logger.withMDC(mapOf("channelId" to actualChannelId)) { logger ->
            actions.forEach { action ->
                when (action) {
                    is ChannelAction.Message.Send -> peerConnection?.send(action.message) // ignore if disconnected
                    // sometimes channel actions include "self" command (such as ChannelCommand.Commitment.Sign)
                    is ChannelAction.Message.SendToSelf -> input.send(WrappedChannelCommand(actualChannelId, action.command))
                    is ChannelAction.Blockchain.SendWatch -> watcher.watch(action.watch)
                    is ChannelAction.Blockchain.PublishTx -> watcher.publish(action.tx)
                    is ChannelAction.ProcessIncomingHtlc -> processIncomingPayment(Either.Right(action.add))
                    is ChannelAction.ProcessCmdRes.NotExecuted -> logger.warning(action.t) { "command not executed" }
                    is ChannelAction.ProcessCmdRes.AddFailed -> {
                        when (val result = outgoingPaymentHandler.processAddFailed(actualChannelId, action, _channels)) {
                            is OutgoingPaymentHandler.Progress -> {
                                _eventsFlow.emit(PaymentProgress(result.request, result.fees))
                                result.actions.forEach { input.send(it) }
                            }

                            is OutgoingPaymentHandler.Failure -> _eventsFlow.emit(PaymentNotSent(result.request, result.failure))
                            null -> logger.debug { "non-final error, more partial payments are still pending: ${action.error.message}" }
                        }
                    }
                    is ChannelAction.ProcessCmdRes.AddSettledFail -> {
                        val currentTip = currentTipFlow.filterNotNull().first()
                        when (val result = outgoingPaymentHandler.processAddSettled(actualChannelId, action, _channels, currentTip)) {
                            is OutgoingPaymentHandler.Progress -> {
                                _eventsFlow.emit(PaymentProgress(result.request, result.fees))
                                result.actions.forEach { input.send(it) }
                            }

                            is OutgoingPaymentHandler.Success -> _eventsFlow.emit(PaymentSent(result.request, result.payment))
                            is OutgoingPaymentHandler.Failure -> _eventsFlow.emit(PaymentNotSent(result.request, result.failure))
                            null -> logger.debug { "non-final error, more partial payments are still pending: ${action.result}" }
                        }
                    }
                    is ChannelAction.ProcessCmdRes.AddSettledFulfill -> {
                        when (val result = outgoingPaymentHandler.processAddSettled(action)) {
                            is OutgoingPaymentHandler.Success -> _eventsFlow.emit(PaymentSent(result.request, result.payment))
                            is OutgoingPaymentHandler.PreimageReceived -> logger.debug(mapOf("paymentId" to result.request.paymentId)) { "payment preimage received: ${result.preimage}" }
                            null -> logger.debug { "unknown payment" }
                        }
                    }
                    is ChannelAction.Storage.StoreState -> {
                        logger.info { "storing state=${action.data::class.simpleName}" }
                        db.channels.addOrUpdateChannel(action.data)
                    }
                    is ChannelAction.Storage.RemoveChannel -> {
                        logger.info { "removing channelId=${action.data.channelId} state=${action.data::class.simpleName}" }
                        db.channels.removeChannel(action.data.channelId)
                    }
                    is ChannelAction.Storage.StoreHtlcInfos -> {
                        action.htlcs.forEach { db.channels.addHtlcInfo(actualChannelId, it.commitmentNumber, it.paymentHash, it.cltvExpiry) }
                    }
                    is ChannelAction.Storage.StoreIncomingPayment -> {
                        logger.info { "storing incoming payment $action" }
                        incomingPaymentHandler.process(actualChannelId, action)
                    }
                    is ChannelAction.Storage.StoreOutgoingPayment -> {
                        logger.info { "storing $action" }
                        val payment = when (action) {
                            is ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceOut ->
                                SpliceOutgoingPayment(
                                    id = UUID.randomUUID(),
                                    recipientAmount = action.amount,
                                    address = action.address,
                                    miningFees = action.miningFees,
                                    channelId = channelId,
                                    txId = action.txId,
                                    createdAt = currentTimestampMillis(),
                                    confirmedAt = null,
                                    lockedAt = null
                                )
                            is ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceCpfp ->
                                SpliceCpfpOutgoingPayment(
                                    id = UUID.randomUUID(),
                                    miningFees = action.miningFees,
                                    channelId = channelId,
                                    txId = action.txId,
                                    createdAt = currentTimestampMillis(),
                                    confirmedAt = null,
                                    lockedAt = null
                                )
                            is ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest ->
                                InboundLiquidityOutgoingPayment(
                                    id = UUID.randomUUID(),
                                    channelId = channelId,
                                    txId = action.txId,
                                    localMiningFees = action.localMiningFees,
                                    purchase = action.purchase,
                                    createdAt = currentTimestampMillis(),
                                    confirmedAt = null,
                                    lockedAt = null
                                )
                            is ChannelAction.Storage.StoreOutgoingPayment.ViaClose -> {
                                _eventsFlow.emit(ChannelClosing(channelId))
                                ChannelCloseOutgoingPayment(
                                    id = UUID.randomUUID(),
                                    recipientAmount = action.amount,
                                    address = action.address,
                                    isSentToDefaultAddress = action.isSentToDefaultAddress,
                                    miningFees = action.miningFees,
                                    channelId = channelId,
                                    txId = action.txId,
                                    createdAt = currentTimestampMillis(),
                                    confirmedAt = null,
                                    lockedAt = currentTimestampMillis(), // channel close are not splices, they are final
                                    closingType = action.closingType
                                )
                            }
                        }
                        nodeParams._nodeEvents.emit(PaymentEvents.PaymentSent(payment))
                        db.payments.addOutgoingPayment(payment)
                    }
                    is ChannelAction.Storage.SetLocked -> {
                        logger.info { "setting status locked for txid=${action.txId}" }
                        db.payments.setLocked(action.txId)
                    }
                    is ChannelAction.Storage.GetHtlcInfos -> {
                        val htlcInfos = db.channels.listHtlcInfos(actualChannelId, action.commitmentNumber).map { ChannelAction.Storage.HtlcInfo(actualChannelId, action.commitmentNumber, it.first, it.second) }
                        input.send(WrappedChannelCommand(actualChannelId, ChannelCommand.Closing.GetHtlcInfosResponse(action.revokedCommitTxId, htlcInfos)))
                    }
                    is ChannelAction.ChannelId.IdAssigned -> {
                        logger.info { "switching channel id from ${action.temporaryChannelId} to ${action.channelId}" }
                        _channels[action.temporaryChannelId]?.let { _channels = _channels + (action.channelId to it) - action.temporaryChannelId }
                    }
                    is ChannelAction.EmitEvent -> nodeParams._nodeEvents.emit(action.event)
                    is ChannelAction.Disconnect -> {
                        logger.warning { "channel disconnected due to a protocol error" }
                        disconnect()
                    }
                }
            }
        }
    }

    private suspend fun processIncomingPayment(item: Either) {
        val currentBlockHeight = currentTipFlow.filterNotNull().first()
        val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate
        val currentFeeCredit = feeCreditFlow.value
        val result = when (item) {
            is Either.Right -> incomingPaymentHandler.process(item.value, theirInit!!.features, currentBlockHeight, currentFeerate, remoteFundingRates.value, currentFeeCredit)
            is Either.Left -> incomingPaymentHandler.process(item.value, theirInit!!.features, currentBlockHeight, currentFeerate, remoteFundingRates.value, currentFeeCredit)
        }
        when (result) {
            is IncomingPaymentHandler.ProcessAddResult.Accepted -> {
                if ((result.incomingPayment.received?.receivedWith?.size ?: 0) > 1) {
                    // this was a multi-part payment, we signal that the task is finished
                    nodeParams._nodeEvents.tryEmit(SensitiveTaskEvents.TaskEnded(SensitiveTaskEvents.TaskIdentifier.IncomingMultiPartPayment(result.incomingPayment.paymentHash)))
                }
                @Suppress("DEPRECATION")
                _eventsFlow.emit(PaymentReceived(result.incomingPayment, result.received))
            }
            is IncomingPaymentHandler.ProcessAddResult.Pending -> if (result.pendingPayment.parts.size == 1) {
                // this is the first part of a multi-part payment, we request to keep the app alive to receive subsequent parts
                nodeParams._nodeEvents.tryEmit(SensitiveTaskEvents.TaskStarted(SensitiveTaskEvents.TaskIdentifier.IncomingMultiPartPayment(result.incomingPayment.paymentHash)))
            }
            else -> Unit
        }
        result.actions.forEach { input.send(it) }
    }

    private suspend fun handshake(
        ourKeys: Pair,
        theirPubkey: ByteArray,
        r: suspend (Int) -> ByteArray,
        w: suspend (ByteArray) -> Unit
    ): Triple {

        /**
         * See BOLT #8: during the handshake phase we are expecting 3 messages of 50, 50 and 66 bytes (including the prefix)
         *
         * @param reader handshake state reader
         * @return the size of the message the reader is expecting
         */
        fun expectedLength(reader: HandshakeStateReader): Int = when (reader.messages.size) {
            3, 2 -> 50
            1 -> 66
            else -> throw RuntimeException("invalid state")
        }

        val writer = HandshakeState.initializeWriter(
            handshakePatternXK, prologue,
            ourKeys, Pair(ByteArray(0), ByteArray(0)), theirPubkey, ByteArray(0),
            Secp256k1DHFunctions, Chacha20Poly1305CipherFunctions, SHA256HashFunctions
        )
        val (state1, message, _) = writer.write(ByteArray(0))
        w(byteArrayOf(prefix) + message)

        val payload = r(expectedLength(state1))
        require(payload[0] == prefix)

        val (writer1, _, _) = state1.read(payload.drop(1).toByteArray())
        val (_, message1, foo) = writer1.write(ByteArray(0))
        val (enc, dec, ck) = foo!!
        w(byteArrayOf(prefix) + message1)
        return Triple(enc, dec, ck)
    }

    private suspend fun run() {
        logger.info { "peer is active" }
        for (event in input) {
            logger.withMDC(logger.staticMdc + (peerConnection?.logger?.staticMdc ?: emptyMap()) + ((event as? MessageReceived)?.msg?.mdc() ?: emptyMap())) { logger ->
                processEvent(event, logger)
            }
        }
    }

    // MUST ONLY BE SET BY processEvent()
    private val _peerConnection = MutableStateFlow(null)
    private val peerConnection: PeerConnection?
        get() = _peerConnection.value

    @OptIn(ExperimentalCoroutinesApi::class)
    private suspend fun processEvent(cmd: PeerCommand, logger: MDCLogger) {
        when (cmd) {
            is Connected -> {
                logger.info { "new connection with id=${cmd.peerConnection.id}, sending init $ourInit" }
                _peerConnection.value = cmd.peerConnection
                peerConnection?.send(ourInit)
            }
            is MessageReceived -> {
                if (cmd.connectionId != peerConnection?.id) {
                    logger.warning { "ignoring ${cmd.msg} for connectionId=${cmd.connectionId}" }
                    return
                }
                val msg = cmd.msg
                msg.let { if (it !is Ping && it !is Pong) logger.info { "received $it" } }
                when (msg) {
                    is UnknownMessage -> {
                        logger.warning { "unhandled code=${msg.type}" }
                    }
                    is Init -> {
                        logger.info { "peer is using features ${msg.features}" }
                        when (val error = Features.validateFeatureGraph(msg.features)) {
                            is Features.Companion.FeatureException -> {
                                logger.error(error) { "feature validation error" }
                                disconnect()
                            }
                            else -> {
                                theirInit = msg
                                nodeParams._nodeEvents.emit(PeerConnected(remoteNodeId, msg))
                                remoteFundingRates.value = msg.liquidityRates
                                _channels = _channels.mapValues { entry ->
                                    val (state1, actions) = entry.value.process(ChannelCommand.Connected(ourInit, msg))
                                    processActions(entry.key, peerConnection, actions)
                                    state1
                                }
                            }
                        }
                    }
                    is RecommendedFeerates -> {
                        peerFeeratesFlow.value = msg
                    }
                    is CurrentFeeCredit -> {
                        if (nodeParams.features.hasFeature(Feature.FundingFeeCredit)) {
                            feeCreditFlow.value = msg.amount
                        }
                    }
                    is Ping -> {
                        val pong = Pong(ByteVector(ByteArray(msg.pongLength)))
                        peerConnection?.send(pong)
                    }
                    is Pong -> {
                        logger.debug { "received pong" }
                    }
                    is Warning -> {
                        // NB: we don't forward warnings to the channel because it shouldn't take any automatic action,
                        // these warnings are meant for humans.
                        logger.warning { "peer sent warning: ${msg.toAscii()}" }
                    }
                    is OpenDualFundedChannel -> {
                        if (theirInit == null) {
                            logger.error { "they sent open_channel before init" }
                        } else if (_channels.containsKey(msg.temporaryChannelId)) {
                            logger.warning { "ignoring open_channel with duplicate temporaryChannelId=${msg.temporaryChannelId}" }
                        } else {
                            val localParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = msg.channelFlags.nonInitiatorPaysCommitFees)
                            val state = WaitForInit
                            val channelConfig = ChannelConfig.standard
                            val initCommand = ChannelCommand.Init.NonInitiator(
                                replyTo = CompletableDeferred(),
                                temporaryChannelId = msg.temporaryChannelId,
                                fundingAmount = 0.sat,
                                pushAmount = 0.msat,
                                walletInputs = listOf(),
                                localParams = localParams,
                                channelConfig = channelConfig,
                                remoteInit = theirInit!!,
                                fundingRates = null
                            )
                            val (state1, actions1) = state.process(initCommand)
                            val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg))
                            _channels = _channels + (msg.temporaryChannelId to state2)
                            processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2)
                        }
                    }
                    is ChannelReestablish -> {
                        val local: ChannelState? = _channels[msg.channelId]
                        val backup: DeserializationResult? = msg.channelData.takeIf { !it.isEmpty() }?.let { channelData ->
                            PersistedChannelState
                                .from(nodeParams.nodePrivateKey, channelData)
                                .onFailure { logger.warning(it) { "unreadable backup" } }
                                .getOrNull()
                        }

                        suspend fun recoverChannel(recovered: PersistedChannelState) {
                            db.channels.addOrUpdateChannel(recovered)

                            val state = WaitForInit
                            val event1 = ChannelCommand.Init.Restore(recovered)
                            val (state1, actions1) = state.process(event1)
                            processActions(msg.channelId, peerConnection, actions1)

                            val event2 = ChannelCommand.Connected(ourInit, theirInit!!)
                            val (state2, actions2) = state1.process(event2)
                            processActions(msg.channelId, peerConnection, actions2)

                            val event3 = ChannelCommand.MessageReceived(msg)
                            val (state3, actions3) = state2.process(event3)
                            processActions(msg.channelId, peerConnection, actions3)
                            _channels = _channels + (msg.channelId to state3)
                        }

                        when {
                            backup is DeserializationResult.UnknownVersion -> {
                                logger.warning { "peer sent a reestablish with a backup generated by a more recent of phoenix: version=${backup.version}." }
                                // In this corner case, we do not want to return an error to the peer, because they will force-close and we will be unable to
                                // do anything as we can't read the data. Best thing is to not answer, and tell the user to upgrade the app.
                                logger.error { "need to upgrade your app!" }
                                nodeParams._nodeEvents.emit(UpgradeRequired)
                            }
                            local == null && backup == null -> {
                                logger.warning { "peer sent a reestablish for a unknown channel with no or undecipherable backup" }
                                peerConnection?.send(Error(msg.channelId, "unknown channel"))
                            }
                            local == null && backup is DeserializationResult.Success -> {
                                logger.warning { "recovering channel from peer backup" }
                                recoverChannel(backup.state)
                            }
                            local is Syncing && local.state is ChannelStateWithCommitments && backup is DeserializationResult.Success && backup.state is ChannelStateWithCommitments && backup.state.commitments.isMoreRecent(local.state.commitments) -> {
                                logger.warning { "recovering channel from peer backup (it is more recent)" }
                                recoverChannel(backup.state)
                            }
                            local is ChannelState -> {
                                val (state1, actions1) = local.process(ChannelCommand.MessageReceived(msg))
                                processActions(msg.channelId, peerConnection, actions1)
                                _channels = _channels + (msg.channelId to state1)
                            }
                        }
                    }
                    is HasTemporaryChannelId -> {
                        _channels[msg.temporaryChannelId]?.let { state ->
                            logger.info { "received ${msg::class.simpleName} for temporary channel ${msg.temporaryChannelId}" }
                            val event1 = ChannelCommand.MessageReceived(msg)
                            val (state1, actions) = state.process(event1)
                            _channels = _channels + (msg.temporaryChannelId to state1)
                            processActions(msg.temporaryChannelId, peerConnection, actions)
                        } ?: run {
                            logger.error { "received ${msg::class.simpleName} for unknown temporary channel ${msg.temporaryChannelId}" }
                            peerConnection?.send(Error(msg.temporaryChannelId, "unknown channel"))
                        }
                    }
                    is HasChannelId -> {
                        if (msg is Error && msg.channelId == ByteVector32.Zeroes) {
                            logger.error { "connection error: ${msg.toAscii()}" }
                        } else {
                            _channels[msg.channelId]?.let { state ->
                                val event1 = ChannelCommand.MessageReceived(msg)
                                val (state1, actions) = state.process(event1)
                                processActions(msg.channelId, peerConnection, actions)
                                _channels = _channels + (msg.channelId to state1)
                            } ?: run {
                                logger.error { "received ${msg::class.simpleName} for unknown channel ${msg.channelId}" }
                                peerConnection?.send(Error(msg.channelId, "unknown channel"))
                            }
                        }
                    }
                    is ChannelUpdate -> {
                        _channels.values.filterIsInstance().find { it.shortChannelId == msg.shortChannelId }?.let { state ->
                            val event1 = ChannelCommand.MessageReceived(msg)
                            val (state1, actions) = state.process(event1)
                            processActions(state.channelId, peerConnection, actions)
                            _channels = _channels + (state.channelId to state1)
                        }
                    }
                    is WillAddHtlc -> when {
                        nodeParams.features.hasFeature(Feature.OnTheFlyFunding) -> when {
                            nodeParams.liquidityPolicy.value is LiquidityPolicy.Disable -> {
                                logger.warning { "cannot accept on-the-fly funding: policy set to disabled" }
                                val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure)
                                input.send(SendOnTheFlyFundingMessage(failure))
                                nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled))
                            }
                            else -> when (selectChannelForSplicing()) {
                                is SelectChannelResult.Available -> processIncomingPayment(Either.Left(msg))
                                SelectChannelResult.None -> processIncomingPayment(Either.Left(msg))
                                SelectChannelResult.NotReady -> {
                                    // Once the channel will be ready, we may have enough inbound liquidity to receive the payment without
                                    // an on-chain operation, which is more efficient. We thus reject that payment and wait for the sender to retry.
                                    logger.warning { "cannot accept on-the-fly funding: another funding attempt is already in-progress" }
                                    val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure)
                                    input.send(SendOnTheFlyFundingMessage(failure))
                                    nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress))
                                }
                            }
                        }
                        else -> {
                            // If we don't support on-the-fly funding, we simply ignore that proposal.
                            // Our peer will fail the corresponding HTLCs after a small delay.
                            logger.info { "ignoring on-the-fly funding (amount=${msg.amount}): on-the-fly funding is disabled" }
                        }
                    }
                    is PhoenixAndroidLegacyInfo -> {
                        logger.info { "received ${msg::class.simpleName} hasChannels=${msg.hasChannels}" }
                        _eventsFlow.emit(PhoenixAndroidLegacyInfoEvent(msg))
                    }
                    is OnionMessage -> {
                        logger.info { "received ${msg::class.simpleName}" }
                        val remoteChannelUpdates = _channels.values.mapNotNull { channelState ->
                            when (channelState) {
                                is Normal -> channelState.remoteChannelUpdate
                                is Offline -> (channelState.state as? Normal)?.remoteChannelUpdate
                                is Syncing -> (channelState.state as? Normal)?.remoteChannelUpdate
                                else -> null
                            }
                        }
                        offerManager.receiveMessage(msg, remoteChannelUpdates, currentTipFlow.filterNotNull().first())?.let {
                            when (it) {
                                is OnionMessageAction.PayInvoice -> input.send(
                                    PayInvoice(
                                        it.payOffer.paymentId,
                                        it.payOffer.amount,
                                        LightningOutgoingPayment.Details.Blinded(it.invoice, it.payOffer.payerKey),
                                        it.payOffer.trampolineFeesOverride
                                    )
                                )
                                is OnionMessageAction.SendMessage -> input.send(SendOnionMessage(it.message))
                            }
                        }
                    }
                    is DNSAddressResponse -> {
                        logger.info { "bip353 dns address assigned: ${msg.address}" }
                        _eventsFlow.emit(AddressAssigned(msg.address))
                    }
                }
            }
            is WatchReceived -> {
                if (!_channels.containsKey(cmd.watch.channelId)) {
                    logger.error { "received watch event ${cmd.watch} for unknown channel ${cmd.watch.channelId}}" }
                } else {
                    val state = _channels[cmd.watch.channelId] ?: error("channel ${cmd.watch.channelId} not found")
                    val event1 = ChannelCommand.WatchReceived(cmd.watch)
                    val (state1, actions) = state.process(event1)
                    processActions(cmd.watch.channelId, peerConnection, actions)
                    _channels = _channels + (cmd.watch.channelId to state1)
                }
            }
            is OpenChannel -> {
                val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = true)
                val state = WaitForInit
                val (state1, actions1) = state.process(
                    ChannelCommand.Init.Initiator(
                        replyTo = CompletableDeferred(),
                        fundingAmount = cmd.fundingAmount,
                        pushAmount = cmd.pushAmount,
                        walletInputs = cmd.walletInputs,
                        commitTxFeerate = cmd.commitTxFeerate,
                        fundingTxFeerate = cmd.fundingTxFeerate,
                        localParams = localParams,
                        remoteInit = theirInit!!,
                        channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false),
                        channelConfig = ChannelConfig.standard,
                        channelType = cmd.channelType,
                        requestRemoteFunding = null,
                        channelOrigin = null,
                    )
                )
                val msg = actions1.filterIsInstance().map { it.message }.filterIsInstance().first()
                _channels = _channels + (msg.temporaryChannelId to state1)
                processActions(msg.temporaryChannelId, peerConnection, actions1)
            }
            is AddWalletInputsToChannel -> {
                when (val available = selectChannelForSplicing()) {
                    is SelectChannelResult.Available -> {
                        // We have a channel and we are connected.
                        val targetFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate
                        val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList())
                        val (feerate, fee) = client.computeSpliceCpfpFeerate(available.channel.commitments, targetFeerate, spliceWeight = weight, logger)
                        logger.info { "requesting splice-in using balance=${cmd.walletInputs.balance} feerate=$feerate fee=$fee" }
                        when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) {
                            is LiquidityEvents.Rejected -> {
                                logger.info { "rejecting splice: reason=${rejected.reason}" }
                                nodeParams._nodeEvents.emit(rejected)
                                swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                            }
                            else -> {
                                val spliceCommand = ChannelCommand.Commitment.Splice.Request(
                                    replyTo = CompletableDeferred(),
                                    spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs),
                                    spliceOut = null,
                                    requestRemoteFunding = null,
                                    currentFeeCredit = feeCreditFlow.value,
                                    feerate = feerate,
                                    origins = listOf(Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), ChannelManagementFees(fee, 0.sat)))
                                )
                                // If the splice fails, we immediately unlock the utxos to reuse them in the next attempt.
                                spliceCommand.replyTo.invokeOnCompletion { ex ->
                                    if (ex == null && spliceCommand.replyTo.getCompleted() is ChannelFundingResponse.Failure) {
                                        swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                                    }
                                }
                                input.send(WrappedChannelCommand(available.channel.channelId, spliceCommand))
                                nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs))
                            }
                        }
                    }
                    SelectChannelResult.NotReady -> {
                        // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet.
                        logger.info { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" }
                        swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                    }
                    SelectChannelResult.None -> {
                        // Either there are no channels, or they will never be suitable for a splice-in: we open a new channel.
                        val currentFeerates = peerFeeratesFlow.filterNotNull().first()
                        // We need our peer to contribute, because they must have enough funds to pay the commitment fees.
                        // That means they will add at least one input to the funding transaction, and pay the corresponding mining fees.
                        // We always request a liquidity purchase, even for a dummy amount, which ensures that we refund their mining fees.
                        val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.first()) {
                            is LiquidityPolicy.Disable -> 1.sat
                            is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: 1.sat
                        }
                        when (val fundingRate = remoteFundingRates.value?.findRate(inboundLiquidityTarget)) {
                            null -> {
                                logger.warning { "cannot find suitable funding rate (remoteFundingRates=${remoteFundingRates.value}, inboundLiquidityTarget=$inboundLiquidityTarget)" }
                                swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                            }
                            else -> {
                                val requestRemoteFunding = LiquidityAds.RequestFunding(inboundLiquidityTarget, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)
                                val (localFundingAmount, fees) = run {
                                    // We need to know the local channel funding amount to be able use channel opening messages.
                                    // We must pay on-chain fees for our inputs/outputs of the transaction: we compute them first
                                    // and proceed backwards to retrieve the funding amount.
                                    val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector()
                                    val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList()))
                                    val localFundingAmount = cmd.totalAmount - localMiningFee
                                    val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate, isChannelCreation = true)
                                    // We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction.
                                    // This will be taken from our channel balance during the interactive-tx construction, they shouldn't be deducted from our funding amount.
                                    val totalFees = ChannelManagementFees(miningFee = localMiningFee + fundingFees.miningFee, serviceFee = fundingFees.serviceFee)
                                    Pair(localFundingAmount, totalFees)
                                }
                                if (cmd.totalAmount - fees.total < nodeParams.dustLimit) {
                                    logger.warning { "cannot create channel, not enough funds to pay fees (fees=${fees.total}, available=${cmd.totalAmount})" }
                                    swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                                } else {
                                    val totalAmount = cmd.walletInputs.balance.toMilliSatoshi() + requestRemoteFunding.requestedAmount.toMilliSatoshi()
                                    when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(totalAmount, fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) {
                                        is LiquidityEvents.Rejected -> {
                                            logger.info { "rejecting channel open: reason=${rejected.reason}" }
                                            nodeParams._nodeEvents.emit(rejected)
                                            swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                                        }
                                        else -> {
                                            // We ask our peer to pay the commit tx fees.
                                            val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false)
                                            val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true)
                                            val initCommand = ChannelCommand.Init.Initiator(
                                                replyTo = CompletableDeferred(),
                                                fundingAmount = localFundingAmount,
                                                pushAmount = 0.msat,
                                                walletInputs = cmd.walletInputs,
                                                commitTxFeerate = currentFeerates.commitmentFeerate,
                                                fundingTxFeerate = currentFeerates.fundingFeerate,
                                                localParams = localParams,
                                                remoteInit = theirInit!!,
                                                channelFlags = channelFlags,
                                                channelConfig = ChannelConfig.standard,
                                                channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve,
                                                requestRemoteFunding = requestRemoteFunding,
                                                channelOrigin = Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), fees),
                                            )
                                            // If the channel creation fails, we immediately unlock the utxos to reuse them in the next attempt.
                                            initCommand.replyTo.invokeOnCompletion { ex ->
                                                if (ex == null && initCommand.replyTo.getCompleted() is ChannelFundingResponse.Failure) {
                                                    swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
                                                }
                                            }
                                            val (state, actions) = WaitForInit.process(initCommand)
                                            val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first()
                                            _channels = _channels + (msg.temporaryChannelId to state)
                                            processActions(msg.temporaryChannelId, peerConnection, actions)
                                            nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs))
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            is AddLiquidityForIncomingPayment -> {
                val currentFeerates = peerFeeratesFlow.filterNotNull().first()
                val paymentTypes = remoteFundingRates.value?.paymentTypes ?: setOf()
                val currentFeeCredit = feeCreditFlow.value
                when (val available = selectChannelForSplicing()) {
                    is SelectChannelResult.Available -> {
                        // We don't contribute any input or output, but we must pay on-chain fees for the shared input and output.
                        // We pay those on-chain fees using our current channel balance.
                        val localBalance = available.channel.commitments.active.first().localCommit.spec.toLocal
                        val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf())
                        val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger)
                        val (targetFeerate, paymentDetails) = when {
                            localBalance + currentFeeCredit >= localMiningFee + cmd.fees(fundingFeerate, isChannelCreation = false).total -> {
                                // We have enough funds to pay the mining fee and the lease fees.
                                // This the ideal scenario because the fees can be paid immediately with the splice transaction.
                                Pair(fundingFeerate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(cmd.paymentHash)))
                            }
                            else -> {
                                val targetFeerate = when {
                                    localBalance >= localMiningFee * 0.75 -> fundingFeerate
                                    // Our current balance is too low to pay the mining fees for our weight of the splice transaction.
                                    // We target a higher feerate so that the effective feerate isn't too low compared to our target.
                                    else -> fundingFeerate * AddLiquidityForIncomingPayment.SpliceWithNoBalanceFeerateRatio
                                }
                                // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs.
                                val paymentDetails = when {
                                    paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash))
                                    paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage))
                                    else -> null
                                }
                                Pair(targetFeerate, paymentDetails)
                            }
                        }
                        when (paymentDetails) {
                            null -> {
                                // Our peer doesn't allow paying liquidity fees from future HTLCs.
                                // We'll need to wait until we have more channel balance or do a splice-in to purchase more inbound liquidity.
                                logger.warning { "cannot request on-the-fly splice: payment types not supported (${paymentTypes.joinToString()})" }
                            }
                            else -> {
                                val leaseFees = cmd.fees(targetFeerate, isChannelCreation = false)
                                val totalFees = ChannelManagementFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
                                logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} requestedAmount=${cmd.requestedAmount} feerate=$targetFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" }
                                val spliceCommand = ChannelCommand.Commitment.Splice.Request(
                                    replyTo = CompletableDeferred(),
                                    spliceIn = null,
                                    spliceOut = null,
                                    requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails),
                                    currentFeeCredit = currentFeeCredit,
                                    feerate = targetFeerate,
                                    origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees))
                                )
                                val (state, actions) = available.channel.process(spliceCommand)
                                _channels = _channels + (available.channel.channelId to state)
                                processActions(available.channel.channelId, peerConnection, actions)
                            }
                        }
                    }
                    SelectChannelResult.None -> {
                        // We ask our peer to pay the commit tx fees.
                        val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false)
                        val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true)
                        // Since we don't have inputs to contribute, we're unable to pay on-chain fees for the shared output.
                        // We target a higher feerate so that the effective feerate isn't too low compared to our target.
                        val fundingFeerate = currentFeerates.fundingFeerate * AddLiquidityForIncomingPayment.ChannelOpenFeerateRatio
                        // We don't pay any local on-chain fees, our fee is only for the liquidity lease.
                        val leaseFees = cmd.fees(fundingFeerate, isChannelCreation = true)
                        val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
                        // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs.
                        val paymentDetails = when {
                            paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash))
                            paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage))
                            else -> null
                        }
                        when (paymentDetails) {
                            null -> {
                                // Our peer doesn't allow paying liquidity fees from future HTLCs.
                                // We'll need to swap-in some funds to create a new channel.
                                logger.warning { "cannot request on-the-fly channel: payment types not supported (${paymentTypes.joinToString()})" }
                            }
                            else -> {
                                logger.info { "requesting on-the-fly channel for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" }
                                val (state, actions) = WaitForInit.process(
                                    ChannelCommand.Init.Initiator(
                                        replyTo = CompletableDeferred(),
                                        fundingAmount = 0.sat, // we don't have funds to contribute
                                        pushAmount = 0.msat,
                                        walletInputs = listOf(),
                                        commitTxFeerate = currentFeerates.commitmentFeerate,
                                        fundingTxFeerate = fundingFeerate,
                                        localParams = localParams,
                                        remoteInit = theirInit!!,
                                        channelFlags = channelFlags,
                                        channelConfig = ChannelConfig.standard,
                                        channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve,
                                        requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails),
                                        channelOrigin = Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees),
                                    )
                                )
                                val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first()
                                _channels = _channels + (msg.temporaryChannelId to state)
                                processActions(msg.temporaryChannelId, peerConnection, actions)
                            }
                        }
                    }
                    SelectChannelResult.NotReady -> {
                        // There is an existing channel but not immediately usable (e.g. we're already in the process of funding it).
                        logger.warning { "cancelling on-the-fly funding, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" }
                        cmd.willAddHtlcs.forEach {
                            val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, it, TemporaryNodeFailure)
                            input.send(SendOnTheFlyFundingMessage(failure))
                        }
                        nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(cmd.requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress))
                    }
                }
            }
            is PayInvoice -> {
                val currentTip = currentTipFlow.filterNotNull().first()
                when (val result = outgoingPaymentHandler.sendPayment(cmd, _channels, currentTip)) {
                    is OutgoingPaymentHandler.Progress -> {
                        _eventsFlow.emit(PaymentProgress(result.request, result.fees))
                        result.actions.forEach { input.send(it) }
                    }
                    is OutgoingPaymentHandler.Failure -> _eventsFlow.emit(PaymentNotSent(result.request, result.failure))
                }
            }
            is PurgeExpiredPayments -> {
                incomingPaymentHandler.purgeExpiredPayments(cmd.fromCreatedAt, cmd.toCreatedAt)
            }
            is CheckPaymentsTimeout -> {
                val actions = incomingPaymentHandler.checkPaymentsTimeout(currentTimestampSeconds())
                actions.forEach { input.send(it) }
            }
            is WrappedChannelCommand -> {
                if (cmd.channelId == ByteVector32.Zeroes) {
                    // this is for all channels
                    _channels.forEach { (key, value) ->
                        val (state1, actions) = value.process(cmd.channelCommand)
                        processActions(key, peerConnection, actions)
                        _channels = _channels + (key to state1)
                    }
                } else {
                    _channels[cmd.channelId]?.let { state ->
                        val (state1, actions) = state.process(cmd.channelCommand)
                        processActions(cmd.channelId, peerConnection, actions)
                        _channels = _channels + (cmd.channelId to state1)
                    } ?: logger.error { "received ${cmd.channelCommand::class.simpleName} for an unknown channel ${cmd.channelId}" }
                }
            }
            is Disconnected -> {
                when (peerConnection) {
                    null -> logger.info { "ignoring disconnected event, we're already disconnected" }
                    else -> {
                        logger.warning { "disconnecting channels from connectionId=${peerConnection?.id}" }
                        _peerConnection.value = null
                        _channels.forEach { (key, value) ->
                            val (state1, actions) = value.process(ChannelCommand.Disconnected)
                            _channels = _channels + (key to state1)
                            processActions(key, peerConnection, actions)
                        }
                        // We must purge pending incoming payments: incoming HTLCs that aren't settled yet will be
                        // re-processed on reconnection, and we must not keep HTLCs pending in the payment handler since
                        // another instance of the application may resolve them, which would lead to inconsistent
                        // payment handler state (whereas the channel state is kept consistent thanks to the encrypted
                        // channel backup).
                        incomingPaymentHandler.purgePendingPayments()
                    }
                }
            }
            is PayOffer -> {
                val (pathId, invoiceRequests) = offerManager.requestInvoice(cmd)
                invoiceRequests.forEach { input.send(SendOnionMessage(it)) }
                this.launch {
                    delay(cmd.fetchInvoiceTimeout)
                    input.send(CheckInvoiceRequestTimeout(pathId, cmd))
                }
            }
            is CheckInvoiceRequestTimeout -> offerManager.checkInvoiceRequestTimeout(cmd.pathId, cmd.payOffer)
            is SendOnionMessage -> peerConnection?.send(cmd.message)
            is SendOnTheFlyFundingMessage -> peerConnection?.send(cmd.message)
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy