Skip to content

Commit cfdb088

Browse files
authored
Extensible Liquidity Ads (#2848)
* Add support for extensible liquidity ads The initiator of `open_channel2`, `tx_init_rbf` and `splice_init` can request funding from the remote node. The non-initiator node will: - let the open-channel-interceptor plugin decide whether to provide liquidity for new channels or not, and how much - always honor liquidity requests on existing channels (RBF and splice) when funding rates have been configured Liquidity ads are included in the `node_announcement` message, which lets buyers compare sellers and connect to sellers that provide rates they are comfortable with. They are also included in the `init` message which allows providing different rates to specific peers. This implements lightning/bolts#1153. We currently use the temporary tlv tag 1339 while we're waiting for feedback on the spec proposal. * Add `channelCreationFee` to liquidity ads Creating a new channel has an additional cost compared to adding liquidity to an existing channel: the channel will be closed in the future, which will require paying on-chain fees. Node operators can include a `channel-creation-fee-satoshis` in their liquidity ads to cover some of that future cost. * Add liquidity purchases to the `AuditDb` Whenever liquidity is purchased, we store it in the `AuditDb`. This lets node operators gather useful statistics on their peers, and which ones are actively using the liquidity that is purchased. We store minimal information about the liquidity ads itself to be more easily compatible with potential changes in the spec.
1 parent 885b45b commit cfdb088

File tree

67 files changed

+1761
-343
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1761
-343
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
## Major changes
66

7+
### Liquidity Ads
8+
9+
This release includes an early prototype for [liquidity ads](https://github.com/lightning/bolts/pull/1153).
10+
Liquidity ads allow nodes to sell their liquidity in a trustless and decentralized manner.
11+
Every node advertizes the rates at which they sell their liquidity, and buyers connect to sellers that offer interesting rates.
12+
13+
The liquidity ads specification is still under review and will likely change.
14+
This feature isn't meant to be used on mainnet yet and is thus disabled by default.
15+
716
### Update minimal version of Bitcoin Core
817

918
With this release, eclair requires using Bitcoin Core 27.1.
@@ -28,6 +37,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
2837

2938
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
3039
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)
40+
- `nodes` allows filtering nodes that offer liquidity ads (#2848)
3141

3242
### Miscellaneous improvements and bug fixes
3343

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,40 @@ eclair {
306306
update-fee-min-diff-ratio = 0.1
307307
}
308308

309+
// Liquidity Ads allow remote nodes to pay us to provide them with inbound liquidity.
310+
liquidity-ads {
311+
// Multiple funding rates can be provided, for different funding amounts.
312+
funding-rates = []
313+
// Sample funding rates:
314+
// funding-rates = [
315+
// {
316+
// min-funding-amount-satoshis = 100000 // minimum funding amount at this rate
317+
// max-funding-amount-satoshis = 500000 // maximum funding amount at this rate
318+
// // The seller can ask the buyer to pay for some of the weight of the funding transaction (for the inputs and
319+
// // outputs added by the seller). This field contains the transaction weight (in vbytes) that the seller asks the
320+
// // buyer to pay for. The default value matches the weight of one p2wpkh input with one p2wpkh change output.
321+
// funding-weight = 400
322+
// fee-base-satoshis = 500 // flat fee that we will receive every time we accept a liquidity request
323+
// fee-basis-points = 250 // proportional fee based on the amount requested by our peer (2.5%)
324+
// channel-creation-fee-satoshis = 2500 // flat fee that is added when creating a new channel
325+
// },
326+
// {
327+
// min-funding-amount-satoshis = 500000
328+
// max-funding-amount-satoshis = 5000000
329+
// funding-weight = 750
330+
// fee-base-satoshis = 1000
331+
// fee-basis-points = 200 // 2%
332+
// channel-creation-fee-satoshis = 2000
333+
// }
334+
// ]
335+
// Multiple ways of paying the liquidity fees can be provided.
336+
payment-types = [
337+
// Liquidity fees must be paid from the buyer's channel balance during the transaction creation.
338+
// This doesn't involve trust from the buyer or the seller.
339+
"from_channel_balance"
340+
]
341+
}
342+
309343
peer-connection {
310344
auth-timeout = 15 seconds // will disconnect if connection authentication doesn't happen within that timeframe
311345
init-timeout = 15 seconds // will disconnect if initialization doesn't happen within that timeframe

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
220220
pushAmount_opt = pushAmount_opt,
221221
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
222222
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
223+
requestFunding_opt = None,
223224
channelFlags_opt = announceChannel_opt.map(announceChannel => ChannelFlags(announceChannel = announceChannel)),
224225
timeout_opt = Some(openTimeout))
225226
res <- (appKit.switchboard ? open).mapTo[OpenChannelResponse]
@@ -228,14 +229,15 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
228229

229230
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
230231
sendToChannelTyped(channel = Left(channelId),
231-
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong)))
232+
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None))
232233
}
233234

234235
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
235236
sendToChannelTyped(channel = Left(channelId),
236237
cmdBuilder = CMD_SPLICE(_,
237238
spliceIn_opt = Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))),
238-
spliceOut_opt = None
239+
spliceOut_opt = None,
240+
requestFunding_opt = None,
239241
))
240242
}
241243

@@ -250,7 +252,8 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
250252
sendToChannelTyped(channel = Left(channelId),
251253
cmdBuilder = CMD_SPLICE(_,
252254
spliceIn_opt = None,
253-
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script))
255+
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script)),
256+
requestFunding_opt = None,
254257
))
255258
}
256259

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair
1818

1919
import com.typesafe.config.{Config, ConfigFactory, ConfigValueType}
2020
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
21-
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi}
21+
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi, SatoshiLong}
2222
import fr.acinq.eclair.Setup.Seeds
2323
import fr.acinq.eclair.blockchain.fee._
2424
import fr.acinq.eclair.channel.ChannelFlags
@@ -88,6 +88,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
8888
onionMessageConfig: OnionMessageConfig,
8989
purgeInvoicesInterval: Option[FiniteDuration],
9090
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
91+
willFundRates_opt: Option[LiquidityAds.WillFundRates],
9192
peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig) {
9293
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey
9394

@@ -479,6 +480,33 @@ object NodeParams extends Logging {
479480
val maxNoChannels = config.getInt("peer-connection.max-no-channels")
480481
require(maxNoChannels > 0, "peer-connection.max-no-channels must be > 0")
481482

483+
val willFundRates_opt = {
484+
val supportedPaymentTypes = Map(
485+
LiquidityAds.PaymentType.FromChannelBalance.rfcName -> LiquidityAds.PaymentType.FromChannelBalance
486+
)
487+
val paymentTypes: Set[LiquidityAds.PaymentType] = config.getStringList("liquidity-ads.payment-types").asScala.map(s => {
488+
supportedPaymentTypes.get(s) match {
489+
case Some(paymentType) => paymentType
490+
case None => throw new IllegalArgumentException(s"unknown liquidity ads payment type: $s")
491+
}
492+
}).toSet
493+
val fundingRates: List[LiquidityAds.FundingRate] = config.getConfigList("liquidity-ads.funding-rates").asScala.map { r =>
494+
LiquidityAds.FundingRate(
495+
minAmount = r.getLong("min-funding-amount-satoshis").sat,
496+
maxAmount = r.getLong("max-funding-amount-satoshis").sat,
497+
fundingWeight = r.getInt("funding-weight"),
498+
feeBase = r.getLong("fee-base-satoshis").sat,
499+
feeProportional = r.getInt("fee-basis-points"),
500+
channelCreationFee = r.getLong("channel-creation-fee-satoshis").sat,
501+
)
502+
}.toList
503+
if (fundingRates.nonEmpty && paymentTypes.nonEmpty) {
504+
Some(LiquidityAds.WillFundRates(fundingRates, paymentTypes))
505+
} else {
506+
None
507+
}
508+
}
509+
482510
NodeParams(
483511
nodeKeyManager = nodeKeyManager,
484512
channelKeyManager = channelKeyManager,
@@ -615,6 +643,7 @@ object NodeParams extends Logging {
615643
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
616644
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
617645
),
646+
willFundRates_opt = willFundRates_opt,
618647
peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(
619648
enabled = config.getBoolean("peer-wake-up.enabled"),
620649
timeout = FiniteDuration(config.getDuration("peer-wake-up.timeout").getSeconds, TimeUnit.SECONDS)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
2222
import fr.acinq.eclair.channel.Origin
2323
import fr.acinq.eclair.io.OpenChannelInterceptor.{DefaultParams, OpenChannelNonInitiator}
2424
import fr.acinq.eclair.payment.relay.PostRestartHtlcCleaner.IncomingHtlc
25-
import fr.acinq.eclair.wire.protocol.Error
25+
import fr.acinq.eclair.wire.protocol.{Error, LiquidityAds}
2626

2727
/** Custom plugin parameters. */
2828
trait PluginParams {
@@ -67,7 +67,7 @@ case class InterceptOpenChannelReceived(replyTo: ActorRef[InterceptOpenChannelRe
6767
}
6868

6969
sealed trait InterceptOpenChannelResponse
70-
case class AcceptOpenChannel(temporaryChannelId: ByteVector32, defaultParams: DefaultParams, localFundingAmount_opt: Option[Satoshi]) extends InterceptOpenChannelResponse
70+
case class AcceptOpenChannel(temporaryChannelId: ByteVector32, defaultParams: DefaultParams, addFunding_opt: Option[LiquidityAds.AddFunding]) extends InterceptOpenChannelResponse
7171
case class RejectOpenChannel(temporaryChannelId: ByteVector32, error: Error) extends InterceptOpenChannelResponse
7272
// @formatter:on
7373

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningS
2626
import fr.acinq.eclair.io.Peer
2727
import fr.acinq.eclair.transactions.CommitmentSpec
2828
import fr.acinq.eclair.transactions.Transactions._
29-
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
29+
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
3030
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, TimestampMilli, UInt64}
3131
import scodec.bits.ByteVector
3232

@@ -98,6 +98,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
9898
fundingTxFeeBudget_opt: Option[Satoshi],
9999
pushAmount_opt: Option[MilliSatoshi],
100100
requireConfirmedInputs: Boolean,
101+
requestFunding_opt: Option[LiquidityAds.RequestFunding],
101102
localParams: LocalParams,
102103
remote: ActorRef,
103104
remoteInit: Init,
@@ -109,7 +110,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
109110
require(!(channelType.features.contains(Features.ScidAlias) && channelFlags.announceChannel), "option_scid_alias is not compatible with public channels")
110111
}
111112
case class INPUT_INIT_CHANNEL_NON_INITIATOR(temporaryChannelId: ByteVector32,
112-
fundingContribution_opt: Option[Satoshi],
113+
fundingContribution_opt: Option[LiquidityAds.AddFunding],
113114
dualFunded: Boolean,
114115
pushAmount_opt: Option[MilliSatoshi],
115116
localParams: LocalParams,
@@ -214,10 +215,10 @@ final case class CMD_CLOSE(replyTo: ActorRef, scriptPubKey: Option[ByteVector],
214215
final case class CMD_FORCECLOSE(replyTo: ActorRef) extends CloseCommand
215216
final case class CMD_BUMP_FORCE_CLOSE_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]], confirmationTarget: ConfirmationTarget) extends Command
216217

217-
final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long) extends Command
218+
final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends Command
218219
case class SpliceIn(additionalLocalFunding: Satoshi, pushAmount: MilliSatoshi = 0 msat)
219220
case class SpliceOut(amount: Satoshi, scriptPubKey: ByteVector)
220-
final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_SPLICE]], spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut]) extends Command {
221+
final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_SPLICE]], spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends Command {
221222
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined, "there must be a splice-in or a splice-out")
222223
val additionalLocalFunding: Satoshi = spliceIn_opt.map(_.additionalLocalFunding).getOrElse(0 sat)
223224
val pushAmount: MilliSatoshi = spliceIn_opt.map(_.pushAmount).getOrElse(0 msat)

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelEvents.scala

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ package fr.acinq.eclair.channel
1818

1919
import akka.actor.ActorRef
2020
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
21-
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction}
21+
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction, TxId}
2222
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2323
import fr.acinq.eclair.channel.Helpers.Closing.ClosingType
24-
import fr.acinq.eclair.io.Peer.OpenChannelResponse
25-
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate}
26-
import fr.acinq.eclair.{BlockHeight, Features, ShortChannelId}
24+
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, LiquidityAds}
25+
import fr.acinq.eclair.{BlockHeight, Features, MilliSatoshi, ShortChannelId}
2726

2827
/**
2928
* Created by PM on 17/08/2016.
@@ -79,6 +78,14 @@ case class ChannelSignatureSent(channel: ActorRef, commitments: Commitments) ext
7978

8079
case class ChannelSignatureReceived(channel: ActorRef, commitments: Commitments) extends ChannelEvent
8180

81+
case class LiquidityPurchase(fundingTxId: TxId, fundingTxIndex: Long, isBuyer: Boolean, amount: Satoshi, fees: LiquidityAds.Fees, capacity: Satoshi, localContribution: Satoshi, remoteContribution: Satoshi, localBalance: MilliSatoshi, remoteBalance: MilliSatoshi, outgoingHtlcCount: Long, incomingHtlcCount: Long) {
82+
val previousCapacity: Satoshi = capacity - localContribution - remoteContribution
83+
val previousLocalBalance: MilliSatoshi = if (isBuyer) localBalance - localContribution + fees.total else localBalance - localContribution - fees.total
84+
val previousRemoteBalance: MilliSatoshi = if (isBuyer) remoteBalance - remoteContribution - fees.total else remoteBalance - remoteContribution + fees.total
85+
}
86+
87+
case class ChannelLiquidityPurchased(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, purchase: LiquidityPurchase) extends ChannelEvent
88+
8289
case class ChannelErrorOccurred(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, error: ChannelError, isFatal: Boolean) extends ChannelEvent
8390

8491
// NB: the fee should be set to 0 when we're not paying it.

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ package fr.acinq.eclair.channel
1919
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, Satoshi, Transaction, TxId}
2020
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2121
import fr.acinq.eclair.wire.protocol
22-
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, InteractiveTxMessage, UpdateAddHtlc}
22+
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, InteractiveTxMessage, LiquidityAds, UpdateAddHtlc}
2323
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, UInt64}
2424
import scodec.bits.ByteVector
2525

@@ -51,6 +51,11 @@ case class ToSelfDelayTooHigh (override val channelId: Byte
5151
case class ChannelReserveTooHigh (override val channelId: ByteVector32, channelReserve: Satoshi, reserveToFundingRatio: Double, maxReserveToFundingRatio: Double) extends ChannelException(channelId, s"channelReserve too high: reserve=$channelReserve fundingRatio=$reserveToFundingRatio maxFundingRatio=$maxReserveToFundingRatio")
5252
case class ChannelReserveBelowOurDustLimit (override val channelId: ByteVector32, channelReserve: Satoshi, dustLimit: Satoshi) extends ChannelException(channelId, s"their channelReserve=$channelReserve is below our dustLimit=$dustLimit")
5353
case class ChannelReserveNotMet (override val channelId: ByteVector32, toLocal: MilliSatoshi, toRemote: MilliSatoshi, reserve: Satoshi) extends ChannelException(channelId, s"channel reserve is not met toLocal=$toLocal toRemote=$toRemote reserve=$reserve")
54+
case class MissingLiquidityAds (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads field is missing")
55+
case class InvalidLiquidityAdsSig (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads signature is invalid")
56+
case class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, proposed: Satoshi, min: Satoshi) extends ChannelException(channelId, s"liquidity ads funding amount is too low (expected at least $min, got $proposed)")
57+
case class InvalidLiquidityAdsPaymentType (override val channelId: ByteVector32, proposed: LiquidityAds.PaymentType, allowed: Set[LiquidityAds.PaymentType]) extends ChannelException(channelId, s"liquidity ads ${proposed.rfcName} payment type is not supported (allowed=${allowed.map(_.rfcName).mkString(", ")})")
58+
case class InvalidLiquidityAdsRate (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads funding rates don't match")
5459
case class ChannelFundingError (override val channelId: ByteVector32) extends ChannelException(channelId, "channel funding error")
5560
case class InvalidFundingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid funding tx")
5661
case class InvalidSerialId (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"invalid serial_id=${serialId.toByteVector.toHex}")

0 commit comments

Comments
 (0)