Skip to content

Commit

Permalink
Add liquidity ads to the channel opening flow
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Jun 27, 2024
1 parent 6d83c7a commit 0fb2000
Show file tree
Hide file tree
Showing 50 changed files with 1,080 additions and 470 deletions.
5 changes: 3 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,15 @@ object DefaultSwapInParams {
* @param trampolineFees ordered list of trampoline fees to try when making an outgoing payment.
* @param invoiceDefaultRoutingFees default routing fees set in invoices when we don't have any channel.
* @param swapInParams parameters for swap-in transactions.
* @param leaseRate rate at which our peer sells their liquidity.
* @param remoteFundingRates rates at which our peer sells their liquidity.
*/
data class WalletParams(
val trampolineNode: NodeUri,
val trampolineFees: List<TrampolineFees>,
val invoiceDefaultRoutingFees: InvoiceDefaultRoutingFees,
val swapInParams: SwapInParams,
val leaseRate: LiquidityAds.LeaseRate,
// TODO: once standardized, we should get this data from our peer's init message.
val remoteFundingRates: LiquidityAds.WillFundRates,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ sealed class ChannelCommand {
val channelFlags: ChannelFlags,
val channelConfig: ChannelConfig,
val channelType: ChannelType.SupportedChannelType,
val requestRemoteFunding: LiquidityAds.RequestRemoteFunding?,
val requestRemoteFunding: LiquidityAds.RequestFunding?,
val channelOrigin: Origin?,
) : Init() {
fun temporaryChannelId(keyManager: KeyManager): ByteVector32 = keyManager.channelKeys(localParams.fundingKeyPath).temporaryChannelId
Expand All @@ -48,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 @@ -86,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>) : 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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ data class ToSelfDelayTooHigh (override val channelId: Byte
data class MissingLiquidityAds (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads field is missing")
data class InvalidLiquidityAdsSig (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads signature is invalid")
data class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, val proposed: Satoshi, val min: Satoshi) : ChannelException(channelId, "liquidity ads funding amount is too low (expected at least $min, got $proposed)")
data class InvalidLiquidityRates (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting liquidity ads proposed rates")
data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error")
data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted")
data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted")
Expand Down
21 changes: 14 additions & 7 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -673,8 +673,7 @@ data class InteractiveTxSession(
val isComplete: Boolean = txCompleteSent != null && txCompleteReceived != null

fun send(): Pair<InteractiveTxSession, InteractiveTxSessionAction> {
val msg = toSend.firstOrNull()
return when (msg) {
return when (val msg = toSend.firstOrNull()) {
null -> {
val localSwapIns = localInputs.filterIsInstance<InteractiveTxInput.LocalSwapIn>()
val remoteSwapIns = remoteInputs.filterIsInstance<InteractiveTxInput.RemoteSwapIn>()
Expand Down Expand Up @@ -987,7 +986,6 @@ data class InteractiveTxSigningSession(
val fundingParams: InteractiveTxParams,
val fundingTxIndex: Long,
val fundingTx: PartiallySignedSharedTransaction,
val liquidityLease: LiquidityAds.Lease?,
val localCommit: Either<UnsignedLocalCommit, LocalCommit>,
val remoteCommit: RemoteCommit,
) {
Expand Down Expand Up @@ -1075,7 +1073,16 @@ data class InteractiveTxSigningSession(
val channelKeys = channelParams.localParams.channelKeys(keyManager)
val unsignedTx = sharedTx.buildUnsignedTx()
val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) }
val liquidityFees = liquidityLease?.fees?.total?.toMilliSatoshi() ?: 0.msat
val liquidityFees = liquidityLease?.let { l ->
val fees = l.fees.total.toMilliSatoshi()
when (l.paymentDetails) {
is LiquidityAds.PaymentDetails.FromChannelBalance -> if (fundingParams.isInitiator) fees else -fees
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (fundingParams.isInitiator) fees else -fees
// Fees will be paid later, from relayed HTLCs.
is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat
}
} ?: 0.msat
return Helpers.Funding.makeCommitTxs(
channelKeys,
channelParams.channelId,
Expand Down Expand Up @@ -1120,7 +1127,7 @@ data class InteractiveTxSigningSession(
val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf())
val remoteCommit = RemoteCommit(remoteCommitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint)
val signedFundingTx = sharedTx.sign(session, keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId)
Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, liquidityLease, Either.Left(unsignedLocalCommit), remoteCommit), commitSig)
Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, Either.Left(unsignedLocalCommit), remoteCommit), commitSig)
}
}

Expand Down Expand Up @@ -1168,7 +1175,7 @@ sealed class SpliceStatus {
/** Our peer has asked us to stop sending new updates and wait for our updates to be added to the local and remote commitments. */
data class ReceivedStfu(val stfu: Stfu) : QuiescenceNegotiation.NonInitiator()
/** Our updates have been added to the local and remote commitments, we wait for our peer to use the now quiescent channel. */
object NonInitiatorQuiescent : QuiescentSpliceStatus()
data object NonInitiatorQuiescent : QuiescentSpliceStatus()
/** We told our peer we want to splice funds in the channel. */
data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : QuiescentSpliceStatus()
/** We both agreed to splice and are building the splice transaction. */
Expand All @@ -1181,7 +1188,7 @@ sealed class SpliceStatus {
val origins: List<Origin>
) : QuiescentSpliceStatus()
/** The splice transaction has been negotiated, we're exchanging signatures. */
data class WaitingForSigs(val session: InteractiveTxSigningSession, val origins: List<Origin>) : QuiescentSpliceStatus()
data class WaitingForSigs(val session: InteractiveTxSigningSession, val liquidityLease: LiquidityAds.Lease?, val origins: List<Origin>) : QuiescentSpliceStatus()
/** The splice attempt was aborted by us, we're waiting for our peer to ack. */
data object Aborted : QuiescentSpliceStatus()
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ data class LegacyWaitForFundingLocked(
null,
null,
SpliceStatus.None,
listOf(),
)
val actions = listOf(
ChannelAction.Storage.StoreState(nextState),
Expand Down
28 changes: 20 additions & 8 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ data class Normal(
val remoteShutdown: Shutdown?,
val closingFeerates: ClosingFeerates?,
val spliceStatus: SpliceStatus,
val liquidityLeases: List<LiquidityAds.Lease>,
) : ChannelStateWithCommitments() {

override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input)
Expand Down Expand Up @@ -179,7 +178,7 @@ data class Normal(
logger.info { "waiting for tx_sigs" }
Pair(this@Normal.copy(spliceStatus = spliceStatus.copy(session = signingSession1)), listOf())
}
is InteractiveTxSigningSessionAction.SendTxSigs -> sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData)
is InteractiveTxSigningSessionAction.SendTxSigs -> sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.liquidityLease, cmd.message.channelData)
}
}
ignoreRetransmittedCommitSig(cmd.message) -> {
Expand Down Expand Up @@ -406,8 +405,8 @@ data class Normal(
add(ChannelAction.Disconnect)
}
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
} else if (spliceStatus.command.requestRemoteFunding?.let { r -> r.rate.fees(spliceStatus.command.feerate, r.fundingAmount, r.fundingAmount).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } == false) {
val missing = spliceStatus.command.requestRemoteFunding.let { r -> r.rate.fees(spliceStatus.command.feerate, r.fundingAmount, r.fundingAmount).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
} else if (!canAffordSpliceLiquidityFees(spliceStatus.command, parentCommitment)) {
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds)
Pair(this@Normal, emptyList())
Expand All @@ -419,7 +418,7 @@ data class Normal(
feerate = spliceStatus.command.feerate,
fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1),
pushAmount = spliceStatus.command.pushAmount,
requestFunds = spliceStatus.command.requestRemoteFunding?.requestFunds,
requestFunding = spliceStatus.command.requestRemoteFunding,
)
logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount}" }
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Requested(spliceStatus.command, spliceInit)), listOf(ChannelAction.Message.Send(spliceInit)))
Expand Down Expand Up @@ -642,7 +641,7 @@ data class Normal(
liquidityLease = spliceStatus.liquidityLease,
)
)
val nextState = this@Normal.copy(spliceStatus = SpliceStatus.WaitingForSigs(session, spliceStatus.origins))
val nextState = this@Normal.copy(spliceStatus = SpliceStatus.WaitingForSigs(session, spliceStatus.liquidityLease, spliceStatus.origins))
val actions = buildList {
interactiveTxAction.txComplete?.let { add(ChannelAction.Message.Send(it)) }
add(ChannelAction.Storage.StoreState(nextState))
Expand Down Expand Up @@ -674,7 +673,7 @@ data class Normal(
}
is Either.Right -> {
val action: InteractiveTxSigningSessionAction.SendTxSigs = res.value
sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData)
sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.liquidityLease, cmd.message.channelData)
}
}
}
Expand Down Expand Up @@ -840,6 +839,19 @@ data class Normal(
}
}

private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean {
return when (val request = splice.requestRemoteFunding) {
null -> true
else -> when (request.paymentDetails) {
is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
// Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs.
is LiquidityAds.PaymentDetails.FromFutureHtlc -> true
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true
}
}
}

private fun ChannelContext.sendSpliceTxSigs(
origins: List<Origin>,
action: InteractiveTxSigningSessionAction.SendTxSigs,
Expand All @@ -851,7 +863,7 @@ data class Normal(
val fundingMinDepth = Helpers.minDepthForFunding(staticParams.nodeParams, action.fundingTx.fundingParams.fundingAmount)
val watchConfirmed = WatchConfirmed(channelId, action.commitment.fundingTxId, action.commitment.commitInput.txOut.publicKeyScript, fundingMinDepth.toLong(), BITCOIN_FUNDING_DEPTHOK)
val commitments = commitments.add(action.commitment).copy(remoteChannelData = remoteChannelData)
val nextState = this@Normal.copy(commitments = commitments, spliceStatus = SpliceStatus.None, liquidityLeases = liquidityLeases + listOfNotNull(liquidityLease))
val nextState = this@Normal.copy(commitments = commitments, spliceStatus = SpliceStatus.None)
val actions = buildList {
add(ChannelAction.Storage.StoreState(nextState))
action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) }
Expand Down
Loading

0 comments on commit 0fb2000

Please sign in to comment.