Skip to content

Commit

Permalink
Unlock swap-in utxos if initial channel creation fails
Browse files Browse the repository at this point in the history
Add a `replyTo` field when opening or accepting a channel. This is used
for swaps, to free utxos in case a failure happens during funding.
Without this mechanism, the user needs to restart the app to be able to
reuse those utxos.

Fixes #680
  • Loading branch information
t-bast committed Sep 26, 2024
1 parent 7750d05 commit 6b8162d
Show file tree
Hide file tree
Showing 14 changed files with 223 additions and 109 deletions.
66 changes: 35 additions & 31 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ sealed class ChannelCommand {
data object Disconnected : ChannelCommand()
sealed class Init : ChannelCommand() {
data class Initiator(
val replyTo: CompletableDeferred<ChannelFundingResponse>,
val fundingAmount: Satoshi,
val pushAmount: MilliSatoshi,
val walletInputs: List<WalletState.Utxo>,
Expand All @@ -42,6 +43,7 @@ sealed class ChannelCommand {
}

data class NonInitiator(
val replyTo: CompletableDeferred<ChannelFundingResponse>,
val temporaryChannelId: ByteVector32,
val fundingAmount: Satoshi,
val pushAmount: MilliSatoshi,
Expand Down Expand Up @@ -88,7 +90,7 @@ sealed class ChannelCommand {
data object CheckHtlcTimeout : Commitment()
sealed class Splice : Commitment() {
data class Request(
val replyTo: CompletableDeferred<Response>,
val replyTo: CompletableDeferred<ChannelFundingResponse>,
val spliceIn: SpliceIn?,
val spliceOut: SpliceOut?,
val requestRemoteFunding: LiquidityAds.RequestFunding?,
Expand All @@ -102,36 +104,6 @@ sealed class ChannelCommand {
data class SpliceIn(val walletInputs: List<WalletState.Utxo>, val pushAmount: MilliSatoshi = 0.msat)
data class SpliceOut(val amount: Satoshi, val scriptPubKey: ByteVector)
}

sealed class Response {
/**
* This response doesn't fully guarantee that the splice will confirm, because our peer may potentially double-spend
* the splice transaction. Callers should wait for on-chain confirmations and handle double-spend events.
*/
data class Created(
val channelId: ByteVector32,
val fundingTxIndex: Long,
val fundingTxId: TxId,
val capacity: Satoshi,
val balance: MilliSatoshi,
val liquidityPurchase: LiquidityAds.Purchase?,
) : Response()

sealed class Failure : Response() {
data class InsufficientFunds(val balanceAfterFees: MilliSatoshi, val liquidityFees: MilliSatoshi, val currentFeeCredit: MilliSatoshi) : Failure()
data object InvalidSpliceOutPubKeyScript : Failure()
data object SpliceAlreadyInProgress : Failure()
data object ConcurrentRemoteSplice : Failure()
data object ChannelNotQuiescent : Failure()
data class InvalidLiquidityAds(val reason: ChannelException) : Failure()
data class FundingFailure(val reason: FundingContributionFailure) : Failure()
data object CannotStartSession : Failure()
data class InteractiveTxSessionFailed(val reason: InteractiveTxSessionAction.RemoteFailure) : Failure()
data class CannotCreateCommitTx(val reason: ChannelException) : Failure()
data class AbortedByPeer(val reason: String) : Failure()
data object Disconnected : Failure()
}
}
}
}

Expand All @@ -144,4 +116,36 @@ sealed class ChannelCommand {
data class GetHtlcInfosResponse(val revokedCommitTxId: TxId, val htlcInfos: List<ChannelAction.Storage.HtlcInfo>) : Closing()
}
// @formatter:on
}

sealed class ChannelFundingResponse {
/**
* This response doesn't fully guarantee that the channel transaction will confirm, because our peer may potentially double-spend it.
* Callers should wait for on-chain confirmations and handle double-spend events.
*/
data class Success(
val channelId: ByteVector32,
val fundingTxIndex: Long,
val fundingTxId: TxId,
val capacity: Satoshi,
val balance: MilliSatoshi,
val liquidityPurchase: LiquidityAds.Purchase?,
) : ChannelFundingResponse()

sealed class Failure : ChannelFundingResponse() {
data class InsufficientFunds(val balanceAfterFees: MilliSatoshi, val liquidityFees: MilliSatoshi, val currentFeeCredit: MilliSatoshi) : Failure()
data object InvalidSpliceOutPubKeyScript : ChannelFundingResponse.Failure()
data object SpliceAlreadyInProgress : Failure()
data object ConcurrentRemoteSplice : Failure()
data object ChannelNotQuiescent : Failure()
data class InvalidChannelParameters(val reason: ChannelException) : Failure()
data class InvalidLiquidityAds(val reason: ChannelException) : Failure()
data class FundingFailure(val reason: FundingContributionFailure) : Failure()
data object CannotStartSession : Failure()
data class InteractiveTxSessionFailed(val reason: InteractiveTxSessionAction.RemoteFailure) : Failure()
data class CannotCreateCommitTx(val reason: ChannelException) : Failure()
data class AbortedByPeer(val reason: String) : Failure()
data class UnexpectedMessage(val msg: LightningMessage) : Failure()
data object Disconnected : Failure()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1201,7 +1201,7 @@ sealed class SpliceStatus {
data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : QuiescentSpliceStatus()
/** We both agreed to splice and are building the splice transaction. */
data class InProgress(
val replyTo: CompletableDeferred<ChannelCommand.Commitment.Splice.Response>?,
val replyTo: CompletableDeferred<ChannelFundingResponse>?,
val spliceSession: InteractiveTxSession,
val localPushAmount: MilliSatoshi,
val remotePushAmount: MilliSatoshi,
Expand Down
36 changes: 18 additions & 18 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ data class Normal(
}
else -> {
logger.warning { "cannot initiate splice, another splice is already in progress" }
cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress)
cmd.replyTo.complete(ChannelFundingResponse.Failure.SpliceAlreadyInProgress)
Pair(this@Normal, emptyList())
}
}
Expand Down Expand Up @@ -370,7 +370,7 @@ data class Normal(
// We could keep track of our splice attempt and merge it with the remote splice instead of cancelling it.
// But this is an edge case that should rarely occur, so it's probably not worth the additional complexity.
logger.warning { "our peer initiated quiescence before us, cancelling our splice attempt" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.ConcurrentRemoteSplice)
Pair(this@Normal.copy(spliceStatus = SpliceStatus.ReceivedStfu(cmd.message)), emptyList())
}
is SpliceStatus.InitiatorQuiescent -> {
Expand Down Expand Up @@ -404,12 +404,12 @@ data class Normal(
val balanceAfterFees = parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() - liquidityFeesOwed
if (balanceAfterFees < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) {
logger.warning { "cannot do splice: insufficient funds (balanceAfterFees=$balanceAfterFees, liquidityFees=$liquidityFees, feeCredit=${spliceStatus.command.currentFeeCredit})" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds(balanceAfterFees, liquidityFees, spliceStatus.command.currentFeeCredit))
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.InsufficientFunds(balanceAfterFees, liquidityFees, spliceStatus.command.currentFeeCredit))
val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message)))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action)
} else if (spliceStatus.command.spliceOut?.scriptPubKey?.let { Helpers.Closing.isValidFinalScriptPubkey(it, allowAnySegwit = true) } == false) {
logger.warning { "cannot do splice: invalid splice-out script" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.InvalidSpliceOutPubKeyScript)
val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message)))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action)
} else {
Expand All @@ -427,7 +427,7 @@ data class Normal(
}
} else {
logger.warning { "cannot initiate splice, channel not quiescent" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.ChannelNotQuiescent)
val actions = buildList {
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceNotQuiescent(channelId).message)))
add(ChannelAction.Disconnect)
Expand All @@ -436,7 +436,7 @@ data class Normal(
}
} else {
logger.warning { "concurrent stfu received and our peer is the channel initiator, cancelling our splice attempt" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.ConcurrentRemoteSplice)
Pair(this@Normal.copy(spliceStatus = SpliceStatus.NonInitiatorQuiescent), emptyList())
}
}
Expand Down Expand Up @@ -525,7 +525,7 @@ data class Normal(
)) {
is Either.Left<ChannelException> -> {
logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds(liquidityPurchase.value))
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.InvalidLiquidityAds(liquidityPurchase.value))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, liquidityPurchase.value.message))))
}
is Either.Right<LiquidityAds.Purchase?> -> {
Expand Down Expand Up @@ -557,7 +557,7 @@ data class Normal(
)) {
is Either.Left -> {
logger.error { "could not create splice contributions: ${fundingContributions.value}" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure(fundingContributions.value))
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.FundingFailure(fundingContributions.value))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message))))
}
is Either.Right -> {
Expand Down Expand Up @@ -589,7 +589,7 @@ data class Normal(
}
else -> {
logger.error { "could not start interactive-tx session: $interactiveTxAction" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.CannotStartSession)
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message))))
}
}
Expand Down Expand Up @@ -629,7 +629,7 @@ data class Normal(
when (signingSession) {
is Either.Left -> {
logger.error(signingSession.value) { "cannot initiate interactive-tx splice signing session" }
spliceStatus.replyTo?.complete(ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx(signingSession.value))
spliceStatus.replyTo?.complete(ChannelFundingResponse.Failure.CannotCreateCommitTx(signingSession.value))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, signingSession.value.message))))
}
is Either.Right -> {
Expand All @@ -639,7 +639,7 @@ data class Normal(
// It is likely that we will restart before the transaction is confirmed, in which case we will lose the replyTo and the ability to notify the caller.
// We should be able to resume the signing steps and complete the splice if we disconnect, so we optimistically notify the caller now.
spliceStatus.replyTo?.complete(
ChannelCommand.Commitment.Splice.Response.Created(
ChannelFundingResponse.Success(
channelId = channelId,
fundingTxIndex = session.fundingTxIndex,
fundingTxId = session.fundingTx.txId,
Expand All @@ -660,7 +660,7 @@ data class Normal(
}
is InteractiveTxSessionAction.RemoteFailure -> {
logger.warning { "interactive-tx failed: $interactiveTxAction" }
spliceStatus.replyTo?.complete(ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed(interactiveTxAction))
spliceStatus.replyTo?.complete(ChannelFundingResponse.Failure.InteractiveTxSessionFailed(interactiveTxAction))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, interactiveTxAction.toString()))))
}
}
Expand Down Expand Up @@ -723,7 +723,7 @@ data class Normal(
is TxAbort -> when (spliceStatus) {
is SpliceStatus.Requested -> {
logger.info { "our peer rejected our splice request: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii()))
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.AbortedByPeer(cmd.message.toAscii()))
val actions = buildList {
add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message)))
addAll(endQuiescence())
Expand All @@ -732,7 +732,7 @@ data class Normal(
}
is SpliceStatus.InProgress -> {
logger.info { "our peer aborted the splice attempt: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" }
spliceStatus.replyTo?.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii()))
spliceStatus.replyTo?.complete(ChannelFundingResponse.Failure.AbortedByPeer(cmd.message.toAscii()))
val actions = buildList {
add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message)))
addAll(endQuiescence())
Expand Down Expand Up @@ -778,7 +778,7 @@ data class Normal(
is CancelOnTheFlyFunding -> when (spliceStatus) {
is SpliceStatus.Requested -> {
logger.info { "our peer rejected our on-the-fly splice request: ascii='${cmd.message.toAscii()}'" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii()))
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.AbortedByPeer(cmd.message.toAscii()))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence())
}
else -> {
Expand Down Expand Up @@ -832,18 +832,18 @@ data class Normal(
is SpliceStatus.None -> SpliceStatus.None
is SpliceStatus.Aborted -> SpliceStatus.None
is SpliceStatus.Requested -> {
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.Disconnected)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.Disconnected)
SpliceStatus.None
}
is SpliceStatus.InProgress -> {
spliceStatus.replyTo?.complete(ChannelCommand.Commitment.Splice.Response.Failure.Disconnected)
spliceStatus.replyTo?.complete(ChannelFundingResponse.Failure.Disconnected)
SpliceStatus.None
}
is SpliceStatus.WaitingForSigs -> spliceStatus
is SpliceStatus.NonInitiatorQuiescent -> SpliceStatus.None
is QuiescenceNegotiation.NonInitiator -> SpliceStatus.None
is QuiescenceNegotiation.Initiator -> {
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.Disconnected)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.Disconnected)
SpliceStatus.None
}
}
Expand Down
Loading

0 comments on commit 6b8162d

Please sign in to comment.