From f11f922c6bd92565db8f51c0eb7a9f2a5b969964 Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:21:05 +0200 Subject: [PATCH] Add support for `funding_fee_credit` (#2875) 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. Co-authored-by: Pierre-Marie Padiou --- .../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 + .../fr/acinq/eclair/db/LiquidityDb.scala | 10 + .../fr/acinq/eclair/db/pg/PgLiquidityDb.scala | 43 ++- .../eclair/db/sqlite/SqliteLiquidityDb.scala | 48 +++ .../scala/fr/acinq/eclair/io/Monitoring.scala | 1 + .../eclair/io/OpenChannelInterceptor.scala | 27 +- .../main/scala/fr/acinq/eclair/io/Peer.scala | 166 ++++++-- .../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 +- .../scala/fr/acinq/eclair/TestConstants.scala | 2 + .../channel/InteractiveTxBuilderSpec.scala | 93 ++++- ...aitForOpenDualFundedChannelStateSpec.scala | 13 + .../fr/acinq/eclair/db/LiquidityDbSpec.scala | 23 ++ .../payment/relay/OnTheFlyFundingSpec.scala | 361 ++++++++++++++++-- .../protocol/LightningMessageCodecsSpec.scala | 36 +- .../wire/protocol/LiquidityAdsSpec.scala | 2 +- 24 files changed, 880 insertions(+), 116 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 4771371e3f..033899eeb5 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, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt)) + willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, isChannelCreation = true, 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 056cbbed46..5d73fb0241 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, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, 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 e60549fd13..46daea5e66 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 (d.init.requireConfirmedInputs) 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, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, 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 10255527a2..0b78f2de31 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 @@ -37,7 +37,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} @@ -157,13 +157,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) } } @@ -744,6 +749,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.liquidity.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 662c842bb5..d1ff3487ea 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 @@ -463,4 +463,19 @@ case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends primary.getOnTheFlyFundingPreimage(paymentHash) } + 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/LiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala index fb2217fe28..3feacaf1dc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala @@ -20,6 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, TxId} import fr.acinq.eclair.channel.{ChannelLiquidityPurchased, LiquidityPurchase} import fr.acinq.eclair.payment.relay.OnTheFlyFunding +import fr.acinq.eclair.{MilliSatoshi, TimestampMilli} /** * Created by t-bast on 13/09/2024. @@ -57,4 +58,13 @@ trait LiquidityDb { /** Check if we received the preimage for the given payment hash of an on-the-fly payment. */ def getOnTheFlyFundingPreimage(paymentHash: ByteVector32): Option[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/PgLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala index 279b7ffb40..a9629aaaca 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala @@ -25,7 +25,7 @@ import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.wire.protocol.LiquidityAds -import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong} +import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli} import grizzled.slf4j.Logging import scodec.bits.BitVector @@ -58,6 +58,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { // On-the-fly funding. statement.executeUpdate("CREATE TABLE liquidity.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 liquidity.pending_on_the_fly_funding (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 (node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE liquidity.fee_credits (node_id TEXT NOT NULL PRIMARY KEY, amount_msat BIGINT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL)") // Indexes. statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity.purchases(node_id)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do @@ -129,6 +130,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", 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 liquidity.pending_on_the_fly_funding (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) @@ -237,4 +239,43 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("INSERT INTO liquidity.fee_credits(node_id, amount_msat, updated_at) VALUES (?, ?, ?) ON CONFLICT (node_id) DO UPDATE SET (amount_msat, updated_at) = (liquidity.fee_credits.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("liquidity/get-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE 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("liquidity/remove-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE node_id = ?")) { statement => + statement.setString(1, nodeId.toHex) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(pg.prepareStatement("UPDATE liquidity.fee_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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/SqliteLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala index ffaa4af5c6..c2796ba588 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala @@ -53,6 +53,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging // On-the-fly funding. 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 (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 (node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE fee_credits (node_id BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, updated_at INTEGER NOT NULL)") // Indexes. statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity_purchases(node_id)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do @@ -117,6 +118,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", 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 (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) @@ -212,4 +214,50 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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_credits(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("liquidity/get-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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("liquidity/remove-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala index d5a61aa119..8714ac9b5a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala @@ -84,7 +84,7 @@ object OpenChannelInterceptor { } } - def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = { + def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = { val maxHtlcValueInFlightMsat = if (unlimitedMaxHtlcValueInFlight) { // We don't want to impose limits on the amount in flight, typically to allow fully emptying the channel. 21e6.btc.toMilliSatoshi @@ -104,7 +104,7 @@ object OpenChannelInterceptor { toSelfDelay = nodeParams.channelConf.toRemoteDelay, // we choose their delay maxAcceptedHtlcs = nodeParams.channelConf.maxAcceptedHtlcs, isChannelOpener = isChannelOpener, - paysCommitTxFees = isChannelOpener, + paysCommitTxFees = paysCommitTxFees, upfrontShutdownScript_opt = upfrontShutdownScript_opt, walletStaticPaymentBasepoint = walletStaticPaymentBasepoint_opt, initFeatures = initFeatures @@ -142,7 +142,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], val channelType = request.open.channelType_opt.getOrElse(ChannelTypes.defaultFromFeatures(request.localFeatures, request.remoteFeatures, channelFlags.announceChannel)) val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding) val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) - val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight) + val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, paysCommitTxFees = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight) peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType, localParams) waitForRequest() } @@ -161,18 +161,24 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], case Right(channelType) => val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding) val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) - val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = false, dualFunded = dualFunded, request.fundingAmount, disableMaxHtlcValueInFlight = false) // We only accept paying the commit fees if: // - our peer supports on-the-fly funding, indicating that they're a mobile wallet // - they are purchasing liquidity for this channel val nonInitiatorPaysCommitTxFees = request.channelFlags.nonInitiatorPaysCommitFees && Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) && request.open.fold(_ => false, _.requestFunding_opt.isDefined) - if (nonInitiatorPaysCommitTxFees) { - checkRateLimits(request, channelType, localParams.copy(paysCommitTxFees = true)) - } else { - checkRateLimits(request, channelType, localParams) - } + val localParams = createLocalParams( + nodeParams, + request.localFeatures, + upfrontShutdownScript, + channelType, + isChannelOpener = false, + paysCommitTxFees = nonInitiatorPaysCommitTxFees, + dualFunded = dualFunded, + fundingAmount = request.fundingAmount, + disableMaxHtlcValueInFlight = false + ) + checkRateLimits(request, channelType, localParams) case Left(ex) => context.log.warn(s"ignoring remote channel open: ${ex.getMessage}") sendFailure(ex.getMessage, request) @@ -308,13 +314,14 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], } } - private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = { + private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = { val pubkey_opt = if (upfrontShutdownScript || channelType.paysDirectlyToWallet) Some(wallet.getP2wpkhPubkey()) else None makeChannelParams( nodeParams, initFeatures, if (upfrontShutdownScript) Some(Script.write(Script.pay2wpkh(pubkey_opt.get))) else None, if (channelType.paysDirectlyToWallet) Some(pubkey_opt.get) else None, isChannelOpener = isChannelOpener, + paysCommitTxFees = paysCommitTxFees, dualFunded = dualFunded, fundingAmount, disableMaxHtlcValueInFlight 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 a11ccdfff6..68e31f63ba 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. @@ -69,6 +68,7 @@ class Peer(val nodeParams: NodeParams, import Peer._ private var pendingOnTheFlyFunding = Map.empty[ByteVector32, OnTheFlyFunding.Pending] + private var feeCredit = Option.empty[MilliSatoshi] context.system.eventStream.subscribe(self, classOf[CurrentFeerates]) context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight]) @@ -100,7 +100,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. @@ -113,7 +113,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 { @@ -214,7 +214,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.getOrElse(0 msat)) match { case reject: OnTheFlyFunding.ValidationResult.Reject => log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) @@ -231,7 +231,10 @@ class Peer(val nodeParams: NodeParams, case Right(open) => val requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, addFunding_opt, dualFunded = true, None, requireConfirmedInputs, 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)) @@ -263,6 +266,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)) @@ -300,6 +308,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) @@ -320,6 +331,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) } @@ -328,17 +341,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 = Some(nodeParams.db.liquidity.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.getOrElse(0 msat)), 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.getOrElse(0 msat)), 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.getOrElse(0 msat)) 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) } @@ -349,6 +401,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) @@ -396,7 +449,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 { @@ -506,16 +559,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.liquidity.removePendingOnTheFlyFunding(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 => + // Nothing to do, we already fulfilled the upstream HTLCs. + log.debug("forgetting will_add_htlc added to fee credit for payment_hash={}", paymentHash) + 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.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash) } } pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(expired.keys) @@ -524,22 +581,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 = Some(nodeParams.db.liquidity.removeFeeCredit(remoteNodeId, p.feeCreditUsed)) + self ! OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), 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 @@ -556,6 +625,17 @@ class Peer(val nodeParams: NodeParams, Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment() nodeParams.db.liquidity.addPendingOnTheFlyFunding(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 = Some(nodeParams.db.liquidity.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) } @@ -637,7 +717,7 @@ class Peer(val nodeParams: NodeParams, } private def gotoConnected(connectionReady: PeerConnection.ConnectionReady, channels: Map[ChannelId, ActorRef]): State = { - require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeid: $remoteNodeId != ${connectionReady.remoteNodeId}") + require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeId: $remoteNodeId != ${connectionReady.remoteNodeId}") log.debug("got authenticated connection to address {}", connectionReady.address) if (connectionReady.outgoing) { @@ -652,6 +732,16 @@ class Peer(val nodeParams: NodeParams, // We tell our peer what our current feerates are. connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, connectionReady.localInit.features, connectionReady.remoteInit.features) + if (Features.canUseFeature(connectionReady.localInit.features, connectionReady.remoteInit.features, Features.FundingFeeCredit)) { + if (feeCredit.isEmpty) { + // We read the fee credit from the database on the first connection attempt. + // We keep track of the latest credit afterwards and don't need to read it from the DB at every reconnection. + feeCredit = Some(nodeParams.db.liquidity.getFeeCredit(remoteNodeId)) + } + log.info("reconnecting with fee credit = {}", feeCredit) + connectionReady.peerConnection ! CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)) + } + goto(CONNECTED) using ConnectedData(connectionReady.address, connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit, channels) } @@ -685,17 +775,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) } @@ -706,12 +797,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 ce30ae6c69..0fb8d76f39 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, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, 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, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit) + case None => ValidationResult.Accept(Set.empty, None) } } @@ -134,7 +137,8 @@ object OnTheFlyFunding { isChannelCreation: Boolean, 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 @@ -145,17 +149,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, isChannelCreation) + 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, isChannelCreation).total.toMilliSatoshi + val useFeeCredit = feeCredit.min(fees) + (fees - useFeeCredit, Some(useFeeCredit)) + } else { + (requestFunding.fees(feerate, isChannelCreation).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 da492c9ca7..7d0fa016f2 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 5bc5dc75b5..e045da1475 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( if (pushAmount > 0.msat) Some(ChannelTlv.PushAmountTlv(pushAmount)) else None, 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 7a9e37ff15..933e3090fa 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} @@ -124,7 +124,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, isChannelCreation: Boolean): Either[ChannelException, WillFundPurchase] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean, 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)) { @@ -133,7 +133,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, isChannelCreation), request.paymentDetails) + val fees = request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation) + 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)) } } @@ -141,9 +145,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, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, 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, isChannelCreation).map(l => Some(l)) + case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation, feeCreditUsed_opt).map(l => Some(l)) case _ => Right(None) } } @@ -225,7 +229,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/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index b48c937552..6f56850eb0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -246,6 +246,7 @@ object TestConstants { None, None, isChannelOpener = true, + paysCommitTxFees = true, dualFunded = false, fundingSatoshis, unlimitedMaxHtlcValueInFlight = false, @@ -419,6 +420,7 @@ object TestConstants { None, None, isChannelOpener = false, + paysCommitTxFees = false, dualFunded = false, fundingSatoshis, unlimitedMaxHtlcValueInFlight = false, 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 d82768400c..ec58b9cc3c 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 @@ -214,8 +214,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = { val channelFeatures = ChannelFeatures(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true) val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]))) - val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = !nonInitiatorPaysCommitTxFees) - val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = nonInitiatorPaysCommitTxFees) + val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, paysCommitTxFees = !nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false) + val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, paysCommitTxFees = nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false) val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map { case (nodeParams, localParams) => @@ -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.liquidity.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.liquidity.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: @@ -2254,6 +2339,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 db65e9607a..246a6fc34c 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/LiquidityDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala index 978236a911..acd86a9f28 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala @@ -185,4 +185,27 @@ class LiquidityDbSpec extends AnyFunSuite { } } + test("add/get/remove fee credit") { + forAllDbs { dbs => + val db = dbs.liquidity + 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 ef58e5b332..b4f2793992 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.liquidity.listPendingOnTheFlyFunding(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, 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.liquidity.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, 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.liquidity.getFeeCredit(remoteNodeId) == 2_500_000.msat, interval = 100 millis) } test("reject invalid open_channel2") { f => @@ -581,15 +821,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.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis) register.expectNoMessage(100 millis) } @@ -732,12 +966,94 @@ 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.liquidity.addOnTheFlyFundingPreimage(preimage) register.expectNoMessage(100 millis) awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(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) + cmd.replyTo ! RES_SUCCESS(cmd, purchase.channelId) + 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.liquidity.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, 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, isChannelCreation = true)) + awaitCond(nodeParams.db.liquidity.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._ @@ -773,10 +1089,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 4d2858c678..3e67307905 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 @@ -395,10 +397,12 @@ class LightningMessageCodecsSpec extends AnyFunSuite { SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.RequestFunding(100_000 sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance))) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000", SpliceAck(channelId, 25_000 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", + SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", SpliceAck(channelId, 0 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", SpliceAck(channelId, (-25_000).sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes)), None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1", SpliceLocked(channelId, fundingTxId) -> hex"908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566", // @formatter:on ) @@ -464,7 +468,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 1e 00000000000b71b0 0007a120004c4b40044c004b00000000000005dc 0000" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 0007a120004c4b40044c004b00000000000005dc 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c c57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -480,7 +484,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -496,7 +500,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -608,6 +612,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 67337d123e..54610e73c6 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 @@ -40,7 +40,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, isChannelCreation = true).map(_.willFund) + val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true, None).map(_.willFund) assert(willFund.fundingRate == fundingRate) assert(willFund.fundingScript == fundingScript) assert(willFund.signature == ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265"))