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