From 41a935b4f39ba9f785205e78f4c8aa4639612f62 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 9 Jul 2024 16:45:03 +0200 Subject: [PATCH] Add funding_fee_credit feature We add an optional feature that lets on-the-fly funding clients accept payments that are too small to pay the fees for an on-the-fly funding. When that happens, the payment amount is added as "fee credit" without performing an on-chain operation. Once enough fee credit has been obtained, we can initiate an on-chain operation to create a channel or a splice by paying part of the fees from the fee credit. This feature makes more efficient use of on-chain transactions by trusting that the seller will honor our fee credit in the future. The fee credit takes precedence over other ways of paying the fees (from the channel balance or future HTLCs), which guarantees that the fee credit eventually converges to 0. --- .../main/scala/fr/acinq/eclair/Features.scala | 14 +- .../fr/acinq/eclair/channel/Helpers.scala | 2 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 5 +- .../channel/fsm/ChannelOpenDualFunded.scala | 3 +- .../channel/fund/InteractiveTxBuilder.scala | 25 +- .../fr/acinq/eclair/db/DualDatabases.scala | 15 + .../acinq/eclair/db/OnTheFlyFundingDb.scala | 10 + .../eclair/db/pg/PgOnTheFlyFundingDb.scala | 43 ++- .../db/sqlite/SqliteOnTheFlyFundingDb.scala | 50 ++- .../scala/fr/acinq/eclair/io/Monitoring.scala | 1 + .../main/scala/fr/acinq/eclair/io/Peer.scala | 161 ++++++-- .../payment/relay/OnTheFlyFunding.scala | 47 ++- .../eclair/wire/protocol/ChannelTlv.scala | 13 + .../protocol/LightningMessageCodecs.scala | 12 + .../wire/protocol/LightningMessageTypes.scala | 17 +- .../eclair/wire/protocol/LiquidityAds.scala | 18 +- .../channel/InteractiveTxBuilderSpec.scala | 89 +++++ ...aitForOpenDualFundedChannelStateSpec.scala | 13 + .../eclair/db/OnTheFlyFundingDbSpec.scala | 23 ++ .../payment/relay/OnTheFlyFundingSpec.scala | 360 ++++++++++++++++-- .../protocol/LightningMessageCodecsSpec.scala | 30 +- .../wire/protocol/LiquidityAdsSpec.scala | 2 +- 22 files changed, 850 insertions(+), 103 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index 7d4e965a6f..c9886b031e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -332,6 +332,14 @@ object Features { val mandatory = 560 } + // TODO: + // - add NodeFeature once stable + // - add link to bLIP + case object FundingFeeCredit extends Feature with InitFeature { + val rfcName = "funding_fee_credit" + val mandatory = 562 + } + val knownFeatures: Set[Feature] = Set( DataLossProtect, InitialRoutingSync, @@ -358,7 +366,8 @@ object Features { TrampolinePaymentPrototype, AsyncPaymentPrototype, SplicePrototype, - OnTheFlyFunding + OnTheFlyFunding, + FundingFeeCredit ) // Features may depend on other features, as specified in Bolt 9. @@ -372,7 +381,8 @@ object Features { TrampolinePaymentPrototype -> (PaymentSecret :: Nil), KeySend -> (VariableLengthOnion :: Nil), AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil), - OnTheFlyFunding -> (SplicePrototype :: Nil) + OnTheFlyFunding -> (SplicePrototype :: Nil), + FundingFeeCredit -> (OnTheFlyFunding :: Nil) ) case class FeatureException(message: String) extends IllegalArgumentException(message) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 5c5867b3f3..76361bb2b4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -171,7 +171,7 @@ object Helpers { for { script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt) - willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt)) + willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt), open.useFeeCredit_opt) } yield (channelFeatures, script_opt, willFund_opt) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index a067b04df4..c00603fdad 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -952,7 +952,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with val parentCommitment = d.commitments.latest.commitment val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey) - LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt, msg.useFeeCredit_opt) match { case Left(t) => log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage) stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage) @@ -963,7 +963,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with fundingPubKey = localFundingPubKey, pushAmount = 0.msat, requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, - willFund_opt = willFund_opt.map(_.willFund) + willFund_opt = willFund_opt.map(_.willFund), + feeCreditUsed_opt = msg.useFeeCredit_opt ) val fundingParams = InteractiveTxParams( channelId = d.channelId, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index 3e2821b60e..deadc665eb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)), if (nodeParams.channelConf.requireConfirmedInputsForDualFunding) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, willFund_opt.map(l => ChannelTlv.ProvideFundingTlv(l.willFund)), + open.useFeeCredit_opt.map(c => ChannelTlv.FeeCreditUsedTlv(c)), d.init.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), ).flatten val accept = AcceptDualFundedChannel( @@ -547,7 +548,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage) } else { val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript - LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt, None) match { case Left(t) => log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage) stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index b85ca1b15d..b8206a1dc8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -35,7 +35,7 @@ import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.transactions.Transactions.{CommitTx, HtlcTx, InputInfo, TxOwner} import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, UInt64} +import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64} import scodec.bits.ByteVector import scala.concurrent.{ExecutionContext, Future} @@ -155,13 +155,18 @@ object InteractiveTxBuilder { // BOLT 2: the initiator's serial IDs MUST use even values and the non-initiator odd values. val serialIdParity: Int = if (isInitiator) 0 else 1 - def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): Satoshi = { + def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): MilliSatoshi = { liquidityPurchase_opt.map(l => l.paymentDetails match { // The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used). - case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => if (isInitiator) l.fees.total else -l.fees.total + case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => + val feesOwed = l match { + case l: LiquidityAds.Purchase.Standard => l.fees.total.toMilliSatoshi + case l: LiquidityAds.Purchase.WithFeeCredit => l.fees.total.toMilliSatoshi - l.feeCreditUsed + } + if (isInitiator) feesOwed else -feesOwed // Fees will be paid later, when relaying HTLCs. - case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0.sat - }).getOrElse(0 sat) + case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0 msat + }).getOrElse(0 msat) } } @@ -734,6 +739,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) } + liquidityPurchase_opt match { + case Some(p: LiquidityAds.Purchase.WithFeeCredit) if !fundingParams.isInitiator => + val currentFeeCredit = nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) + if (currentFeeCredit < p.feeCreditUsed) { + log.warn("not enough fee credit: our peer may be malicious ({} < {})", currentFeeCredit, p.feeCreditUsed) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + case _ => () + } + previousTransactions.headOption match { case Some(previousTx) => // This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index 7b6a906768..f33e0aa8a0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -442,4 +442,19 @@ case class DualOnTheFlyFundingDb(primary: OnTheFlyFundingDb, secondary: OnTheFly runAsync(secondary.listPendingPayments()) primary.listPendingPayments() } + + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = { + runAsync(secondary.addFeeCredit(nodeId, amount, receivedAt)) + primary.addFeeCredit(nodeId, amount, receivedAt) + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = { + runAsync(secondary.getFeeCredit(nodeId)) + primary.getFeeCredit(nodeId) + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = { + runAsync(secondary.removeFeeCredit(nodeId, amountUsed)) + primary.removeFeeCredit(nodeId, amountUsed) + } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/OnTheFlyFundingDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/OnTheFlyFundingDb.scala index ee729d3bf8..aba212cac3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/OnTheFlyFundingDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/OnTheFlyFundingDb.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.db import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.{MilliSatoshi, TimestampMilli} import fr.acinq.eclair.payment.relay.OnTheFlyFunding /** @@ -44,4 +45,13 @@ trait OnTheFlyFundingDb { /** List the payment_hashes of pending proposals we funded for all remote nodes. */ def listPendingPayments(): Map[PublicKey, Set[ByteVector32]] + /** Add fee credit for the given remote node and return the updated fee credit. */ + def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli = TimestampMilli.now()): MilliSatoshi + + /** Return the amount owed to the given remote node as fee credit. */ + def getFeeCredit(nodeId: PublicKey): MilliSatoshi + + /** Remove fee credit for the given remote node and return the remaining fee credit. */ + def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOnTheFlyFundingDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOnTheFlyFundingDb.scala index b5c59f9769..3c4bca02d3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOnTheFlyFundingDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOnTheFlyFundingDb.scala @@ -18,12 +18,12 @@ package fr.acinq.eclair.db.pg import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, TxId} -import fr.acinq.eclair.MilliSatoshiLong import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.OnTheFlyFundingDb import fr.acinq.eclair.db.pg.PgUtils.PgLock import fr.acinq.eclair.payment.relay.OnTheFlyFunding +import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli} import scodec.bits.BitVector import java.sql.Timestamp @@ -53,6 +53,7 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl statement.executeUpdate("CREATE SCHEMA IF NOT EXISTS on_the_fly_funding") statement.executeUpdate("CREATE TABLE on_the_fly_funding.preimages (payment_hash TEXT NOT NULL PRIMARY KEY, preimage TEXT NOT NULL, received_at TIMESTAMP WITH TIME ZONE NOT NULL)") statement.executeUpdate("CREATE TABLE on_the_fly_funding.pending (remote_node_id TEXT NOT NULL, payment_hash TEXT NOT NULL, channel_id TEXT NOT NULL, tx_id TEXT NOT NULL, funding_tx_index BIGINT NOT NULL, remaining_fees_msat BIGINT NOT NULL, proposed BYTEA NOT NULL, funded_at TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (remote_node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE on_the_fly_funding.fee_credit (remote_node_id TEXT NOT NULL PRIMARY KEY, amount_msat BIGINT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") } @@ -83,6 +84,7 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl override def addPending(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("on-the-fly-funding/add-pending", DbBackends.Postgres) { pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => withLock { pg => using(pg.prepareStatement("INSERT INTO on_the_fly_funding.pending (remote_node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING")) { statement => statement.setString(1, remoteNodeId.toHex) @@ -144,4 +146,43 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("on-the-fly-funding/add-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("INSERT INTO on_the_fly_funding.fee_credit(remote_node_id, amount_msat, updated_at) VALUES (?, ?, ?) ON CONFLICT (remote_node_id) DO UPDATE SET (amount_msat, updated_at) = (on_the_fly_funding.fee_credit.amount_msat + EXCLUDED.amount_msat, EXCLUDED.updated_at) RETURNING amount_msat")) { statement => + statement.setString(1, nodeId.toHex) + statement.setLong(2, amount.toLong) + statement.setTimestamp(3, receivedAt.toSqlTimestamp) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("on-the-fly-funding/get-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM on_the_fly_funding.fee_credit WHERE remote_node_id = ?")) { statement => + statement.setString(1, nodeId.toHex) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("on-the-fly-funding/remove-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM on_the_fly_funding.fee_credit WHERE remote_node_id = ?")) { statement => + statement.setString(1, nodeId.toHex) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(pg.prepareStatement("UPDATE on_the_fly_funding.fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement => + val updated = (current - amountUsed).max(0 msat) + statement.setLong(1, updated.toLong) + statement.setTimestamp(2, Timestamp.from(Instant.now())) + statement.setString(3, nodeId.toHex) + statement.executeUpdate() + updated + } + case None => 0 msat + } + } + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOnTheFlyFundingDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOnTheFlyFundingDb.scala index 7ed667179c..d068a10c49 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOnTheFlyFundingDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOnTheFlyFundingDb.scala @@ -22,7 +22,7 @@ import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.OnTheFlyFundingDb import fr.acinq.eclair.payment.relay.OnTheFlyFunding -import fr.acinq.eclair.{MilliSatoshiLong, TimestampMilli} +import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli} import scodec.bits.BitVector import java.sql.Connection @@ -47,6 +47,7 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb case None => statement.executeUpdate("CREATE TABLE on_the_fly_funding_preimages (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, received_at INTEGER NOT NULL)") statement.executeUpdate("CREATE TABLE on_the_fly_funding_pending (remote_node_id BLOB NOT NULL, payment_hash BLOB NOT NULL, channel_id BLOB NOT NULL, tx_id BLOB NOT NULL, funding_tx_index INTEGER NOT NULL, remaining_fees_msat INTEGER NOT NULL, proposed BLOB NOT NULL, funded_at INTEGER NOT NULL, PRIMARY KEY (remote_node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE fee_credit (remote_node_id BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, updated_at INTEGER NOT NULL)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") } @@ -72,6 +73,7 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb override def addPending(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("on-the-fly-funding/add-pending", DbBackends.Sqlite) { pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => using(sqlite.prepareStatement("INSERT OR IGNORE INTO on_the_fly_funding_pending (remote_node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { statement => statement.setBytes(1, remoteNodeId.value.toArray) @@ -126,4 +128,50 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("on-the-fly-funding/add-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement => + statement.setLong(1, (current + amount).toLong) + statement.setLong(2, receivedAt.toLong) + statement.setBytes(3, nodeId.value.toArray) + statement.executeUpdate() + amount + current + } + case None => using(sqlite.prepareStatement("INSERT OR IGNORE INTO fee_credit(remote_node_id, amount_msat, updated_at) VALUES (?, ?, ?)")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.setLong(2, amount.toLong) + statement.setLong(3, receivedAt.toLong) + statement.executeUpdate() + amount + } + } + } + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("on-the-fly-funding/get-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("on-the-fly-funding/remove-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement => + val updated = (current - amountUsed).max(0 msat) + statement.setLong(1, updated.toLong) + statement.setLong(2, TimestampMilli.now().toLong) + statement.setBytes(3, nodeId.value.toArray) + statement.executeUpdate() + updated + } + case None => 0 msat + } + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala index d60a10cfe7..aec4fdef9a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala @@ -75,6 +75,7 @@ object Monitoring { val Rejected = "rejected" val Expired = "expired" val Timeout = "timeout" + val AddedToFeeCredit = "added-to-fee-credit" val Funded = "funded" val RelaySucceeded = "relay-succeeded" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 0f5fec711d..01d0307ef2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -44,8 +44,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure -import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails -import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc} +import fr.acinq.eclair.wire.protocol.{AddFeeCredit, ChannelTlv, CurrentFeeCredit, Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, TlvStream, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc} /** * This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time. @@ -68,7 +67,8 @@ class Peer(val nodeParams: NodeParams, import Peer._ - private var pendingOnTheFlyFunding = nodeParams.db.onTheFlyFunding.listPending(remoteNodeId) + private var pendingOnTheFlyFunding = if (nodeParams.features.hasFeature(Features.OnTheFlyFunding)) nodeParams.db.onTheFlyFunding.listPending(remoteNodeId) else Map.empty[ByteVector32, OnTheFlyFunding.Pending] + private var feeCredit = if (nodeParams.features.hasFeature(Features.FundingFeeCredit)) nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) else 0 msat context.system.eventStream.subscribe(self, classOf[CurrentFeerates]) context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight]) @@ -99,7 +99,7 @@ class Peer(val nodeParams: NodeParams, val channelIds = d.channels.filter(_._2 == actor).keys log.info(s"channel closed: channelId=${channelIds.mkString("/")}") val channels1 = d.channels -- channelIds - if (channels1.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (channels1.isEmpty && canForgetPendingOnTheFlyFunding()) { log.info("that was the last open channel") context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId)) // We have no existing channels or pending signed transaction, we can forget about this peer. @@ -112,7 +112,7 @@ class Peer(val nodeParams: NodeParams, Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) { log.debug("connection lost while negotiating connection") } - if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) { // We have no existing channels or pending signed transaction, we can forget about this peer. stopPeer() } else { @@ -213,7 +213,7 @@ class Peer(val nodeParams: NodeParams, case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) => val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId) if (peerConnection == d.peerConnection) { - OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding) match { + OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit) match { case reject: OnTheFlyFunding.ValidationResult.Reject => log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) @@ -229,7 +229,10 @@ class Peer(val nodeParams: NodeParams, channel ! open case Right(open) => channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, addFunding_opt, dualFunded = true, None, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType) - channel ! open + accept.useFeeCredit_opt match { + case Some(useFeeCredit) => channel ! open.copy(tlvStream = TlvStream(open.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit))) + case None => channel ! open + } } fulfillOnTheFlyFundingHtlcs(accept.preimages) stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel)) @@ -261,6 +264,11 @@ class Peer(val nodeParams: NodeParams, proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream), status = OnTheFlyFunding.Status.Proposed(timer) ) + case status: OnTheFlyFunding.Status.AddedToFeeCredit => + log.info("received extra payment for on-the-fly funding that was added to fee credit (payment_hash={}, amount={})", cmd.paymentHash, cmd.amount) + val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream) + proposal.createFulfillCommands(status.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + pending.copy(proposed = pending.proposed :+ proposal) case status: OnTheFlyFunding.Status.Funded => log.info("received extra payment for on-the-fly funding that has already been funded with txId={} (payment_hash={}, amount={})", status.txId, cmd.paymentHash, cmd.amount) pending.copy(proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream)) @@ -298,6 +306,9 @@ class Peer(val nodeParams: NodeParams, log.warning("ignoring will_fail_htlc: no matching proposal for id={}", msg.id) self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: no matching proposal for id=${msg.id}"), d.peerConnection) } + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit") + self ! Peer.OutgoingMessage(Warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit"), d.peerConnection) case status: OnTheFlyFunding.Status.Funded => log.warning("ignoring will_fail_htlc: on-the-fly funding already signed with txId={}", status.txId) self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: on-the-fly funding already signed with txId=${status.txId}"), d.peerConnection) @@ -318,6 +329,8 @@ class Peer(val nodeParams: NodeParams, Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Expired).increment() pendingOnTheFlyFunding -= timeout.paymentHash self ! Peer.OutgoingMessage(Warning(s"on-the-fly funding proposal timed out for payment_hash=${timeout.paymentHash}"), d.peerConnection) + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring on-the-fly funding proposal timeout, already added to fee credit") case status: OnTheFlyFunding.Status.Funded => log.warning("ignoring on-the-fly funding proposal timeout, already funded with txId={}", status.txId) } @@ -326,17 +339,56 @@ class Peer(val nodeParams: NodeParams, } stay() + case Event(msg: AddFeeCredit, d: ConnectedData) if !nodeParams.features.hasFeature(Features.FundingFeeCredit) => + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit for payment_hash=${Crypto.sha256(msg.preimage)}, ${Features.FundingFeeCredit.rfcName} is not supported"), d.peerConnection) + stay() + + case Event(msg: AddFeeCredit, d: ConnectedData) => + val paymentHash = Crypto.sha256(msg.preimage) + pendingOnTheFlyFunding.get(paymentHash) match { + case Some(pending) => + pending.status match { + case status: OnTheFlyFunding.Status.Proposed => + feeCredit = nodeParams.db.onTheFlyFunding.addFeeCredit(remoteNodeId, pending.amountOut) + log.info("received add_fee_credit for payment_hash={}, adding {} to fee credit (total = {})", paymentHash, pending.amountOut, feeCredit) + status.timer.cancel() + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.AddedToFeeCredit).increment() + pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit), d.peerConnection) + pendingOnTheFlyFunding += (paymentHash -> pending.copy(status = OnTheFlyFunding.Status.AddedToFeeCredit(msg.preimage))) + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring duplicate add_fee_credit for payment_hash={}", paymentHash) + // We already fulfilled upstream HTLCs, there is nothing else to do. + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection) + case _: OnTheFlyFunding.Status.Funded => + log.warning("ignoring add_fee_credit for funded on-the-fly proposal (payment_hash={})", paymentHash) + // They seem to be malicious, so let's fulfill upstream HTLCs for safety. + pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection) + } + case None => + log.warning("ignoring add_fee_credit for unknown payment_hash={}", paymentHash) + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: unknown payment_hash=$paymentHash"), d.peerConnection) + // This may happen if the remote node is very slow and the timeout was reached before receiving their message. + // We sent the current fee credit to let them detect it and reconcile their state. + self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit), d.peerConnection) + } + stay() + case Event(msg: SpliceInit, d: ConnectedData) => d.channels.get(FinalChannelId(msg.channelId)) match { case Some(channel) => - OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding) match { + OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit) match { case reject: OnTheFlyFunding.ValidationResult.Reject => log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) cancelUnsignedOnTheFlyFunding(reject.paymentHashes) case accept: OnTheFlyFunding.ValidationResult.Accept => fulfillOnTheFlyFundingHtlcs(accept.preimages) - channel forward msg + accept.useFeeCredit_opt match { + case Some(useFeeCredit) => channel forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit))) + case None => channel forward msg + } } case None => replyUnknownChannel(d.peerConnection, msg.channelId) } @@ -347,6 +399,7 @@ class Peer(val nodeParams: NodeParams, case (paymentHash, pending) => pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => context.child(paymentHash.toHex) match { case Some(_) => log.debug("already relaying payment_hash={}", paymentHash) @@ -394,7 +447,7 @@ class Peer(val nodeParams: NodeParams, Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) { log.debug("connection lost") } - if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) { // We have no existing channels or pending signed transaction, we can forget about this peer. stopPeer() } else { @@ -503,16 +556,20 @@ class Peer(val nodeParams: NodeParams, val expired = pendingOnTheFlyFunding.filter { case (_, pending) => pending.proposed.exists(_.htlc.expiry.blockHeight <= current.blockHeight) } - expired.foreach { - case (paymentHash, pending) => - log.warning("will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash) - Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() - pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } - } expired.foreach { case (paymentHash, pending) => pending.status match { - case _: OnTheFlyFunding.Status.Proposed => () - case _: OnTheFlyFunding.Status.Funded => nodeParams.db.onTheFlyFunding.removePending(remoteNodeId, paymentHash) + case _: OnTheFlyFunding.Status.Proposed => + log.warning("proposed will_add_htlc expired for payment_hash={}", paymentHash) + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.debug("forgetting will_add_htlc added to fee credit for payment_hash={}", paymentHash) + // Nothing to do, we already fulfilled the upstream HTLCs. + case _: OnTheFlyFunding.Status.Funded => + log.warning("funded will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash) + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + nodeParams.db.onTheFlyFunding.removePending(remoteNodeId, paymentHash) } } pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(expired.keys) @@ -522,22 +579,34 @@ class Peer(val nodeParams: NodeParams, case _ => stay() } - case Event(e: LiquidityPurchaseSigned, _: ConnectedData) => + case Event(e: LiquidityPurchaseSigned, d: ConnectedData) => + // If that liquidity purchase was partially paid with fee credit, we will deduce it from what our peer owes us + // and remove the corresponding amount from our peer's credit. + // Note that since we only allow a single channel per user when on-the-fly funding is used, and it's not possible + // to request a splice while one is already in progress, it's safe to only remove fee credit once the funding + // transaction has been signed. + val feeCreditUsed = e.purchase match { + case _: LiquidityAds.Purchase.Standard => 0 msat + case p: LiquidityAds.Purchase.WithFeeCredit => + feeCredit = nodeParams.db.onTheFlyFunding.removeFeeCredit(remoteNodeId, p.feeCreditUsed) + self ! OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit), d.peerConnection) + p.feeCreditUsed + } // We signed a liquidity purchase from our peer. At that point we're not 100% sure yet it will succeed: if // we disconnect before our peer sends their signature, the funding attempt may be cancelled when reconnecting. // If that happens, the on-the-fly proposal will stay in our state until we reach the CLTV expiry, at which // point we will forget it and fail the upstream HTLCs. This is also what would happen if we successfully // funded the channel, but it closed before we could relay the HTLCs. - val (paymentHashes, fees) = e.purchase.paymentDetails match { - case PaymentDetails.FromChannelBalance => (Nil, 0 sat) - case p: PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 sat) - case p: PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total) - case p: PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total) + val (paymentHashes, feesOwed) = e.purchase.paymentDetails match { + case LiquidityAds.PaymentDetails.FromChannelBalance => (Nil, 0 msat) + case p: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 msat) + case p: LiquidityAds.PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total - feeCreditUsed) + case p: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total - feeCreditUsed) } // We split the fees across payments. We could dynamically re-split depending on whether some payments are failed // instead of fulfilled, but that's overkill: if our peer fails one of those payment, they're likely malicious // and will fail anyway, even if we try to be clever with fees splitting. - var remainingFees = fees.toMilliSatoshi + var remainingFees = feesOwed.max(0 msat) pendingOnTheFlyFunding .filter { case (paymentHash, _) => paymentHashes.contains(paymentHash) } .values.toSeq @@ -554,6 +623,17 @@ class Peer(val nodeParams: NodeParams, Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment() nodeParams.db.onTheFlyFunding.addPending(remoteNodeId, payment1) pendingOnTheFlyFunding += payment.paymentHash -> payment1 + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("liquidity purchase was signed for payment_hash={} that was also added to fee credit: our peer may be malicious", payment.paymentHash) + // Our peer tried to concurrently get a channel funded *and* add the same payment to its fee credit. + // We've already signed the funding transaction so we can't abort, but we have also received the preimage + // and fulfilled the upstream HTLCs: we simply won't forward the matching HTLCs on the funded channel. + // Instead of being paid the funding fees, we've claimed the entire incoming HTLC set, which is bigger + // than the fees (otherwise we wouldn't have accepted the on-the-fly funding attempt), so it's fine. + // They cannot have used that additional fee credit yet because we only allow a single channel per user + // when on-the-fly funding is used, and it's not possible to request a splice while one is already in + // progress. + feeCredit = nodeParams.db.onTheFlyFunding.removeFeeCredit(remoteNodeId, payment.amountOut) case status: OnTheFlyFunding.Status.Funded => log.warning("liquidity purchase was already signed for payment_hash={} (previousTxId={}, currentTxId={})", payment.paymentHash, status.txId, e.txId) } @@ -650,6 +730,11 @@ class Peer(val nodeParams: NodeParams, // We tell our peer what our current feerates are. connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, nodeParams.currentFeerates, connectionReady.localInit.features, connectionReady.remoteInit.features) + if (nodeParams.features.hasFeature(Features.FundingFeeCredit) && connectionReady.remoteInit.features.hasFeature(Features.FundingFeeCredit)) { + log.debug("reconnecting with fee credit = {}", feeCredit) + connectionReady.peerConnection ! CurrentFeeCredit(nodeParams.chainHash, feeCredit) + } + goto(CONNECTED) using ConnectedData(connectionReady.address, connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit, channels) } @@ -683,17 +768,18 @@ class Peer(val nodeParams: NodeParams, case (paymentHash, pending) if paymentHashes.contains(paymentHash) => pending.status match { case status: OnTheFlyFunding.Status.Proposed => + log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash) status.timer.cancel() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } true + // We keep proposals that have been added to fee credit until we reach the HTLC expiry or we restart. This + // guarantees that our peer cannot concurrently add to their fee credit a payment for which we've signed a + // funding transaction. + case _: OnTheFlyFunding.Status.AddedToFeeCredit => false case _: OnTheFlyFunding.Status.Funded => false } case _ => false } - unsigned.foreach { - case (paymentHash, pending) => - log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash) - pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } - } pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(unsigned.keys) } @@ -704,12 +790,17 @@ class Peer(val nodeParams: NodeParams, }) } - /** Return true if we have signed on-the-fly funding transactions and haven't settled the corresponding HTLCs yet. */ - private def pendingSignedOnTheFlyFunding(): Boolean = { - pendingOnTheFlyFunding.exists { + /** Return true if we can forget pending on-the-fly funding transactions and stop ourselves. */ + private def canForgetPendingOnTheFlyFunding(): Boolean = { + pendingOnTheFlyFunding.forall { case (_, pending) => pending.status match { - case _: OnTheFlyFunding.Status.Proposed => false - case _: OnTheFlyFunding.Status.Funded => true + case _: OnTheFlyFunding.Status.Proposed => true + // We don't stop ourselves if our peer has some fee credit. + // They will likely come back online to use that fee credit. + case _: OnTheFlyFunding.Status.AddedToFeeCredit => false + // We don't stop ourselves if we've signed an on-the-fly funding proposal but haven't settled HTLCs yet. + // We must watch the expiry of those HTLCs and obtain the preimage before they expire to get paid. + case _: OnTheFlyFunding.Status.Funded => false } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala index 793741fd8d..3b10fee2ea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams, TimestampMilli, ToMilliSatoshiConversion} +import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, ToMilliSatoshiConversion} import scodec.bits.ByteVector import scala.concurrent.duration.FiniteDuration @@ -45,6 +45,8 @@ object OnTheFlyFunding { object Status { /** We sent will_add_htlc, but didn't fund a transaction yet. */ case class Proposed(timer: Cancellable) extends Status + /** Our peer revealed the preimage to add this payment to their fee credit for a future on-chain transaction. */ + case class AddedToFeeCredit(preimage: ByteVector32) extends Status /** * We signed a transaction matching the on-the-fly funding proposed. We're waiting for the liquidity to be * available (channel ready or splice locked) to relay the HTLCs and complete the payment. @@ -89,6 +91,7 @@ object OnTheFlyFunding { case class Pending(proposed: Seq[Proposal], status: Status) { val paymentHash = proposed.head.htlc.paymentHash val expiry = proposed.map(_.htlc.expiry).min + val amountOut = proposed.map(_.htlc.amount).sum /** Maximum fees that can be collected from this HTLC set. */ def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = proposed.map(_.maxFees(htlcMinimum)).sum @@ -106,26 +109,26 @@ object OnTheFlyFunding { /** The incoming channel or splice cannot pay the liquidity fees: we must reject it and fail the corresponding upstream HTLCs. */ case class Reject(cancel: CancelOnTheFlyFunding, paymentHashes: Set[ByteVector32]) extends ValidationResult /** We are on-the-fly funding a channel: if we received preimages, we must fulfill the corresponding upstream HTLCs. */ - case class Accept(preimages: Set[ByteVector32]) extends ValidationResult + case class Accept(preimages: Set[ByteVector32], useFeeCredit_opt: Option[MilliSatoshi]) extends ValidationResult } // @formatter:on /** Validate an incoming channel that may use on-the-fly funding. */ - def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = { open match { - case Left(_) => ValidationResult.Accept(Set.empty) + case Left(_) => ValidationResult.Accept(Set.empty, None) case Right(open) => open.requestFunding_opt match { - case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit) + case None => ValidationResult.Accept(Set.empty, None) } } } /** Validate an incoming splice that may use on-the-fly funding. */ - def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = { splice.requestFunding_opt match { - case Some(requestFunding) => validate(splice.channelId, requestFunding, splice.feerate, htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(splice.channelId, requestFunding, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit) + case None => ValidationResult.Accept(Set.empty, None) } } @@ -133,7 +136,8 @@ object OnTheFlyFunding { requestFunding: LiquidityAds.RequestFunding, feerate: FeeratePerKw, htlcMinimum: MilliSatoshi, - pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + pendingOnTheFlyFunding: Map[ByteVector32, Pending], + feeCredit: MilliSatoshi): ValidationResult = { val paymentHashes = requestFunding.paymentDetails match { case PaymentDetails.FromChannelBalance => Nil case PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes) => paymentHashes @@ -144,17 +148,24 @@ object OnTheFlyFunding { val totalPaymentAmount = pending.flatMap(_.proposed.map(_.htlc.amount)).sum // We will deduce fees from HTLCs: we check that the amount is large enough to cover the fees. val availableAmountForFees = pending.map(_.maxFees(htlcMinimum)).sum - val fees = requestFunding.fees(feerate) + val (feesOwed, useFeeCredit_opt) = if (feeCredit > 0.msat) { + // We prioritize using our peer's fee credit if they have some available. + val fees = requestFunding.fees(feerate).total.toMilliSatoshi + val useFeeCredit = feeCredit.min(fees) + (fees - useFeeCredit, Some(useFeeCredit)) + } else { + (requestFunding.fees(feerate).total.toMilliSatoshi, None) + } val cancelAmountTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"requested amount is too low to relay HTLCs: ${requestFunding.requestedAmount} < $totalPaymentAmount") - val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < ${fees.total}") + val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < $feesOwed") requestFunding.paymentDetails match { - case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty) + case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty, None) case _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet) - case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty) - case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) - case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty) - case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) - case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet) + case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt) + case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) + case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt) + case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) + case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet, useFeeCredit_opt) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index f8c7bc273a..94cde7de77 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -74,10 +74,21 @@ object ChannelTlv { val provideFundingCodec: Codec[ProvideFundingTlv] = tlvField(LiquidityAds.Codecs.willFund) + /** Fee credit that will be used for the given on-the-fly funding operation. */ + case class FeeCreditUsedTlv(amount: MilliSatoshi) extends AcceptDualFundedChannelTlv with SpliceAckTlv + + val feeCreditUsedCodec: Codec[FeeCreditUsedTlv] = tlvField(tmillisatoshi) + case class PushAmountTlv(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with AcceptDualFundedChannelTlv with SpliceInitTlv with SpliceAckTlv val pushAmountCodec: Codec[PushAmountTlv] = tlvField(tmillisatoshi) + /** + * This is an internal TLV for which we DON'T specify a codec: this isn't meant to be read or written on the wire. + * This is only used to decorate open_channel2 and splice_init with the [[Features.FundingFeeCredit]] available. + */ + case class UseFeeCredit(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with SpliceInitTlv + } object OpenChannelTlv { @@ -169,6 +180,7 @@ object SpliceAckTlv { .typecase(UInt64(2), requireConfirmedInputsCodec) // We use a temporary TLV while the spec is being reviewed. .typecase(UInt64(1339), provideFundingCodec) + .typecase(UInt64(41042), feeCreditUsedCodec) .typecase(UInt64(0x47000007), tlvField(tmillisatoshi.as[PushAmountTlv])) ) } @@ -187,6 +199,7 @@ object AcceptDualFundedChannelTlv { .typecase(UInt64(2), requireConfirmedInputsCodec) // We use a temporary TLV while the spec is being reviewed. .typecase(UInt64(1339), provideFundingCodec) + .typecase(UInt64(41042), feeCreditUsedCodec) .typecase(UInt64(0x47000007), pushAmountCodec) ) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 417d7cee94..b422d8598c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -460,6 +460,14 @@ object LightningMessageCodecs { ("paymentHashes" | listOfN(uint16, bytes32)) :: ("reason" | varsizebinarydata)).as[CancelOnTheFlyFunding] + val addFeeCreditCodec: Codec[AddFeeCredit] = ( + ("chainHash" | blockHash) :: + ("preimage" | bytes32)).as[AddFeeCredit] + + val currentFeeCreditCodec: Codec[CurrentFeeCredit] = ( + ("chainHash" | blockHash) :: + ("amount" | millisatoshi)).as[CurrentFeeCredit] + val unknownMessageCodec: Codec[UnknownMessage] = ( ("tag" | uint16) :: ("message" | bytes) @@ -517,6 +525,10 @@ object LightningMessageCodecs { .typecase(41043, willFailMalformedHtlcCodec) .typecase(41044, cancelOnTheFlyFundingCodec) // + // + .typecase(41045, addFeeCreditCodec) + .typecase(41046, currentFeeCreditCodec) + // .typecase(37000, spliceInitCodec) .typecase(37002, spliceAckCodec) .typecase(37004, spliceLockedCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 8b378af24e..640f78d6a7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -254,6 +254,7 @@ case class OpenDualFundedChannel(chainHash: BlockHash, val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) + val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount) val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat) } @@ -307,6 +308,7 @@ case class SpliceInit(channelId: ByteVector32, tlvStream: TlvStream[SpliceInitTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) + val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount) val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat) } @@ -331,11 +333,12 @@ case class SpliceAck(channelId: ByteVector32, } object SpliceAck { - def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund]): SpliceAck = { + def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund], feeCreditUsed_opt: Option[MilliSatoshi]): SpliceAck = { val tlvs: Set[SpliceAckTlv] = Set( Some(ChannelTlv.PushAmountTlv(pushAmount)), if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, - willFund_opt.map(ChannelTlv.ProvideFundingTlv) + willFund_opt.map(ChannelTlv.ProvideFundingTlv), + feeCreditUsed_opt.map(ChannelTlv.FeeCreditUsedTlv), ).flatten SpliceAck(channelId, fundingContribution, fundingPubKey, TlvStream(tlvs)) } @@ -673,4 +676,14 @@ object CancelOnTheFlyFunding { def apply(channelId: ByteVector32, paymentHashes: List[ByteVector32], reason: String): CancelOnTheFlyFunding = CancelOnTheFlyFunding(channelId, paymentHashes, ByteVector.view(reason.getBytes(Charsets.US_ASCII))) } +/** + * This message is used to reveal the preimage of a small payment for which it isn't economical to perform an on-chain + * transaction. The amount of the payment will be added to our fee credit, which can be used when a future on-chain + * transaction is needed. This message requires the [[Features.FundingFeeCredit]] feature. + */ +case class AddFeeCredit(chainHash: BlockHash, preimage: ByteVector32) extends HasChainHash + +/** This message contains our current fee credit: the liquidity provider is the source of truth for that value. */ +case class CurrentFeeCredit(chainHash: BlockHash, amount: MilliSatoshi) extends HasChainHash + case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala index 90ff115fbc..edc5e3da02 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.CommonCodecs._ -import fr.acinq.eclair.wire.protocol.TlvCodecs.{genericTlv, tlvField, tsatoshi32} +import fr.acinq.eclair.wire.protocol.TlvCodecs.tlvField import fr.acinq.eclair.{MilliSatoshi, ToMilliSatoshiConversion, UInt64} import scodec.Codec import scodec.bits.{BitVector, ByteVector} @@ -122,7 +122,7 @@ object LiquidityAds { /** Sellers offer various rates and payment options. */ case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) { - def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding): Either[ChannelException, WillFundPurchase] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, WillFundPurchase] = { if (!paymentTypes.contains(request.paymentDetails.paymentType)) { Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes)) } else if (!fundingRates.contains(request.fundingRate)) { @@ -131,7 +131,11 @@ object LiquidityAds { Left(InvalidLiquidityAdsRate(channelId)) } else { val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey) - val purchase = Purchase.Standard(request.requestedAmount, request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount), request.paymentDetails) + val fees = request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount) + val purchase = feeCreditUsed_opt match { + case Some(feeCreditUsed) => Purchase.WithFeeCredit(request.requestedAmount, fees, feeCreditUsed, request.paymentDetails) + case None => Purchase.Standard(request.requestedAmount, fees, request.paymentDetails) + } Right(WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase)) } } @@ -139,9 +143,9 @@ object LiquidityAds { def findRate(requestedAmount: Satoshi): Option[FundingRate] = fundingRates.find(r => r.minAmount <= requestedAmount && requestedAmount <= r.maxAmount) } - def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates], feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, Option[WillFundPurchase]] = { (request_opt, rates_opt) match { - case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request).map(l => Some(l)) + case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, feeCreditUsed_opt).map(l => Some(l)) case _ => Right(None) } } @@ -219,7 +223,11 @@ object LiquidityAds { } object Purchase { + // @formatter:off case class Standard(amount: Satoshi, fees: Fees, paymentDetails: PaymentDetails) extends Purchase() + /** The liquidity purchase was paid (partially or entirely) using [[fr.acinq.eclair.Features.FundingFeeCredit]]. */ + case class WithFeeCredit(amount: Satoshi, fees: Fees, feeCreditUsed: MilliSatoshi, paymentDetails: PaymentDetails) extends Purchase() + // @formatter:on } case class WillFundPurchase(willFund: WillFund, purchase: Purchase) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala index 6d3f8ba113..aa7ed49b83 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -617,6 +617,91 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } + test("initiator does not contribute -- on-the-fly funding with fee credit") { + val targetFeerate = FeeratePerKw(5000 sat) + val fundingA = 2_500.sat + val utxosA = Seq(5_000 sat) + val fundingB = 150_000.sat + val utxosB = Seq(200_000 sat) + // The initiator contributes a small amount, and pays the remaining liquidity fees from its fee credit. + val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 7_500_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) + withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + import f._ + + // Alice has enough fee credit. + fixtureParams.nodeParamsB.db.onTheFlyFunding.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 7_500_000 msat) + + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + + // Alice --- tx_add_input --> Bob + fwd.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + fwd.forwardBob2Alice[TxAddInput] + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_output --- Bob + fwd.forwardBob2Alice[TxAddOutput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + + // Alice sends signatures first as she contributed less. + val successA = alice2bob.expectMsgType[Succeeded] + val successB = bob2alice.expectMsgType[Succeeded] + val (txA, _, txB, commitmentB) = fixtureParams.exchangeSigsAliceFirst(aliceParams, successA, successB) + // Alice partially paid fees to Bob during the interactive-tx using her channel balance, the rest was paid from fee credit. + assert(commitmentB.localCommit.spec.toLocal == (fundingA + fundingB).toMilliSatoshi) + assert(commitmentB.localCommit.spec.toRemote == 0.msat) + + // The resulting transaction is valid. + assert(txA.txId == txB.txId) + assert(txA.tx.localFees == 2_500_000.msat) + assert(txB.tx.remoteFees == 2_500_000.msat) + assert(txB.tx.localFees > 0.msat) + val probe = TestProbe() + walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA.txId) + walletA.getMempoolTx(txA.txId).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == txA.tx.fees) + assert(targetFeerate * 0.9 <= txA.feerate && txA.feerate < targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})") + } + } + + test("initiator does not contribute -- on-the-fly funding without enough fee credit") { + val targetFeerate = FeeratePerKw(5000 sat) + val fundingB = 150_000.sat + val utxosB = Seq(200_000 sat) + // The initiator wants to pay the liquidity fees from their fee credit, but they don't have enough of it. + val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 10_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) + withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + import f._ + + // Alice doesn't have enough fee credit. + fixtureParams.nodeParamsB.db.onTheFlyFunding.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 9_000_000 msat) + + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_input --- Bob + fwd.forwardBob2Alice[TxAddInput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_add_output --- Bob + fwd.forwardBob2Alice[TxAddOutput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + // Bob rejects the funding attempt because Alice doesn't have enough fee credit. + assert(bob2alice.expectMsgType[RemoteFailure].cause.isInstanceOf[InvalidCompleteInteractiveTx]) + } + } + test("initiator and non-initiator splice-in") { val targetFeerate = FeeratePerKw(1000 sat) // We chose those amounts to ensure that Bob always signs first: @@ -2246,6 +2331,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val bobSplice = params.spawnTxBuilderSpliceBob(spliceParams, previousCommitment, wallet, Some(purchase)) bobSplice ! Start(probe.ref) assert(probe.expectMsgType[LocalFailure].cause == InvalidFundingBalances(params.channelId, 620_000 sat, 625_000_000 msat, -5_000_000 msat)) + // If Alice is using fee credit to pay the liquidity fees, the funding attempt is valid. + val bobFeeCredit = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(LiquidityAds.Purchase.WithFeeCredit(500_000 sat, LiquidityAds.Fees(5000 sat, 20_000 sat), 25_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)))) + bobFeeCredit ! Start(probe.ref) + probe.expectNoMessage(100 millis) // If we use a payment type where fees are paid outside of the interactive-tx session, the funding attempt is valid. val bobFutureHtlc = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase.copy(paymentDetails = LiquidityAds.PaymentDetails.FromFutureHtlc(Nil)))) bobFutureHtlc ! Start(probe.ref) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala index 26954fbf39..6aa19b318f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala @@ -108,6 +108,19 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur assert(accept.willFund_opt.nonEmpty) } + test("recv OpenDualFundedChannel (with liquidity ads and fee credit)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val open = alice2bob.expectMsgType[OpenDualFundedChannel] + val requestFunds = LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) + val openWithFundsRequest = open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records + ChannelTlv.RequestFundingTlv(requestFunds) + ChannelTlv.UseFeeCredit(2_500_000 msat))) + alice2bob.forward(bob, openWithFundsRequest) + val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] + assert(accept.fundingAmount == TestConstants.nonInitiatorFundingSatoshis) + assert(accept.willFund_opt.nonEmpty) + assert(accept.tlvStream.get[ChannelTlv.FeeCreditUsedTlv].map(_.amount).contains(2_500_000 msat)) + } + test("recv OpenDualFundedChannel (with push amount)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/OnTheFlyFundingDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/OnTheFlyFundingDbSpec.scala index 8623914899..ddb603b2be 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/OnTheFlyFundingDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/OnTheFlyFundingDbSpec.scala @@ -135,4 +135,27 @@ class OnTheFlyFundingDbSpec extends AnyFunSuite { } } + test("add/get/remove fee credit") { + forAllDbs { dbs => + val db = dbs.onTheFlyFunding + val nodeId = randomKey().publicKey + + // Initially, the DB is empty. + assert(db.getFeeCredit(nodeId) == 0.msat) + assert(db.removeFeeCredit(nodeId, 0 msat) == 0.msat) + + // We owe some fee credit to our peer. + assert(db.addFeeCredit(nodeId, 211_754 msat, receivedAt = TimestampMilli(50_000)) == 211_754.msat) + assert(db.getFeeCredit(nodeId) == 211_754.msat) + assert(db.addFeeCredit(nodeId, 245 msat, receivedAt = TimestampMilli(55_000)) == 211_999.msat) + assert(db.getFeeCredit(nodeId) == 211_999.msat) + + // We consume some of the fee credit. + assert(db.removeFeeCredit(nodeId, 11_999 msat) == 200_000.msat) + assert(db.getFeeCredit(nodeId) == 200_000.msat) + assert(db.removeFeeCredit(nodeId, 250_000 msat) == 0.msat) + assert(db.getFeeCredit(nodeId) == 0.msat) + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala index c3d1eace9b..eded544bfb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala @@ -32,8 +32,8 @@ import fr.acinq.eclair.io.{Peer, PeerConnection, PendingChannelsRateLimiter} import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, FeatureSupport, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, TimestampMilli, ToMilliSatoshiConversion, UInt64, randomBytes, randomBytes32, randomKey, randomLong} -import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{Outcome, Tag} import java.util.UUID import scala.concurrent.duration.DurationInt @@ -42,6 +42,8 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { import OnTheFlyFundingSpec._ + val withFeeCredit = "with_fee_credit" + val remoteFeatures = Features( Features.StaticRemoteKey -> FeatureSupport.Optional, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, @@ -50,6 +52,13 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { Features.OnTheFlyFunding -> FeatureSupport.Optional, ) + val remoteFeaturesWithFeeCredit = Features( + Features.DualFunding -> FeatureSupport.Optional, + Features.SplicePrototype -> FeatureSupport.Optional, + Features.OnTheFlyFunding -> FeatureSupport.Optional, + Features.FundingFeeCredit -> FeatureSupport.Optional, + ) + case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, register: TestProbe, rateLimiter: TestProbe, probe: TestProbe) { def connect(peer: TestFSMRef[Peer.State, Peer.Data, Peer], remoteInit: protocol.Init = protocol.Init(remoteFeatures.initFeatures()), channelCount: Int = 0): Unit = { val localInit = protocol.Init(nodeParams.features.initFeatures()) @@ -110,13 +119,40 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { channelId: ByteVector32 = randomBytes32(), fees: LiquidityAds.Fees = LiquidityAds.Fees(0 sat, 0 sat), fundingTxIndex: Long = 0, - htlcMinimum: MilliSatoshi = 1 msat): LiquidityPurchaseSigned = { - val purchase = LiquidityAds.Purchase.Standard(amount, fees, paymentDetails) + htlcMinimum: MilliSatoshi = 1 msat, + feeCreditUsed_opt: Option[MilliSatoshi] = None): LiquidityPurchaseSigned = { + val purchase = feeCreditUsed_opt match { + case Some(feeCredit) => LiquidityAds.Purchase.WithFeeCredit(amount, fees, feeCredit, paymentDetails) + case None => LiquidityAds.Purchase.Standard(amount, fees, paymentDetails) + } val event = LiquidityPurchaseSigned(channelId, TxId(randomBytes32()), fundingTxIndex, htlcMinimum, purchase) peer ! event event } + def verifyFulfilledUpstream(upstream: Upstream.Hot, preimage: ByteVector32): Unit = { + val incomingHtlcs = upstream match { + case u: Upstream.Hot.Channel => Seq(u.add) + case u: Upstream.Hot.Trampoline => u.received.map(_.add) + case _: Upstream.Local => Nil + } + val fulfilled = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]) + assert(fulfilled.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet) + assert(fulfilled.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet) + assert(fulfilled.map(_.message.r).toSet == Set(preimage)) + } + + def verifyFailedUpstream(upstream: Upstream.Hot): Unit = { + val incomingHtlcs = upstream match { + case u: Upstream.Hot.Channel => Seq(u.add) + case u: Upstream.Hot.Trampoline => u.received.map(_.add) + case _: Upstream.Local => Nil + } + val failed = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]]) + assert(failed.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet) + assert(failed.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet) + } + def makeChannelData(htlcMinimum: MilliSatoshi = 1 msat, localChanges: LocalChanges = LocalChanges(Nil, Nil, Nil)): DATA_NORMAL = { val commitments = CommitmentsSpec.makeCommitments(500_000_000 msat, 500_000_000 msat, nodeParams.nodeId, remoteNodeId, announceChannel = false) .modify(_.params.remoteParams.htlcMinimum).setTo(htlcMinimum) @@ -138,6 +174,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { .modify(_.features.activated).using(_ + (Features.DualFunding -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) + .modify(_.features.activated).usingIf(test.tags.contains(withFeeCredit))(_ + (Features.FundingFeeCredit -> FeatureSupport.Optional)) val remoteNodeId = randomKey().publicKey val register = TestProbe() val channel = TestProbe() @@ -228,6 +265,25 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { }) } + test("ignore remote failure after adding to fee credit", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(1_500 msat, expiryIn, paymentHash) + val willAdd = proposeFunding(1_000 msat, expiryOut, paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 1_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + peerConnection.send(peer, WillFailHtlc(willAdd.id, paymentHash, randomBytes(25))) + peerConnection.expectMsgType[Warning] + peerConnection.send(peer, WillFailMalformedHtlc(willAdd.id, paymentHash, randomBytes32(), InvalidOnionHmac(randomBytes32()).code)) + peerConnection.expectMsgType[Warning] + peerConnection.expectNoMessage(100 millis) + register.expectNoMessage(100 millis) + } + test("proposed on-the-fly funding timeout") { f => import f._ @@ -285,6 +341,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { peerConnection.expectNoMessage(100 millis) } + test("proposed on-the-fly funding timeout (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(10_000_000 msat, CltvExpiry(550), paymentHash) + proposeFunding(10_000_000 msat, CltvExpiry(500), paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 10_000_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + peer ! OnTheFlyFundingTimeout(paymentHash) + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("proposed on-the-fly funding HTLC timeout") { f => import f._ @@ -336,6 +408,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { awaitCond(nodeParams.db.onTheFlyFunding.listPending(remoteNodeId).isEmpty, interval = 100 millis) } + test("proposed on-the-fly funding HTLC timeout (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(500 msat, CltvExpiry(550), paymentHash) + proposeFunding(500 msat, CltvExpiry(500), paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 500.msat) + verifyFulfilledUpstream(upstream, preimage) + + peer ! CurrentBlockHeight(BlockHeight(560)) + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("signed on-the-fly funding HTLC timeout after disconnection") { f => import f._ @@ -379,6 +467,74 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { probe.expectTerminated(peerAfterRestart.ref) } + test("add proposal to fee credit", Tag(withFeeCredit)) { f => + import f._ + + val remoteInit = protocol.Init(remoteFeaturesWithFeeCredit.initFeatures()) + connect(peer, remoteInit) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + + val upstream1 = upstreamChannel(10_000_000 msat, expiryIn, paymentHash) + proposeFunding(10_000_000 msat, expiryOut, paymentHash, upstream1) + val upstream2 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash) + proposeFunding(5_000_000 msat, expiryOut, paymentHash, upstream2) + + // Both HTLCs are automatically added to fee credit. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 15_000_000.msat) + verifyFulfilledUpstream(Upstream.Hot.Trampoline(upstream1 :: upstream2 :: Nil), preimage) + + // Another unrelated payment is added to fee credit. + val preimage3 = randomBytes32() + val paymentHash3 = Crypto.sha256(preimage3) + val upstream3 = upstreamChannel(2_500_000 msat, expiryIn, paymentHash3) + proposeFunding(2_000_000 msat, expiryOut, paymentHash3, upstream3) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + verifyFulfilledUpstream(upstream3, preimage3) + + // Another payment for the same payment_hash is added to fee credit. + val upstream4 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash) + proposeExtraFunding(3_000_000 msat, expiryOut, paymentHash, upstream4) + verifyFulfilledUpstream(upstream4, preimage) + + // We don't fail proposals added to fee credit on disconnection. + disconnect() + connect(peer, remoteInit) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + + // Duplicate or unknown add_fee_credit are ignored. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, randomBytes32())) + peerConnection.expectMsgType[Warning] + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + peerConnection.expectMsgType[Warning] + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3)) + peerConnection.expectMsgType[Warning] + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + + test("add proposal to fee credit after signing transaction", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(25_000_000 msat, expiryIn, paymentHash) + proposeFunding(25_000_000 msat, expiryOut, paymentHash, upstream) + signLiquidityPurchase(25_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil)) + + // The proposal was signed, it cannot also be added to fee credit. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + peerConnection.expectMsgType[Warning] + verifyFulfilledUpstream(upstream, preimage) + + // We don't added the payment amount to fee credit. + disconnect() + connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures())) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + } + test("receive open_channel2") { f => import f._ @@ -401,10 +557,63 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))) // The preimage was provided, so we fulfill upstream HTLCs. - val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd.channelId == upstream.add.channelId) - assert(fwd.message.id == upstream.add.id) - assert(fwd.message.r == preimage) + verifyFulfilledUpstream(upstream, preimage) + } + + test("receive open_channel2 (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val requestFunding = LiquidityAds.RequestFunding( + 500_000 sat, + LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil) + ) + + // We don't have any fee credit yet to open a channel and the HTLC amount is too low to cover liquidity fees. + val upstream1 = upstreamChannel(500_000 msat, expiryIn, paymentHash) + proposeFunding(500_000 msat, expiryOut, paymentHash, upstream1) + val open1 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open1) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + peerConnection.expectMsgType[CancelOnTheFlyFunding] + verifyFailedUpstream(upstream1) + + // We add some fee credit, but not enough to cover liquidity fees. + val preimage2 = randomBytes32() + val paymentHash2 = Crypto.sha256(preimage2) + val upstream2 = upstreamChannel(3_000_000 msat, expiryIn, paymentHash2) + proposeFunding(3_000_000 msat, expiryOut, paymentHash2, upstream2) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage2)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 3_000_000.msat) + verifyFulfilledUpstream(upstream2, preimage2) + + // We have some fee credit but it's not enough, even with HTLCs, to cover liquidity fees. + val upstream3 = upstreamChannel(2_000_000 msat, expiryIn, paymentHash) + proposeFunding(1_999_999 msat, expiryOut, paymentHash, upstream3) + val open2 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open2) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + peerConnection.expectMsgType[CancelOnTheFlyFunding] + verifyFailedUpstream(upstream3) + + // We have some fee credit which can pay the liquidity fees when combined with HTLCs. + val upstream4 = upstreamChannel(4_000_000 msat, expiryIn, paymentHash) + proposeFunding(4_000_000 msat, expiryOut, paymentHash, upstream4) + val open3 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open3) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + val init = channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR] + assert(!init.localParams.isChannelOpener) + assert(init.localParams.paysCommitTxFees) + assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))) + assert(channel.expectMsgType[OpenDualFundedChannel].useFeeCredit_opt.contains(3_000_000 msat)) + + // Once the funding transaction is signed, we remove the fee credit consumed. + signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(3_000_000 msat)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + awaitCond(nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) } test("receive splice_init") { f => @@ -427,10 +636,41 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { channel.expectNoMessage(100 millis) // The preimage was provided, so we fulfill upstream HTLCs. - val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd.channelId == upstream.add.channelId) - assert(fwd.message.id == upstream.add.id) - assert(fwd.message.r == preimage) + verifyFulfilledUpstream(upstream, preimage) + } + + test("receive splice_init (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + val channelId = openChannel(200_000 sat) + + // We add some fee credit to cover liquidity fees. + val preimage1 = randomBytes32() + val paymentHash1 = Crypto.sha256(preimage1) + val upstream1 = upstreamChannel(8_000_000 msat, expiryIn, paymentHash1) + proposeFunding(7_500_000 msat, expiryOut, paymentHash1, upstream1) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 7_500_000.msat) + verifyFulfilledUpstream(upstream1, preimage1) + + // We consume that fee credit when splicing. + val upstream2 = upstreamChannel(1_000_000 msat, expiryIn, paymentHash) + proposeFunding(1_000_000 msat, expiryOut, paymentHash, upstream2) + val requestFunding = LiquidityAds.RequestFunding( + 500_000 sat, + LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat), + LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHash :: Nil) + ) + val splice = createSpliceMessage(channelId, requestFunding) + peerConnection.send(peer, splice) + assert(channel.expectMsgType[SpliceInit].useFeeCredit_opt.contains(5_000_000 msat)) + channel.expectNoMessage(100 millis) + + // Once the splice transaction is signed, we remove the fee credit consumed. + signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(5_000_000 msat)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 2_500_000.msat) + awaitCond(nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) == 2_500_000.msat, interval = 100 millis) } test("reject invalid open_channel2") { f => @@ -579,15 +819,9 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val (add1, add2) = if (cmd1.paymentHash == paymentHash1) (cmd1, cmd2) else (cmd2, cmd1) val outgoing = Seq(add1, add2).map(add => UpdateAddHtlc(purchase.channelId, randomHtlcId(), add.amount, add.paymentHash, add.cltvExpiry, add.onion, add.nextBlindingKey_opt, add.confidence, add.fundingFee_opt)) add1.replyTo ! RES_ADD_SETTLED(add1.origin, outgoing.head, HtlcResult.RemoteFulfill(UpdateFulfillHtlc(purchase.channelId, outgoing.head.id, preimage1))) - val fwd1 = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd1.channelId == upstream1.add.channelId) - assert(fwd1.message.id == upstream1.add.id) - assert(fwd1.message.r == preimage1) + verifyFulfilledUpstream(upstream1, preimage1) add2.replyTo ! RES_ADD_SETTLED(add2.origin, outgoing.last, HtlcResult.OnChainFulfill(preimage2)) - val fwd2 = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd2.channelId == upstream2.add.channelId) - assert(fwd2.message.id == upstream2.add.id) - assert(fwd2.message.r == preimage2) + verifyFulfilledUpstream(upstream2, preimage2) awaitCond(nodeParams.db.onTheFlyFunding.listPending(remoteNodeId).isEmpty, interval = 100 millis) register.expectNoMessage(100 millis) } @@ -726,12 +960,93 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { // The payment is fulfilled by our peer. cmd2.replyTo ! RES_ADD_SETTLED(cmd2.origin, htlc, HtlcResult.OnChainFulfill(preimage)) - assert(register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]].channelId == upstream.add.channelId) + verifyFulfilledUpstream(upstream, preimage) nodeParams.db.onTheFlyFunding.addPreimage(preimage) register.expectNoMessage(100 millis) awaitCond(nodeParams.db.onTheFlyFunding.listPending(remoteNodeId).isEmpty, interval = 100 millis) } + test("successfully relay HTLCs to on-the-fly funded channel (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + // A first payment adds some fee credit. + val preimage1 = randomBytes32() + val paymentHash1 = Crypto.sha256(preimage1) + val upstream1 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash1) + proposeFunding(4_000_000 msat, expiryOut, paymentHash1, upstream1) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 4_000_000.msat) + verifyFulfilledUpstream(upstream1, preimage1) + + // A second payment will pay the rest of the liquidity fees. + val preimage2 = randomBytes32() + val paymentHash2 = Crypto.sha256(preimage2) + val upstream2 = upstreamChannel(16_000_000 msat, expiryIn, paymentHash2) + proposeFunding(15_000_000 msat, expiryOut, paymentHash2, upstream2) + val fees = LiquidityAds.Fees(5_000 sat, 4_000 sat) + val purchase = signLiquidityPurchase(200_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash2 :: Nil), fees = fees, feeCreditUsed_opt = Some(4_000_000 msat)) + + // Once the channel is ready to relay payments, we forward the remaining HTLC. + // We collect the liquidity fees that weren't paid by the fee credit. + val channelData = makeChannelData() + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, channelData) + val cmd = channel.expectMsgType[CMD_ADD_HTLC] + assert(cmd.amount == 10_000_000.msat) + assert(cmd.fundingFee_opt.contains(LiquidityAds.FundingFee(5_000_000 msat, purchase.txId))) + assert(cmd.paymentHash == paymentHash2) + channel.expectNoMessage(100 millis) + + val add = UpdateAddHtlc(purchase.channelId, randomHtlcId(), cmd.amount, cmd.paymentHash, cmd.cltvExpiry, cmd.onion, cmd.nextBlindingKey_opt, cmd.confidence, cmd.fundingFee_opt) + cmd.replyTo ! RES_ADD_SETTLED(cmd.origin, add, HtlcResult.RemoteFulfill(UpdateFulfillHtlc(purchase.channelId, add.id, preimage2))) + verifyFulfilledUpstream(upstream2, preimage2) + register.expectNoMessage(100 millis) + awaitCond(nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) + } + + test("don't relay payments if added to fee credit while signing", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(100_000_000 msat, expiryIn, paymentHash) + proposeFunding(100_000_000 msat, CltvExpiry(TestConstants.defaultBlockHeight), paymentHash, upstream) + + // The proposal is accepted: we start funding a channel. + val requestFunding = LiquidityAds.RequestFunding( + 200_000 sat, + LiquidityAds.FundingRate(10_000 sat, 500_000 sat, 0, 100, 0 sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil) + ) + val open = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR] + channel.expectMsgType[OpenDualFundedChannel] + + // The payment is added to fee credit while we're funding the channel. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 100_000_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + // The channel transaction is signed: we invalidate the fee credit and won't relay HTLCs. + // We've fulfilled the upstream HTLCs, so we're earning more than our expected fees. + val purchase = signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, fees = requestFunding.fees(open.fundingFeerate)) + awaitCond(nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectNoMessage(100 millis) + + // We don't relay the payment on reconnection either. + disconnect(channelCount = 1) + connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures())) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("don't relay payments too close to expiry") { f => import f._ @@ -767,10 +1082,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { channel.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, makeChannelData()) channel.expectNoMessage(100 millis) - val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd.channelId == upstream.add.channelId) - assert(fwd.message.id == upstream.add.id) - assert(fwd.message.r == preimage) + verifyFulfilledUpstream(upstream, preimage) register.expectNoMessage(100 millis) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index ea238d7a58..aa60decfef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -28,7 +28,7 @@ import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes} import fr.acinq.eclair.json.JsonSerializers import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Scripts -import fr.acinq.eclair.wire.protocol.ChannelTlv.{ChannelTypeTlv, PushAmountTlv, RequireConfirmedInputsTlv, UpfrontShutdownScriptTlv} +import fr.acinq.eclair.wire.protocol.ChannelTlv._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs._ import fr.acinq.eclair.wire.protocol.ReplyChannelRangeTlv._ import org.json4s.jackson.Serialization @@ -372,7 +372,9 @@ class LightningMessageCodecsSpec extends AnyFunSuite { defaultAccept -> defaultEncoded, defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()))) -> (defaultEncoded ++ hex"01021000"), defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), PushAmountTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fe470000070206c1"), - defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200") + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200"), + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(0 msat))) -> (defaultEncoded ++ hex"0103401000 fda05200"), + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fda0520206c1"), ) testCases.foreach { case (accept, bin) => val decoded = lightningMessageCodec.decode(bin.bits).require.value @@ -436,7 +438,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 1a 00000000000b71b0 0007a120004c4b40044c004b00000000 0000" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request).map(_.willFund) + val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 74 0007a120004c4b40044c004b00000000 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 35962783e077e3c5214ba829752be2a3994a7c5e0e9d735ef5a9dab3ce1d6dda6282c3252b20af52e58c33c0e164167fd59e19114a8a8f9eb76b33008205dcb6" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -452,7 +454,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5a 000000000007a120 000186a00007a1200226006400001388 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 74 000186a00007a1200226006400001388 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c d928070bc5c68c3c6362a0041b523fe03aa853e50214864a3339296e37ebda74343a227b24bc37d27af550bc2ee3d56b00d8c342cc3104b29cc8f09cac9fad9f" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -468,7 +470,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5a 000000000007a120 000186a00007a1200226006400001388 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 74 000186a00007a1200226006400001388 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c d928070bc5c68c3c6362a0041b523fe03aa853e50214864a3339296e37ebda74343a227b24bc37d27af550bc2ee3d56b00d8c342cc3104b29cc8f09cac9fad9f" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -580,6 +582,24 @@ class LightningMessageCodecsSpec extends AnyFunSuite { } } + test("encode/decode fee credit messages") { + val preimages = Seq( + ByteVector32(hex"6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f"), + ByteVector32(hex"4ad834d418faf74ebf7c8a026f2767a41c3a0995c334d7d3dab47737794b0c16"), + ) + val testCases = Seq( + AddFeeCredit(Block.RegtestGenesisBlock.hash, preimages.head) -> hex"a055 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f", + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 0 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000000000000", + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 20_000_000 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000001312d00", + ) + for ((expected, encoded) <- testCases) { + val decoded = lightningMessageCodec.decode(encoded.bits).require.value + assert(decoded == expected) + val reEncoded = lightningMessageCodec.encode(decoded).require.bytes + assert(reEncoded == encoded) + } + } + test("unknown messages") { // Non-standard tag number so this message can only be handled by a codec with a fallback val unknown = UnknownMessage(tag = 47282, data = ByteVector32.Zeroes.bytes) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala index 732a12cc48..9bf977faef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala @@ -39,7 +39,7 @@ class LiquidityAdsSpec extends AnyFunSuite { val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance)) val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates) val fundingScript = hex"00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff" - val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request).map(_.willFund) + val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, None).map(_.willFund) assert(willFund.fundingRate == fundingRate) assert(willFund.fundingScript == fundingScript) assert(willFund.signature == ByteVector64.fromValidHex("0d99b73ecc32a81581cb761d8737e8bccf2358a01f7dea8e2f2579f32db42e94668786a2245287848c550b502fee9aca232c0c343afb16ac44d9be9c59d16f70"))