Skip to content

Commit a97dd41

Browse files
committed
Add on_the_fly_funding feature bit and messages
Add the (disabled by default) `on_the_fly_funding` feature bit and codecs for the corresponding messages: - `will_add_htlc` - `will_fail_htlc` - `will_fail_malformed_htlc` - `cancel_on_the_fly_funding` We also add a TLV to `update_add_htlc` to notify the recipient that we relayed less data than what the onion encodes, in exchange for the fees of the specified funding transaction.
1 parent a14de11 commit a97dd41

9 files changed

Lines changed: 299 additions & 53 deletions

File tree

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ eclair {
8383
keysend = disabled
8484
trampoline_payment_prototype = disabled
8585
async_payment_prototype = disabled
86+
on_the_fly_funding = disabled
8687
}
8788
// The following section lets you customize features for specific nodes.
8889
// The overrides will be applied on top of the default features settings.

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,15 @@ object Features {
323323
val mandatory = 154
324324
}
325325

326+
/**
327+
* Activate this feature to provide on-the-fly funding to remote nodes, as specified in bLIP 36: https://github.com/lightning/blips/blob/master/blip-0036.md.
328+
* TODO: add NodeFeature once bLIP is merged.
329+
*/
330+
case object OnTheFlyFunding extends Feature with InitFeature {
331+
val rfcName = "on_the_fly_funding"
332+
val mandatory = 560
333+
}
334+
326335
val knownFeatures: Set[Feature] = Set(
327336
DataLossProtect,
328337
InitialRoutingSync,
@@ -349,6 +358,7 @@ object Features {
349358
TrampolinePaymentPrototype,
350359
AsyncPaymentPrototype,
351360
SplicePrototype,
361+
OnTheFlyFunding
352362
)
353363

354364
// Features may depend on other features, as specified in Bolt 9.
@@ -361,7 +371,8 @@ object Features {
361371
RouteBlinding -> (VariableLengthOnion :: Nil),
362372
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
363373
KeySend -> (VariableLengthOnion :: Nil),
364-
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil)
374+
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
375+
OnTheFlyFunding -> (SplicePrototype :: Nil)
365376
)
366377

367378
case class FeatureException(message: String) extends IllegalArgumentException(message)

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ object InteractiveTxBuilder {
154154
val minNextFeerate: FeeratePerKw = targetFeerate * 25 / 24
155155
// BOLT 2: the initiator's serial IDs MUST use even values and the non-initiator odd values.
156156
val serialIdParity: Int = if (isInitiator) 0 else 1
157+
158+
def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): Satoshi = {
159+
liquidityPurchase_opt.map(l => l.paymentDetails match {
160+
// The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used).
161+
case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => if (isInitiator) l.fees.total else -l.fees.total
162+
// Fees will be paid later, when relaying HTLCs.
163+
case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0.sat
164+
}).getOrElse(0 sat)
165+
}
157166
}
158167

159168
// @formatter:off
@@ -357,10 +366,7 @@ object InteractiveTxBuilder {
357366
Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(channelParams.remoteParams.nodeId), channelId_opt = Some(fundingParams.channelId))) {
358367
Behaviors.receiveMessagePartial {
359368
case Start(replyTo) =>
360-
val liquidityFee = liquidityPurchase_opt.map(l => l.paymentDetails match {
361-
// The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used).
362-
case LiquidityAds.PaymentDetails.FromChannelBalance => if (fundingParams.isInitiator) l.fees.total else -l.fees.total
363-
}).getOrElse(0 sat)
369+
val liquidityFee = fundingParams.liquidityFees(liquidityPurchase_opt)
364370
// Note that pending HTLCs are ignored: splices only affect the main outputs.
365371
val nextLocalBalance = purpose.previousLocalBalance + fundingParams.localContribution - localPushAmount + remotePushAmount - liquidityFee
366372
val nextRemoteBalance = purpose.previousRemoteBalance + fundingParams.remoteContribution - remotePushAmount + localPushAmount + liquidityFee
@@ -757,10 +763,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
757763
private def signCommitTx(completeTx: SharedTransaction): Behavior[Command] = {
758764
val fundingTx = completeTx.buildUnsignedTx()
759765
val fundingOutputIndex = fundingTx.txOut.indexWhere(_.publicKeyScript == fundingPubkeyScript)
760-
val liquidityFee = liquidityPurchase_opt.map(l => l.paymentDetails match {
761-
// The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used).
762-
case LiquidityAds.PaymentDetails.FromChannelBalance => if (fundingParams.isInitiator) l.fees.total else -l.fees.total
763-
}).getOrElse(0 sat)
766+
val liquidityFee = fundingParams.liquidityFees(liquidityPurchase_opt)
764767
Funding.makeCommitTxs(keyManager, channelParams,
765768
fundingAmount = fundingParams.fundingAmount,
766769
toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount - liquidityFee,

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/HtlcTlv.scala

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,15 @@ object UpdateAddHtlcTlv {
3636

3737
private val blindingPoint: Codec[BlindingPoint] = (("length" | constant(hex"21")) :: ("blinding" | publicKey)).as[BlindingPoint]
3838

39-
val addHtlcTlvCodec: Codec[TlvStream[UpdateAddHtlcTlv]] = tlvStream(discriminated[UpdateAddHtlcTlv].by(varint).typecase(UInt64(0), blindingPoint))
39+
/** When on-the-fly funding is used, the liquidity fees may be taken from HTLCs relayed after funding. */
40+
case class FundingFeeTlv(fee: LiquidityAds.FundingFee) extends UpdateAddHtlcTlv
41+
42+
private val fundingFee: Codec[FundingFeeTlv] = tlvField((("amount" | millisatoshi) :: ("txId" | txIdAsHash)).as[LiquidityAds.FundingFee])
43+
44+
val addHtlcTlvCodec: Codec[TlvStream[UpdateAddHtlcTlv]] = tlvStream(discriminated[UpdateAddHtlcTlv].by(varint)
45+
.typecase(UInt64(0), blindingPoint)
46+
.typecase(UInt64(41041), fundingFee)
47+
)
4048
}
4149

4250
sealed trait UpdateFulfillHtlcTlv extends Tlv

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,31 @@ object LightningMessageCodecs {
435435
("commitmentFeerate" | feeratePerKw) ::
436436
("tlvStream" | RecommendedFeeratesTlv.recommendedFeeratesTlvCodec)).as[RecommendedFeerates]
437437

438+
val willAddHtlcCodec: Codec[WillAddHtlc] = (
439+
("chainHash" | blockHash) ::
440+
("id" | bytes32) ::
441+
("amount" | millisatoshi) ::
442+
("paymentHash" | bytes32) ::
443+
("expiry" | cltvExpiry) ::
444+
("onionRoutingPacket" | PaymentOnionCodecs.paymentOnionPacketCodec) ::
445+
("tlvStream" | WillAddHtlcTlv.willAddHtlcTlvCodec)).as[WillAddHtlc]
446+
447+
val willFailHtlcCodec: Codec[WillFailHtlc] = (
448+
("id" | bytes32) ::
449+
("paymentHash" | bytes32) ::
450+
("reason" | varsizebinarydata)).as[WillFailHtlc]
451+
452+
val willFailMalformedHtlcCodec: Codec[WillFailMalformedHtlc] = (
453+
("id" | bytes32) ::
454+
("paymentHash" | bytes32) ::
455+
("onionHash" | bytes32) ::
456+
("failureCode" | uint16)).as[WillFailMalformedHtlc]
457+
458+
val cancelOnTheFlyFundingCodec: Codec[CancelOnTheFlyFunding] = (
459+
("channelId" | bytes32) ::
460+
("paymentHashes" | listOfN(uint16, bytes32)) ::
461+
("reason" | varsizebinarydata)).as[CancelOnTheFlyFunding]
462+
438463
val unknownMessageCodec: Codec[UnknownMessage] = (
439464
("tag" | uint16) ::
440465
("message" | bytes)
@@ -487,6 +512,11 @@ object LightningMessageCodecs {
487512

488513
//
489514
//
515+
.typecase(41041, willAddHtlcCodec)
516+
.typecase(41042, willFailHtlcCodec)
517+
.typecase(41043, willFailMalformedHtlcCodec)
518+
.typecase(41044, cancelOnTheFlyFundingCodec)
519+
//
490520
.typecase(37000, spliceInitCodec)
491521
.typecase(37002, spliceAckCodec)
492522
.typecase(37004, spliceLockedCodec)

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ sealed trait InteractiveTxConstructionMessage extends InteractiveTxMessage // <-
4444
sealed trait HtlcMessage extends LightningMessage
4545
sealed trait RoutingMessage extends LightningMessage
4646
sealed trait AnnouncementMessage extends RoutingMessage // <- not in the spec
47+
sealed trait OnTheFlyFundingMessage extends LightningMessage { def paymentHash: ByteVector32 }
48+
sealed trait OnTheFlyFundingFailureMessage extends OnTheFlyFundingMessage { def id: ByteVector32 }
4749
sealed trait HasTimestamp extends LightningMessage { def timestamp: TimestampSecond }
4850
sealed trait HasTemporaryChannelId extends LightningMessage { def temporaryChannelId: ByteVector32 } // <- not in the spec
4951
sealed trait HasChannelId extends LightningMessage { def channelId: ByteVector32 } // <- not in the spec
@@ -363,6 +365,7 @@ case class UpdateAddHtlc(channelId: ByteVector32,
363365
onionRoutingPacket: OnionRoutingPacket,
364366
tlvStream: TlvStream[UpdateAddHtlcTlv]) extends HtlcMessage with UpdateMessage with HasChannelId {
365367
val blinding_opt: Option[PublicKey] = tlvStream.get[UpdateAddHtlcTlv.BlindingPoint].map(_.publicKey)
368+
val fundingFee_opt: Option[LiquidityAds.FundingFee] = tlvStream.get[UpdateAddHtlcTlv.FundingFeeTlv].map(_.fee)
366369

367370
/** When storing in our DB, we avoid wasting storage with unknown data. */
368371
def removeUnknownTlvs(): UpdateAddHtlc = this.copy(tlvStream = tlvStream.copy(unknown = Set.empty))
@@ -375,8 +378,12 @@ object UpdateAddHtlc {
375378
paymentHash: ByteVector32,
376379
cltvExpiry: CltvExpiry,
377380
onionRoutingPacket: OnionRoutingPacket,
378-
blinding_opt: Option[PublicKey]): UpdateAddHtlc = {
379-
val tlvs = blinding_opt.map(UpdateAddHtlcTlv.BlindingPoint).toSet[UpdateAddHtlcTlv]
381+
blinding_opt: Option[PublicKey],
382+
fundingFee_opt: Option[LiquidityAds.FundingFee] = None): UpdateAddHtlc = {
383+
val tlvs = Set(
384+
blinding_opt.map(UpdateAddHtlcTlv.BlindingPoint),
385+
fundingFee_opt.map(UpdateAddHtlcTlv.FundingFeeTlv),
386+
).flatten[UpdateAddHtlcTlv]
380387
UpdateAddHtlc(channelId, id, amountMsat, paymentHash, cltvExpiry, onionRoutingPacket, TlvStream(tlvs))
381388
}
382389
}
@@ -615,4 +622,51 @@ case class RecommendedFeerates(chainHash: BlockHash, fundingFeerate: FeeratePerK
615622
val maxCommitmentFeerate: FeeratePerKw = tlvStream.get[RecommendedFeeratesTlv.CommitmentFeerateRange].map(_.max).getOrElse(commitmentFeerate)
616623
}
617624

625+
/**
626+
* This message is sent when an HTLC couldn't be relayed to our node because we don't have enough inbound liquidity.
627+
* This allows us to treat it as an incoming payment, and request on-the-fly liquidity accordingly if we wish to receive that payment.
628+
* If we accept the payment, we will send an [[OpenDualFundedChannel]] or [[SpliceInit]] message containing [[ChannelTlv.RequestFundingTlv]].
629+
* Our peer will then provide the requested funding liquidity and will relay the corresponding HTLC(s) afterwards.
630+
*/
631+
case class WillAddHtlc(chainHash: BlockHash,
632+
id: ByteVector32,
633+
amount: MilliSatoshi,
634+
paymentHash: ByteVector32,
635+
expiry: CltvExpiry,
636+
finalPacket: OnionRoutingPacket,
637+
tlvStream: TlvStream[WillAddHtlcTlv] = TlvStream.empty) extends OnTheFlyFundingMessage {
638+
val blinding_opt: Option[PublicKey] = tlvStream.get[WillAddHtlcTlv.BlindingPoint].map(_.publicKey)
639+
}
640+
641+
object WillAddHtlc {
642+
def apply(chainHash: BlockHash,
643+
id: ByteVector32,
644+
amount: MilliSatoshi,
645+
paymentHash: ByteVector32,
646+
expiry: CltvExpiry,
647+
finalPacket: OnionRoutingPacket,
648+
blinding_opt: Option[PublicKey]): WillAddHtlc = {
649+
val tlvs = blinding_opt.map(WillAddHtlcTlv.BlindingPoint).toSet[WillAddHtlcTlv]
650+
WillAddHtlc(chainHash, id, amount, paymentHash, expiry, finalPacket, TlvStream(tlvs))
651+
}
652+
}
653+
654+
/** This message is similar to [[UpdateFailHtlc]], but for [[WillAddHtlc]]. */
655+
case class WillFailHtlc(id: ByteVector32, paymentHash: ByteVector32, reason: ByteVector) extends OnTheFlyFundingFailureMessage
656+
657+
/** This message is similar to [[UpdateFailMalformedHtlc]], but for [[WillAddHtlc]]. */
658+
case class WillFailMalformedHtlc(id: ByteVector32, paymentHash: ByteVector32, onionHash: ByteVector32, failureCode: Int) extends OnTheFlyFundingFailureMessage
659+
660+
/**
661+
* This message is sent in response to an [[OpenDualFundedChannel]] or [[SpliceInit]] message containing an invalid [[LiquidityAds.RequestFunding]].
662+
* The receiver must consider the funding attempt failed when receiving this message.
663+
*/
664+
case class CancelOnTheFlyFunding(channelId: ByteVector32, paymentHashes: List[ByteVector32], reason: ByteVector) extends LightningMessage with HasChannelId {
665+
def toAscii: String = if (isAsciiPrintable(reason)) new String(reason.toArray, StandardCharsets.US_ASCII) else "n/a"
666+
}
667+
668+
object CancelOnTheFlyFunding {
669+
def apply(channelId: ByteVector32, paymentHashes: List[ByteVector32], reason: String): CancelOnTheFlyFunding = CancelOnTheFlyFunding(channelId, paymentHashes, ByteVector.view(reason.getBytes(Charsets.US_ASCII)))
670+
}
671+
618672
case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ package fr.acinq.eclair.wire.protocol
1818

1919
import com.google.common.base.Charsets
2020
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
21-
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Satoshi}
21+
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Satoshi, TxId}
2222
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2323
import fr.acinq.eclair.channel._
2424
import fr.acinq.eclair.transactions.Transactions
2525
import fr.acinq.eclair.wire.protocol.CommonCodecs._
26-
import fr.acinq.eclair.wire.protocol.TlvCodecs.tlvField
27-
import fr.acinq.eclair.{ToMilliSatoshiConversion, UInt64}
26+
import fr.acinq.eclair.wire.protocol.TlvCodecs.{genericTlv, tlvField, tsatoshi32}
27+
import fr.acinq.eclair.{MilliSatoshi, ToMilliSatoshiConversion, UInt64}
2828
import scodec.Codec
2929
import scodec.bits.{BitVector, ByteVector}
3030
import scodec.codecs._
@@ -48,6 +48,9 @@ object LiquidityAds {
4848
val total: Satoshi = miningFee + serviceFee
4949
}
5050

51+
/** Fees paid for the funding transaction that provides liquidity. */
52+
case class FundingFee(amount: MilliSatoshi, fundingTxId: TxId)
53+
5154
/**
5255
* Rate at which a liquidity seller sells its liquidity.
5356
* Liquidity fees are computed based on multiple components.
@@ -92,6 +95,12 @@ object LiquidityAds {
9295
// @formatter:off
9396
/** Fees are transferred from the buyer's channel balance to the seller's during the interactive-tx construction. */
9497
case object FromChannelBalance extends PaymentType { override val rfcName: String = "from_channel_balance" }
98+
/** Fees will be deducted from future HTLCs that will be relayed to the buyer. */
99+
case object FromFutureHtlc extends PaymentType { override val rfcName: String = "from_future_htlc" }
100+
/** Fees will be deducted from future HTLCs that will be relayed to the buyer, but the preimage is revealed immediately. */
101+
case object FromFutureHtlcWithPreimage extends PaymentType { override val rfcName: String = "from_future_htlc_with_preimage" }
102+
/** Similar to [[FromChannelBalance]] but expects HTLCs to be relayed after funding. */
103+
case object FromChannelBalanceForFutureHtlc extends PaymentType { override val rfcName: String = "from_channel_balance_for_future_htlc" }
95104
/** Sellers may support unknown payment types, which we must ignore. */
96105
case class Unknown(bitIndex: Int) extends PaymentType { override val rfcName: String = s"unknown_$bitIndex" }
97106
// @formatter:on
@@ -105,6 +114,9 @@ object LiquidityAds {
105114
object PaymentDetails {
106115
// @formatter:off
107116
case object FromChannelBalance extends PaymentDetails { override val paymentType: PaymentType = PaymentType.FromChannelBalance }
117+
case class FromFutureHtlc(paymentHashes: List[ByteVector32]) extends PaymentDetails { override val paymentType: PaymentType = PaymentType.FromFutureHtlc }
118+
case class FromFutureHtlcWithPreimage(preimages: List[ByteVector32]) extends PaymentDetails { override val paymentType: PaymentType = PaymentType.FromFutureHtlcWithPreimage }
119+
case class FromChannelBalanceForFutureHtlc(paymentHashes: List[ByteVector32]) extends PaymentDetails { override val paymentType: PaymentType = PaymentType.FromChannelBalanceForFutureHtlc }
108120
// @formatter:on
109121
}
110122

@@ -223,6 +235,9 @@ object LiquidityAds {
223235

224236
private val paymentDetails: Codec[PaymentDetails] = discriminated[PaymentDetails].by(varint)
225237
.typecase(UInt64(0), tlvField(provide(PaymentDetails.FromChannelBalance)))
238+
.typecase(UInt64(128), tlvField(list(bytes32).as[PaymentDetails.FromFutureHtlc]))
239+
.typecase(UInt64(129), tlvField(list(bytes32).as[PaymentDetails.FromFutureHtlcWithPreimage]))
240+
.typecase(UInt64(130), tlvField(list(bytes32).as[PaymentDetails.FromChannelBalanceForFutureHtlc]))
226241

227242
val requestFunding: Codec[RequestFunding] = (
228243
("requestedAmount" | satoshi) ::
@@ -240,12 +255,18 @@ object LiquidityAds {
240255
f = { bytes =>
241256
bytes.bits.toIndexedSeq.reverse.zipWithIndex.collect {
242257
case (true, 0) => PaymentType.FromChannelBalance
258+
case (true, 128) => PaymentType.FromFutureHtlc
259+
case (true, 129) => PaymentType.FromFutureHtlcWithPreimage
260+
case (true, 130) => PaymentType.FromChannelBalanceForFutureHtlc
243261
case (true, idx) => PaymentType.Unknown(idx)
244262
}.toSet
245263
},
246264
g = { paymentTypes =>
247265
val indexes = paymentTypes.collect {
248266
case PaymentType.FromChannelBalance => 0
267+
case PaymentType.FromFutureHtlc => 128
268+
case PaymentType.FromFutureHtlcWithPreimage => 129
269+
case PaymentType.FromChannelBalanceForFutureHtlc => 130
249270
case PaymentType.Unknown(idx) => idx
250271
}
251272
// When converting from BitVector to ByteVector, scodec pads right instead of left, so we make sure we pad to bytes *before* setting bits.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2024 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package fr.acinq.eclair.wire.protocol
18+
19+
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
20+
import fr.acinq.eclair.UInt64
21+
import fr.acinq.eclair.wire.protocol.CommonCodecs.{publicKey, varint}
22+
import fr.acinq.eclair.wire.protocol.TlvCodecs.tlvStream
23+
import scodec.Codec
24+
import scodec.bits.HexStringSyntax
25+
import scodec.codecs._
26+
27+
/**
28+
* Created by t-bast on 07/06/2024.
29+
*/
30+
31+
sealed trait WillAddHtlcTlv extends Tlv
32+
33+
object WillAddHtlcTlv {
34+
/** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */
35+
case class BlindingPoint(publicKey: PublicKey) extends WillAddHtlcTlv
36+
37+
private val blindingPoint: Codec[BlindingPoint] = (("length" | constant(hex"21")) :: ("blinding" | publicKey)).as[BlindingPoint]
38+
39+
val willAddHtlcTlvCodec: Codec[TlvStream[WillAddHtlcTlv]] = tlvStream(discriminated[WillAddHtlcTlv].by(varint)
40+
.typecase(UInt64(0), blindingPoint)
41+
)
42+
}

0 commit comments

Comments
 (0)