Skip to content

Commit

Permalink
Replace pay_to_open with maybe_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
`maybe_add_htlc` that contains all the HTLC data.

If the recipient wishes to accept that payment, it reveals the preimage
when sending `open_channel2` or `splice_init` and marks the on-chain
part of that payment as pending. It then keeps retrying the on-chain
funding operation until it completes or our peer sends a dedicated error
asking us to cancel it.
  • Loading branch information
t-bast committed Mar 13, 2024
1 parent ff2191f commit 05b49a8
Show file tree
Hide file tree
Showing 28 changed files with 932 additions and 788 deletions.
30 changes: 24 additions & 6 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 @@ -249,10 +256,19 @@ sealed class Feature {
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init)
}

/** This feature bit should be activated when a node accepts on-the-fly funding using the [MaybeAddHtlc] message. */
@Serializable
object Quiescence : Feature() {
override val rfcName get() = "option_quiescence"
override val mandatory get() = 34
object OnTheFlyFundingClient : Feature() {
override val rfcName get() = "on_the_fly_funding_client"
override val mandatory get() = 156
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init)
}

/** This feature bit should be activated when a node supports on-the-fly funding when liquidity is missing to receive a payment. */
@Serializable
object OnTheFlyFundingProvider : Feature() {
override val rfcName get() = "on_the_fly_funding_provider"
override val mandatory get() = 158
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

Expand Down Expand Up @@ -321,6 +337,7 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.AnchorOutputs,
Feature.ShutdownAnySegwit,
Feature.DualFunding,
Feature.Quiescence,
Feature.ChannelType,
Feature.PaymentMetadata,
Feature.TrampolinePayment,
Expand All @@ -336,7 +353,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.ChannelBackupClient,
Feature.ChannelBackupProvider,
Feature.ExperimentalSplice,
Feature.Quiescence
Feature.OnTheFlyFundingClient,
Feature.OnTheFlyFundingProvider,
)

operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray())
Expand Down
7 changes: 3 additions & 4 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import fr.acinq.lightning.channel.states.Normal
import fr.acinq.lightning.channel.states.WaitForFundingCreated
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.utils.sum
import kotlinx.coroutines.CompletableDeferred

sealed interface NodeEvents

Expand All @@ -32,11 +31,11 @@ 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 class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason()
data class ChannelFundingCancelled(val paymentHash: ByteVector32) : Reason()
}
}

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

/** This is useful on iOS to ask the OS for time to finish some sensitive tasks. */
Expand Down
4 changes: 2 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,16 @@ data class NodeParams(
Feature.StaticRemoteKey to FeatureSupport.Mandatory,
Feature.AnchorOutputs to FeatureSupport.Optional, // can't set Mandatory because peers prefers AnchorOutputsZeroFeeHtlcTx
Feature.DualFunding to FeatureSupport.Mandatory,
Feature.Quiescence to FeatureSupport.Mandatory,
Feature.ShutdownAnySegwit 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.OnTheFlyFundingClient to FeatureSupport.Optional,
),
dustLimit = 546.sat,
maxRemoteDustLimit = 600.sat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,14 @@ sealed class ChannelAction {
abstract val origin: Origin?
abstract val txId: TxId
abstract val localInputs: Set<OutPoint>
/** @param amount amount received after deducing service and mining fees. */
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()
/** @param amount amount received after deducing service and mining fees. */
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?) : StoreIncomingPayment()
data class Cancelled(override val origin: Origin.OffChainPayment) : StoreIncomingPayment() {
override val localInputs: Set<OutPoint> = setOf()
override val txId: TxId = TxId(ByteVector32.Zeroes)
}
}
/** Payment sent through on-chain operations (channel close or splice-out) */
sealed class StoreOutgoingPayment : Storage() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,14 @@ data class TransactionFees(val miningFee: Satoshi, val serviceFee: Satoshi) {
/** Reason for creating a new channel or splicing into an existing channel. */
// @formatter:off
sealed class Origin {
/** Amount of the origin payment, before fees are paid. */
abstract val amount: MilliSatoshi
/** Fees applied for the channel funding transaction. */
abstract val fees: TransactionFees

data class OffChainPayment(val paymentHash: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin()
data class OffChainPayment(val paymentPreimage: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() {
val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage).byteVector32()
}
data class OnChainWallet(val inputs: Set<OutPoint>, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin()
}
// @formatter:on
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ data class InvalidLiquidityAdsSig (override val channelId: Byte
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 CancelOnTheFlyFunding (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting on-the-fly funding: payment timed out and should be cancelled")
data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted")
data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted")
data class DualFundingAborted (override val channelId: ByteVector32, val reason: String) : ChannelException(channelId, "dual funding aborted: $reason")
Expand Down Expand Up @@ -63,7 +64,6 @@ data class InvalidHtlcSignature (override val channelId: Byte
data class InvalidCloseSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid close signature: txId=$txId")
data class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid closing tx: some outputs are below dust: txId=$txId")
data class CommitSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "commit sig count mismatch: expected=$expected actual=$actual")
data class SwapInSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "swap-in sig count mismatch: expected=$expected actual=$actual")
data class HtlcSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "htlc sig count mismatch: expected=$expected actual: $actual")
data class ForcedLocalCommit (override val channelId: ByteVector32) : ChannelException(channelId, "forced local commit")
data class UnexpectedHtlcId (override val channelId: ByteVector32, val expected: Long, val actual: Long) : ChannelException(channelId, "unexpected htlc id: expected=$expected actual=$actual")
Expand Down
19 changes: 6 additions & 13 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -222,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 Down Expand Up @@ -271,27 +270,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 @@ -936,8 +927,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 Expand Up @@ -1163,7 +1156,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 Down
Loading

0 comments on commit 05b49a8

Please sign in to comment.