Skip to content

Commit

Permalink
Replace pay_to_open with will_add_htlc
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Jul 19, 2024
1 parent 86253a4 commit 371ac59
Show file tree
Hide file tree
Showing 29 changed files with 1,475 additions and 919 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
5 changes: 4 additions & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ 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 Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : LiquidityEvents
Expand Down
6 changes: 4 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,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 @@ -197,15 +199,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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ 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 UnexpectedLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId) : ChannelException(channelId, "unexpected liquidity ads funding fee for txId=$fundingTxId (transaction not found)")
data class InvalidLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId, val paymentHash: ByteVector32, val expected: Satoshi, val proposed: MilliSatoshi) : ChannelException(channelId, "invalid liquidity ads funding fee for txId=$fundingTxId and paymentHash=$paymentHash (expected $expected, got $proposed)")
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
37 changes: 16 additions & 21 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,17 @@ import fr.acinq.bitcoin.Script.tail
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
import fr.acinq.bitcoin.crypto.musig2.Musig2
import fr.acinq.bitcoin.crypto.musig2.SecretNonce
import fr.acinq.bitcoin.utils.getOrDefault
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.Try
import fr.acinq.bitcoin.utils.getOrDefault
import fr.acinq.bitcoin.utils.runTrying
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.blockchain.electrum.WalletState
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment
import fr.acinq.lightning.crypto.KeyManager
import fr.acinq.lightning.logging.*
import fr.acinq.lightning.transactions.CommitmentSpec
import fr.acinq.lightning.transactions.DirectedHtlc
import fr.acinq.lightning.transactions.Scripts
import fr.acinq.lightning.transactions.SwapInProtocol
import fr.acinq.lightning.transactions.Transactions
import fr.acinq.lightning.logging.MDCLogger
import fr.acinq.lightning.transactions.*
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.*
Expand Down Expand Up @@ -227,7 +222,6 @@ sealed class FundingContributionFailure {
data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" }
data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" }
data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" }
data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" }
data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" }
// @formatter:on
}
Expand All @@ -239,7 +233,14 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List<WalletState.Utxo>, localOutputs: List<TxOut>, targetFeerate: FeeratePerKw): Satoshi {
val weight = computeWeightPaid(isInitiator, commitment, walletInputs, localOutputs)
val fees = Transactions.weight2fee(targetFeerate, weight)
return walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees
return when {
// When buying inbound liquidity, we may not have enough funds in our current balance to pay on-chain fees.
// The maximum amount we can use for on-chain fees is our current balance, which is fine because:
// - this will simply result in a splice transaction with a lower feerate than expected
// - liquidity fees will be paid later from future HTLCs relayed to us
walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi()))
else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees
}
}

/**
Expand Down Expand Up @@ -276,27 +277,19 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn))
}

// We compute the fees that we should pay in the shared transaction.
val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
val weightWithoutChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs)
val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey))))
val feesWithoutChange = totalAmountIn - totalAmountOut
// If we're not the initiator, we don't return an error when we're unable to meet the desired feerate.
if (params.isInitiator && feesWithoutChange < Transactions.weight2fee(params.targetFeerate, weightWithoutChange)) {
return Either.Left(FundingContributionFailure.NotEnoughFees(feesWithoutChange, Transactions.weight2fee(params.targetFeerate, weightWithoutChange)))
}

val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi()
val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi()
if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) {
return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance))
}

val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat))
val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) }
val changeOutput = when (changePubKey) {
null -> listOf()
else -> {
val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey))))
val changeAmount = totalAmountIn - totalAmountOut - Transactions.weight2fee(params.targetFeerate, weightWithChange)
if (params.dustLimit <= changeAmount) {
listOf(InteractiveTxOutput.Local.Change(0, changeAmount, Script.write(Script.pay2wpkh(changePubKey)).byteVector()))
Expand Down Expand Up @@ -940,8 +933,10 @@ data class InteractiveTxSession(
return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, nextFeerate)
}
} else {
// We allow the feerate to be lower than requested: when using on-the-fly liquidity, we may not be able to contribute
// as much as we expected, but that's fine because we instead overshoot the feerate and pays liquidity fees accordingly.
val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight())
if (sharedTx.fees < minimumFee) {
if (sharedTx.fees < minimumFee * 0.5) {
return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight()))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,11 @@ data class Normal(
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())
val actions = buildList {
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message)))
add(ChannelAction.Disconnect)
}
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
} else {
val spliceInit = SpliceInit(
channelId,
Expand Down Expand Up @@ -768,6 +772,17 @@ data class Normal(
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
}
}
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()))
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence())
}
else -> {
logger.warning { "received unexpected cancel_on_the_fly_funding (spliceStatus=${spliceStatus::class.simpleName}, message='${cmd.message.toAscii()}')" }
Pair(this@Normal, listOf(ChannelAction.Disconnect))
}
}
is SpliceLocked -> {
when (val res = commitments.run { updateRemoteFundingStatus(cmd.message.fundingTxId) }) {
is Either.Left -> Pair(this@Normal, emptyList())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import fr.acinq.lightning.ChannelEvents
import fr.acinq.lightning.channel.*
import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.wire.AcceptDualFundedChannel
import fr.acinq.lightning.wire.Error
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.lightning.wire.OpenDualFundedChannel
import fr.acinq.lightning.wire.*

/*
* We initiated a channel open and are waiting for our peer to accept it.
Expand Down Expand Up @@ -123,6 +120,11 @@ data class WaitForAcceptChannel(
}
}
}
is CancelOnTheFlyFunding -> {
// Our peer won't accept this on-the-fly funding attempt: they probably already failed the corresponding HTLCs.
logger.warning { "on-the-fly funding was rejected by our peer: ${cmd.message.toAscii()}" }
Pair(Aborted, listOf())
}
is Error -> handleRemoteError(cmd.message)
else -> unhandled(cmd)
}
Expand Down
Loading

0 comments on commit 371ac59

Please sign in to comment.