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 c36fc5199b..f980b539dd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -171,7 +171,7 @@ object Helpers { for { script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt) - willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt)) + willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt), open.useFeeCredit_opt) } yield (channelFeatures, script_opt, willFund_opt) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 58b4732be4..8acffea5ba 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -952,7 +952,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with val parentCommitment = d.commitments.latest.commitment val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey) - LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt, msg.useFeeCredit_opt) match { case Left(t) => log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage) stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage) @@ -963,7 +963,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with fundingPubKey = localFundingPubKey, pushAmount = 0.msat, requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, - willFund_opt = willFund_opt.map(_.willFund) + willFund_opt = willFund_opt.map(_.willFund), + feeCreditUsed_opt = msg.useFeeCredit_opt ) val fundingParams = InteractiveTxParams( channelId = d.channelId, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index e60549fd13..46daea5e66 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)), if (d.init.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, willFund_opt.map(l => ChannelTlv.ProvideFundingTlv(l.willFund)), + open.useFeeCredit_opt.map(c => ChannelTlv.FeeCreditUsedTlv(c)), d.init.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), ).flatten val accept = AcceptDualFundedChannel( @@ -547,7 +548,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage) } else { val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript - LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt) match { + LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt, None) match { case Left(t) => log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage) stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index 10255527a2..0b78f2de31 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -37,7 +37,7 @@ import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.transactions.Transactions.{CommitTx, HtlcTx, InputInfo, TxOwner} import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, UInt64} +import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64} import scodec.bits.ByteVector import scala.concurrent.{ExecutionContext, Future} @@ -157,13 +157,18 @@ object InteractiveTxBuilder { // BOLT 2: the initiator's serial IDs MUST use even values and the non-initiator odd values. val serialIdParity: Int = if (isInitiator) 0 else 1 - def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): Satoshi = { + def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): MilliSatoshi = { liquidityPurchase_opt.map(l => l.paymentDetails match { // The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used). - case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => if (isInitiator) l.fees.total else -l.fees.total + case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => + val feesOwed = l match { + case l: LiquidityAds.Purchase.Standard => l.fees.total.toMilliSatoshi + case l: LiquidityAds.Purchase.WithFeeCredit => l.fees.total.toMilliSatoshi - l.feeCreditUsed + } + if (isInitiator) feesOwed else -feesOwed // Fees will be paid later, when relaying HTLCs. - case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0.sat - }).getOrElse(0 sat) + case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0 msat + }).getOrElse(0 msat) } } @@ -744,6 +749,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) } + liquidityPurchase_opt match { + case Some(p: LiquidityAds.Purchase.WithFeeCredit) if !fundingParams.isInitiator => + val currentFeeCredit = nodeParams.db.liquidity.getFeeCredit(remoteNodeId) + if (currentFeeCredit < p.feeCreditUsed) { + log.warn("not enough fee credit: our peer may be malicious ({} < {})", currentFeeCredit, p.feeCreditUsed) + return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) + } + case _ => () + } + previousTransactions.headOption match { case Some(previousTx) => // This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index 662c842bb5..d1ff3487ea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -463,4 +463,19 @@ case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends primary.getOnTheFlyFundingPreimage(paymentHash) } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = { + runAsync(secondary.addFeeCredit(nodeId, amount, receivedAt)) + primary.addFeeCredit(nodeId, amount, receivedAt) + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = { + runAsync(secondary.getFeeCredit(nodeId)) + primary.getFeeCredit(nodeId) + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = { + runAsync(secondary.removeFeeCredit(nodeId, amountUsed)) + primary.removeFeeCredit(nodeId, amountUsed) + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala index fb2217fe28..3feacaf1dc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala @@ -20,6 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, TxId} import fr.acinq.eclair.channel.{ChannelLiquidityPurchased, LiquidityPurchase} import fr.acinq.eclair.payment.relay.OnTheFlyFunding +import fr.acinq.eclair.{MilliSatoshi, TimestampMilli} /** * Created by t-bast on 13/09/2024. @@ -57,4 +58,13 @@ trait LiquidityDb { /** Check if we received the preimage for the given payment hash of an on-the-fly payment. */ def getOnTheFlyFundingPreimage(paymentHash: ByteVector32): Option[ByteVector32] + /** Add fee credit for the given remote node and return the updated fee credit. */ + def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli = TimestampMilli.now()): MilliSatoshi + + /** Return the amount owed to the given remote node as fee credit. */ + def getFeeCredit(nodeId: PublicKey): MilliSatoshi + + /** Remove fee credit for the given remote node and return the remaining fee credit. */ + def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala index 279b7ffb40..a9629aaaca 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala @@ -25,7 +25,7 @@ import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.wire.protocol.LiquidityAds -import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong} +import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli} import grizzled.slf4j.Logging import scodec.bits.BitVector @@ -58,6 +58,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { // On-the-fly funding. statement.executeUpdate("CREATE TABLE liquidity.on_the_fly_funding_preimages (payment_hash TEXT NOT NULL PRIMARY KEY, preimage TEXT NOT NULL, received_at TIMESTAMP WITH TIME ZONE NOT NULL)") statement.executeUpdate("CREATE TABLE liquidity.pending_on_the_fly_funding (node_id TEXT NOT NULL, payment_hash TEXT NOT NULL, channel_id TEXT NOT NULL, tx_id TEXT NOT NULL, funding_tx_index BIGINT NOT NULL, remaining_fees_msat BIGINT NOT NULL, proposed BYTEA NOT NULL, funded_at TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE liquidity.fee_credits (node_id TEXT NOT NULL PRIMARY KEY, amount_msat BIGINT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL)") // Indexes. statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity.purchases(node_id)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do @@ -129,6 +130,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", DbBackends.Postgres) { pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => withLock { pg => using(pg.prepareStatement("INSERT INTO liquidity.pending_on_the_fly_funding (node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING")) { statement => statement.setString(1, remoteNodeId.toHex) @@ -237,4 +239,43 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging { } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("INSERT INTO liquidity.fee_credits(node_id, amount_msat, updated_at) VALUES (?, ?, ?) ON CONFLICT (node_id) DO UPDATE SET (amount_msat, updated_at) = (liquidity.fee_credits.amount_msat + EXCLUDED.amount_msat, EXCLUDED.updated_at) RETURNING amount_msat")) { statement => + statement.setString(1, nodeId.toHex) + statement.setLong(2, amount.toLong) + statement.setTimestamp(3, receivedAt.toSqlTimestamp) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("liquidity/get-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE node_id = ?")) { statement => + statement.setString(1, nodeId.toHex) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("liquidity/remove-fee-credit", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE node_id = ?")) { statement => + statement.setString(1, nodeId.toHex) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(pg.prepareStatement("UPDATE liquidity.fee_credits SET (amount_msat, updated_at) = (?, ?) WHERE node_id = ?")) { statement => + val updated = (current - amountUsed).max(0 msat) + statement.setLong(1, updated.toLong) + statement.setTimestamp(2, Timestamp.from(Instant.now())) + statement.setString(3, nodeId.toHex) + statement.executeUpdate() + updated + } + case None => 0 msat + } + } + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala index ffaa4af5c6..c2796ba588 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala @@ -53,6 +53,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging // On-the-fly funding. statement.executeUpdate("CREATE TABLE on_the_fly_funding_preimages (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, received_at INTEGER NOT NULL)") statement.executeUpdate("CREATE TABLE on_the_fly_funding_pending (node_id BLOB NOT NULL, payment_hash BLOB NOT NULL, channel_id BLOB NOT NULL, tx_id BLOB NOT NULL, funding_tx_index INTEGER NOT NULL, remaining_fees_msat INTEGER NOT NULL, proposed BLOB NOT NULL, funded_at INTEGER NOT NULL, PRIMARY KEY (node_id, payment_hash))") + statement.executeUpdate("CREATE TABLE fee_credits (node_id BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, updated_at INTEGER NOT NULL)") // Indexes. statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity_purchases(node_id)") case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do @@ -117,6 +118,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", DbBackends.Sqlite) { pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => using(sqlite.prepareStatement("INSERT OR IGNORE INTO on_the_fly_funding_pending (node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { statement => statement.setBytes(1, remoteNodeId.value.toArray) @@ -212,4 +214,50 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging } } + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credits SET (amount_msat, updated_at) = (?, ?) WHERE node_id = ?")) { statement => + statement.setLong(1, (current + amount).toLong) + statement.setLong(2, receivedAt.toLong) + statement.setBytes(3, nodeId.value.toArray) + statement.executeUpdate() + amount + current + } + case None => using(sqlite.prepareStatement("INSERT OR IGNORE INTO fee_credits(node_id, amount_msat, updated_at) VALUES (?, ?, ?)")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.setLong(2, amount.toLong) + statement.setLong(3, receivedAt.toLong) + statement.executeUpdate() + amount + } + } + } + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("liquidity/get-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat) + } + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("liquidity/remove-fee-credit", DbBackends.Sqlite) { + using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE node_id = ?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match { + case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credits SET (amount_msat, updated_at) = (?, ?) WHERE node_id = ?")) { statement => + val updated = (current - amountUsed).max(0 msat) + statement.setLong(1, updated.toLong) + statement.setLong(2, TimestampMilli.now().toLong) + statement.setBytes(3, nodeId.value.toArray) + statement.executeUpdate() + updated + } + case None => 0 msat + } + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala index d60a10cfe7..aec4fdef9a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala @@ -75,6 +75,7 @@ object Monitoring { val Rejected = "rejected" val Expired = "expired" val Timeout = "timeout" + val AddedToFeeCredit = "added-to-fee-credit" val Funded = "funded" val RelaySucceeded = "relay-succeeded" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala index d5a61aa119..8714ac9b5a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala @@ -84,7 +84,7 @@ object OpenChannelInterceptor { } } - def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = { + def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = { val maxHtlcValueInFlightMsat = if (unlimitedMaxHtlcValueInFlight) { // We don't want to impose limits on the amount in flight, typically to allow fully emptying the channel. 21e6.btc.toMilliSatoshi @@ -104,7 +104,7 @@ object OpenChannelInterceptor { toSelfDelay = nodeParams.channelConf.toRemoteDelay, // we choose their delay maxAcceptedHtlcs = nodeParams.channelConf.maxAcceptedHtlcs, isChannelOpener = isChannelOpener, - paysCommitTxFees = isChannelOpener, + paysCommitTxFees = paysCommitTxFees, upfrontShutdownScript_opt = upfrontShutdownScript_opt, walletStaticPaymentBasepoint = walletStaticPaymentBasepoint_opt, initFeatures = initFeatures @@ -142,7 +142,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], val channelType = request.open.channelType_opt.getOrElse(ChannelTypes.defaultFromFeatures(request.localFeatures, request.remoteFeatures, channelFlags.announceChannel)) val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding) val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) - val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight) + val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, paysCommitTxFees = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight) peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType, localParams) waitForRequest() } @@ -161,18 +161,24 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], case Right(channelType) => val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding) val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) - val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = false, dualFunded = dualFunded, request.fundingAmount, disableMaxHtlcValueInFlight = false) // We only accept paying the commit fees if: // - our peer supports on-the-fly funding, indicating that they're a mobile wallet // - they are purchasing liquidity for this channel val nonInitiatorPaysCommitTxFees = request.channelFlags.nonInitiatorPaysCommitFees && Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) && request.open.fold(_ => false, _.requestFunding_opt.isDefined) - if (nonInitiatorPaysCommitTxFees) { - checkRateLimits(request, channelType, localParams.copy(paysCommitTxFees = true)) - } else { - checkRateLimits(request, channelType, localParams) - } + val localParams = createLocalParams( + nodeParams, + request.localFeatures, + upfrontShutdownScript, + channelType, + isChannelOpener = false, + paysCommitTxFees = nonInitiatorPaysCommitTxFees, + dualFunded = dualFunded, + fundingAmount = request.fundingAmount, + disableMaxHtlcValueInFlight = false + ) + checkRateLimits(request, channelType, localParams) case Left(ex) => context.log.warn(s"ignoring remote channel open: ${ex.getMessage}") sendFailure(ex.getMessage, request) @@ -308,13 +314,14 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], } } - private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = { + private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = { val pubkey_opt = if (upfrontShutdownScript || channelType.paysDirectlyToWallet) Some(wallet.getP2wpkhPubkey()) else None makeChannelParams( nodeParams, initFeatures, if (upfrontShutdownScript) Some(Script.write(Script.pay2wpkh(pubkey_opt.get))) else None, if (channelType.paysDirectlyToWallet) Some(pubkey_opt.get) else None, isChannelOpener = isChannelOpener, + paysCommitTxFees = paysCommitTxFees, dualFunded = dualFunded, fundingAmount, disableMaxHtlcValueInFlight diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 19af4709c2..9bec12f483 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -44,8 +44,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure -import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails -import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc} +import fr.acinq.eclair.wire.protocol.{AddFeeCredit, ChannelTlv, CurrentFeeCredit, Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, TlvStream, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc} /** * This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time. @@ -69,6 +68,7 @@ class Peer(val nodeParams: NodeParams, import Peer._ private var pendingOnTheFlyFunding = Map.empty[ByteVector32, OnTheFlyFunding.Pending] + private var feeCredit = Option.empty[MilliSatoshi] context.system.eventStream.subscribe(self, classOf[CurrentFeerates]) context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight]) @@ -100,7 +100,7 @@ class Peer(val nodeParams: NodeParams, val channelIds = d.channels.filter(_._2 == actor).keys log.info(s"channel closed: channelId=${channelIds.mkString("/")}") val channels1 = d.channels -- channelIds - if (channels1.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (channels1.isEmpty && canForgetPendingOnTheFlyFunding()) { log.info("that was the last open channel") context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId)) // We have no existing channels or pending signed transaction, we can forget about this peer. @@ -113,7 +113,7 @@ class Peer(val nodeParams: NodeParams, Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) { log.debug("connection lost while negotiating connection") } - if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) { // We have no existing channels or pending signed transaction, we can forget about this peer. stopPeer() } else { @@ -214,7 +214,7 @@ class Peer(val nodeParams: NodeParams, case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) => val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId) if (peerConnection == d.peerConnection) { - OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding) match { + OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match { case reject: OnTheFlyFunding.ValidationResult.Reject => log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) @@ -231,7 +231,10 @@ class Peer(val nodeParams: NodeParams, case Right(open) => val requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, addFunding_opt, dualFunded = true, None, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType) - channel ! open + accept.useFeeCredit_opt match { + case Some(useFeeCredit) => channel ! open.copy(tlvStream = TlvStream(open.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit))) + case None => channel ! open + } } fulfillOnTheFlyFundingHtlcs(accept.preimages) stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel)) @@ -263,6 +266,11 @@ class Peer(val nodeParams: NodeParams, proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream), status = OnTheFlyFunding.Status.Proposed(timer) ) + case status: OnTheFlyFunding.Status.AddedToFeeCredit => + log.info("received extra payment for on-the-fly funding that was added to fee credit (payment_hash={}, amount={})", cmd.paymentHash, cmd.amount) + val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream) + proposal.createFulfillCommands(status.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + pending.copy(proposed = pending.proposed :+ proposal) case status: OnTheFlyFunding.Status.Funded => log.info("received extra payment for on-the-fly funding that has already been funded with txId={} (payment_hash={}, amount={})", status.txId, cmd.paymentHash, cmd.amount) pending.copy(proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream)) @@ -300,6 +308,9 @@ class Peer(val nodeParams: NodeParams, log.warning("ignoring will_fail_htlc: no matching proposal for id={}", msg.id) self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: no matching proposal for id=${msg.id}"), d.peerConnection) } + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit") + self ! Peer.OutgoingMessage(Warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit"), d.peerConnection) case status: OnTheFlyFunding.Status.Funded => log.warning("ignoring will_fail_htlc: on-the-fly funding already signed with txId={}", status.txId) self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: on-the-fly funding already signed with txId=${status.txId}"), d.peerConnection) @@ -320,6 +331,8 @@ class Peer(val nodeParams: NodeParams, Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Expired).increment() pendingOnTheFlyFunding -= timeout.paymentHash self ! Peer.OutgoingMessage(Warning(s"on-the-fly funding proposal timed out for payment_hash=${timeout.paymentHash}"), d.peerConnection) + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring on-the-fly funding proposal timeout, already added to fee credit") case status: OnTheFlyFunding.Status.Funded => log.warning("ignoring on-the-fly funding proposal timeout, already funded with txId={}", status.txId) } @@ -328,17 +341,56 @@ class Peer(val nodeParams: NodeParams, } stay() + case Event(msg: AddFeeCredit, d: ConnectedData) if !nodeParams.features.hasFeature(Features.FundingFeeCredit) => + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit for payment_hash=${Crypto.sha256(msg.preimage)}, ${Features.FundingFeeCredit.rfcName} is not supported"), d.peerConnection) + stay() + + case Event(msg: AddFeeCredit, d: ConnectedData) => + val paymentHash = Crypto.sha256(msg.preimage) + pendingOnTheFlyFunding.get(paymentHash) match { + case Some(pending) => + pending.status match { + case status: OnTheFlyFunding.Status.Proposed => + feeCredit = Some(nodeParams.db.liquidity.addFeeCredit(remoteNodeId, pending.amountOut)) + log.info("received add_fee_credit for payment_hash={}, adding {} to fee credit (total = {})", paymentHash, pending.amountOut, feeCredit) + status.timer.cancel() + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.AddedToFeeCredit).increment() + pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection) + pendingOnTheFlyFunding += (paymentHash -> pending.copy(status = OnTheFlyFunding.Status.AddedToFeeCredit(msg.preimage))) + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("ignoring duplicate add_fee_credit for payment_hash={}", paymentHash) + // We already fulfilled upstream HTLCs, there is nothing else to do. + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection) + case _: OnTheFlyFunding.Status.Funded => + log.warning("ignoring add_fee_credit for funded on-the-fly proposal (payment_hash={})", paymentHash) + // They seem to be malicious, so let's fulfill upstream HTLCs for safety. + pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection) + } + case None => + log.warning("ignoring add_fee_credit for unknown payment_hash={}", paymentHash) + self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: unknown payment_hash=$paymentHash"), d.peerConnection) + // This may happen if the remote node is very slow and the timeout was reached before receiving their message. + // We sent the current fee credit to let them detect it and reconcile their state. + self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection) + } + stay() + case Event(msg: SpliceInit, d: ConnectedData) => d.channels.get(FinalChannelId(msg.channelId)) match { case Some(channel) => - OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding) match { + OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match { case reject: OnTheFlyFunding.ValidationResult.Reject => log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) cancelUnsignedOnTheFlyFunding(reject.paymentHashes) case accept: OnTheFlyFunding.ValidationResult.Accept => fulfillOnTheFlyFundingHtlcs(accept.preimages) - channel forward msg + accept.useFeeCredit_opt match { + case Some(useFeeCredit) => channel forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit))) + case None => channel forward msg + } } case None => replyUnknownChannel(d.peerConnection, msg.channelId) } @@ -349,6 +401,7 @@ class Peer(val nodeParams: NodeParams, case (paymentHash, pending) => pending.status match { case _: OnTheFlyFunding.Status.Proposed => () + case _: OnTheFlyFunding.Status.AddedToFeeCredit => () case status: OnTheFlyFunding.Status.Funded => context.child(paymentHash.toHex) match { case Some(_) => log.debug("already relaying payment_hash={}", paymentHash) @@ -396,7 +449,7 @@ class Peer(val nodeParams: NodeParams, Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) { log.debug("connection lost") } - if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) { + if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) { // We have no existing channels or pending signed transaction, we can forget about this peer. stopPeer() } else { @@ -506,16 +559,20 @@ class Peer(val nodeParams: NodeParams, val expired = pendingOnTheFlyFunding.filter { case (_, pending) => pending.proposed.exists(_.htlc.expiry.blockHeight <= current.blockHeight) } - expired.foreach { - case (paymentHash, pending) => - log.warning("will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash) - Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() - pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } - } expired.foreach { case (paymentHash, pending) => pending.status match { - case _: OnTheFlyFunding.Status.Proposed => () - case _: OnTheFlyFunding.Status.Funded => nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash) + case _: OnTheFlyFunding.Status.Proposed => + log.warning("proposed will_add_htlc expired for payment_hash={}", paymentHash) + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + // Nothing to do, we already fulfilled the upstream HTLCs. + log.debug("forgetting will_add_htlc added to fee credit for payment_hash={}", paymentHash) + case _: OnTheFlyFunding.Status.Funded => + log.warning("funded will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash) + Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } + nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash) } } pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(expired.keys) @@ -524,22 +581,34 @@ class Peer(val nodeParams: NodeParams, case _ => stay() } - case Event(e: LiquidityPurchaseSigned, _: ConnectedData) => + case Event(e: LiquidityPurchaseSigned, d: ConnectedData) => + // If that liquidity purchase was partially paid with fee credit, we will deduce it from what our peer owes us + // and remove the corresponding amount from our peer's credit. + // Note that since we only allow a single channel per user when on-the-fly funding is used, and it's not possible + // to request a splice while one is already in progress, it's safe to only remove fee credit once the funding + // transaction has been signed. + val feeCreditUsed = e.purchase match { + case _: LiquidityAds.Purchase.Standard => 0 msat + case p: LiquidityAds.Purchase.WithFeeCredit => + feeCredit = Some(nodeParams.db.liquidity.removeFeeCredit(remoteNodeId, p.feeCreditUsed)) + self ! OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection) + p.feeCreditUsed + } // We signed a liquidity purchase from our peer. At that point we're not 100% sure yet it will succeed: if // we disconnect before our peer sends their signature, the funding attempt may be cancelled when reconnecting. // If that happens, the on-the-fly proposal will stay in our state until we reach the CLTV expiry, at which // point we will forget it and fail the upstream HTLCs. This is also what would happen if we successfully // funded the channel, but it closed before we could relay the HTLCs. - val (paymentHashes, fees) = e.purchase.paymentDetails match { - case PaymentDetails.FromChannelBalance => (Nil, 0 sat) - case p: PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 sat) - case p: PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total) - case p: PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total) + val (paymentHashes, feesOwed) = e.purchase.paymentDetails match { + case LiquidityAds.PaymentDetails.FromChannelBalance => (Nil, 0 msat) + case p: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 msat) + case p: LiquidityAds.PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total - feeCreditUsed) + case p: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total - feeCreditUsed) } // We split the fees across payments. We could dynamically re-split depending on whether some payments are failed // instead of fulfilled, but that's overkill: if our peer fails one of those payment, they're likely malicious // and will fail anyway, even if we try to be clever with fees splitting. - var remainingFees = fees.toMilliSatoshi + var remainingFees = feesOwed.max(0 msat) pendingOnTheFlyFunding .filter { case (paymentHash, _) => paymentHashes.contains(paymentHash) } .values.toSeq @@ -556,6 +625,17 @@ class Peer(val nodeParams: NodeParams, Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment() nodeParams.db.liquidity.addPendingOnTheFlyFunding(remoteNodeId, payment1) pendingOnTheFlyFunding += payment.paymentHash -> payment1 + case _: OnTheFlyFunding.Status.AddedToFeeCredit => + log.warning("liquidity purchase was signed for payment_hash={} that was also added to fee credit: our peer may be malicious", payment.paymentHash) + // Our peer tried to concurrently get a channel funded *and* add the same payment to its fee credit. + // We've already signed the funding transaction so we can't abort, but we have also received the preimage + // and fulfilled the upstream HTLCs: we simply won't forward the matching HTLCs on the funded channel. + // Instead of being paid the funding fees, we've claimed the entire incoming HTLC set, which is bigger + // than the fees (otherwise we wouldn't have accepted the on-the-fly funding attempt), so it's fine. + // They cannot have used that additional fee credit yet because we only allow a single channel per user + // when on-the-fly funding is used, and it's not possible to request a splice while one is already in + // progress. + feeCredit = Some(nodeParams.db.liquidity.removeFeeCredit(remoteNodeId, payment.amountOut)) case status: OnTheFlyFunding.Status.Funded => log.warning("liquidity purchase was already signed for payment_hash={} (previousTxId={}, currentTxId={})", payment.paymentHash, status.txId, e.txId) } @@ -637,7 +717,7 @@ class Peer(val nodeParams: NodeParams, } private def gotoConnected(connectionReady: PeerConnection.ConnectionReady, channels: Map[ChannelId, ActorRef]): State = { - require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeid: $remoteNodeId != ${connectionReady.remoteNodeId}") + require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeId: $remoteNodeId != ${connectionReady.remoteNodeId}") log.debug("got authenticated connection to address {}", connectionReady.address) if (connectionReady.outgoing) { @@ -652,6 +732,16 @@ class Peer(val nodeParams: NodeParams, // We tell our peer what our current feerates are. connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, nodeParams.currentFeerates, connectionReady.localInit.features, connectionReady.remoteInit.features) + if (Features.canUseFeature(connectionReady.localInit.features, connectionReady.remoteInit.features, Features.FundingFeeCredit)) { + if (feeCredit.isEmpty) { + // We read the fee credit from the database on the first connection attempt. + // We keep track of the latest credit afterwards and don't need to read it from the DB at every reconnection. + feeCredit = Some(nodeParams.db.liquidity.getFeeCredit(remoteNodeId)) + } + log.info("reconnecting with fee credit = {}", feeCredit) + connectionReady.peerConnection ! CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)) + } + goto(CONNECTED) using ConnectedData(connectionReady.address, connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit, channels) } @@ -685,17 +775,18 @@ class Peer(val nodeParams: NodeParams, case (paymentHash, pending) if paymentHashes.contains(paymentHash) => pending.status match { case status: OnTheFlyFunding.Status.Proposed => + log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash) status.timer.cancel() + pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } true + // We keep proposals that have been added to fee credit until we reach the HTLC expiry or we restart. This + // guarantees that our peer cannot concurrently add to their fee credit a payment for which we've signed a + // funding transaction. + case _: OnTheFlyFunding.Status.AddedToFeeCredit => false case _: OnTheFlyFunding.Status.Funded => false } case _ => false } - unsigned.foreach { - case (paymentHash, pending) => - log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash) - pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) } - } pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(unsigned.keys) } @@ -706,12 +797,17 @@ class Peer(val nodeParams: NodeParams, }) } - /** Return true if we have signed on-the-fly funding transactions and haven't settled the corresponding HTLCs yet. */ - private def pendingSignedOnTheFlyFunding(): Boolean = { - pendingOnTheFlyFunding.exists { + /** Return true if we can forget pending on-the-fly funding transactions and stop ourselves. */ + private def canForgetPendingOnTheFlyFunding(): Boolean = { + pendingOnTheFlyFunding.forall { case (_, pending) => pending.status match { - case _: OnTheFlyFunding.Status.Proposed => false - case _: OnTheFlyFunding.Status.Funded => true + case _: OnTheFlyFunding.Status.Proposed => true + // We don't stop ourselves if our peer has some fee credit. + // They will likely come back online to use that fee credit. + case _: OnTheFlyFunding.Status.AddedToFeeCredit => false + // We don't stop ourselves if we've signed an on-the-fly funding proposal but haven't settled HTLCs yet. + // We must watch the expiry of those HTLCs and obtain the preimage before they expire to get paid. + case _: OnTheFlyFunding.Status.Funded => false } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala index 4325e9af7f..102e257d40 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams, TimestampMilli, ToMilliSatoshiConversion} +import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, ToMilliSatoshiConversion} import scodec.bits.ByteVector import scala.concurrent.duration.FiniteDuration @@ -45,6 +45,8 @@ object OnTheFlyFunding { object Status { /** We sent will_add_htlc, but didn't fund a transaction yet. */ case class Proposed(timer: Cancellable) extends Status + /** Our peer revealed the preimage to add this payment to their fee credit for a future on-chain transaction. */ + case class AddedToFeeCredit(preimage: ByteVector32) extends Status /** * We signed a transaction matching the on-the-fly funding proposed. We're waiting for the liquidity to be * available (channel ready or splice locked) to relay the HTLCs and complete the payment. @@ -89,6 +91,7 @@ object OnTheFlyFunding { case class Pending(proposed: Seq[Proposal], status: Status) { val paymentHash = proposed.head.htlc.paymentHash val expiry = proposed.map(_.htlc.expiry).min + val amountOut = proposed.map(_.htlc.amount).sum /** Maximum fees that can be collected from this HTLC set. */ def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = proposed.map(_.maxFees(htlcMinimum)).sum @@ -106,26 +109,26 @@ object OnTheFlyFunding { /** The incoming channel or splice cannot pay the liquidity fees: we must reject it and fail the corresponding upstream HTLCs. */ case class Reject(cancel: CancelOnTheFlyFunding, paymentHashes: Set[ByteVector32]) extends ValidationResult /** We are on-the-fly funding a channel: if we received preimages, we must fulfill the corresponding upstream HTLCs. */ - case class Accept(preimages: Set[ByteVector32]) extends ValidationResult + case class Accept(preimages: Set[ByteVector32], useFeeCredit_opt: Option[MilliSatoshi]) extends ValidationResult } // @formatter:on /** Validate an incoming channel that may use on-the-fly funding. */ - def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = { open match { - case Left(_) => ValidationResult.Accept(Set.empty) + case Left(_) => ValidationResult.Accept(Set.empty, None) case Right(open) => open.requestFunding_opt match { - case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit) + case None => ValidationResult.Accept(Set.empty, None) } } } /** Validate an incoming splice that may use on-the-fly funding. */ - def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = { splice.requestFunding_opt match { - case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding) - case None => ValidationResult.Accept(Set.empty) + case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit) + case None => ValidationResult.Accept(Set.empty, None) } } @@ -134,7 +137,8 @@ object OnTheFlyFunding { isChannelCreation: Boolean, feerate: FeeratePerKw, htlcMinimum: MilliSatoshi, - pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { + pendingOnTheFlyFunding: Map[ByteVector32, Pending], + feeCredit: MilliSatoshi): ValidationResult = { val paymentHashes = requestFunding.paymentDetails match { case PaymentDetails.FromChannelBalance => Nil case PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes) => paymentHashes @@ -145,17 +149,24 @@ object OnTheFlyFunding { val totalPaymentAmount = pending.flatMap(_.proposed.map(_.htlc.amount)).sum // We will deduce fees from HTLCs: we check that the amount is large enough to cover the fees. val availableAmountForFees = pending.map(_.maxFees(htlcMinimum)).sum - val fees = requestFunding.fees(feerate, isChannelCreation) + val (feesOwed, useFeeCredit_opt) = if (feeCredit > 0.msat) { + // We prioritize using our peer's fee credit if they have some available. + val fees = requestFunding.fees(feerate, isChannelCreation).total.toMilliSatoshi + val useFeeCredit = feeCredit.min(fees) + (fees - useFeeCredit, Some(useFeeCredit)) + } else { + (requestFunding.fees(feerate, isChannelCreation).total.toMilliSatoshi, None) + } val cancelAmountTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"requested amount is too low to relay HTLCs: ${requestFunding.requestedAmount} < $totalPaymentAmount") - val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < ${fees.total}") + val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < $feesOwed") requestFunding.paymentDetails match { - case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty) + case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty, None) case _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet) - case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty) - case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) - case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty) - case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) - case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet) + case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt) + case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) + case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt) + case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) + case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet, useFeeCredit_opt) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index da492c9ca7..7d0fa016f2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -74,10 +74,21 @@ object ChannelTlv { val provideFundingCodec: Codec[ProvideFundingTlv] = tlvField(LiquidityAds.Codecs.willFund) + /** Fee credit that will be used for the given on-the-fly funding operation. */ + case class FeeCreditUsedTlv(amount: MilliSatoshi) extends AcceptDualFundedChannelTlv with SpliceAckTlv + + val feeCreditUsedCodec: Codec[FeeCreditUsedTlv] = tlvField(tmillisatoshi) + case class PushAmountTlv(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with AcceptDualFundedChannelTlv with SpliceInitTlv with SpliceAckTlv val pushAmountCodec: Codec[PushAmountTlv] = tlvField(tmillisatoshi) + /** + * This is an internal TLV for which we DON'T specify a codec: this isn't meant to be read or written on the wire. + * This is only used to decorate open_channel2 and splice_init with the [[Features.FundingFeeCredit]] available. + */ + case class UseFeeCredit(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with SpliceInitTlv + } object OpenChannelTlv { @@ -169,6 +180,7 @@ object SpliceAckTlv { .typecase(UInt64(2), requireConfirmedInputsCodec) // We use a temporary TLV while the spec is being reviewed. .typecase(UInt64(1339), provideFundingCodec) + .typecase(UInt64(41042), feeCreditUsedCodec) .typecase(UInt64(0x47000007), tlvField(tmillisatoshi.as[PushAmountTlv])) ) } @@ -187,6 +199,7 @@ object AcceptDualFundedChannelTlv { .typecase(UInt64(2), requireConfirmedInputsCodec) // We use a temporary TLV while the spec is being reviewed. .typecase(UInt64(1339), provideFundingCodec) + .typecase(UInt64(41042), feeCreditUsedCodec) .typecase(UInt64(0x47000007), pushAmountCodec) ) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 417d7cee94..b422d8598c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -460,6 +460,14 @@ object LightningMessageCodecs { ("paymentHashes" | listOfN(uint16, bytes32)) :: ("reason" | varsizebinarydata)).as[CancelOnTheFlyFunding] + val addFeeCreditCodec: Codec[AddFeeCredit] = ( + ("chainHash" | blockHash) :: + ("preimage" | bytes32)).as[AddFeeCredit] + + val currentFeeCreditCodec: Codec[CurrentFeeCredit] = ( + ("chainHash" | blockHash) :: + ("amount" | millisatoshi)).as[CurrentFeeCredit] + val unknownMessageCodec: Codec[UnknownMessage] = ( ("tag" | uint16) :: ("message" | bytes) @@ -517,6 +525,10 @@ object LightningMessageCodecs { .typecase(41043, willFailMalformedHtlcCodec) .typecase(41044, cancelOnTheFlyFundingCodec) // + // + .typecase(41045, addFeeCreditCodec) + .typecase(41046, currentFeeCreditCodec) + // .typecase(37000, spliceInitCodec) .typecase(37002, spliceAckCodec) .typecase(37004, spliceLockedCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 5bc5dc75b5..e045da1475 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -254,6 +254,7 @@ case class OpenDualFundedChannel(chainHash: BlockHash, val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) + val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount) val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat) } @@ -307,6 +308,7 @@ case class SpliceInit(channelId: ByteVector32, tlvStream: TlvStream[SpliceInitTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) + val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount) val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat) } @@ -331,11 +333,12 @@ case class SpliceAck(channelId: ByteVector32, } object SpliceAck { - def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund]): SpliceAck = { + def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund], feeCreditUsed_opt: Option[MilliSatoshi]): SpliceAck = { val tlvs: Set[SpliceAckTlv] = Set( if (pushAmount > 0.msat) Some(ChannelTlv.PushAmountTlv(pushAmount)) else None, if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, - willFund_opt.map(ChannelTlv.ProvideFundingTlv) + willFund_opt.map(ChannelTlv.ProvideFundingTlv), + feeCreditUsed_opt.map(ChannelTlv.FeeCreditUsedTlv), ).flatten SpliceAck(channelId, fundingContribution, fundingPubKey, TlvStream(tlvs)) } @@ -673,4 +676,14 @@ object CancelOnTheFlyFunding { def apply(channelId: ByteVector32, paymentHashes: List[ByteVector32], reason: String): CancelOnTheFlyFunding = CancelOnTheFlyFunding(channelId, paymentHashes, ByteVector.view(reason.getBytes(Charsets.US_ASCII))) } +/** + * This message is used to reveal the preimage of a small payment for which it isn't economical to perform an on-chain + * transaction. The amount of the payment will be added to our fee credit, which can be used when a future on-chain + * transaction is needed. This message requires the [[Features.FundingFeeCredit]] feature. + */ +case class AddFeeCredit(chainHash: BlockHash, preimage: ByteVector32) extends HasChainHash + +/** This message contains our current fee credit: the liquidity provider is the source of truth for that value. */ +case class CurrentFeeCredit(chainHash: BlockHash, amount: MilliSatoshi) extends HasChainHash + case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala index 7a9e37ff15..933e3090fa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.CommonCodecs._ -import fr.acinq.eclair.wire.protocol.TlvCodecs.{genericTlv, tlvField, tsatoshi32} +import fr.acinq.eclair.wire.protocol.TlvCodecs.tlvField import fr.acinq.eclair.{MilliSatoshi, ToMilliSatoshiConversion, UInt64} import scodec.Codec import scodec.bits.{BitVector, ByteVector} @@ -124,7 +124,7 @@ object LiquidityAds { /** Sellers offer various rates and payment options. */ case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) { - def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean): Either[ChannelException, WillFundPurchase] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean, feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, WillFundPurchase] = { if (!paymentTypes.contains(request.paymentDetails.paymentType)) { Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes)) } else if (!fundingRates.contains(request.fundingRate)) { @@ -133,7 +133,11 @@ object LiquidityAds { Left(InvalidLiquidityAdsRate(channelId)) } else { val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey) - val purchase = Purchase.Standard(request.requestedAmount, request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation), request.paymentDetails) + val fees = request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation) + val purchase = feeCreditUsed_opt match { + case Some(feeCreditUsed) => Purchase.WithFeeCredit(request.requestedAmount, fees, feeCreditUsed, request.paymentDetails) + case None => Purchase.Standard(request.requestedAmount, fees, request.paymentDetails) + } Right(WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase)) } } @@ -141,9 +145,9 @@ object LiquidityAds { def findRate(requestedAmount: Satoshi): Option[FundingRate] = fundingRates.find(r => r.minAmount <= requestedAmount && requestedAmount <= r.maxAmount) } - def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = { + def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates], feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, Option[WillFundPurchase]] = { (request_opt, rates_opt) match { - case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation).map(l => Some(l)) + case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation, feeCreditUsed_opt).map(l => Some(l)) case _ => Right(None) } } @@ -225,7 +229,11 @@ object LiquidityAds { } object Purchase { + // @formatter:off case class Standard(amount: Satoshi, fees: Fees, paymentDetails: PaymentDetails) extends Purchase() + /** The liquidity purchase was paid (partially or entirely) using [[fr.acinq.eclair.Features.FundingFeeCredit]]. */ + case class WithFeeCredit(amount: Satoshi, fees: Fees, feeCreditUsed: MilliSatoshi, paymentDetails: PaymentDetails) extends Purchase() + // @formatter:on } case class WillFundPurchase(willFund: WillFund, purchase: Purchase) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 4e34958b9e..418522d720 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -246,6 +246,7 @@ object TestConstants { None, None, isChannelOpener = true, + paysCommitTxFees = true, dualFunded = false, fundingSatoshis, unlimitedMaxHtlcValueInFlight = false, @@ -419,6 +420,7 @@ object TestConstants { None, None, isChannelOpener = false, + paysCommitTxFees = false, dualFunded = false, fundingSatoshis, unlimitedMaxHtlcValueInFlight = false, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala index d82768400c..ec58b9cc3c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala @@ -214,8 +214,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = { val channelFeatures = ChannelFeatures(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true) val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]))) - val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = !nonInitiatorPaysCommitTxFees) - val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = nonInitiatorPaysCommitTxFees) + val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, paysCommitTxFees = !nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false) + val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, paysCommitTxFees = nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false) val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map { case (nodeParams, localParams) => @@ -617,6 +617,91 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } + test("initiator does not contribute -- on-the-fly funding with fee credit") { + val targetFeerate = FeeratePerKw(5000 sat) + val fundingA = 2_500.sat + val utxosA = Seq(5_000 sat) + val fundingB = 150_000.sat + val utxosB = Seq(200_000 sat) + // The initiator contributes a small amount, and pays the remaining liquidity fees from its fee credit. + val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 7_500_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) + withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + import f._ + + // Alice has enough fee credit. + fixtureParams.nodeParamsB.db.liquidity.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 7_500_000 msat) + + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + + // Alice --- tx_add_input --> Bob + fwd.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + fwd.forwardBob2Alice[TxAddInput] + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_output --- Bob + fwd.forwardBob2Alice[TxAddOutput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + + // Alice sends signatures first as she contributed less. + val successA = alice2bob.expectMsgType[Succeeded] + val successB = bob2alice.expectMsgType[Succeeded] + val (txA, _, txB, commitmentB) = fixtureParams.exchangeSigsAliceFirst(aliceParams, successA, successB) + // Alice partially paid fees to Bob during the interactive-tx using her channel balance, the rest was paid from fee credit. + assert(commitmentB.localCommit.spec.toLocal == (fundingA + fundingB).toMilliSatoshi) + assert(commitmentB.localCommit.spec.toRemote == 0.msat) + + // The resulting transaction is valid. + assert(txA.txId == txB.txId) + assert(txA.tx.localFees == 2_500_000.msat) + assert(txB.tx.remoteFees == 2_500_000.msat) + assert(txB.tx.localFees > 0.msat) + val probe = TestProbe() + walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA.txId) + walletA.getMempoolTx(txA.txId).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == txA.tx.fees) + assert(targetFeerate * 0.9 <= txA.feerate && txA.feerate < targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})") + } + } + + test("initiator does not contribute -- on-the-fly funding without enough fee credit") { + val targetFeerate = FeeratePerKw(5000 sat) + val fundingB = 150_000.sat + val utxosB = Seq(200_000 sat) + // The initiator wants to pay the liquidity fees from their fee credit, but they don't have enough of it. + val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 10_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) + withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + import f._ + + // Alice doesn't have enough fee credit. + fixtureParams.nodeParamsB.db.liquidity.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 9_000_000 msat) + + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_add_input --- Bob + fwd.forwardBob2Alice[TxAddInput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_add_output --- Bob + fwd.forwardBob2Alice[TxAddOutput] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + // Bob rejects the funding attempt because Alice doesn't have enough fee credit. + assert(bob2alice.expectMsgType[RemoteFailure].cause.isInstanceOf[InvalidCompleteInteractiveTx]) + } + } + test("initiator and non-initiator splice-in") { val targetFeerate = FeeratePerKw(1000 sat) // We chose those amounts to ensure that Bob always signs first: @@ -2254,6 +2339,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val bobSplice = params.spawnTxBuilderSpliceBob(spliceParams, previousCommitment, wallet, Some(purchase)) bobSplice ! Start(probe.ref) assert(probe.expectMsgType[LocalFailure].cause == InvalidFundingBalances(params.channelId, 620_000 sat, 625_000_000 msat, -5_000_000 msat)) + // If Alice is using fee credit to pay the liquidity fees, the funding attempt is valid. + val bobFeeCredit = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(LiquidityAds.Purchase.WithFeeCredit(500_000 sat, LiquidityAds.Fees(5000 sat, 20_000 sat), 25_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)))) + bobFeeCredit ! Start(probe.ref) + probe.expectNoMessage(100 millis) // If we use a payment type where fees are paid outside of the interactive-tx session, the funding attempt is valid. val bobFutureHtlc = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase.copy(paymentDetails = LiquidityAds.PaymentDetails.FromFutureHtlc(Nil)))) bobFutureHtlc ! Start(probe.ref) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala index db65e9607a..246a6fc34c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala @@ -108,6 +108,19 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur assert(accept.willFund_opt.nonEmpty) } + test("recv OpenDualFundedChannel (with liquidity ads and fee credit)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val open = alice2bob.expectMsgType[OpenDualFundedChannel] + val requestFunds = LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) + val openWithFundsRequest = open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records + ChannelTlv.RequestFundingTlv(requestFunds) + ChannelTlv.UseFeeCredit(2_500_000 msat))) + alice2bob.forward(bob, openWithFundsRequest) + val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] + assert(accept.fundingAmount == TestConstants.nonInitiatorFundingSatoshis) + assert(accept.willFund_opt.nonEmpty) + assert(accept.tlvStream.get[ChannelTlv.FeeCreditUsedTlv].map(_.amount).contains(2_500_000 msat)) + } + test("recv OpenDualFundedChannel (with push amount)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala index 978236a911..acd86a9f28 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala @@ -185,4 +185,27 @@ class LiquidityDbSpec extends AnyFunSuite { } } + test("add/get/remove fee credit") { + forAllDbs { dbs => + val db = dbs.liquidity + val nodeId = randomKey().publicKey + + // Initially, the DB is empty. + assert(db.getFeeCredit(nodeId) == 0.msat) + assert(db.removeFeeCredit(nodeId, 0 msat) == 0.msat) + + // We owe some fee credit to our peer. + assert(db.addFeeCredit(nodeId, 211_754 msat, receivedAt = TimestampMilli(50_000)) == 211_754.msat) + assert(db.getFeeCredit(nodeId) == 211_754.msat) + assert(db.addFeeCredit(nodeId, 245 msat, receivedAt = TimestampMilli(55_000)) == 211_999.msat) + assert(db.getFeeCredit(nodeId) == 211_999.msat) + + // We consume some of the fee credit. + assert(db.removeFeeCredit(nodeId, 11_999 msat) == 200_000.msat) + assert(db.getFeeCredit(nodeId) == 200_000.msat) + assert(db.removeFeeCredit(nodeId, 250_000 msat) == 0.msat) + assert(db.getFeeCredit(nodeId) == 0.msat) + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala index ac18cffd4b..bea93c4cb9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala @@ -32,8 +32,8 @@ import fr.acinq.eclair.io.{Peer, PeerConnection, PendingChannelsRateLimiter} import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, FeatureSupport, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, TimestampMilli, ToMilliSatoshiConversion, UInt64, randomBytes, randomBytes32, randomKey, randomLong} -import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike +import org.scalatest.{Outcome, Tag} import java.util.UUID import scala.concurrent.duration.DurationInt @@ -42,6 +42,8 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { import OnTheFlyFundingSpec._ + val withFeeCredit = "with_fee_credit" + val remoteFeatures = Features( Features.StaticRemoteKey -> FeatureSupport.Optional, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, @@ -50,6 +52,13 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { Features.OnTheFlyFunding -> FeatureSupport.Optional, ) + val remoteFeaturesWithFeeCredit = Features( + Features.DualFunding -> FeatureSupport.Optional, + Features.SplicePrototype -> FeatureSupport.Optional, + Features.OnTheFlyFunding -> FeatureSupport.Optional, + Features.FundingFeeCredit -> FeatureSupport.Optional, + ) + case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, register: TestProbe, rateLimiter: TestProbe, probe: TestProbe) { def connect(peer: TestFSMRef[Peer.State, Peer.Data, Peer], remoteInit: protocol.Init = protocol.Init(remoteFeatures.initFeatures()), channelCount: Int = 0): Unit = { val localInit = protocol.Init(nodeParams.features.initFeatures()) @@ -110,13 +119,40 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { channelId: ByteVector32 = randomBytes32(), fees: LiquidityAds.Fees = LiquidityAds.Fees(0 sat, 0 sat), fundingTxIndex: Long = 0, - htlcMinimum: MilliSatoshi = 1 msat): LiquidityPurchaseSigned = { - val purchase = LiquidityAds.Purchase.Standard(amount, fees, paymentDetails) + htlcMinimum: MilliSatoshi = 1 msat, + feeCreditUsed_opt: Option[MilliSatoshi] = None): LiquidityPurchaseSigned = { + val purchase = feeCreditUsed_opt match { + case Some(feeCredit) => LiquidityAds.Purchase.WithFeeCredit(amount, fees, feeCredit, paymentDetails) + case None => LiquidityAds.Purchase.Standard(amount, fees, paymentDetails) + } val event = LiquidityPurchaseSigned(channelId, TxId(randomBytes32()), fundingTxIndex, htlcMinimum, purchase) peer ! event event } + def verifyFulfilledUpstream(upstream: Upstream.Hot, preimage: ByteVector32): Unit = { + val incomingHtlcs = upstream match { + case u: Upstream.Hot.Channel => Seq(u.add) + case u: Upstream.Hot.Trampoline => u.received.map(_.add) + case _: Upstream.Local => Nil + } + val fulfilled = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]) + assert(fulfilled.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet) + assert(fulfilled.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet) + assert(fulfilled.map(_.message.r).toSet == Set(preimage)) + } + + def verifyFailedUpstream(upstream: Upstream.Hot): Unit = { + val incomingHtlcs = upstream match { + case u: Upstream.Hot.Channel => Seq(u.add) + case u: Upstream.Hot.Trampoline => u.received.map(_.add) + case _: Upstream.Local => Nil + } + val failed = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]]) + assert(failed.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet) + assert(failed.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet) + } + def makeChannelData(htlcMinimum: MilliSatoshi = 1 msat, localChanges: LocalChanges = LocalChanges(Nil, Nil, Nil)): DATA_NORMAL = { val commitments = CommitmentsSpec.makeCommitments(500_000_000 msat, 500_000_000 msat, nodeParams.nodeId, remoteNodeId, announceChannel = false) .modify(_.params.remoteParams.htlcMinimum).setTo(htlcMinimum) @@ -138,6 +174,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { .modify(_.features.activated).using(_ + (Features.DualFunding -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) + .modify(_.features.activated).usingIf(test.tags.contains(withFeeCredit))(_ + (Features.FundingFeeCredit -> FeatureSupport.Optional)) val remoteNodeId = randomKey().publicKey val register = TestProbe() val channel = TestProbe() @@ -228,6 +265,25 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { }) } + test("ignore remote failure after adding to fee credit", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(1_500 msat, expiryIn, paymentHash) + val willAdd = proposeFunding(1_000 msat, expiryOut, paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 1_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + peerConnection.send(peer, WillFailHtlc(willAdd.id, paymentHash, randomBytes(25))) + peerConnection.expectMsgType[Warning] + peerConnection.send(peer, WillFailMalformedHtlc(willAdd.id, paymentHash, randomBytes32(), InvalidOnionHmac(randomBytes32()).code)) + peerConnection.expectMsgType[Warning] + peerConnection.expectNoMessage(100 millis) + register.expectNoMessage(100 millis) + } + test("proposed on-the-fly funding timeout") { f => import f._ @@ -285,6 +341,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { peerConnection.expectNoMessage(100 millis) } + test("proposed on-the-fly funding timeout (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(10_000_000 msat, CltvExpiry(550), paymentHash) + proposeFunding(10_000_000 msat, CltvExpiry(500), paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 10_000_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + peer ! OnTheFlyFundingTimeout(paymentHash) + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("proposed on-the-fly funding HTLC timeout") { f => import f._ @@ -336,6 +408,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis) } + test("proposed on-the-fly funding HTLC timeout (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(500 msat, CltvExpiry(550), paymentHash) + proposeFunding(500 msat, CltvExpiry(500), paymentHash, upstream) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 500.msat) + verifyFulfilledUpstream(upstream, preimage) + + peer ! CurrentBlockHeight(BlockHeight(560)) + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("signed on-the-fly funding HTLC timeout after disconnection") { f => import f._ @@ -379,6 +467,74 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { probe.expectTerminated(peerAfterRestart.ref) } + test("add proposal to fee credit", Tag(withFeeCredit)) { f => + import f._ + + val remoteInit = protocol.Init(remoteFeaturesWithFeeCredit.initFeatures()) + connect(peer, remoteInit) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + + val upstream1 = upstreamChannel(10_000_000 msat, expiryIn, paymentHash) + proposeFunding(10_000_000 msat, expiryOut, paymentHash, upstream1) + val upstream2 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash) + proposeFunding(5_000_000 msat, expiryOut, paymentHash, upstream2) + + // Both HTLCs are automatically added to fee credit. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 15_000_000.msat) + verifyFulfilledUpstream(Upstream.Hot.Trampoline(upstream1 :: upstream2 :: Nil), preimage) + + // Another unrelated payment is added to fee credit. + val preimage3 = randomBytes32() + val paymentHash3 = Crypto.sha256(preimage3) + val upstream3 = upstreamChannel(2_500_000 msat, expiryIn, paymentHash3) + proposeFunding(2_000_000 msat, expiryOut, paymentHash3, upstream3) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + verifyFulfilledUpstream(upstream3, preimage3) + + // Another payment for the same payment_hash is added to fee credit. + val upstream4 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash) + proposeExtraFunding(3_000_000 msat, expiryOut, paymentHash, upstream4) + verifyFulfilledUpstream(upstream4, preimage) + + // We don't fail proposals added to fee credit on disconnection. + disconnect() + connect(peer, remoteInit) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + + // Duplicate or unknown add_fee_credit are ignored. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, randomBytes32())) + peerConnection.expectMsgType[Warning] + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + peerConnection.expectMsgType[Warning] + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3)) + peerConnection.expectMsgType[Warning] + register.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + + test("add proposal to fee credit after signing transaction", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(25_000_000 msat, expiryIn, paymentHash) + proposeFunding(25_000_000 msat, expiryOut, paymentHash, upstream) + signLiquidityPurchase(25_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil)) + + // The proposal was signed, it cannot also be added to fee credit. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + peerConnection.expectMsgType[Warning] + verifyFulfilledUpstream(upstream, preimage) + + // We don't added the payment amount to fee credit. + disconnect() + connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures())) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + } + test("receive open_channel2") { f => import f._ @@ -401,10 +557,63 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))) // The preimage was provided, so we fulfill upstream HTLCs. - val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd.channelId == upstream.add.channelId) - assert(fwd.message.id == upstream.add.id) - assert(fwd.message.r == preimage) + verifyFulfilledUpstream(upstream, preimage) + } + + test("receive open_channel2 (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val requestFunding = LiquidityAds.RequestFunding( + 500_000 sat, + LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat, 0 sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil) + ) + + // We don't have any fee credit yet to open a channel and the HTLC amount is too low to cover liquidity fees. + val upstream1 = upstreamChannel(500_000 msat, expiryIn, paymentHash) + proposeFunding(500_000 msat, expiryOut, paymentHash, upstream1) + val open1 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open1) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + peerConnection.expectMsgType[CancelOnTheFlyFunding] + verifyFailedUpstream(upstream1) + + // We add some fee credit, but not enough to cover liquidity fees. + val preimage2 = randomBytes32() + val paymentHash2 = Crypto.sha256(preimage2) + val upstream2 = upstreamChannel(3_000_000 msat, expiryIn, paymentHash2) + proposeFunding(3_000_000 msat, expiryOut, paymentHash2, upstream2) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage2)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 3_000_000.msat) + verifyFulfilledUpstream(upstream2, preimage2) + + // We have some fee credit but it's not enough, even with HTLCs, to cover liquidity fees. + val upstream3 = upstreamChannel(2_000_000 msat, expiryIn, paymentHash) + proposeFunding(1_999_999 msat, expiryOut, paymentHash, upstream3) + val open2 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open2) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + peerConnection.expectMsgType[CancelOnTheFlyFunding] + verifyFailedUpstream(upstream3) + + // We have some fee credit which can pay the liquidity fees when combined with HTLCs. + val upstream4 = upstreamChannel(4_000_000 msat, expiryIn, paymentHash) + proposeFunding(4_000_000 msat, expiryOut, paymentHash, upstream4) + val open3 = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open3) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + val init = channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR] + assert(!init.localParams.isChannelOpener) + assert(init.localParams.paysCommitTxFees) + assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))) + assert(channel.expectMsgType[OpenDualFundedChannel].useFeeCredit_opt.contains(3_000_000 msat)) + + // Once the funding transaction is signed, we remove the fee credit consumed. + signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(3_000_000 msat)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) } test("receive splice_init") { f => @@ -427,10 +636,41 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { channel.expectNoMessage(100 millis) // The preimage was provided, so we fulfill upstream HTLCs. - val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] - assert(fwd.channelId == upstream.add.channelId) - assert(fwd.message.id == upstream.add.id) - assert(fwd.message.r == preimage) + verifyFulfilledUpstream(upstream, preimage) + } + + test("receive splice_init (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + val channelId = openChannel(200_000 sat) + + // We add some fee credit to cover liquidity fees. + val preimage1 = randomBytes32() + val paymentHash1 = Crypto.sha256(preimage1) + val upstream1 = upstreamChannel(8_000_000 msat, expiryIn, paymentHash1) + proposeFunding(7_500_000 msat, expiryOut, paymentHash1, upstream1) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 7_500_000.msat) + verifyFulfilledUpstream(upstream1, preimage1) + + // We consume that fee credit when splicing. + val upstream2 = upstreamChannel(1_000_000 msat, expiryIn, paymentHash) + proposeFunding(1_000_000 msat, expiryOut, paymentHash, upstream2) + val requestFunding = LiquidityAds.RequestFunding( + 500_000 sat, + LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat, 0 sat), + LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHash :: Nil) + ) + val splice = createSpliceMessage(channelId, requestFunding) + peerConnection.send(peer, splice) + assert(channel.expectMsgType[SpliceInit].useFeeCredit_opt.contains(5_000_000 msat)) + channel.expectNoMessage(100 millis) + + // Once the splice transaction is signed, we remove the fee credit consumed. + signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(5_000_000 msat)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 2_500_000.msat) + awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 2_500_000.msat, interval = 100 millis) } test("reject invalid open_channel2") { f => @@ -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.liquidity.listPendingOnTheFlyFunding(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.liquidity.addOnTheFlyFundingPreimage(preimage) register.expectNoMessage(100 millis) awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis) } + test("successfully relay HTLCs to on-the-fly funded channel (fee credit)", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + // A first payment adds some fee credit. + val preimage1 = randomBytes32() + val paymentHash1 = Crypto.sha256(preimage1) + val upstream1 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash1) + proposeFunding(4_000_000 msat, expiryOut, paymentHash1, upstream1) + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 4_000_000.msat) + verifyFulfilledUpstream(upstream1, preimage1) + + // A second payment will pay the rest of the liquidity fees. + val preimage2 = randomBytes32() + val paymentHash2 = Crypto.sha256(preimage2) + val upstream2 = upstreamChannel(16_000_000 msat, expiryIn, paymentHash2) + proposeFunding(15_000_000 msat, expiryOut, paymentHash2, upstream2) + val fees = LiquidityAds.Fees(5_000 sat, 4_000 sat) + val purchase = signLiquidityPurchase(200_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash2 :: Nil), fees = fees, feeCreditUsed_opt = Some(4_000_000 msat)) + + // Once the channel is ready to relay payments, we forward the remaining HTLC. + // We collect the liquidity fees that weren't paid by the fee credit. + val channelData = makeChannelData() + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, channelData) + val cmd = channel.expectMsgType[CMD_ADD_HTLC] + assert(cmd.amount == 10_000_000.msat) + assert(cmd.fundingFee_opt.contains(LiquidityAds.FundingFee(5_000_000 msat, purchase.txId))) + assert(cmd.paymentHash == paymentHash2) + channel.expectNoMessage(100 millis) + + val add = UpdateAddHtlc(purchase.channelId, randomHtlcId(), cmd.amount, cmd.paymentHash, cmd.cltvExpiry, cmd.onion, cmd.nextBlindingKey_opt, cmd.confidence, cmd.fundingFee_opt) + cmd.replyTo ! RES_ADD_SETTLED(cmd.origin, add, HtlcResult.RemoteFulfill(UpdateFulfillHtlc(purchase.channelId, add.id, preimage2))) + verifyFulfilledUpstream(upstream2, preimage2) + register.expectNoMessage(100 millis) + awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) + } + + test("don't relay payments if added to fee credit while signing", Tag(withFeeCredit)) { f => + import f._ + + connect(peer) + + val upstream = upstreamChannel(100_000_000 msat, expiryIn, paymentHash) + proposeFunding(100_000_000 msat, CltvExpiry(TestConstants.defaultBlockHeight), paymentHash, upstream) + + // The proposal is accepted: we start funding a channel. + val requestFunding = LiquidityAds.RequestFunding( + 200_000 sat, + LiquidityAds.FundingRate(10_000 sat, 500_000 sat, 0, 100, 0 sat, 0 sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil) + ) + val open = createOpenChannelMessage(requestFunding) + peerConnection.send(peer, open) + rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel + channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR] + channel.expectMsgType[OpenDualFundedChannel] + + // The payment is added to fee credit while we're funding the channel. + peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage)) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 100_000_000.msat) + verifyFulfilledUpstream(upstream, preimage) + + // The channel transaction is signed: we invalidate the fee credit and won't relay HTLCs. + // We've fulfilled the upstream HTLCs, so we're earning more than our expected fees. + val purchase = signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, fees = requestFunding.fees(open.fundingFeerate, isChannelCreation = true)) + awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis) + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectNoMessage(100 millis) + + // We don't relay the payment on reconnection either. + disconnect(channelCount = 1) + connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures())) + assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat) + peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0) + channel.expectNoMessage(100 millis) + peerConnection.expectNoMessage(100 millis) + } + test("don't relay payments too close to expiry") { f => import f._ @@ -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 4d2858c678..3e67307905 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -28,7 +28,7 @@ import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes} import fr.acinq.eclair.json.JsonSerializers import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Scripts -import fr.acinq.eclair.wire.protocol.ChannelTlv.{ChannelTypeTlv, PushAmountTlv, RequireConfirmedInputsTlv, UpfrontShutdownScriptTlv} +import fr.acinq.eclair.wire.protocol.ChannelTlv._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs._ import fr.acinq.eclair.wire.protocol.ReplyChannelRangeTlv._ import org.json4s.jackson.Serialization @@ -372,7 +372,9 @@ class LightningMessageCodecsSpec extends AnyFunSuite { defaultAccept -> defaultEncoded, defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()))) -> (defaultEncoded ++ hex"01021000"), defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), PushAmountTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fe470000070206c1"), - defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200") + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200"), + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(0 msat))) -> (defaultEncoded ++ hex"0103401000 fda05200"), + defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fda0520206c1"), ) testCases.foreach { case (accept, bin) => val decoded = lightningMessageCodec.decode(bin.bits).require.value @@ -395,10 +397,12 @@ class LightningMessageCodecsSpec extends AnyFunSuite { SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.RequestFunding(100_000 sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance))) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000", SpliceAck(channelId, 25_000 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", + SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", SpliceAck(channelId, 0 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", SpliceAck(channelId, (-25_000).sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes)), None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1", SpliceLocked(channelId, fundingTxId) -> hex"908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566", // @formatter:on ) @@ -464,7 +468,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 1e 00000000000b71b0 0007a120004c4b40044c004b00000000000005dc 0000" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 0007a120004c4b40044c004b00000000000005dc 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c c57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -480,7 +484,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -496,7 +500,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) - val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund) val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))) val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) @@ -608,6 +612,24 @@ class LightningMessageCodecsSpec extends AnyFunSuite { } } + test("encode/decode fee credit messages") { + val preimages = Seq( + ByteVector32(hex"6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f"), + ByteVector32(hex"4ad834d418faf74ebf7c8a026f2767a41c3a0995c334d7d3dab47737794b0c16"), + ) + val testCases = Seq( + AddFeeCredit(Block.RegtestGenesisBlock.hash, preimages.head) -> hex"a055 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f", + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 0 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000000000000", + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 20_000_000 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000001312d00", + ) + for ((expected, encoded) <- testCases) { + val decoded = lightningMessageCodec.decode(encoded.bits).require.value + assert(decoded == expected) + val reEncoded = lightningMessageCodec.encode(decoded).require.bytes + assert(reEncoded == encoded) + } + } + test("unknown messages") { // Non-standard tag number so this message can only be handled by a codec with a fallback val unknown = UnknownMessage(tag = 47282, data = ByteVector32.Zeroes.bytes) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala index 67337d123e..54610e73c6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala @@ -40,7 +40,7 @@ class LiquidityAdsSpec extends AnyFunSuite { val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance)) val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates) val fundingScript = hex"00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff" - val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true).map(_.willFund) + val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true, None).map(_.willFund) assert(willFund.fundingRate == fundingRate) assert(willFund.fundingScript == fundingScript) assert(willFund.signature == ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265"))