Skip to content

Commit

Permalink
Split fees between liquidity ads and swap-in (#709)
Browse files Browse the repository at this point in the history
A channel open triggered by a swap-in is technically also a liquidity operation (with 1 sat liquidity required), thus causing two consecutive db events: a liquidity purchase followed by a swap-in. Both actions happen with the same on-chain transaction and we must be careful not to count mining fees twice.

---------

Co-authored-by: Bastien Teinturier <[email protected]>
  • Loading branch information
pm47 and t-bast authored Oct 9, 2024
1 parent 9bfdfe6 commit e16e7f5
Show file tree
Hide file tree
Showing 6 changed files with 44 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,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 purchase: LiquidityAds.Purchase) : StoreOutgoingPayment()
data class ViaInboundLiquidityRequest(override val txId: TxId, val localMiningFees: Satoshi, val purchase: LiquidityAds.Purchase) : StoreOutgoingPayment() { override val miningFees: Satoshi = localMiningFees + purchase.fees.miningFee }
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()
Expand Down
39 changes: 18 additions & 21 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -874,26 +874,30 @@ data class Normal(
action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) }
add(ChannelAction.Blockchain.SendWatch(watchConfirmed))
add(ChannelAction.Message.Send(action.localSigs))
// If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db
addAll(origins.map { origin ->
ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn(
amountReceived = origin.amountReceived(),
serviceFee = origin.fees.serviceFee.toMilliSatoshi(),
miningFee = origin.fees.miningFee,
localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(),
txId = action.fundingTx.txId,
origin = origin
)
})
// If we added some funds ourselves it's a swap-in
// If we purchased liquidity as part of the splice, we will add it to our payments db.
liquidityPurchase?.let { purchase ->
// If we are purchasing liquidity without any other operation (splice-in, splice-out or splice-cpfp),
// we must include the mining fees we're paying for the shared input and shared output.
// Otherwise, we only count the mining fees that we must refund to our peer as part of the liquidity
// purchase: the mining fees we pay for our inputs/outputs and the shared input/output will be recorded
// in the dedicated splice entry below.
val isPurchaseOnly = action.fundingTx.sharedTx.tx.let {
action.fundingTx.fundingParams.isInitiator && it.localInputs.isEmpty() && it.localOutputs.isEmpty() && it.remoteInputs.isNotEmpty()
}
val localMiningFees = if (isPurchaseOnly) action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() else 0.sat
add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, localMiningFees = localMiningFees, purchase = purchase))
add(ChannelAction.EmitEvent(LiquidityEvents.Purchased(purchase)))
}
// NB: the following assumes that there can't be a splice-in and a splice-out simultaneously,
// or more than one splice-out, because we attribute all local mining fees to each payment entry.
if (action.fundingTx.sharedTx.tx.localInputs.isNotEmpty()) add(
ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn(
amountReceived = 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.localFees.truncateToSatoshi(),
localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(),
txId = action.fundingTx.txId,
origin = null
origin = origins.filterIsInstance<Origin.OnChainWallet>().firstOrNull()
)
)
addAll(action.fundingTx.fundingParams.localOutputs.map { txOut ->
Expand All @@ -904,17 +908,10 @@ data class Normal(
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 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))
}
liquidityPurchase?.let { purchase ->
// 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() + purchase.fees.miningFee
add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, purchase = purchase))
add(ChannelAction.EmitEvent(LiquidityEvents.Purchased(purchase)))
}
origins.filterIsInstance<Origin.OnChainWallet>().forEach { origin ->
add(ChannelAction.EmitEvent(SwapInEvents.Accepted(origin.inputs, origin.amountBeforeFees.truncateToSatoshi(), origin.fees)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import fr.acinq.lightning.blockchain.WatchConfirmed
import fr.acinq.lightning.channel.*
import fr.acinq.lightning.crypto.ShaChain
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.wire.*
import kotlin.math.absoluteValue

Expand Down Expand Up @@ -119,26 +119,29 @@ data class WaitForFundingSigned(
action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) }
add(ChannelAction.Blockchain.SendWatch(watchConfirmed))
add(ChannelAction.Message.Send(action.localSigs))
// If we receive funds as part of the channel creation, we will add it to our payments db
// If we purchased liquidity as part of the channel creation, we will add it to our payments db.
liquidityPurchase?.let { purchase ->
if (channelParams.localParams.isChannelOpener) {
// We only count the mining fees that we must refund to our peer as part of the liquidity purchase.
// If we're also contributing to the funding transaction, the mining fees we pay for our inputs and
// outputs will be recorded in the ViaNewChannel incoming payment entry below.
add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, localMiningFees = 0.sat, purchase = purchase))
add(ChannelAction.EmitEvent(LiquidityEvents.Purchased(purchase)))
}
}
// If we receive funds as part of the channel creation, we will add it to our payments db.
if (action.commitment.localCommit.spec.toLocal > 0.msat) add(
ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel(
amountReceived = action.commitment.localCommit.spec.toLocal,
serviceFee = channelOrigin?.fees?.serviceFee?.toMilliSatoshi() ?: 0.msat,
miningFee = channelOrigin?.fees?.miningFee ?: action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(),
serviceFee = 0.msat,
// We only count the mining fees we're paying for our inputs and outputs.
// The mining fees for the remote inputs and outputs are paid by the remote node.
miningFee = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(),
localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(),
txId = action.fundingTx.txId,
origin = channelOrigin
)
)
liquidityPurchase?.let { purchase ->
if (channelParams.localParams.isChannelOpener) {
// 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() + purchase.fees.miningFee
add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, purchase = purchase))
add(ChannelAction.EmitEvent(LiquidityEvents.Purchased(purchase)))
}
}
listOfNotNull(channelOrigin).filterIsInstance<Origin.OnChainWallet>().forEach { origin ->
add(ChannelAction.EmitEvent(SwapInEvents.Accepted(origin.inputs, origin.amountBeforeFees.truncateToSatoshi(), origin.fees)))
}
Expand Down
7 changes: 4 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r
}

/**
* Payment was added to our fee credit for future on-chain operations (see [Feature.FundingFeeCredit]).
* Payment was added to our fee credit for future on-chain operations (see [fr.acinq.lightning.Feature.FundingFeeCredit]).
* We didn't really receive this amount yet, but we trust our peer to use it for future on-chain operations.
*/
data class AddedToFeeCredit(override val amountReceived: MilliSatoshi) : ReceivedWith() {
Expand Down Expand Up @@ -427,14 +427,15 @@ data class InboundLiquidityOutgoingPayment(
override val id: UUID,
override val channelId: ByteVector32,
override val txId: TxId,
override val miningFees: Satoshi,
val localMiningFees: Satoshi,
val purchase: LiquidityAds.Purchase,
override val createdAt: Long,
override val confirmedAt: Long?,
override val lockedAt: Long?,
) : OnChainOutgoingPayment() {
override val miningFees: Satoshi = localMiningFees + purchase.fees.miningFee
val serviceFees: Satoshi = purchase.fees.serviceFee
override val fees: MilliSatoshi = (miningFees + serviceFees).toMilliSatoshi()
override val fees: MilliSatoshi = (localMiningFees + purchase.fees.total).toMilliSatoshi()
override val amount: MilliSatoshi = fees
override val completedAt: Long? = lockedAt
val fundingFee: LiquidityAds.FundingFee = LiquidityAds.FundingFee(purchase.fees.total.toMilliSatoshi(), txId)
Expand Down
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ class Peer(
id = UUID.randomUUID(),
channelId = channelId,
txId = action.txId,
miningFees = action.miningFees,
localMiningFees = action.localMiningFees,
purchase = action.purchase,
createdAt = currentTimestampMillis(),
confirmedAt = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
splice.fees(TestConstants.feeratePerKw, isChannelCreation = false),
LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)),
)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 0.sat, purchase, 0, null, null)
paymentHandler.db.addOutgoingPayment(payment)
payment
}
Expand Down Expand Up @@ -963,7 +963,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
LiquidityAds.Fees(2000.sat, 3000.sat),
LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)),
)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 100.sat, purchase, 0, null, null)
paymentHandler.db.addOutgoingPayment(payment)

run {
Expand Down Expand Up @@ -999,7 +999,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
LiquidityAds.Fees(2000.sat, 3000.sat),
LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(incomingPayment.paymentHash)),
)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 0.sat, purchase, 0, null, null)
paymentHandler.db.addOutgoingPayment(payment)

val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee)
Expand All @@ -1022,7 +1022,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
250_000.msat,
LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32())),
)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null)
val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 0.sat, purchase, 0, null, null)
paymentHandler.db.addOutgoingPayment(payment)

val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee)
Expand Down

0 comments on commit e16e7f5

Please sign in to comment.