From 1912e975434f052bb1b1c4b5c7c5447d14d9a92c Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Wed, 13 Dec 2023 19:27:44 +0100 Subject: [PATCH] Liquidity Ads (#561) Implement a prototype for liquidity ads, compatible with https://github.com/ACINQ/eclair/pull/2550 Note that we only implement the buyer side, which limits testing. The specification is available here: https://github.com/lightning/bolts/pull/878 We currently don't add CLTV locks to the commitment transactions, for simplicity's sake. --- .../acinq/lightning/channel/ChannelAction.kt | 1 + .../acinq/lightning/channel/ChannelCommand.kt | 13 +- .../lightning/channel/ChannelException.kt | 4 + .../fr/acinq/lightning/channel/Helpers.kt | 4 + .../acinq/lightning/channel/InteractiveTx.kt | 22 ++- .../states/LegacyWaitForFundingLocked.kt | 3 +- .../acinq/lightning/channel/states/Normal.kt | 183 +++++++++++------- .../channel/states/WaitForChannelReady.kt | 3 +- .../channel/states/WaitForFundingConfirmed.kt | 1 + .../channel/states/WaitForFundingCreated.kt | 1 + .../fr/acinq/lightning/db/PaymentsDb.kt | 16 ++ .../kotlin/fr/acinq/lightning/io/Peer.kt | 65 ++++++- .../acinq/lightning/json/JsonSerializers.kt | 16 +- .../serialization/v2/ChannelState.kt | 3 +- .../serialization/v3/ChannelState.kt | 3 +- .../serialization/v4/Deserialization.kt | 87 ++++++--- .../serialization/v4/Serialization.kt | 83 ++++++-- .../fr/acinq/lightning/wire/ChannelTlv.kt | 50 +++++ .../kotlin/fr/acinq/lightning/wire/InitTlv.kt | 19 ++ .../acinq/lightning/wire/LightningMessages.kt | 50 +++-- .../fr/acinq/lightning/wire/LiquidityAds.kt | 159 +++++++++++++++ .../channel/states/SpliceTestsCommon.kt | 71 +++++++ .../StateSerializationTestsCommon.kt | 51 ++++- .../wire/LightningCodecsTestsCommon.kt | 66 ++++++- .../nonreg/v2/Normal_748a735b/data.json | 4 +- .../nonreg/v2/Normal_e2253ddd/data.json | 4 +- .../nonreg/v2/Normal_ff248f8d/data.json | 4 +- .../nonreg/v2/Normal_ff4a71b6/data.json | 4 +- .../nonreg/v2/Normal_ffd9f5db/data.json | 4 +- .../nonreg/v3/Normal_fd10d3cc/data.json | 4 +- .../nonreg/v3/Normal_fe897b64/data.json | 4 +- .../nonreg/v3/Normal_ff248f8d/data.json | 4 +- .../nonreg/v3/Normal_ff4a71b6/data.json | 4 +- 33 files changed, 837 insertions(+), 173 deletions(-) create mode 100644 src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index 518971e47..7a2689776 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -87,6 +87,7 @@ sealed class ChannelAction { abstract val txId: TxId data class ViaSpliceOut(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId) : StoreOutgoingPayment() data class ViaSpliceCpfp(override val miningFees: Satoshi, override val txId: TxId) : StoreOutgoingPayment() + data class ViaInboundLiquidityRequest(override val txId: TxId, override val miningFees: Satoshi, val lease: LiquidityAds.Lease) : StoreOutgoingPayment() data class ViaClose(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId, val isSentToDefaultAddress: Boolean, val closingType: ChannelClosingType) : StoreOutgoingPayment() } data class SetLocked(val txId: TxId) : Storage() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 5791eb484..9cbe825ae 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -13,6 +13,7 @@ import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.FailureMessage import fr.acinq.lightning.wire.LightningMessage +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OnionRoutingPacket import kotlinx.coroutines.CompletableDeferred import fr.acinq.lightning.wire.Init as InitMessage @@ -83,7 +84,7 @@ sealed class ChannelCommand { data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice object CheckHtlcTimeout : Commitment() sealed class Splice : Commitment() { - data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val feerate: FeeratePerKw, val origins: List = emptyList()) : Splice() { + data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestRemoteFunding?, val feerate: FeeratePerKw, val origins: List = emptyList()) : Splice() { val pushAmount: MilliSatoshi = spliceIn?.pushAmount ?: 0.msat val spliceOutputs: List = spliceOut?.let { listOf(TxOut(it.amount, it.scriptPubKey)) } ?: emptyList() @@ -91,6 +92,12 @@ sealed class ChannelCommand { data class SpliceOut(val amount: Satoshi, val scriptPubKey: ByteVector) } + /** + * @param miningFee on-chain fee that will be paid for the splice transaction. + * @param serviceFee service-fee that will be paid to the remote node for a service they provide with the splice transaction. + */ + data class Fees(val miningFee: Satoshi, val serviceFee: MilliSatoshi) + sealed class Response { /** * This response doesn't fully guarantee that the splice will confirm, because our peer may potentially double-spend @@ -101,7 +108,8 @@ sealed class ChannelCommand { val fundingTxIndex: Long, val fundingTxId: TxId, val capacity: Satoshi, - val balance: MilliSatoshi + val balance: MilliSatoshi, + val liquidityLease: LiquidityAds.Lease?, ) : Response() sealed class Failure : Response() { @@ -109,6 +117,7 @@ sealed class ChannelCommand { object InvalidSpliceOutPubKeyScript : Failure() object SpliceAlreadyInProgress : Failure() object ChannelNotIdle : Failure() + data class InvalidLiquidityAds(val reason: ChannelException) : Failure() data class FundingFailure(val reason: FundingContributionFailure) : Failure() object CannotStartSession : Failure() data class InteractiveTxSessionFailed(val reason: InteractiveTxSessionAction.RemoteFailure) : Failure() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 970ce5967..75ae81213 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -25,6 +25,10 @@ data class MissingChannelType (override val channelId: Byte data class DustLimitTooSmall (override val channelId: ByteVector32, val dustLimit: Satoshi, val min: Satoshi) : ChannelException(channelId, "dustLimit=$dustLimit is too small (min=$min)") data class DustLimitTooLarge (override val channelId: ByteVector32, val dustLimit: Satoshi, val max: Satoshi) : ChannelException(channelId, "dustLimit=$dustLimit is too large (max=$max)") data class ToSelfDelayTooHigh (override val channelId: ByteVector32, val toSelfDelay: CltvExpiryDelta, val max: CltvExpiryDelta) : ChannelException(channelId, "unreasonable to_self_delay=$toSelfDelay (max=$max)") +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") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index 73c70f532..253510421 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -257,6 +257,10 @@ object Helpers { } } + fun makeFundingPubKeyScript(localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey): ByteVector { + return write(pay2wsh(multiSig2of2(localFundingPubkey, remoteFundingPubkey))).toByteVector() + } + fun makeFundingInputInfo( fundingTxId: TxId, fundingTxOutputIndex: Int, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index f68fa92a7..301f4aa7f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -86,7 +86,7 @@ data class InteractiveTxParams( fun fundingPubkeyScript(channelKeys: KeyManager.ChannelKeys): ByteVector { val fundingTxIndex = (sharedInput as? SharedFundingInput.Multisig2of2)?.let { it.fundingTxIndex + 1 } ?: 0 - return Script.write(Script.pay2wsh(Scripts.multiSig2of2(channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey))).toByteVector() + return Helpers.Funding.makeFundingPubKeyScript(channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey) } } @@ -751,8 +751,9 @@ data class InteractiveTxSigningSession( val fundingParams: InteractiveTxParams, val fundingTxIndex: Long, val fundingTx: PartiallySignedSharedTransaction, + val liquidityLease: LiquidityAds.Lease?, val localCommit: Either, - val remoteCommit: RemoteCommit + val remoteCommit: RemoteCommit, ) { // Example flow: @@ -826,6 +827,7 @@ data class InteractiveTxSigningSession( sharedTx: SharedTransaction, localPushAmount: MilliSatoshi, remotePushAmount: MilliSatoshi, + liquidityLease: LiquidityAds.Lease?, localCommitmentIndex: Long, remoteCommitmentIndex: Long, commitTxFeerate: FeeratePerKw, @@ -834,13 +836,14 @@ 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 return Helpers.Funding.makeCommitTxsWithoutHtlcs( channelKeys, channelParams.channelId, channelParams.localParams, channelParams.remoteParams, fundingAmount = sharedTx.sharedOutput.amount, - toLocal = sharedTx.sharedOutput.localAmount - localPushAmount + remotePushAmount, - toRemote = sharedTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount, + toLocal = sharedTx.sharedOutput.localAmount - localPushAmount + remotePushAmount - liquidityFees, + toRemote = sharedTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount + liquidityFees, localCommitmentIndex = localCommitmentIndex, remoteCommitmentIndex = remoteCommitmentIndex, commitTxFeerate, @@ -869,7 +872,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(keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId) - Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, Either.Left(unsignedLocalCommit), remoteCommit), commitSig) + Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, liquidityLease, Either.Left(unsignedLocalCommit), remoteCommit), commitSig) } } @@ -900,7 +903,14 @@ sealed class RbfStatus { sealed class SpliceStatus { object None : SpliceStatus() data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : SpliceStatus() - data class InProgress(val replyTo: CompletableDeferred?, val spliceSession: InteractiveTxSession, val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, val origins: List) : SpliceStatus() + data class InProgress( + val replyTo: CompletableDeferred?, + val spliceSession: InteractiveTxSession, + val localPushAmount: MilliSatoshi, + val remotePushAmount: MilliSatoshi, + val liquidityLease: LiquidityAds.Lease?, + val origins: List + ) : SpliceStatus() data class WaitingForSigs(val session: InteractiveTxSigningSession, val origins: List) : SpliceStatus() object Aborted : SpliceStatus() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt index 24feabf49..63f1e41f5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt @@ -47,7 +47,8 @@ data class LegacyWaitForFundingLocked( null, null, null, - SpliceStatus.None + SpliceStatus.None, + listOf(), ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index a0e145cef..ef8f10e83 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -24,7 +24,8 @@ data class Normal( val localShutdown: Shutdown?, val remoteShutdown: Shutdown?, val closingFeerates: ClosingFeerates?, - val spliceStatus: SpliceStatus + val spliceStatus: SpliceStatus, + val liquidityLeases: List, ) : ChannelStateWithCommitments() { override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) @@ -116,6 +117,11 @@ data class Normal( logger.warning { "cannot do splice: invalid splice-out script" } cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript) Pair(this@Normal, emptyList()) + } else if (cmd.requestRemoteFunding?.let { r -> r.rate.fees(cmd.feerate, r.fundingAmount, r.fundingAmount).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } == false) { + val missing = cmd.requestRemoteFunding.let { r -> r.rate.fees(cmd.feerate, r.fundingAmount, r.fundingAmount).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } + logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" } + cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) + Pair(this@Normal, emptyList()) } else { val spliceInit = SpliceInit( channelId, @@ -123,9 +129,10 @@ data class Normal( lockTime = currentBlockHeight.toLong(), feerate = cmd.feerate, fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), - pushAmount = cmd.pushAmount + pushAmount = cmd.pushAmount, + requestFunds = cmd.requestRemoteFunding?.requestFunds, ) - logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount}" } + logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount} requesting ${cmd.requestRemoteFunding?.fundingAmount ?: 0.sat} from our peer" } Pair(this@Normal.copy(spliceStatus = SpliceStatus.Requested(cmd, spliceInit)), listOf(ChannelAction.Message.Send(spliceInit))) } } else { @@ -192,7 +199,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, cmd.message.channelData) + is InteractiveTxSigningSessionAction.SendTxSigs -> sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData) } } ignoreRetransmittedCommitSig(cmd.message) -> { @@ -356,6 +363,7 @@ data class Normal( fundingContribution = 0.sat, // only remote contributes to the splice pushAmount = 0.msat, fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), + willFund = null, ) val fundingParams = InteractiveTxParams( channelId = channelId, @@ -378,7 +386,16 @@ data class Normal( fundingContributions = FundingContributions(emptyList(), emptyList()), // as non-initiator we don't contribute to this splice for now previousTxs = emptyList() ) - val nextState = this@Normal.copy(spliceStatus = SpliceStatus.InProgress(replyTo = null, session, localPushAmount = 0.msat, remotePushAmount = cmd.message.pushAmount, origins = cmd.message.origins)) + val nextState = this@Normal.copy( + spliceStatus = SpliceStatus.InProgress( + replyTo = null, + session, + localPushAmount = 0.msat, + remotePushAmount = cmd.message.pushAmount, + liquidityLease = null, + origins = cmd.message.origins + ) + ) Pair(nextState, listOf(ChannelAction.Message.Send(spliceAck))) } else { logger.info { "rejecting splice attempt: channel is not idle" } @@ -396,62 +413,80 @@ data class Normal( is SpliceAck -> when (spliceStatus) { is SpliceStatus.Requested -> { logger.info { "our peer accepted our splice request and will contribute ${cmd.message.fundingContribution} to the funding transaction" } - val parentCommitment = commitments.active.first() - val sharedInput = SharedFundingInput.Multisig2of2(parentCommitment) - val fundingParams = InteractiveTxParams( - channelId = channelId, - isInitiator = true, - localContribution = spliceStatus.spliceInit.fundingContribution, - remoteContribution = cmd.message.fundingContribution, - sharedInput = sharedInput, - remoteFundingPubkey = cmd.message.fundingPubkey, - localOutputs = spliceStatus.command.spliceOutputs, - lockTime = spliceStatus.spliceInit.lockTime, - dustLimit = commitments.params.localParams.dustLimit.max(commitments.params.remoteParams.dustLimit), - targetFeerate = spliceStatus.spliceInit.feerate - ) - when (val fundingContributions = FundingContributions.create( - channelKeys = channelKeys(), - swapInKeys = keyManager.swapInOnChainWallet, - params = fundingParams, - sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote)), - walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), - localOutputs = spliceStatus.command.spliceOutputs, - changePubKey = null // we don't want a change output: we're spending every funds available + when (val liquidityLease = LiquidityAds.validateLease( + spliceStatus.command.requestRemoteFunding, + remoteNodeId, + channelId, + Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey), + cmd.message.fundingContribution, + spliceStatus.spliceInit.feerate, + cmd.message.willFund, )) { is Either.Left -> { - logger.error { "could not create splice contributions: ${fundingContributions.value}" } - spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure(fundingContributions.value)) - Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message)))) + logger.error { "rejecting liquidity proposal: ${liquidityLease.value.message}" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds(liquidityLease.value)) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, liquidityLease.value.message)))) } is Either.Right -> { - // The splice initiator always sends the first interactive-tx message. - val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession( - channelKeys(), - keyManager.swapInOnChainWallet, - fundingParams, - previousLocalBalance = parentCommitment.localCommit.spec.toLocal, - previousRemoteBalance = parentCommitment.localCommit.spec.toRemote, - fundingContributions.value, previousTxs = emptyList() - ).send() - when (interactiveTxAction) { - is InteractiveTxSessionAction.SendMessage -> { - val nextState = this@Normal.copy( - spliceStatus = SpliceStatus.InProgress( - replyTo = spliceStatus.command.replyTo, - interactiveTxSession, - localPushAmount = spliceStatus.spliceInit.pushAmount, - remotePushAmount = cmd.message.pushAmount, - origins = spliceStatus.spliceInit.origins - ) - ) - Pair(nextState, listOf(ChannelAction.Message.Send(interactiveTxAction.msg))) - } - else -> { - logger.error { "could not start interactive-tx session: $interactiveTxAction" } - spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession) + val parentCommitment = commitments.active.first() + val sharedInput = SharedFundingInput.Multisig2of2(parentCommitment) + val fundingParams = InteractiveTxParams( + channelId = channelId, + isInitiator = true, + localContribution = spliceStatus.spliceInit.fundingContribution, + remoteContribution = cmd.message.fundingContribution, + sharedInput = sharedInput, + remoteFundingPubkey = cmd.message.fundingPubkey, + localOutputs = spliceStatus.command.spliceOutputs, + lockTime = spliceStatus.spliceInit.lockTime, + dustLimit = commitments.params.localParams.dustLimit.max(commitments.params.remoteParams.dustLimit), + targetFeerate = spliceStatus.spliceInit.feerate + ) + when (val fundingContributions = FundingContributions.create( + channelKeys = channelKeys(), + swapInKeys = keyManager.swapInOnChainWallet, + params = fundingParams, + sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote)), + walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), + localOutputs = spliceStatus.command.spliceOutputs, + changePubKey = null // we don't want a change output: we're spending every funds available + )) { + is Either.Left -> { + logger.error { "could not create splice contributions: ${fundingContributions.value}" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure(fundingContributions.value)) Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message)))) } + is Either.Right -> { + // The splice initiator always sends the first interactive-tx message. + val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession( + channelKeys(), + keyManager.swapInOnChainWallet, + fundingParams, + previousLocalBalance = parentCommitment.localCommit.spec.toLocal, + previousRemoteBalance = parentCommitment.localCommit.spec.toRemote, + fundingContributions.value, previousTxs = emptyList() + ).send() + when (interactiveTxAction) { + is InteractiveTxSessionAction.SendMessage -> { + val nextState = this@Normal.copy( + spliceStatus = SpliceStatus.InProgress( + replyTo = spliceStatus.command.replyTo, + interactiveTxSession, + localPushAmount = spliceStatus.spliceInit.pushAmount, + remotePushAmount = cmd.message.pushAmount, + liquidityLease = liquidityLease.value, + origins = spliceStatus.spliceInit.origins + ) + ) + Pair(nextState, listOf(ChannelAction.Message.Send(interactiveTxAction.msg))) + } + else -> { + logger.error { "could not start interactive-tx session: $interactiveTxAction" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message)))) + } + } + } } } } @@ -476,6 +511,7 @@ data class Normal( interactiveTxAction.sharedTx, localPushAmount = spliceStatus.localPushAmount, remotePushAmount = spliceStatus.remotePushAmount, + liquidityLease = spliceStatus.liquidityLease, localCommitmentIndex = parentCommitment.localCommit.index, remoteCommitmentIndex = parentCommitment.remoteCommit.index, parentCommitment.localCommit.spec.feerate, @@ -499,7 +535,8 @@ data class Normal( fundingTxIndex = session.fundingTxIndex, fundingTxId = session.fundingTx.txId, capacity = session.fundingParams.fundingAmount, - balance = session.localCommit.fold({ it.spec }, { it.spec }).toLocal + balance = session.localCommit.fold({ it.spec }, { it.spec }).toLocal, + liquidityLease = spliceStatus.liquidityLease, ) ) val nextState = this@Normal.copy(spliceStatus = SpliceStatus.WaitingForSigs(session, spliceStatus.origins)) @@ -534,7 +571,7 @@ data class Normal( } is Either.Right -> { val action: InteractiveTxSigningSessionAction.SendTxSigs = res.value - sendSpliceTxSigs(spliceStatus.origins, action, cmd.message.channelData) + sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData) } } } @@ -681,13 +718,18 @@ data class Normal( } } - private fun ChannelContext.sendSpliceTxSigs(origins: List, action: InteractiveTxSigningSessionAction.SendTxSigs, remoteChannelData: EncryptedChannelData): Pair> { + private fun ChannelContext.sendSpliceTxSigs( + origins: List, + action: InteractiveTxSigningSessionAction.SendTxSigs, + liquidityLease: LiquidityAds.Lease?, + remoteChannelData: EncryptedChannelData + ): Pair> { logger.info { "sending tx_sigs" } // We watch for confirmation in all cases, to allow pruning outdated commitments when transactions confirm. 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) + val nextState = this@Normal.copy(commitments = commitments, spliceStatus = SpliceStatus.None, liquidityLeases = liquidityLeases + listOfNotNull(liquidityLease)) val actions = buildList { add(ChannelAction.Storage.StoreState(nextState)) action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) } @@ -707,9 +749,9 @@ data class Normal( // If we added some funds ourselves it's a swap-in if (action.fundingTx.sharedTx.tx.localInputs.isNotEmpty()) add( ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = action.fundingTx.sharedTx.tx.localInputs.map { i -> i.txOut.amount }.sum().toMilliSatoshi() - action.fundingTx.sharedTx.tx.fees.toMilliSatoshi(), + amount = action.fundingTx.sharedTx.tx.localInputs.map { i -> i.txOut.amount }.sum().toMilliSatoshi() - action.fundingTx.sharedTx.tx.localFees, serviceFee = 0.msat, - miningFee = action.fundingTx.sharedTx.tx.fees, + miningFee = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), txId = action.fundingTx.txId, origin = null @@ -718,18 +760,21 @@ data class Normal( addAll(action.fundingTx.fundingParams.localOutputs.map { txOut -> ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceOut( amount = txOut.amount, - miningFees = action.fundingTx.sharedTx.tx.fees, + miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), address = Bitcoin.addressFromPublicKeyScript(staticParams.nodeParams.chainHash, txOut.publicKeyScript.toByteArray()).result ?: "unknown", txId = action.fundingTx.txId ) }) - // If we initiated the splice but there are no new inputs or outputs, it's a cpfp - if (action.fundingTx.fundingParams.isInitiator && action.fundingTx.sharedTx.tx.localInputs.isEmpty() && action.fundingTx.fundingParams.localOutputs.isEmpty()) add( - ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceCpfp( - miningFees = action.fundingTx.sharedTx.tx.fees, - txId = action.fundingTx.txId - ) - ) + // If we initiated the splice but there are no new inputs on either side and no new output on our side, it's a cpfp + if (action.fundingTx.fundingParams.isInitiator && action.fundingTx.sharedTx.tx.localInputs.isEmpty() && action.fundingTx.sharedTx.tx.remoteInputs.isEmpty() && action.fundingTx.fundingParams.localOutputs.isEmpty()) { + add(ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceCpfp(miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), txId = action.fundingTx.txId)) + } + liquidityLease?.let { lease -> + // The actual mining fees contain the inputs and outputs we paid for in the interactive-tx transaction, + // and what we refunded the remote peer for some of their inputs and outputs via the lease. + val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + lease.fees.miningFee + add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = lease)) + } if (staticParams.useZeroConf) { logger.info { "channel is using 0-conf, sending splice_locked right away" } val spliceLocked = SpliceLocked(channelId, action.fundingTx.txId) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt index f4949101a..9df8f13f4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt @@ -80,7 +80,8 @@ data class WaitForChannelReady( null, null, null, - SpliceStatus.None + SpliceStatus.None, + listOf(), ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt index e4b6e9c1d..5730f9604 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt @@ -176,6 +176,7 @@ data class WaitForFundingConfirmed( interactiveTxAction.sharedTx, localPushAmount, remotePushAmount, + liquidityLease = null, localCommitmentIndex = replacedCommitment.localCommit.index, remoteCommitmentIndex = replacedCommitment.remoteCommit.index, replacedCommitment.localCommit.spec.feerate, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt index 209848c21..7facc1db0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt @@ -62,6 +62,7 @@ data class WaitForFundingCreated( interactiveTxAction.sharedTx, localPushAmount, remotePushAmount, + liquidityLease = null, localCommitmentIndex = 0, remoteCommitmentIndex = 0, commitTxFeerate, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index c453ffb19..cccd4cd02 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -8,6 +8,7 @@ import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.FailureMessage +import fr.acinq.lightning.wire.LiquidityAds interface PaymentsDb : IncomingPaymentsDb, OutgoingPaymentsDb { /** @@ -387,6 +388,21 @@ data class SpliceCpfpOutgoingPayment( override val completedAt: Long? = confirmedAt } +data class InboundLiquidityOutgoingPayment( + override val id: UUID, + override val channelId: ByteVector32, + override val txId: TxId, + override val miningFees: Satoshi, + val lease: LiquidityAds.Lease, + override val createdAt: Long, + override val confirmedAt: Long?, + override val lockedAt: Long?, +) : OnChainOutgoingPayment() { + override val fees: MilliSatoshi = (miningFees + lease.fees.serviceFee).toMilliSatoshi() + override val amount: MilliSatoshi = fees + override val completedAt: Long? = confirmedAt +} + enum class ChannelClosingType { Mutual, Local, Remote, Revoked, Other; } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index c5a8ad8de..68d4060d8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -168,6 +168,7 @@ class Peer( val currentTipFlow = MutableStateFlow?>(null) val onChainFeeratesFlow = MutableStateFlow(null) val swapInFeeratesFlow = MutableStateFlow(null) + val liquidityRatesFlow = MutableStateFlow(null) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -507,13 +508,14 @@ class Peer( * Estimate the actual feerate to use (and corresponding fee to pay) in order to reach the target feerate * for a splice out, taking into account potential unconfirmed parent splices. */ - suspend fun estimateFeeForSpliceOut(amount: Satoshi, scriptPubKey: ByteVector, targetFeerate: FeeratePerKw): Pair? { + suspend fun estimateFeeForSpliceOut(amount: Satoshi, scriptPubKey: ByteVector, targetFeerate: FeeratePerKw): Pair? { return channels.values .filterIsInstance() .firstOrNull { it.commitments.availableBalanceForSend() > amount } ?.let { channel -> val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = listOf(TxOut(amount, scriptPubKey))) - watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) + val (actualFeerate, miningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) + Pair(actualFeerate, ChannelCommand.Commitment.Splice.Fees(miningFee, 0.msat)) } } @@ -525,13 +527,33 @@ class Peer( * NB: if the output feerate is equal to the input feerate then the cpfp is useless and * should not be attempted. */ - suspend fun estimateFeeForSpliceCpfp(channelId: ByteVector32, targetFeerate: FeeratePerKw): Pair? { + suspend fun estimateFeeForSpliceCpfp(channelId: ByteVector32, targetFeerate: FeeratePerKw): Pair? { return channels.values .filterIsInstance() .find { it.channelId == channelId } ?.let { channel -> val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) - watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) + val (actualFeerate, miningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) + Pair(actualFeerate, ChannelCommand.Commitment.Splice.Fees(miningFee, 0.msat)) + } + } + + /** + * Estimate the actual feerate to use (and corresponding fee to pay) to purchase inbound liquidity with a splice + * that reaches the target feerate. + */ + suspend fun estimateFeeForInboundLiquidity(amount: Satoshi, targetFeerate: FeeratePerKw): Pair? { + return channels.values + .filterIsInstance() + .firstOrNull() + ?.let { channel -> + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) + // The mining fee below pays for the shared input and output of the splice transaction. + val (actualFeerate, miningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) + val leaseRate = liquidityRatesFlow.filterNotNull().first { it.leaseDuration == 0 } + // The mining fee in the lease covers the remote node's inputs and outputs, depending on the weight they're requesting. + val leaseFees = leaseRate.fees(actualFeerate, amount, amount) + Pair(actualFeerate, ChannelCommand.Commitment.Splice.Fees(miningFee + leaseFees.miningFee, leaseFees.serviceFee.toMilliSatoshi())) } } @@ -549,6 +571,7 @@ class Peer( replyTo = CompletableDeferred(), spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, scriptPubKey), + requestRemoteFunding = null, feerate = feerate ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) @@ -566,6 +589,26 @@ class Peer( // no additional inputs or outputs, the splice is only meant to bump fees spliceIn = null, spliceOut = null, + requestRemoteFunding = null, + feerate = feerate + ) + send(WrappedChannelCommand(channel.channelId, spliceCommand)) + spliceCommand.replyTo.await() + } + } + + suspend fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw): ChannelCommand.Commitment.Splice.Response? { + return channels.values + .filterIsInstance() + .firstOrNull() + ?.let { channel -> + val leaseStart = currentTipFlow.filterNotNull().first().first + val leaseRate = liquidityRatesFlow.filterNotNull().first { it.leaseDuration == 0 } + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = null, + requestRemoteFunding = LiquidityAds.RequestRemoteFunding(amount, leaseStart, leaseRate), feerate = feerate ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) @@ -697,6 +740,17 @@ class Peer( confirmedAt = null, lockedAt = null ) + is ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest -> + InboundLiquidityOutgoingPayment( + id = UUID.randomUUID(), + channelId = channelId, + txId = action.txId, + miningFees = action.miningFees, + lease = action.lease, + createdAt = currentTimestampMillis(), + confirmedAt = null, + lockedAt = null + ) is ChannelAction.Storage.StoreOutgoingPayment.ViaClose -> ChannelCloseOutgoingPayment( id = UUID.randomUUID(), @@ -825,10 +879,10 @@ class Peer( logger.error(error) { "feature validation error" } // TODO: disconnect peer } - else -> { theirInit = msg _connectionState.value = Connection.ESTABLISHED + msg.liquidityRates.forEach { liquidityRatesFlow.emit(it) } _channels = _channels.mapValues { entry -> val (state1, actions) = entry.value.process(ChannelCommand.Connected(ourInit, theirInit!!)) processActions(entry.key, peerConnection, actions) @@ -1051,6 +1105,7 @@ class Peer( replyTo = CompletableDeferred(), spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs), spliceOut = null, + requestRemoteFunding = null, feerate = feerate ) // If the splice fails, we immediately unlock the utxos to reuse them in the next attempt. diff --git a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 068288b85..a9a9bd3d4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -61,6 +61,9 @@ JsonSerializers.InteractiveTxSigningSessionSerializer::class, JsonSerializers.RbfStatusSerializer::class, JsonSerializers.SpliceStatusSerializer::class, + JsonSerializers.LiquidityLeaseFeesSerializer::class, + JsonSerializers.LiquidityLeaseWitnessSerializer::class, + JsonSerializers.LiquidityLeaseSerializer::class, JsonSerializers.ChannelParamsSerializer::class, JsonSerializers.ChannelOriginSerializer::class, JsonSerializers.CommitmentChangesSerializer::class, @@ -105,7 +108,9 @@ import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.json.JsonSerializers.LongSerializer import fr.acinq.lightning.json.JsonSerializers.StringSerializer import fr.acinq.lightning.json.JsonSerializers.SurrogateSerializer -import fr.acinq.lightning.transactions.* +import fr.acinq.lightning.transactions.CommitmentSpec +import fr.acinq.lightning.transactions.IncomingHtlc +import fr.acinq.lightning.transactions.OutgoingHtlc import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.* @@ -278,6 +283,15 @@ object JsonSerializers { object SpliceStatusSerializer : StringSerializer({ it::class.simpleName!! }) + @Serializer(forClass = LiquidityAds.LeaseFees::class) + object LiquidityLeaseFeesSerializer + + @Serializer(forClass = LiquidityAds.LeaseWitness::class) + object LiquidityLeaseWitnessSerializer + + @Serializer(forClass = LiquidityAds.Lease::class) + object LiquidityLeaseSerializer + @Serializer(forClass = ChannelParams::class) object ChannelParamsSerializer diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt index 686d35035..0236186c2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -510,7 +510,8 @@ internal data class Normal( localShutdown, remoteShutdown, null, - SpliceStatus.None + SpliceStatus.None, + listOf(), ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt index 0a1121c66..dc7becec6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -512,7 +512,8 @@ internal data class Normal( localShutdown, remoteShutdown, closingFeerates?.export(), - SpliceStatus.None + SpliceStatus.None, + listOf(), ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 98db83189..c825933f0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.serialization.v4 import fr.acinq.bitcoin.* import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.io.Input +import fr.acinq.bitcoin.io.readNBytes import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Features import fr.acinq.lightning.ShortChannelId @@ -99,6 +100,13 @@ object Deserialization { origins = readCollection { readChannelOrigin() as Origin.PayToOpenOrigin }.toList() ) else -> error("unknown discriminator $discriminator for class ${SpliceStatus::class}") + }, + liquidityLeases = when { + availableBytes == 0 -> listOf() + else -> when (val discriminator = read()) { + 0x01 -> readCollection { readLiquidityLease() }.toList() + else -> error("unknown discriminator $discriminator for class ${Normal::class}") + } } ) @@ -307,43 +315,62 @@ object Deserialization { else -> error("unknown discriminator $discriminator for class ${SignedSharedTransaction::class}") } - private fun Input.readInteractiveTxSigningSession(): InteractiveTxSigningSession = InteractiveTxSigningSession( - fundingParams = readInteractiveTxParams(), - fundingTxIndex = readNumber(), - fundingTx = readSignedSharedTransaction() as PartiallySignedSharedTransaction, - localCommit = readEither( - readLeft = { - InteractiveTxSigningSession.Companion.UnsignedLocalCommit( - index = readNumber(), - spec = readCommitmentSpecWithHtlcs(), - commitTx = readTransactionWithInputInfo() as CommitTx, - htlcTxs = readCollection { readTransactionWithInputInfo() as HtlcTx }.toList(), - ) - }, - readRight = { - LocalCommit( - index = readNumber(), - spec = readCommitmentSpecWithHtlcs(), - publishableTxs = PublishableTxs( - commitTx = readTransactionWithInputInfo() as CommitTx, - htlcTxsAndSigs = readCollection { - HtlcTxAndSigs( - txinfo = readTransactionWithInputInfo() as HtlcTx, - localSig = readByteVector64(), - remoteSig = readByteVector64() - ) - }.toList() - ) + private fun Input.readUnsignedLocalCommitWithHtlcs(): InteractiveTxSigningSession.Companion.UnsignedLocalCommit = InteractiveTxSigningSession.Companion.UnsignedLocalCommit( + index = readNumber(), + spec = readCommitmentSpecWithHtlcs(), + commitTx = readTransactionWithInputInfo() as CommitTx, + htlcTxs = readCollection { readTransactionWithInputInfo() as HtlcTx }.toList(), + ) + + private fun Input.readLocalCommitWithHtlcs(): LocalCommit = LocalCommit( + index = readNumber(), + spec = readCommitmentSpecWithHtlcs(), + publishableTxs = PublishableTxs( + commitTx = readTransactionWithInputInfo() as CommitTx, + htlcTxsAndSigs = readCollection { + HtlcTxAndSigs( + txinfo = readTransactionWithInputInfo() as HtlcTx, + localSig = readByteVector64(), + remoteSig = readByteVector64() ) - }, + }.toList() + ) + ) + + private fun Input.readLiquidityLease(): LiquidityAds.Lease = LiquidityAds.Lease( + amount = readNumber().sat, + fees = LiquidityAds.LeaseFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), + sellerSig = readByteVector64(), + witness = LiquidityAds.LeaseWitness( + fundingScript = readNBytes(readNumber().toInt())!!.toByteVector(), + leaseDuration = readNumber().toInt(), + leaseEnd = readNumber().toInt(), + maxRelayFeeProportional = readNumber().toInt(), + maxRelayFeeBase = readNumber().msat, ), - remoteCommit = RemoteCommit( + ) + + private fun Input.readInteractiveTxSigningSession(): InteractiveTxSigningSession { + val fundingParams = readInteractiveTxParams() + val fundingTxIndex = readNumber() + val fundingTx = readSignedSharedTransaction() as PartiallySignedSharedTransaction + // liquidityLease and localCommit are logically independent, this is just a serialization trick for backwards + // compatibility since the liquidityLease field was introduced later. + val (liquidityLease, localCommit) = when (val discriminator = read()) { + 0 -> Pair(null, Either.Left(readUnsignedLocalCommitWithHtlcs())) + 1 -> Pair(null, Either.Right(readLocalCommitWithHtlcs())) + 2 -> Pair(readLiquidityLease(), Either.Left(readUnsignedLocalCommitWithHtlcs())) + 3 -> Pair(readLiquidityLease(), Either.Right(readLocalCommitWithHtlcs())) + else -> error("unknown discriminator $discriminator for class ${InteractiveTxSigningSession::class}") + } + val remoteCommit = RemoteCommit( index = readNumber(), spec = readCommitmentSpecWithHtlcs(), txid = readTxId(), remotePerCommitmentPoint = readPublicKey() ) - ) + return InteractiveTxSigningSession(fundingParams, fundingTxIndex, fundingTx, liquidityLease, localCommit, remoteCommit) + } private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { 0x01 -> Origin.PayToOpenOrigin( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index 3077b451f..eede7f3cc 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -12,6 +12,7 @@ import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* import fr.acinq.lightning.utils.Either import fr.acinq.lightning.wire.LightningCodecs import fr.acinq.lightning.wire.LightningMessage +import fr.acinq.lightning.wire.LiquidityAds /** * Serialization for [ChannelStateWithCommitments]. @@ -144,6 +145,8 @@ object Serialization { write(0x00) } } + write(0x01) + writeCollection(liquidityLeases) { writeLiquidityLease(it) } } private fun Output.writeShuttingDown(o: ShuttingDown) = o.run { @@ -356,31 +359,73 @@ object Serialization { } } + private fun Output.writeUnsignedLocalCommitWithHtlcs(localCommit: InteractiveTxSigningSession.Companion.UnsignedLocalCommit) { + writeNumber(localCommit.index) + writeCommitmentSpecWithHtlcs(localCommit.spec) + writeTransactionWithInputInfo(localCommit.commitTx) + writeCollection(localCommit.htlcTxs) { writeTransactionWithInputInfo(it) } + } + + private fun Output.writeLocalCommitWithHtlcs(localCommit: LocalCommit) { + writeNumber(localCommit.index) + writeCommitmentSpecWithHtlcs(localCommit.spec) + localCommit.publishableTxs.run { + writeTransactionWithInputInfo(commitTx) + writeCollection(htlcTxsAndSigs) { htlc -> + writeTransactionWithInputInfo(htlc.txinfo) + writeByteVector64(htlc.localSig) + writeByteVector64(htlc.remoteSig) + } + } + } + + private fun Output.writeLiquidityLease(lease: LiquidityAds.Lease) { + writeNumber(lease.amount.toLong()) + writeNumber(lease.fees.miningFee.toLong()) + writeNumber(lease.fees.serviceFee.toLong()) + writeByteVector64(lease.sellerSig) + writeNumber(lease.witness.fundingScript.size()) + write(lease.witness.fundingScript.toByteArray()) + writeNumber(lease.witness.leaseDuration) + writeNumber(lease.witness.leaseEnd) + writeNumber(lease.witness.maxRelayFeeProportional) + writeNumber(lease.witness.maxRelayFeeBase.toLong()) + } + private fun Output.writeInteractiveTxSigningSession(s: InteractiveTxSigningSession) = s.run { writeInteractiveTxParams(fundingParams) writeNumber(s.fundingTxIndex) writeSignedSharedTransaction(fundingTx) - // We don't bother removing the duplication across HTLCs: this is a short-lived state during which the channel cannot be used for payments. - writeEither(localCommit, - writeLeft = { localCommit -> - writeNumber(localCommit.index) - writeCommitmentSpecWithHtlcs(localCommit.spec) - writeTransactionWithInputInfo(localCommit.commitTx) - writeCollection(localCommit.htlcTxs) { writeTransactionWithInputInfo(it) } - }, - writeRight = { localCommit -> - writeNumber(localCommit.index) - writeCommitmentSpecWithHtlcs(localCommit.spec) - localCommit.publishableTxs.run { - writeTransactionWithInputInfo(commitTx) - writeCollection(htlcTxsAndSigs) { htlc -> - writeTransactionWithInputInfo(htlc.txinfo) - writeByteVector64(htlc.localSig) - writeByteVector64(htlc.remoteSig) - } + // The liquidity purchase field was added afterwards. For backwards-compatibility, we extend the discriminator + // we previously used for the local commit to insert the liquidity purchase if available. + // Note that we don't bother removing the duplication across HTLCs in the local commit: this is a short-lived + // state during which the channel cannot be used for payments. + when (liquidityLease) { + // Before introducing the liquidity purchase field, we serialized the local commit as an Either, with + // discriminators 0 and 1. + null -> when (localCommit) { + is Either.Left -> { + write(0) + writeUnsignedLocalCommitWithHtlcs(localCommit.value) + } + is Either.Right -> { + write(1) + writeLocalCommitWithHtlcs(localCommit.value) } } - ) + else -> when (localCommit) { + is Either.Left -> { + write(2) + writeLiquidityLease(liquidityLease) + writeUnsignedLocalCommitWithHtlcs(localCommit.value) + } + is Either.Right -> { + write(3) + writeLiquidityLease(liquidityLease) + writeLocalCommitWithHtlcs(localCommit.value) + } + } + } remoteCommit.run { writeNumber(index) writeCommitmentSpecWithHtlcs(spec) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 02f3e3be6..bd1aa3e0d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -66,6 +66,56 @@ sealed class ChannelTlv : Tlv { override fun read(input: Input): RequireConfirmedInputsTlv = this } + /** Request inbound liquidity from our peer. */ + data class RequestFunds(val amount: Satoshi, val leaseDuration: Int, val leaseExpiry: Int) : ChannelTlv() { + override val tag: Long get() = RequestFunds.tag + + override fun write(out: Output) { + LightningCodecs.writeU64(amount.toLong(), out) + LightningCodecs.writeU16(leaseDuration, out) + LightningCodecs.writeU32(leaseExpiry, out) + } + + companion object : TlvValueReader { + const val tag: Long = 1337 + + override fun read(input: Input): RequestFunds = RequestFunds( + amount = LightningCodecs.u64(input).sat, + leaseDuration = LightningCodecs.u16(input), + leaseExpiry = LightningCodecs.u32(input), + ) + } + } + + /** Liquidity rates applied to an incoming [[RequestFunds]]. */ + data class WillFund(val sig: ByteVector64, val fundingWeight: Int, val leaseFeeProportional: Int, val leaseFeeBase: Satoshi, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) : ChannelTlv() { + override val tag: Long get() = WillFund.tag + + fun leaseRate(leaseDuration: Int): LiquidityAds.LeaseRate = LiquidityAds.LeaseRate(leaseDuration, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) + + override fun write(out: Output) { + LightningCodecs.writeBytes(sig, out) + LightningCodecs.writeU16(fundingWeight, out) + LightningCodecs.writeU16(leaseFeeProportional, out) + LightningCodecs.writeU32(leaseFeeBase.sat.toInt(), out) + LightningCodecs.writeU16(maxRelayFeeProportional, out) + LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) + } + + companion object : TlvValueReader { + const val tag: Long = 1337 + + override fun read(input: Input): WillFund = WillFund( + sig = LightningCodecs.bytes(input, 64).toByteVector64(), + fundingWeight = LightningCodecs.u16(input), + leaseFeeProportional = LightningCodecs.u16(input), + leaseFeeBase = LightningCodecs.u32(input).sat, + maxRelayFeeProportional = LightningCodecs.u16(input), + maxRelayFeeBase = LightningCodecs.u32(input).msat, + ) + } + } + data class OriginTlv(val origin: Origin) : ChannelTlv() { override val tag: Long get() = OriginTlv.tag diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt index 5ea9b822e..a4ae87672 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt @@ -31,6 +31,25 @@ sealed class InitTlv : Tlv { } } + /** Rates at which we sell inbound liquidity to remote peers. */ + data class LiquidityAdsRates(val leaseRates: List) : InitTlv() { + override val tag: Long get() = LiquidityAdsRates.tag + + override fun write(out: Output) { + leaseRates.forEach { it.write(out) } + } + + companion object : TlvValueReader { + const val tag: Long = 1337 + + override fun read(input: Input): LiquidityAdsRates { + val count = input.availableBytes / 16 + val rates = (0 until count).map { LiquidityAds.LeaseRate.read(input) } + return LiquidityAdsRates(rates) + } + } + } + data class PhoenixAndroidLegacyNodeId(val legacyNodeId: PublicKey, val signature: ByteVector64) : InitTlv() { override val tag: Long get() = PhoenixAndroidLegacyNodeId.tag diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index bb21ebefd..b3d969b63 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -180,8 +180,17 @@ interface ChannelMessage data class Init(val features: Features, val tlvs: TlvStream = TlvStream.empty()) : SetupMessage { val networks = tlvs.get()?.chainHashes ?: listOf() + val liquidityRates = tlvs.get()?.leaseRates ?: listOf() - constructor(features: Features, chainHashs: List) : this(features, TlvStream(InitTlv.Networks(chainHashs))) + constructor(features: Features, chainHashs: List, liquidityRates: List) : this( + features, + TlvStream( + setOfNotNull( + if (chainHashs.isNotEmpty()) InitTlv.Networks(chainHashs) else null, + if (liquidityRates.isNotEmpty()) InitTlv.LiquidityAdsRates(liquidityRates) else null, + ) + ) + ) override val type: Long get() = Init.type @@ -191,18 +200,19 @@ data class Init(val features: Features, val tlvs: TlvStream = TlvStream LightningCodecs.writeU16(it.size, out) LightningCodecs.writeBytes(it, out) } - val tlvReaders = HashMap>() - @Suppress("UNCHECKED_CAST") - tlvReaders[InitTlv.Networks.tag] = InitTlv.Networks.Companion as TlvValueReader - @Suppress("UNCHECKED_CAST") - tlvReaders[InitTlv.PhoenixAndroidLegacyNodeId.tag] = InitTlv.PhoenixAndroidLegacyNodeId.Companion as TlvValueReader - val serializer = TlvStreamSerializer(false, tlvReaders) - serializer.write(tlvs, out) + TlvStreamSerializer(false, readers).write(tlvs, out) } companion object : LightningMessageReader { const val type: Long = 16 + @Suppress("UNCHECKED_CAST") + val readers = mapOf( + InitTlv.Networks.tag to InitTlv.Networks.Companion as TlvValueReader, + InitTlv.LiquidityAdsRates.tag to InitTlv.LiquidityAdsRates.Companion as TlvValueReader, + InitTlv.PhoenixAndroidLegacyNodeId.tag to InitTlv.PhoenixAndroidLegacyNodeId.Companion as TlvValueReader, + ) + override fun read(input: Input): Init { val gflen = LightningCodecs.u16(input) val globalFeatures = LightningCodecs.bytes(input, gflen) @@ -211,13 +221,7 @@ data class Init(val features: Features, val tlvs: TlvStream = TlvStream val len = max(gflen, lflen) // merge features together val features = Features(ByteVector(globalFeatures.leftPaddedCopyOf(len).or(localFeatures.leftPaddedCopyOf(len)))) - val tlvReaders = HashMap>() - @Suppress("UNCHECKED_CAST") - tlvReaders[InitTlv.Networks.tag] = InitTlv.Networks.Companion as TlvValueReader - @Suppress("UNCHECKED_CAST") - tlvReaders[InitTlv.PhoenixAndroidLegacyNodeId.tag] = InitTlv.PhoenixAndroidLegacyNodeId.Companion as TlvValueReader - val serializer = TlvStreamSerializer(false, tlvReaders) - val tlvs = serializer.read(input) + val tlvs = TlvStreamSerializer(false, readers).read(input) return Init(features, tlvs) } } @@ -635,6 +639,7 @@ data class OpenDualFundedChannel( ) : ChannelMessage, HasTemporaryChannelId, HasChainHash { val channelType: ChannelType? get() = tlvStream.get()?.channelType val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat + val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() val origin: Origin? get() = tlvStream.get()?.origin override val type: Long get() = OpenDualFundedChannel.type @@ -670,6 +675,7 @@ data class OpenDualFundedChannel( ChannelTlv.UpfrontShutdownScriptTlv.tag to ChannelTlv.UpfrontShutdownScriptTlv.Companion as TlvValueReader, ChannelTlv.ChannelTypeTlv.tag to ChannelTlv.ChannelTypeTlv.Companion as TlvValueReader, ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, + ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.OriginTlv.tag to ChannelTlv.OriginTlv.Companion as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -718,6 +724,7 @@ data class AcceptDualFundedChannel( val tlvStream: TlvStream = TlvStream.empty() ) : ChannelMessage, HasTemporaryChannelId { val channelType: ChannelType? get() = tlvStream.get()?.channelType + val willFund: ChannelTlv.WillFund? get() = tlvStream.get() val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat override val type: Long get() = AcceptDualFundedChannel.type @@ -749,6 +756,7 @@ data class AcceptDualFundedChannel( ChannelTlv.UpfrontShutdownScriptTlv.tag to ChannelTlv.UpfrontShutdownScriptTlv.Companion as TlvValueReader, ChannelTlv.ChannelTypeTlv.tag to ChannelTlv.ChannelTypeTlv.Companion as TlvValueReader, ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, + ChannelTlv.WillFund.tag to ChannelTlv.WillFund as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -863,16 +871,17 @@ data class SpliceInit( ) : ChannelMessage, HasChannelId { override val type: Long get() = SpliceInit.type val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false + val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat val origins: List = tlvStream.get()?.origins?.filterIsInstance() ?: emptyList() - constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey) : this( + constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?) : this( channelId, fundingContribution, feerate, lockTime, fundingPubkey, - TlvStream(ChannelTlv.PushAmountTlv(pushAmount)) + TlvStream(setOfNotNull(if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, requestFunds)) ) override fun write(out: Output) { @@ -890,6 +899,7 @@ data class SpliceInit( @Suppress("UNCHECKED_CAST") private val readers = mapOf( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, + ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ChannelTlv.OriginsTlv.tag to ChannelTlv.OriginsTlv.Companion as TlvValueReader ) @@ -913,13 +923,14 @@ data class SpliceAck( ) : ChannelMessage, HasChannelId { override val type: Long get() = SpliceAck.type val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false + val willFund: ChannelTlv.WillFund? get() = tlvStream.get() val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat - constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, fundingPubkey: PublicKey) : this( + constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, fundingPubkey: PublicKey, willFund: ChannelTlv.WillFund?) : this( channelId, fundingContribution, fundingPubkey, - TlvStream(ChannelTlv.PushAmountTlv(pushAmount)) + TlvStream(setOfNotNull(if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, willFund)) ) override fun write(out: Output) { @@ -935,6 +946,7 @@ data class SpliceAck( @Suppress("UNCHECKED_CAST") private val readers = mapOf( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, + ChannelTlv.WillFund.tag to ChannelTlv.WillFund as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt new file mode 100644 index 000000000..03682d10b --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -0,0 +1,159 @@ +package fr.acinq.lightning.wire + +import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.io.ByteArrayOutput +import fr.acinq.bitcoin.io.Input +import fr.acinq.bitcoin.io.Output +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.* +import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.utils.Either +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat + +/** + * Liquidity ads create a decentralized market for channel liquidity. + * Nodes advertise fee rates for their available liquidity using the gossip protocol. + * Other nodes can then pay the advertised rate to get inbound liquidity allocated towards them. + */ +object LiquidityAds { + + /** + * @param miningFee fee paid to miners for the underlying on-chain transaction. + * @param serviceFee fee paid to the liquidity provider for the inbound liquidity. + */ + data class LeaseFees(val miningFee: Satoshi, val serviceFee: Satoshi) { + val total: Satoshi = miningFee + serviceFee + } + + /** + * Liquidity is leased using the following rates: + * + * - the buyer pays [leaseFeeBase] regardless of the amount contributed by the seller + * - the buyer pays [leaseFeeProportional] (expressed in basis points) of the amount contributed by the seller + * - the seller will have to add inputs/outputs to the transaction and pay on-chain fees for them, but the buyer + * refunds on-chain fees for [fundingWeight] vbytes + * + * The seller promises that their relay fees towards the buyer will never exceed [maxRelayFeeBase] and [maxRelayFeeProportional]. + * This cannot be enforced, but if the buyer notices the seller cheating, they should blacklist them and can prove + * that they misbehaved using the seller's signature of the [LeaseWitness]. + */ + data class LeaseRate(val leaseDuration: Int, val fundingWeight: Int, val leaseFeeProportional: Int, val leaseFeeBase: Satoshi, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) { + /** + * Fees paid by the liquidity buyer: the resulting amount must be added to the seller's output in the corresponding + * commitment transaction. + */ + fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi): LeaseFees { + val onChainFees = Transactions.weight2fee(feerate, fundingWeight) + // If the seller adds more liquidity than requested, the buyer doesn't pay for that extra liquidity. + val proportionalFee = requestedAmount.min(contributedAmount) * leaseFeeProportional / 10_000 + return LeaseFees(onChainFees, leaseFeeBase + proportionalFee) + } + + fun signLease(nodeKey: PrivateKey, fundingScript: ByteVector, requestFunds: ChannelTlv.RequestFunds): ChannelTlv.WillFund { + val witness = LeaseWitness(fundingScript, requestFunds.leaseDuration, requestFunds.leaseExpiry, maxRelayFeeProportional, maxRelayFeeBase) + val sig = witness.sign(nodeKey) + return ChannelTlv.WillFund(sig, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) + } + + fun write(out: Output) { + LightningCodecs.writeU16(leaseDuration, out) + LightningCodecs.writeU16(fundingWeight, out) + LightningCodecs.writeU16(leaseFeeProportional, out) + LightningCodecs.writeU32(leaseFeeBase.sat.toInt(), out) + LightningCodecs.writeU16(maxRelayFeeProportional, out) + LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) + } + + companion object { + fun read(input: Input): LeaseRate = LeaseRate( + leaseDuration = LightningCodecs.u16(input), + fundingWeight = LightningCodecs.u16(input), + leaseFeeProportional = LightningCodecs.u16(input), + leaseFeeBase = LightningCodecs.u32(input).sat, + maxRelayFeeProportional = LightningCodecs.u16(input), + maxRelayFeeBase = LightningCodecs.u32(input).msat, + ) + } + } + + /** Request inbound liquidity from a remote peer that supports liquidity ads. */ + data class RequestRemoteFunding(val fundingAmount: Satoshi, val leaseStart: Int, val rate: LeaseRate) { + private val leaseExpiry: Int = leaseStart + rate.leaseDuration + val requestFunds: ChannelTlv.RequestFunds = ChannelTlv.RequestFunds(fundingAmount, rate.leaseDuration, leaseExpiry) + + fun validateLease( + remoteNodeId: PublicKey, + channelId: ByteVector32, + fundingScript: ByteVector, + remoteFundingAmount: Satoshi, + fundingFeerate: FeeratePerKw, + willFund: ChannelTlv.WillFund? + ): Either { + return when (willFund) { + // If the remote peer doesn't want to provide inbound liquidity, we immediately fail the attempt. + // The user should retry this funding attempt without requesting inbound liquidity. + null -> Either.Left(MissingLiquidityAds(channelId)) + else -> { + val witness = LeaseWitness(fundingScript, rate.leaseDuration, leaseExpiry, willFund.maxRelayFeeProportional, willFund.maxRelayFeeBase) + return if (!witness.verify(remoteNodeId, willFund.sig)) { + Either.Left(InvalidLiquidityAdsSig(channelId)) + } else if (remoteFundingAmount < fundingAmount) { + Either.Left(InvalidLiquidityAdsAmount(channelId, remoteFundingAmount, fundingAmount)) + } else if (willFund.leaseRate(rate.leaseDuration) != rate) { + Either.Left(InvalidLiquidityRates(channelId)) + } else { + val leaseAmount = fundingAmount.min(remoteFundingAmount) + val leaseFees = rate.fees(fundingFeerate, fundingAmount, remoteFundingAmount) + Either.Right(Lease(leaseAmount, leaseFees, willFund.sig, witness)) + } + } + } + } + } + + fun validateLease( + request: RequestRemoteFunding?, + remoteNodeId: PublicKey, + channelId: ByteVector32, + fundingScript: ByteVector, + remoteFundingAmount: Satoshi, + fundingFeerate: FeeratePerKw, + willFund: ChannelTlv.WillFund?, + ): Either { + return when (request) { + null -> Either.Right(null) + else -> request.validateLease(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund) + } + } + + /** + * Once a liquidity ads has been paid, we should keep track of the lease, and check that our peer doesn't raise their + * routing fees above the values they signed up for. + */ + data class Lease(val amount: Satoshi, val fees: LeaseFees, val sellerSig: ByteVector64, val witness: LeaseWitness) { + val start: Int = witness.leaseEnd - witness.leaseDuration + val expiry: Int = witness.leaseEnd + } + + /** The seller signs the lease parameters: if they cheat, the buyer can use that signature to prove they cheated. */ + data class LeaseWitness(val fundingScript: ByteVector, val leaseDuration: Int, val leaseEnd: Int, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) { + fun sign(nodeKey: PrivateKey): ByteVector64 = Crypto.sign(Crypto.sha256(encode()), nodeKey) + + fun verify(nodeId: PublicKey, sig: ByteVector64): Boolean = Crypto.verifySignature(Crypto.sha256(encode()), sig, nodeId) + + fun encode(): ByteArray { + val out = ByteArrayOutput() + LightningCodecs.writeBytes("option_will_fund".encodeToByteArray(), out) + LightningCodecs.writeU16(fundingScript.size(), out) + LightningCodecs.writeBytes(fundingScript, out) + LightningCodecs.writeU16(leaseDuration, out) + LightningCodecs.writeU32(leaseEnd, out) + LightningCodecs.writeU16(maxRelayFeeProportional, out) + LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) + return out.toByteArray() + } + } + +} \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 0cda32304..8c5f086ae 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -20,6 +20,7 @@ import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlin.test.* @@ -107,6 +108,73 @@ class SpliceTestsCommon : LightningTestSuite() { spliceCpfp(alice, bob) } + @Test + fun `splice to purchase inbound liquidity`() { + val (alice, bob) = reachNormal() + val leaseRate = LiquidityAds.LeaseRate(0, 250, 250 /* 2.5% */, 10.sat, 200, 100.msat) + val liquidityRequest = LiquidityAds.RequestRemoteFunding(200_000.sat, alice.currentBlockHeight, leaseRate) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val (alice1, actionsAlice1) = alice.process(cmd) + val spliceInit = actionsAlice1.findOutgoingMessage() + assertEquals(spliceInit.requestFunds, liquidityRequest.requestFunds) + // Alice's contribution is negative: she needs to pay on-chain fees for the splice. + assertTrue(spliceInit.fundingContribution < 0.sat) + // We haven't implemented the seller side, so we mock it. + val (_, actionsBob2) = bob.process(ChannelCommand.MessageReceived(spliceInit)) + val defaultSpliceAck = actionsBob2.findOutgoingMessage() + assertNull(defaultSpliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, defaultSpliceAck.fundingPubkey) + run { + val willFund = leaseRate.signLease(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, spliceInit.requestFunds!!) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) + assertIs(alice2.state) + assertIs(alice2.state.spliceStatus) + actionsAlice2.hasOutgoingMessage() + } + run { + // Bob proposes different fees from what Alice expects. + val bobLiquidityRates = leaseRate.copy(leaseFeeProportional = 500 /* 5% */) + val willFund = bobLiquidityRates.signLease(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, spliceInit.requestFunds!!) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) + assertIs(alice2.state) + assertIs(alice2.state.spliceStatus) + actionsAlice2.hasOutgoingMessage() + } + run { + // Bob doesn't fund the splice. + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund = null) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) + assertIs(alice2.state) + assertIs(alice2.state.spliceStatus) + actionsAlice2.hasOutgoingMessage() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `splice to purchase inbound liquidity -- not enough funds`() { + val (_, bob) = reachNormal(aliceFundingAmount = 100_000.sat, bobFundingAmount = 10_000.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val leaseRate = LiquidityAds.LeaseRate(0, 0, 100 /* 5% */, 1.sat, 200, 100.msat) + run { + val liquidityRequest = LiquidityAds.RequestRemoteFunding(1_000_000.sat, bob.currentBlockHeight, leaseRate) + assertEquals(10_001.sat, liquidityRequest.rate.fees(FeeratePerKw(1000.sat), liquidityRequest.fundingAmount, liquidityRequest.fundingAmount).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val (_, actions1) = bob.process(cmd) + assertTrue(actions1.isEmpty()) + assertTrue(cmd.replyTo.isCompleted) + assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) + } + run { + val liquidityRequest = LiquidityAds.RequestRemoteFunding(1_000_000.sat, bob.currentBlockHeight, leaseRate.copy(leaseFeeBase = 0.sat)) + assertEquals(10_000.sat, liquidityRequest.rate.fees(FeeratePerKw(1000.sat), liquidityRequest.fundingAmount, liquidityRequest.fundingAmount).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val (_, actions1) = bob.process(cmd) + actions1.hasOutgoingMessage() + } + } + @Test fun `reject splice_init`() { val cmd = createSpliceOutRequest(25_000.sat) @@ -985,6 +1053,7 @@ class SpliceTestsCommon : LightningTestSuite() { replyTo = CompletableDeferred(), spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), + requestRemoteFunding = null, feerate = FeeratePerKw(253.sat) ) @@ -1030,6 +1099,7 @@ class SpliceTestsCommon : LightningTestSuite() { replyTo = CompletableDeferred(), spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, amounts)), spliceOut = null, + requestRemoteFunding = null, feerate = FeeratePerKw(253.sat) ) @@ -1069,6 +1139,7 @@ class SpliceTestsCommon : LightningTestSuite() { replyTo = CompletableDeferred(), spliceIn = null, spliceOut = null, + requestRemoteFunding = null, feerate = FeeratePerKw(253.sat) ) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt index 0f7117262..4dace9e8d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt @@ -1,18 +1,23 @@ package fr.acinq.lightning.serialization +import fr.acinq.bitcoin.byteVector import fr.acinq.lightning.Feature +import fr.acinq.lightning.Lightning.randomBytes +import fr.acinq.lightning.Lightning.randomBytes64 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.PersistedChannelState import fr.acinq.lightning.serialization.Encryption.from import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.value import fr.acinq.lightning.wire.CommitSig import fr.acinq.lightning.wire.EncryptedChannelData import fr.acinq.lightning.wire.LightningMessage +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.secp256k1.Hex import kotlin.math.max import kotlin.test.* @@ -97,4 +102,48 @@ class StateSerializationTestsCommon : LightningTestSuite() { // with 6 incoming payments and 6 outgoing payments, we can still add our encrypted backup to commig_sig messages assertTrue(commitSigSize(6, 6) < 65000) } + + @Test + fun `liquidity ads lease backwards compatibility`() { + // The serialized data was created with lightning-kmp v1.5.12. + run { + val bin = Hex.decode( + "0402b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a01010410101000037108815ff0128f7ed22640485c226d9ad64a9fd6d8b41b6623565aed6b34812c09fe8824646afed5cc44a9fecb08263bfee1c34a83feba92e4e8fe65d93543fecb5ee602fe43ec9100fe80000001fd044cfe59682f000090640116001405e0104aa726e34ff5cd3a6320d05c0862b5b01c13280880000000000000000000001000101a51020362b19a83930389b4468be40308efb3f352b23142ae25e6aba0465a8220f95b06fd03e8fe59682f00fd03e89064026843554d5e604ffd3fcabc56cefe5849abbb7fd395f36bcf3e9550594aace9690236633b1e8f7a54ef367482c31c74162f4fd3e4c7d78694e2c6d769af6e33047202e97df1b0423c20ba41a1955e71cfcb96cec4f636b1d310be78e989f92229edb302b3c6959eefecdee406b9b4df0d76126f2c5038811b27abf44738e6db1be0bdf11408220222000000000000000000001000142a5102000000000000000000000100022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b02fd024a02000000000102d70488b7709a2ea05d808ec1f46d6ec100f85b3c1f1fe909d3dc6332b1b9153a0000000000fdffffff3bd4776fba4675b6b2e56d4ef0b81159c4319cf9942918fc29798f06b95a84270000000000fdffffff0140420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e03473044022012d7967e817c6f369aa4f9a69f78ac1008a7f0ea8f62e3510b8ec2ed3e9e109302202fd1fd54d104f7e2fe0a5404edccb7b3f786cd5f82447bcca2bd23fee34cb596014830450221009c192a826ecf488216eea24279c6699c774e9c930194a8357a61d2e4e91b9c1e02202a085efb5276f6d34ec310b8d2ede7bf65fe63d4027b93b89902d3c4e179e91d014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26803483045022100ec3085bef0557c2329c0a9ff1df9374973f42247bab7cdcff10b2fa5fa8b89680220040eeeb7576228bcde8d62f930c368010c8c665467299ea7f3a735140a27ae3301483045022100e628ebd5b4f433c1e4127b7d7fb0f625a6dcb1e4cf8cd62aa4a120312c723138022020f22620ebb280dfc8ad5eb1c1671ce13c4cd9bb7166cd50f3a8577a8b79b167014d21020bc21d9cb5ecc60fc2002195429d55ff4dfd0888e377a1b3226b4dc1ee7cedf3ad2102d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951ac7364024065b268801a0600fd1388cc0047b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a1f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f180000fd025b409c192a826ecf488216eea24279c6699c774e9c930194a8357a61d2e4e91b9c1e2a085efb5276f6d34ec310b8d2ede7bf65fe63d4027b93b89902d3c4e179e91dfd025d40ec3085bef0557c2329c0a9ff1df9374973f42247bab7cdcff10b2fa5fa8b8968040eeeb7576228bcde8d62f930c368010c8c665467299ea7f3a735140a27ae33000000fd1388fe2faf0800fe0bebc20000241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f18000000002b40420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652aefd01bc020000000001011f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f180000000000bc63fb80044a010000000000002200202a962bfb8410b4d8515002cdca69755a4e7b2f35c1d3c8ca23c8c2eb2c663ea84a0100000000000022002086bc033f5435e003d1be7f8d21ffcba84d5177f72d9cab95ddca49557b0db016400d03000000000022002066b48675ced51cc392a9c208c5b6b37173fcd1c88601188c0a55fa5db4a3df87781c0c0000000000220020d4234a5c556b42832ec8985edc4f377809fa254fc509ee5533de099a91579848040048304502210097e686048e14f2e862970d384734ec72d4799afaf7a67f679e5ef1c685e37279022052f6c4647e44bc9ea197431e47903398fc077314e47a19046f5c6f6b130d9fca0147304402204c85f5c533eaf8bfd8fcdbfca7184522eec4d9f3028226450051a7cedd15d0d802202a5ead9a0284bdec30d52326eb2ddc8d6e00a991f14faf5656a05769eb52ce8401475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652ae34d5bc20000000fd1388fe0bebc200fe2faf080002613a5fffafc39766ca252b1470bc96161211c3bf0533aa04fd7cb23d05bf6e02cd117f0145d8c5780d5fd5145251257cfc0898145a99750c181927bbe526f4a600000001035a3feef004f091d822802e715c2d6e9e75020af11be99fd3d4c30e2c6ffa2a480000fd0760040042686e87cb623bed5376be9b0b6314dc871fe35781bbbccf91d12cd07adf711c72e520bb2ff4cf3fa9611868a324e153ebf63586083258028b322fbce9995ddd2593a122a97b082d0f2d58a895c9fa06fd535089a04e05fcdf0e8907492a6c5244541b806bcc49120e464ec1eca87840b41e694725528fe8d7d94f640d958d0b43c17478977617d134a4a1fa85c45f135bd626e70ca862cca3e4861e88771a120bfa6898971b4dd3b022dc920f481cca8102fc101e69ac2d92e18773ef6b262356370514cad85f6531f3ae1d8f404e04172917483227ad9ca8ac29a0b01302ecb67adf54e8289f6dedf3c323f1e52daee77b3bd2524a2959f5dc0dec361212b77c593b67419adeb7aeb75b39b9daca003755b0fd50653724df439d95e6a00bb6afb303ac8a39bc47ebc6a0b906532fd14140e6ca727c4b85ca970da5b249374ec813d1f78ff7171711bfd2a2bc204fbe29834bbf8b9bdf1be88f987315e2d3cb56b50056feee5970c9939af176d829e08106dc4101f5f18a8f04c8067e375505f7bea0a20ccadccf3ece22eccb873efd221877100e08ab9b1c241ef36176dc0ab7b41c17a5bddbf243e22c2dc5f5f9b410a90b6e77e09bc95d7e9e50c5a8afdc462408c453d37571a695dbf37945565b605b0b13c70ce03580d0c4c36f453c7a0a1a7418fdaf057c1c3cbbd9f3fdbf667f3d7342b24c4cc5b7b078891b2fb31d2a2f37f9beab0a503c34df80c39eb19c9194bf4b04c164dade1b176c0cc1690ff64bcfc3f4365d7f7ab7777ee20374c1707a794e32eb7792b20cab4d67cd0d226eb93643d35dd479567a90245e518ce4150709a7d550d3b175ca880393830fe784aeac55811ccf62ce15bac14630263ba1c182827646a4bbd26ddbad3100b23b04afc042cefb6489fe1c77f38826d8a39c9cdc906d73317eaa33cf6ca2ed8756925c8919622ee80a87d66f3eb2f43534c6ecb749b2c473d32c7eaaff659d84bf680c702c1e13adcfadd8e907b886300e07cd431fab9affb451196e3dfd77cfafb8de0e1fe65e66ddb7ba594b7369aa52113c3d752b312fbc51a17d504244933cee42909c60c517a4411f841af48799e719554a07bdd3ffbeb14e694e913514856656e7fcdfaaf84daf8f0b2ef4639c0682524874dd7eb4c16844074ac0d97354a7e643a2e3220bf30855c54461464c0bf82bbabbba7fe407e1f2fa394f8e3822c507e2d705e32e13f2a50a5f2c8b3d73b63847cf985f06e25de5629e8a570092a92996c655f5ef3871d2a3a4b556c9b52d40b828475c35262c6f9f5bbfbd3e6ebf09864bfb3d3dcf4f78961d4fc85fd9b9c924ce6ba8c6df4c8525ee4c3f67f97e361566b31a9df0c4bc6da36e9e0e47f0b91a67f489fba2d0eddee58bac5ddc4cfde2c74947a27b49e89fa838bbfaeae6605a7e2dad611252a5d30a5c99592de44aad8fb4253880ce16f60c3231f9824898751e99eb4d554bea9042843a56d5239f8d3aae93696583970822429beef912dcd7129693e11ad39ec0191ee5fc06b58544fbed9c6c12ad73690bf64bb78fb16902e97bc8f8fcbdba321ce0241141542cca9235489459b1b50d44d76bb36492241dfa43f5252331556a9c618f14f89f9b7dc9944498a73ce242a0ec0b2953b25cc5b11c25dbf336a6319f479e561c2c4f6f196a43f93ddb22da68bfe3909c3cb21503a554b895ef4dbd0033684b16b974042386eddef9faf63389d6d07bafdc934884589333da2fd0a6e1e15bdcab663c562e00e887c1b9b5296b8bee678a21d11c45005729bb0e6eb225cb9a480673483634ad21ceb0bec52ec78b13058847e750412ab67e3631187c289aeba97371926027b348bc932b600ece0fa5a8fa69a18d44e51eb7857011e72484e1e8393d94382ddd8e012b676dde44da75eda81aa0ba5ed8e474b7465c5af2b1a1de7aa870fdd191de0caf78875880ab6d5d3fcef3057002e17a07f9e870ae13634cef3bf8a60b41104c39145a1b6dd44f37c3b7d3c78f2f6f1fe83d38c2a54c1597270fed60b157fbdf431d51e98899f6894ad41c4142e271af7d5557faaacdc337caa9f3ecae7a0dbfdf27057c437556c9c9442fdca9e9a07e61741ee56a3db89e29d3d4ab7fc3feda7d737d5ceb3787d103efbd72772a1ebf65541d6d7cdb5ac82e834060d1b9f58be80db537f9ca696a57f21d74fdee7947ff90cb238c3f6f7e084012a1c1466c230d841e7b3cc0b670696e7f3b6186770b2e61c3bae625da4232831058ad73c87744be94f301ce839d6fd46d62fec3edd60ea7cf92fd119b98232cd9621f5e5d37bde331e2db7d4742531e93531676150bbcf8dd28e7acd3181128ebfc36c49aa7ced8fb5af96833769deb6d46f49010ce92ba80e5f7e841360ae01f86a39e24383cab02d31af0745f70b752ebf6e149e38c1c4bc7a6f39124555b449e4887fca29bdc51efd56c0f682458a41a5cf28697c3f79c980df742e80aeae1dcc91309389885c3f2386f4edd14956bf562884f5983bb906fea2bc394efbb67de76720209ae47b3a6ed5e00e82287f3586113de2476d514dc58086e03890ce3247a0d2969a32c995ec7b306c8c6b6737310f8d2bf2499d1659b523e0ceb00eba41af9bb32a81fa230a560a866606481a5086da9ea8b61d0d4dd8b0cff00002a00000000008a01023a973e5a95ba9356ebb5d884eda57169e214d46afbe7e0ede00f4bf4a3acc0336825ba58984754922a4f3a28cbcb5fa52a9b983210bb992eef6e2dfe391a806d06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f00002a00000000006572fa6b0101009000000000000003e8000000640000000a000000003b9aca000000000001b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a01fdc256000101241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f18000000002b40420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652ae00022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b0369b5f974eb92be821826fe7d0969744283c917f3411655f190b56fc5930c3cfa00fe00061a80fd044cfd00fd0101010102241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f1800000000fefffffffdfe2faf0800fe0bebc2000104220020431b8ae82b805d47da74e00ecde92e930c666124d5930fae2958ba309885380ffe32a627f0fe0bebc2000102007d02000000011134cd9d56bee35f5db7b8a8e17ae69eabc8738653da42247fad8996e7419b7d0200000000000000000250c3000000000000220020778f86e213a518e8336ec940874ccf4b92910332f5b3c0265374a86dbd34e6a596000000000000001600141240d4b7fcfbfbd7234cf2dedf071673a0c1e5590000000000fefffffffd03d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc49190256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdfd6540000000fe00061a80cc0047b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a3448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c60000fd0259406e731b1649d06176d0ecf590b385b0123f685cb93ef518124d6b9cbd7062c4265af87d8986ac6fd525d0e738dff61e00d18ce04fe9cee80a99744a65fcd4fb04fd025b4045e91597bd2826f18f58321051c3e0a6728ebbb0d633eba9428139335460c9da322b01137b5a3b5041fd526d5043ffd586d8ef44b7ccb27c9605ab6bb2943d27000000fd1388fe32a627f0fe0bebc20000243448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c6000000002b9604100000000000220020431b8ae82b805d47da74e00ecde92e930c666124d5930fae2958ba309885380f47522102c3cdf2cd990536f7ac520b3a2f66c0a6e302c2fe15a8c3baee24eba1cb9a8b02210369b5f974eb92be821826fe7d0969744283c917f3411655f190b56fc5930c3cfa52aedf02000000013448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c60000000000bc63fb80044a010000000000002200204725be4ed490e91c4ad5824fcc202c53787b147d4ad28a30aafeff90400d17634a010000000000002200204eea61d0b3215da0c03b103e98d24aa4466e0fb1ba80f8d8d85d33ed0a969da3400d03000000000022002066b48675ced51cc392a9c208c5b6b37173fcd1c88601188c0a55fa5db4a3df87cede0c0000000000220020d4234a5c556b42832ec8985edc4f377809fa254fc509ee5533de099a9157984834d5bc20000000fd1388fe0bebc200fe32a627f05f4cb3d37e1f420f5f39b563929d1a82a8e93ee4d864eaca609508b3ad2b6a5702cd117f0145d8c5780d5fd5145251257cfc0898145a99750c181927bbe526f4a600" + ) + val state = Serialization.deserialize(bin).value + assertIs(state) + val splice = state.spliceStatus + assertIs(splice) + assertNull(splice.session.liquidityLease) + assertTrue(splice.session.localCommit.isLeft) + assertContentEquals(bin, Serialization.serialize(state).dropLast(2).toByteArray()) // we add a discriminator byte and the liquidity lease count (0x0100) + assertTrue(state.liquidityLeases.isEmpty()) + val state1 = state.copy( + liquidityLeases = listOf( + LiquidityAds.Lease(50_000.sat, LiquidityAds.LeaseFees(1337.sat, 1329.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 850_000, 100, 1_000.msat)) + ) + ) + assertEquals(state1, Serialization.deserialize(Serialization.serialize(state1)).value) + } + run { + val bin = Hex.decode( + "040238ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f701010410101000037108815ff0128f7ed22640485c226d9ad64a9fd6d8b41b6623565aed6b34812c09fe5b62e3a5fecf5fd832fec7e172c8fe15fff232fe17015da3fe71c99b0afea90a802bfe3993f9cefe80000001fd044cfe59682f000090640116001405e0104aa726e34ff5cd3a6320d05c0862b5b01c13280880000000000000000000001000101a51020362b19a83930389b4468be40308efb3f352b23142ae25e6aba0465a8220f95b06fd03e8fe59682f00fd03e890640349b56ccb150862271cdc1b280d484db844d48ee85f07515cc6e847d1d32a147a02d78bdcc7f2160d5ccdcfbc0ba3dcc4b547d06f38cb65ffac7589ae5ad529d08a03e62f14d41cdf68d7ac982dc03e6492d093d7aec4ef7d1765d5a5bcc995e204b602e95f9d9281919ff9cae84e7dc3b5b1ef1161a6c503617e4f3207e05d722c15a71408220222000000000000000000001000142a510200000000000000000000010002e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f02fd02490200000000010256724a067da52a008fa768ad15f2a003054882bf0c09693a2c0f386eb5d8c4340000000000fdffffff3be96364f874547c41cf86f1f57c35029a6e082700bcd25f5b3cbd742417ced80000000000fdffffff0140420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b003483045022100e66fec8848962770b61c9835b4e09954dd6dec98c2cd621a8592defe58796ef4022067aa7588b08614346f544322577b7797116c727f452b06cad40c2f9b2af8774601473044022064df9cd5e6e68bad426f4ffe06ccee9eeb5769f4fe65931bc54c0df618accbf1022018488e51b6c6969771de015e9ac41f9426701c5c961df270675cb1476920de98014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26803483045022100e9882e40951f43da434e1d86cf9830f5499f8dbb846f5131becfa0fea181ef4602206146db93b26271798d40f453d5255557191d0f45f0b5fb0a0d78ce261afcd386014730440220379f14da69fa108168d32351e5c9127479a8e1858f89cdf1a70c66010348a6c1022039bc27eabe21078071c35c35adc1c11ab8e3f70fc9d4e649f6527618e70ba647014d21020bc21d9cb5ecc60fc2002195429d55ff4dfd0888e377a1b3226b4dc1ee7cedf3ad2102d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951ac7364024065b268801a0600fd1388cc004738ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f7f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d240000fd025b4064df9cd5e6e68bad426f4ffe06ccee9eeb5769f4fe65931bc54c0df618accbf118488e51b6c6969771de015e9ac41f9426701c5c961df270675cb1476920de98fd025d40e9882e40951f43da434e1d86cf9830f5499f8dbb846f5131becfa0fea181ef466146db93b26271798d40f453d5255557191d0f45f0b5fb0a0d78ce261afcd386000000fd1388fe2faf0800fe0bebc2000024f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d24000000002b40420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b04752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52aefd01bb02000000000101f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d240000000000620f4680044a01000000000000220020ad6e712f2f3ff4a279f7c1cc4bb31d88c98ad807537616a4f53beed64cb5091d4a01000000000000220020c79d8484429b469c3230783f14fd3228d9b6da520dac471f3b3d826c59ad0b52400d0300000000002200202a563e407578aae18800c2388759b5bad0ceef5a3759de39d7acedffd868d30d781c0c0000000000220020bf57f237188dde15a4a0947bf64cd985f95163d77fee3d8372cdce323de1efdf04004730440220178102bdc1fce536c08c0660749208ff2d1e0aa9bb5ad1b98b120e9e5e263324022057c7033283b0f397f98378d0b2666879ed5da822445ed43dbb26563644d397370147304402206dd5be99fe9473e0221aaf2e37a72fbe38f666c4129c0c164cf9bd2eb7d93fe802204ec11e93732a56f4e759c4ea8359cfda3e1db4cef0cf91f79b1d9906e60eb2b3014752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52ae65031b20000000fd1388fe0bebc200fe2faf0800b3822442b3a5d53e6410fe106e9ec9408a9bb0b6b6f34c0ad39d0811b466f86a03cb5d3b7310e6532d403dc7ad5857869e6ace3a2a1a7cf710c8a9b307a5ebb02c0000000103b6668d222ea88836cd25b40784f759c9cc0ff9ac03ef0408a08543ae185405060000fd076004003749c4912f86c5594f2e9775e78a8292f386ca75711bbb7f89c841e99158d24e1aea0c5bf2f810cedf6ca3842aba47df127ad165b13052c8fbc30aa23feb59d01960f2226127e20affb1637bb17394140be970a45c79fa4dd3ac0ba32c6bba5095be8a5ad1c0d6747788fcf128a8f71378d8921d2b7d2c9e999e2898fcb7ae7a5048900b111c973622ebcbdc5e3232efb330464f4d76b1a0fb2d70ddb3882ae9a45a7f3115ad94acc926d1ed33f940cd7bcd8a296983bb3ff4592009ce498b9d4552e6e019d453210545ac5c2f48a1fe75b5dd93cff4f124c363f22578cd7d3b5a5244a871c37244e79eaa1ffc3966f716520b8cbf38ba1c33ec68939fce45a2519eda4f1029d2e5fa3069e5fed848d9e078ed29af5a10541933db39ed353895f2b269437f2a04ba09528b0ba92bf725ea300752226b888f4cf3c3fa973e4b2017b74c86cdbe81829513bd62f2055076e0463b39c2155635772d80b2f6945319bede15535a3becbd9374122f0f974ef2c9ec990369f2a90dfb7f1355e5e183489880c4a9e63740967dec2a77dbfa003361bfee2f3e4f1e4cc02afe0d82a14a47ba9fac237ed616fc892c1d93387b9a9682a78994cd62074b295afc542b190ef2391e8352e8ada52147b448ee2e2cc8cd5170af58cbf211f7b0d49a6b6b6ec628b0dfb4e4636df58dc5c55b3634457d7f949a1f26abc64db158fa51343a5990d707218b01dabf223361cc4f6ce3cfc6b62c5306ad1bfbabf5c51003551a07bb053e5a419d5d8c8c200feb87ab9dd0802d418068285bfa3f0c0ae717d4671cb9d4b2cb0c12d44985961c259f4433fe732da40458c3903d6191f7a6167132a9db3476dfbfc37f3c5d37b49e3027ab9a981ed788e124ed88abe2f3a10f52fd5ba278e6555acc89d916b30c2dcf3bbcc6cfc1985e66a169a6eb1f251cef9ece3487e88d1f81676d97955ef374465e16ad36abaabd3888236dc0eb27050b9050a396a6d8a2cb451b8d75e480d8afa13ddefcda4c28a8483a441edbc034023fe5332c52e86dde7f71dd1865d471deb7ea04a09f38a9307206e2fc53e205362d95247adc5dc5cb5cb064609f2cd11ecbf005612d12165725799044eb45673a1e9c1a1275dc70ff5992500754c6efd851666b6a5d02d438d01e881b430876245d4bc4b888988471fdad5104e5bc5a518a83a9be98f9a1ea11473b8eb32150714ffa1bdddaf35fb7e0b50cb075a8a38437638cd4803e3e8baea0630420947dfb274a4980fd6c8d4a1a79d033406b7dae6cf68f83cfabe9e5bc9e4ef51c49362017fa835497b909bf0599ec764709a527a8bbf59007602bbeb676a60a3c990bb1630c18f4ba3b3ce71d73ebdff879af9347c7fd9d0789cf7d15fbe3196a4cbaecc3fbd2a5ed2f1d995cc03c6e5bfb48395b317ca4b3ff626e291f6cf877186eddd707b8d5a66de90ced49f276b417032d264d992d6dcf26267bdfbeb37a7e65438ae136bf65ad0da4998a3a331e7593786157562ac0eb4d37e68d41181d79677265b27099d770b4443cbf9d08859e4ac79f9adcbc41000ce203fedb40ceeb5050fa56a5bc9f038d4f13cc860e3e68a5df055ae2df2c09a392435e5770790835e2db2081dd21d28f2bc76eba810d5cdba41c97a8a64512af71eb9bbfc8b7ea17f41710cc034d33e92ca73c02a6e7501e33efe57efb54ecdf36e1e18207994779fe8a8e299ec5ddf186b6c859e5884994ac780d6f800d7e65ab1746e56b9dca3f08a0fd7a86680a53ffc70bb1b3138844a3ae4ee7267c2cdbba2cd8da1af7522fb6eaeb6b737637df1e69c0356ba02ca06a064d80add016c1a5fe804be21250c93dc859313ff0c41a68c351a702b2f24279d197cd1201080edb1006ae100ffa7d660a5439a79bcbda24e2fdf445f010bc49514e5030f10b4760101d07cec44773136f884264a3c0dc465fb950bbc2c11cebfd9a7de7b0f18e77e03e2a2e5199308f21fde4d9092328651d13d9b86cfbadde55d4eb3bd815d3c4349ca4e3944bfad27ef31b6034b3c934f8eeed228845091fbd030858ffbb6448dfb3454a5049bc86e3894814de855627b4cbb9a90515360f9087c7f99b894b7839c6b3beb0a6dcfe102d549cf571e287720b02fac463bddbaf1fd3d4865c9444f36d763d977d6e4741bdd983133112bd2567af10bbeba5944c39f3cd3ea9d5249cfcaa56f762224c1fe4ed7a847303859cb36d642c6bf903012327fa4af7ee0d901e09d4b2443f4036e3c7cf46971b90750fdf2c63f3f10b18ad46da18b62f7320d0d05366aecef0d0281ab8ec80888c332761300eff916a846fa3887e58f76fcbd5122861324e748dc0544b5886aab637188d40d4232587d5f11d9ddb9f7c8a1fd9b084032a6724e786d8ea632dfb85a0e650f8120758c1a58ebe077658f28e1181802ea5a90e1fce99a1e0246e60837048a75e47e6deba2959cec9f63029671dba511ff1a3ebee4a0b5868621c56afa369f17cff651d03cc620984dd1d985184ae7b3d2c9d0e838d9843b6a893e22643ce7057d33075dc937146e7d0194a2bbbf87a427d5235fa361f1eae8f35090b83f4a589205b683f773a2588018ae306383fdff65a857fc13be804b20a13f8982a3652eea9006ad3e4767f520abe82f199dbe74870cec8e466cb98ff00002a00000000008a0102a9c1d59ef327d76207a8a26373c89b9d0d0eb25d6ca7551a3f291c8c6cd3f0b45a36669e8960989d59bb5412a3aea7a6c0d994375d43bf812f38c96eedaf712506226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f00002a0000000000657313c00101009000000000000003e8000000640000000a000000003b9aca00000000000138ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f701fdc25600010124f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d24000000002b40420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b04752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52ae0002e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285500fe00061a80fd044cfd00fd010101010024f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d2400000000fefffffffdfe2faf0800fe0bebc2000104220020e4187f51de13d85db945c888357da388e2877ec578316fc25ecac3e716f4866bfe32a627f0fe0bebc2000102027d02000000018c19fefe4b851e9da17f94d44b549d87124aec35a4a85c40c566564c51ada7220200000000000000000250c3000000000000220020778f86e213a518e8336ec940874ccf4b92910332f5b3c0265374a86dbd34e6a5960000000000000016001425deb8d8a6cb84452c47904350e79c523dbfefdb0000000000fefffffffd03d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc49190256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdfd6540000000fe00061a80cc004738ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f79ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d5300000fd02594066195c52f770b513f87862384c79a4ad543fff3a81fa6e3d45e76ebea9bd319a38706e847ae4e53fb9d97111b69f0cbfc86f20c92d35f155855b3de9de804548fd025b40849a87ab6815902abbc4f3d9773517c18eeddc5d10dd949aeccdad068be0e5d8260b54dff0c783ae0c330157954e371fadd82bb084d2fd9de28867a87ca60b70010000fd1388fe32a627f0fe0bebc20000249ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d530000000002b9604100000000000220020e4187f51de13d85db945c888357da388e2877ec578316fc25ecac3e716f4866b475221029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285521029e2ff8460c1ea74f8892509f4c60340d19a1981c4339e2a728ddef8df08eea4b52aefd01bc020000000001019ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d5300000000000620f4680044a0100000000000022002063d3fcf7f93eb1b30ff3d7f185c2889a5fa8c4a584cedd4e0ce3a16e10ea2c994a01000000000000220020a483a289ea09e761fae87c628675e3d52f23fcc6355ff15c3bde6ce2dc86d802400d0300000000002200202a563e407578aae18800c2388759b5bad0ceef5a3759de39d7acedffd868d30dcede0c0000000000220020bf57f237188dde15a4a0947bf64cd985f95163d77fee3d8372cdce323de1efdf04004730440220772d80d88ac5156fb8096ba19129492e66bdf8bea76e750847534f7aafb9621d022075f0480e72f576632ad9b80fbc5e7630951753c8425892adac6eb0352823de12014830450221009ea0e484d4d4c43c46960cb2f182e0460ebe560c036691076e2ddc03e2d87933022042074738e8a9c263694060f5fbcdd42a308d56abca4e5466f8d958d018fc5d0501475221029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285521029e2ff8460c1ea74f8892509f4c60340d19a1981c4339e2a728ddef8df08eea4b52ae65031b20000000fd1388fe0bebc200fe32a627f0b8b2f694372a292f7822c17f2400184c2f70195afc7523987a14f65e7601042d03cb5d3b7310e6532d403dc7ad5857869e6ace3a2a1a7cf710c8a9b307a5ebb02c00" + ) + val state = Serialization.deserialize(bin).value + assertIs(state) + val splice = state.spliceStatus + assertIs(splice) + assertNull(splice.session.liquidityLease) + assertTrue(splice.session.localCommit.isRight) + assertContentEquals(bin, Serialization.serialize(state).dropLast(2).toByteArray()) // we add a discriminator byte and the liquidity lease count (0x0100) + assertTrue(state.liquidityLeases.isEmpty()) + val state1 = state.copy( + liquidityLeases = listOf( + LiquidityAds.Lease(50_000.sat, LiquidityAds.LeaseFees(1337.sat, 1329.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 850_000, 100, 1_000.msat)), + LiquidityAds.Lease(37_000.sat, LiquidityAds.LeaseFees(2500.sat, 4001.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 900_000, 100, 1_000.msat)) + ) + ) + assertEquals(state1, Serialization.deserialize(Serialization.serialize(state1)).value) + } + } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 5ab126227..50fe736e3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -8,9 +8,9 @@ import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomBytes64 import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin +import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.assertArrayEquals import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat @@ -212,16 +212,28 @@ class LightningCodecsTestsCommon : LightningTestSuite() { ), // unknown odd records TestCase(ByteVector("0000 0002088a 03012a04022aa2"), decoded = null), // unknown even records TestCase(ByteVector("0000 0002088a 0120010101010101010101010101010101010101010101010101010101010101"), decoded = null), // invalid tlv stream - TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101"), Init(Features(ByteVector("088a")), listOf(chainHash1))), // single network + TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101"), Init(Features(ByteVector("088a")), listOf(chainHash1), listOf())), // single network TestCase( ByteVector("0000 0002088a 014001010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202"), - Init(Features(ByteVector("088a")), listOf(chainHash1, chainHash2)) + Init(Features(ByteVector("088a")), listOf(chainHash1, chainHash2), listOf()) ), // multiple networks TestCase( - ByteVector("0000 0002088a 0120010101010101010101010101010101010101010101010101010101010101010103012a"), + ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101 03012a"), Init(Features(ByteVector("088a")), tlvs = TlvStream(records = setOf(InitTlv.Networks(listOf(chainHash1))), unknown = setOf(GenericTlv(3, ByteVector("2a"))))) ), // network and unknown odd records - TestCase(ByteVector("0000 0002088a 0120010101010101010101010101010101010101010101010101010101010101010102012a"), decoded = null), // network and unknown even records + TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101 02012a"), decoded = null), // network and unknown even records + TestCase( + ByteVector("0000 0002088a fd05391007d001f4003200000000025800000000"), + Init(Features(ByteVector("088a")), chainHashs = listOf(), liquidityRates = listOf(LiquidityAds.LeaseRate(2000, 500, 50, 0.sat, 600, 0.msat))), + ), // one liquidity ads + TestCase( + ByteVector("0000 0002088a fd05392003f0019000c8000061a80064000186a00fc001f401f4000027100096000249f0"), + Init( + Features(ByteVector("088a")), + chainHashs = listOf(), + liquidityRates = listOf(LiquidityAds.LeaseRate(1008, 400, 200, 25_000.sat, 100, 100_000.msat), LiquidityAds.LeaseRate(4032, 500, 500, 10_000.sat, 150, 150_000.msat)) + ), + ), // two liquidity ads ) for (testCase in testCases) { @@ -284,6 +296,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs))) to (defaultEncoded + ByteVector("0103101000")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.PushAmountTlv(25_000.msat))) to (defaultEncoded + ByteVector("0103101000 fe470000070261a8")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequireConfirmedInputsTlv)) to (defaultEncoded + ByteVector("0103101000 0200")), + defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequestFunds(50_000.sat, 2500, 500_000))) to (defaultEncoded + ByteVector("0103101000 fd05390e000000000000c35009c40007a120")), defaultOpen.copy(tlvStream = TlvStream(setOf(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs)), setOf(GenericTlv(321, ByteVector("2a2a")), GenericTlv(325, ByteVector("02"))))) to (defaultEncoded + ByteVector("0103101000 fd0141022a2a fd01450102")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.OriginTlv(Origin.PayToOpenOrigin(ByteVector32.fromValidHex("187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281"), 1_000_000.msat, 1234.sat, 200_000_000.msat)))) to (defaultEncoded + ByteVector("fe47000005 3a 0001 187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281 00000000000004d2 00000000000f4240 000000000bebc200")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.OriginTlv(Origin.PleaseOpenChannelOrigin(ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 1_234_567.msat, 321.sat, 1_111_000.msat)))) to (defaultEncoded + ByteVector("fe47000005 3a 0004 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 0000000000000141 000000000012d687 000000000010f3d8")), @@ -307,6 +320,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { defaultAccept to defaultEncoded, defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.UnsupportedChannelType(Features(Feature.StaticRemoteKey to FeatureSupport.Mandatory))))) to (defaultEncoded + ByteVector("01021000")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector("01abcdef")), ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs))) to (defaultEncoded + ByteVector("000401abcdef 0103101000")), + defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.WillFund(ByteVector64.Zeroes, 750, 150, 250.sat, 100, 5.msat))) to (defaultEncoded + ByteVector("0103101000 fd05394e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ee0096000000fa006400000005")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.PushAmountTlv(1729.msat))) to (defaultEncoded + ByteVector("0103101000 fe470000070206c1")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequireConfirmedInputsTlv)) to (defaultEncoded + ByteVector("0103101000 0200")), defaultAccept.copy(tlvStream = TlvStream(setOf(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs)), setOf(GenericTlv(113, ByteVector("deadbeef"))))) to (defaultEncoded + ByteVector("0103101000 7104deadbeef")), @@ -432,13 +446,15 @@ class LightningCodecsTestsCommon : LightningTestSuite() { val testCases = listOf( // @formatter:off SpliceInit(channelId, 100_000.sat, FeeratePerKw(2500.sat), 100, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), - SpliceInit(channelId, 150_000.sat, 25_000_000.msat, FeeratePerKw(2500.sat), 100, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000249f0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000704017d7840"), + SpliceInit(channelId, 150_000.sat, 25_000_000.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, null) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000249f0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000704017d7840"), SpliceInit(channelId, 0.sat, FeeratePerKw(500.sat), 0, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500.sat), 0, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + SpliceInit(channelId, 100_000.sat, 0.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, ChannelTlv.RequestFunds(100_000.sat, 4000, 850_000)) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05390e00000000000186a00fa0000cf850"), SpliceAck(channelId, 25_000.sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), - SpliceAck(channelId, 40_000.sat, 10_000_000.msat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680"), + SpliceAck(channelId, 40_000.sat, 10_000_000.msat, fundingPubkey, null) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680"), SpliceAck(channelId, 0.sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceAck(channelId, (-25_000).sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + SpliceAck(channelId, 25_000.sat, 0.msat, fundingPubkey, ChannelTlv.WillFund(ByteVector64.Zeroes, 750, 150, 250.sat, 100, 0.msat)) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05394e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ee0096000000fa006400000000"), SpliceLocked(channelId, fundingTxId) to ByteVector("908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"), // @formatter:on ) @@ -766,4 +782,38 @@ class LightningCodecsTestsCommon : LightningTestSuite() { assertArrayEquals(it.second, encoded) } } + + @Test + fun `validate liquidity ads lease`() { + // The following lease has been signed by eclair. + val channelId = randomBytes32() + val remoteNodeId = PublicKey.fromHex("024dd1d24f950df788c124fe855d5a48c632d5fb6e59cf95f7ea6bee2ad47e5bc8") + val fundingScript = ByteVector.fromHex("00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff") + val remoteWillFund = ChannelTlv.WillFund( + sig = ByteVector64("a1b9850389d21b49e074f183e6e1e2d0416e47b4c031843f4cf6f02f68e44ebd5f6ad1baee0b49098c517ac1f04fee6c58335e64ed45f5b0e4ce4b8546cbba09"), + fundingWeight = 500, + leaseFeeProportional = 100, + leaseFeeBase = 10.sat, + maxRelayFeeProportional = 250, + maxRelayFeeBase = 2000.msat, + ) + assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 500_000.sat).total, 5635.sat) + assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 600_000.sat).total, 5635.sat) + assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 400_000.sat).total, 4635.sat) + + data class TestCase(val remoteFundingAmount: Satoshi, val feerate: FeeratePerKw, val willFund: ChannelTlv.WillFund?, val failure: ChannelException?) + + val testCases = listOf( + TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = null), + TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), willFund = null, failure = MissingLiquidityAds(channelId)), + TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund.copy(sig = randomBytes64()), failure = InvalidLiquidityAdsSig(channelId)), + TestCase(0.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = InvalidLiquidityAdsAmount(channelId, 0.sat, 500_000.sat)), + ) + testCases.forEach { + val request = LiquidityAds.RequestRemoteFunding(500_000.sat, leaseStart = 820_000, rate = remoteWillFund.leaseRate(leaseDuration = 0)) + val result = request.validateLease(remoteNodeId, channelId, fundingScript, it.remoteFundingAmount, it.feerate, it.willFund) + assertEquals(result.left, it.failure) + } + + } } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json b/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json index ba3b527c2..1972b21d2 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json @@ -186,5 +186,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json b/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json index 329b43d0a..f5e6d6451 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json @@ -361,5 +361,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json index c47184a79..85e46b5a8 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json @@ -199,5 +199,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json index 43ba5c258..fdf1580ff 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json @@ -369,5 +369,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json b/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json index 4509cfc83..77e09eece 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json @@ -218,5 +218,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json b/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json index 70a9ea4cd..60ae64d06 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json @@ -259,5 +259,7 @@ }, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json b/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json index 19d128f35..4add406ad 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json @@ -230,5 +230,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json index 30f347162..4ab6752e6 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json @@ -197,5 +197,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json index b2cee2e2d..43df4d86f 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json @@ -367,5 +367,7 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None" + "spliceStatus": "None", + "liquidityLeases": [ + ] } \ No newline at end of file