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 Jul 18, 2024
1 parent 850f884 commit d8f653b
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 108 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 @@ -87,43 +89,13 @@ 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.RequestFunding?, val feerate: FeeratePerKw, val origins: List<Origin>) : Splice() {
data class Request(val replyTo: CompletableDeferred<ChannelFundingResponse>, 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()

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 object InsufficientFunds : 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 @@ -136,4 +108,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 object InsufficientFunds : 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 All @@ -391,15 +391,15 @@ data class Normal(
}
if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) {
logger.warning { "cannot do splice: insufficient funds" }
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds)
spliceStatus.command.replyTo.complete(ChannelFundingResponse.Failure.InsufficientFunds)
val actions = buildList {
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message)))
add(ChannelAction.Disconnect)
}
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
} 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 actions = buildList {
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message)))
add(ChannelAction.Disconnect)
Expand All @@ -420,7 +420,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 @@ -429,7 +429,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 @@ -517,7 +517,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 @@ -549,7 +549,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 @@ -581,7 +581,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 @@ -621,7 +621,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 @@ -631,7 +631,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 @@ -652,7 +652,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 @@ -715,7 +715,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 @@ -724,7 +724,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 @@ -770,7 +770,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 @@ -824,18 +824,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 d8f653b

Please sign in to comment.