Skip to content

Commit

Permalink
On-the-fly channel funding based on splicing and liquidity ads (#649)
Browse files Browse the repository at this point in the history
* Remove `please_open_channel`

It is usually the wallet that decides that it needs a channel, but we
want the LSP to pay the commit fees to allow the wallet user to empty
its wallet over lightning. We previously used a `please_open_channel`
message that was sent by the wallet to the LSP, but it doesn't work
well with liquidity ads.

We remove that message and instead send `open_channel` from the wallet
but with a custom channel flag that tells the LSP that they should be
paying the commit fees. This only works if the LSP adds funds on their
side of the channel, so we couple that with liquidity ads to request
funds from the LSP.

We also add a `recommended_feerates` message from the LSP which lets
the wallet know the on-chain feerates that the LSP will accept for
on-chain funding operations, since those feerates are set in the
`open_channel` message that is now sent by the wallet.

* Add liquidity ads to the channel opening flow

We previously only used liquidity ads with splicing: we now support it
during the initial channel opening flow as well. This lets us add more
unit tests, including tests for the case where the node receiving the
`open_channel` message is responsible for paying the commitment fees.

We also update liquidity ads to use the latest version of the spec from
lightning/bolts#1153. This introduces more ways
of paying the liquidity fees, to support on-the-fly funding without
existing channel balance (not implemented in this commit).

Note that we need some backwards-compatibility with the previous
liquidity ads types in our state serialization code: when we're in the
middle of signing a splice transaction, we may have a legacy liquidity
lease in our splice status. We ignore it when finalizing the splice: the
only consequence is that we won't store an entry in our DB for that
lease, but the channel will otherwise work correctly.

* Replace `pay_to_open` with `will_add_htlc`

We replace the previous pay-to-open protocol with a new protocol that
only relies on liquidity ads for paying fees. We simply transmit HTLCs
that cannot be relayed on existing channels with a new message called
`will_add_htlc` that contains all the HTLC data.

The recipient can verify that the HTLC that would match this promise is
valid, and if it wishes to accept that payment, it can trigger a channel
open or a splice to purchase the required inbound liquidity. Once that
transaction completes, the sender will relay HTLCs matching the proposed
`will_add_htlc`, which completes the payment.

If the fees for the inbound liquidity purchase couldn't be paid from the
previous channel balance, they can be taken from the HTLCs relayed after
the funding transaction. When that happens, one side needs to trust that
the other will comply. Each side can independently configure the options
they're comfortable with, depending on whether they trust their peer or
not.

* Add `channelCreationFee` to liquidity ads

Creating a new channel has an additional cost compared to adding
liquidity to an existing channel: the channel will be closed in the
future, which will require paying on-chain fees. Node operators can
include a `channel-creation-fee-satoshis` in their liquidity ads to
cover some of that future cost.

* Clarify received amount before or after fees

We clarify some of our event types that previously had an `amount`
field to detail whether this amount includes fees or not.

This impacts:

- SwapInEvents.Accepted
- StoreIncomingPayment.ViaNewChannel
- StoreIncomingPayment.ViaSpliceIn
- Origin.OnChainWallet
- Origin.OffChainPayment

There was an inconsistency in the `ViaSpliceIn` event, where in some
cases we used the received amount, and in others the amount with fees.

* Remove `minInboundLiquidityTarget`

We previously forced wallets to purchase additional inbound liquidity
every time an on-chain transaction was created. We now allow wallets
to disable automatic liquidity purchases: the LSP will need to add
enough funds on their side to cover the commitment fees, which the
wallet won't be paying for. But we still make a dummy purchase of 1 sat
to ensure that the liquidity ads flow is used and the wallet refunds
the mining fees paid by the LSP.

* Read remote funding rates from their `init` message

Instead of using a hard-coded value from `WalletParams`, we read the
liquidity funding rates from our peer's `init` message.
  • Loading branch information
t-bast authored Sep 25, 2024
1 parent 0d85f88 commit 149b8c3
Show file tree
Hide file tree
Showing 117 changed files with 3,505 additions and 2,196 deletions.
23 changes: 16 additions & 7 deletions src/commonMain/kotlin/fr/acinq/lightning/Features.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ sealed class Feature {
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

@Serializable
object Quiescence : Feature() {
override val rfcName get() = "option_quiescence"
override val mandatory get() = 34
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

@Serializable
object ChannelType : Feature() {
override val rfcName get() = "option_channel_type"
Expand Down Expand Up @@ -185,15 +192,15 @@ sealed class Feature {
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

/** This feature bit should be activated when a node accepts on-the-fly channel creation. */
/** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */
@Serializable
object PayToOpenClient : Feature() {
override val rfcName get() = "pay_to_open_client"
override val mandatory get() = 136
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init)
}

/** This feature bit should be activated when a node supports opening channels on-the-fly when liquidity is missing to receive a payment. */
/** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */
@Serializable
object PayToOpenProvider : Feature() {
override val rfcName get() = "pay_to_open_provider"
Expand Down Expand Up @@ -250,9 +257,9 @@ sealed class Feature {
}

@Serializable
object Quiescence : Feature() {
override val rfcName get() = "option_quiescence"
override val mandatory get() = 34
object OnTheFlyFunding : Feature() {
override val rfcName get() = "on_the_fly_funding"
override val mandatory get() = 560
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

Expand Down Expand Up @@ -322,6 +329,7 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.RouteBlinding,
Feature.ShutdownAnySegwit,
Feature.DualFunding,
Feature.Quiescence,
Feature.ChannelType,
Feature.PaymentMetadata,
Feature.TrampolinePayment,
Expand All @@ -337,7 +345,7 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.ChannelBackupClient,
Feature.ChannelBackupProvider,
Feature.ExperimentalSplice,
Feature.Quiescence
Feature.OnTheFlyFunding
)

operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray())
Expand Down Expand Up @@ -369,7 +377,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.BasicMultiPartPayment to listOf(Feature.PaymentSecret),
Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey),
Feature.TrampolinePayment to listOf(Feature.PaymentSecret),
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret)
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret),
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice)
)

class FeatureException(message: String) : IllegalArgumentException(message)
Expand Down
25 changes: 16 additions & 9 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package fr.acinq.lightning

import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.OutPoint
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.electrum.WalletState
import fr.acinq.lightning.channel.ChannelManagementFees
import fr.acinq.lightning.channel.InteractiveTxParams
import fr.acinq.lightning.channel.SharedFundingInput
import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
Expand All @@ -11,16 +14,18 @@ import fr.acinq.lightning.channel.states.WaitForFundingCreated
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.wire.Init
import fr.acinq.lightning.wire.PleaseOpenChannel
import kotlinx.coroutines.CompletableDeferred

sealed interface NodeEvents

data class PeerConnected(val remoteNodeId: PublicKey, val theirInit: Init) : NodeEvents

sealed interface SwapInEvents : NodeEvents {
data class Requested(val req: PleaseOpenChannel) : SwapInEvents
data class Accepted(val requestId: ByteVector32, val serviceFee: MilliSatoshi, val miningFee: Satoshi) : SwapInEvents
data class Requested(val walletInputs: List<WalletState.Utxo>) : SwapInEvents {
val totalAmount: Satoshi = walletInputs.map { it.amount }.sum()
}
data class Accepted(val inputs: Set<OutPoint>, val amountBeforeFees: Satoshi, val fees: ChannelManagementFees) : SwapInEvents {
val receivedAmount: Satoshi = amountBeforeFees - fees.total
}
}

sealed interface ChannelEvents : NodeEvents {
Expand All @@ -30,6 +35,7 @@ sealed interface ChannelEvents : NodeEvents {
}

sealed interface LiquidityEvents : NodeEvents {
/** Amount of liquidity purchased, before fees are paid. */
val amount: MilliSatoshi
val fee: MilliSatoshi
val source: Source
Expand All @@ -42,11 +48,13 @@ sealed interface LiquidityEvents : NodeEvents {
data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive()
data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive()
}
data object ChannelInitializing : Reason()
data object ChannelFundingInProgress : Reason()
data object NoMatchingFundingRate : Reason()
data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason()
data class TooManyParts(val parts: Int) : Reason()
}
}

data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred<Boolean>) : LiquidityEvents
data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : LiquidityEvents
}

/** This is useful on iOS to ask the OS for time to finish some sensitive tasks. */
Expand All @@ -59,15 +67,14 @@ sealed interface SensitiveTaskEvents : NodeEvents {
}
data class TaskStarted(val id: TaskIdentifier) : SensitiveTaskEvents
data class TaskEnded(val id: TaskIdentifier) : SensitiveTaskEvents

}

/** This will be emitted in a corner case where the user restores a wallet on an older version of the app, which is unable to read the channel data. */
data object UpgradeRequired : NodeEvents

sealed interface PaymentEvents : NodeEvents {
data class PaymentReceived(val paymentHash: ByteVector32, val receivedWith: List<IncomingPayment.ReceivedWith>) : PaymentEvents {
val amount: MilliSatoshi = receivedWith.map { it.amount }.sum()
val amount: MilliSatoshi = receivedWith.map { it.amountReceived }.sum()
val fees: MilliSatoshi = receivedWith.map { it.fees }.sum()
}
}
9 changes: 6 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
Expand Down Expand Up @@ -172,6 +173,8 @@ data class NodeParams(
require(!features.hasFeature(Feature.ZeroConfChannels)) { "${Feature.ZeroConfChannels.rfcName} has been deprecated: use the zeroConfPeers whitelist instead" }
require(!features.hasFeature(Feature.TrustedSwapInClient)) { "${Feature.TrustedSwapInClient.rfcName} has been deprecated" }
require(!features.hasFeature(Feature.TrustedSwapInProvider)) { "${Feature.TrustedSwapInProvider.rfcName} has been deprecated" }
require(!features.hasFeature(Feature.PayToOpenClient)) { "${Feature.PayToOpenClient.rfcName} has been deprecated" }
require(!features.hasFeature(Feature.PayToOpenProvider)) { "${Feature.PayToOpenProvider.rfcName} has been deprecated" }
Features.validateFeatureGraph(features)
}

Expand All @@ -193,15 +196,15 @@ data class NodeParams(
Feature.RouteBlinding to FeatureSupport.Optional,
Feature.DualFunding to FeatureSupport.Mandatory,
Feature.ShutdownAnySegwit to FeatureSupport.Mandatory,
Feature.Quiescence to FeatureSupport.Mandatory,
Feature.ChannelType to FeatureSupport.Mandatory,
Feature.PaymentMetadata to FeatureSupport.Optional,
Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional,
Feature.ZeroReserveChannels to FeatureSupport.Optional,
Feature.WakeUpNotificationClient to FeatureSupport.Optional,
Feature.PayToOpenClient to FeatureSupport.Optional,
Feature.ChannelBackupClient to FeatureSupport.Optional,
Feature.ExperimentalSplice to FeatureSupport.Optional,
Feature.Quiescence to FeatureSupport.Mandatory
Feature.OnTheFlyFunding to FeatureSupport.Optional,
),
dustLimit = 546.sat,
maxRemoteDustLimit = 600.sat,
Expand Down Expand Up @@ -229,7 +232,7 @@ data class NodeParams(
maxPaymentAttempts = 5,
zeroConfPeers = emptySet(),
paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)),
liquidityPolicy = MutableStateFlow<LiquidityPolicy>(LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)),
liquidityPolicy = MutableStateFlow<LiquidityPolicy>(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)),
minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA,
maxFinalCltvExpiryDelta = CltvExpiryDelta(360),
bolt12invoiceExpiry = 60.seconds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,18 @@ package fr.acinq.lightning.blockchain.electrum

import fr.acinq.bitcoin.OutPoint
import fr.acinq.bitcoin.Transaction
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.SwapInParams
import fr.acinq.lightning.channel.FundingContributions.Companion.stripInputWitnesses
import fr.acinq.lightning.channel.LocalFundingStatus
import fr.acinq.lightning.channel.RbfStatus
import fr.acinq.lightning.channel.SignedSharedTransaction
import fr.acinq.lightning.channel.SpliceStatus
import fr.acinq.lightning.channel.states.*
import fr.acinq.lightning.io.RequestChannelOpen
import fr.acinq.lightning.io.AddWalletInputsToChannel
import fr.acinq.lightning.logging.MDCLogger
import fr.acinq.lightning.utils.sat

internal sealed class SwapInCommand {
data class TrySwapIn(val currentBlockHeight: Int, val wallet: WalletState, val swapInParams: SwapInParams, val trustedTxs: Set<TxId>) : SwapInCommand()
data class TrySwapIn(val currentBlockHeight: Int, val wallet: WalletState, val swapInParams: SwapInParams) : SwapInCommand()
data class UnlockWalletInputs(val inputs: Set<OutPoint>) : SwapInCommand()
}

Expand All @@ -33,20 +30,15 @@ internal sealed class SwapInCommand {
class SwapInManager(private var reservedUtxos: Set<OutPoint>, private val logger: MDCLogger) {
constructor(bootChannels: List<PersistedChannelState>, logger: MDCLogger) : this(reservedWalletInputs(bootChannels), logger)

internal fun process(cmd: SwapInCommand): RequestChannelOpen? = when (cmd) {
internal fun process(cmd: SwapInCommand): AddWalletInputsToChannel? = when (cmd) {
is SwapInCommand.TrySwapIn -> {
val availableWallet = cmd.wallet.withoutReservedUtxos(reservedUtxos).withConfirmations(cmd.currentBlockHeight, cmd.swapInParams)
logger.info { "swap-in wallet balance: deeplyConfirmed=${availableWallet.deeplyConfirmed.balance}, weaklyConfirmed=${availableWallet.weaklyConfirmed.balance}, unconfirmed=${availableWallet.unconfirmed.balance}" }
val utxos = buildSet {
// some utxos may be used for swap-in even if they are not confirmed, for example when migrating from the legacy phoenix android app
addAll(availableWallet.unconfirmed.filter { cmd.trustedTxs.contains(it.outPoint.txid) })
addAll(availableWallet.weaklyConfirmed.filter { cmd.trustedTxs.contains(it.outPoint.txid) })
addAll(availableWallet.deeplyConfirmed.filter { Transaction.write(it.previousTx.stripInputWitnesses()).size < 65_000 })
}.toList()
val utxos = availableWallet.deeplyConfirmed.filter { Transaction.write(it.previousTx.stripInputWitnesses()).size < 65_000 }
if (utxos.balance > 0.sat) {
logger.info { "swap-in wallet: requesting channel using ${utxos.size} utxos with balance=${utxos.balance}" }
reservedUtxos = reservedUtxos.union(utxos.map { it.outPoint })
RequestChannelOpen(Lightning.randomBytes32(), utxos)
AddWalletInputsToChannel(utxos)
} else {
null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package fr.acinq.lightning.channel

import fr.acinq.bitcoin.*
import fr.acinq.lightning.ChannelEvents
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.NodeEvents
import fr.acinq.lightning.blockchain.Watch
import fr.acinq.lightning.channel.states.PersistedChannelState
import fr.acinq.lightning.db.ChannelClosingType
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.utils.UUID
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.wire.*

/** Channel Actions (outputs produced by the state machine). */
Expand Down Expand Up @@ -78,16 +79,16 @@ sealed class ChannelAction {
abstract val origin: Origin?
abstract val txId: TxId
abstract val localInputs: Set<OutPoint>
data class ViaNewChannel(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set<OutPoint>, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment()
data class ViaSpliceIn(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set<OutPoint>, override val txId: TxId, override val origin: Origin.PayToOpenOrigin?) : StoreIncomingPayment()
data class ViaNewChannel(val amountReceived: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set<OutPoint>, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment()
data class ViaSpliceIn(val amountReceived: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set<OutPoint>, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment()
}
/** Payment sent through on-chain operations (channel close or splice-out) */
sealed class StoreOutgoingPayment : Storage() {
abstract val miningFees: Satoshi
abstract val txId: TxId
data class ViaSpliceOut(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId) : StoreOutgoingPayment()
data class ViaSpliceCpfp(override val miningFees: Satoshi, override val txId: TxId) : StoreOutgoingPayment()
data class ViaInboundLiquidityRequest(override val txId: TxId, override val miningFees: Satoshi, val lease: LiquidityAds.Lease) : StoreOutgoingPayment()
data class ViaInboundLiquidityRequest(override val txId: TxId, override val miningFees: Satoshi, val purchase: LiquidityAds.Purchase) : StoreOutgoingPayment()
data class ViaClose(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId, val isSentToDefaultAddress: Boolean, val closingType: ChannelClosingType) : StoreOutgoingPayment()
}
data class SetLocked(val txId: TxId) : Storage()
Expand Down Expand Up @@ -128,8 +129,8 @@ sealed class ChannelAction {
}
}

data class EmitEvent(val event: ChannelEvents) : ChannelAction()
data class EmitEvent(val event: NodeEvents) : ChannelAction()

object Disconnect : ChannelAction()
data object Disconnect : ChannelAction()
// @formatter:on
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ sealed class ChannelCommand {
val fundingTxFeerate: FeeratePerKw,
val localParams: LocalParams,
val remoteInit: InitMessage,
val channelFlags: Byte,
val channelFlags: ChannelFlags,
val channelConfig: ChannelConfig,
val channelType: ChannelType.SupportedChannelType,
val channelOrigin: Origin? = null
val requestRemoteFunding: LiquidityAds.RequestFunding?,
val channelOrigin: Origin?,
) : Init() {
fun temporaryChannelId(keyManager: KeyManager): ByteVector32 = keyManager.channelKeys(localParams.fundingKeyPath).temporaryChannelId
}
Expand All @@ -47,7 +48,8 @@ sealed class ChannelCommand {
val walletInputs: List<WalletState.Utxo>,
val localParams: LocalParams,
val channelConfig: ChannelConfig,
val remoteInit: InitMessage
val remoteInit: InitMessage,
val fundingRates: LiquidityAds.WillFundRates?
) : Init()

data class Restore(val state: PersistedChannelState) : Init()
Expand Down Expand Up @@ -85,7 +87,7 @@ sealed class ChannelCommand {
data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence
data object CheckHtlcTimeout : Commitment()
sealed class Splice : Commitment() {
data class Request(val replyTo: CompletableDeferred<Response>, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestRemoteFunding?, val feerate: FeeratePerKw, val origins: List<Origin.PayToOpenOrigin> = emptyList()) : Splice() {
data class Request(val replyTo: CompletableDeferred<Response>, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestFunding?, val feerate: FeeratePerKw, val origins: List<Origin>) : Splice() {
val pushAmount: MilliSatoshi = spliceIn?.pushAmount ?: 0.msat
val spliceOutputs: List<TxOut> = spliceOut?.let { listOf(TxOut(it.amount, it.scriptPubKey)) } ?: emptyList()

Expand All @@ -104,7 +106,7 @@ sealed class ChannelCommand {
val fundingTxId: TxId,
val capacity: Satoshi,
val balance: MilliSatoshi,
val liquidityLease: LiquidityAds.Lease?,
val liquidityPurchase: LiquidityAds.Purchase?,
) : Response()

sealed class Failure : Response() {
Expand Down
Loading

0 comments on commit 149b8c3

Please sign in to comment.