What changed, and why it matters
This commit only adds and reorganizes internal metrics (statistics counters and histograms) for monitoring liquidity advertisements and interactive Bitcoin transactions. It does not change how funds are handled, validated, or secured. The code that records metrics was moved to run after transaction validation instead of before, which is a safer ordering, but no vulnerability is being fixed.
No security action required. Review as normal telemetry/observability code if desired.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces new Kamon metrics (histograms for target funding amount, change amount, local mining fee, liquidity purchase amount, and mining-fee refund difference) and refactors existing interactive-tx metric recording into a helper method recordInteractiveTx. The recording call is relocated from the beginning of validateAndSign to the end, after all validation checks have passed. This is a telemetry-only refactor with no functional changes to transaction construction, signature validation, or channel state logic.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scalaInspect captured patch +55 / −16
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scala
index d0322f1..23c1aee 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Monitoring.scala
@@ -17,8 +17,10 @@
package fr.acinq.eclair.channel
import fr.acinq.bitcoin.scalacompat.SatoshiLong
+import fr.acinq.eclair.channel.fund.InteractiveTxBuilder
import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{InteractiveTxParams, SharedTransaction}
import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc}
+import fr.acinq.eclair.wire.protocol.LiquidityAds
import kamon.Kamon
object Monitoring {
@@ -33,10 +35,15 @@ object Monitoring {
val HtlcValueInFlightGlobal = Kamon.gauge("channels.htlc-value-in-flight-global", "Global HTLC value in flight across all channels")
val LocalFeeratePerByte = Kamon.histogram("channels.local-feerate-per-byte")
val RemoteFeeratePerByte = Kamon.histogram("channels.remote-feerate-per-byte")
+ val InteractiveTxFundingTargetAmount = Kamon.histogram("channels.interactive-tx.target-funding-amount", "Interactive tx funding target amount")
+ val InteractiveTxFundingChangeAmount = Kamon.histogram("channels.interactive-tx.change-amount", "Interactive tx funding change")
+ val InteractiveTxLocalMiningFee = Kamon.histogram("channels.interactive-tx.local-mining-fee", "Interactive tx mining fee paid by us")
val InteractiveTxInputs = Kamon.histogram("channels.interactive-tx.inputs", "Interactive tx inputs")
val InteractiveTxOutputs = Kamon.histogram("channels.interactive-tx.outputs", "Interactive tx outputs")
val InteractiveTxInputsPerSession = Kamon.histogram("channels.interactive-tx.inputs-per-session", "Interactive tx inputs per session")
val InteractiveTxOutputsPerSession = Kamon.histogram("channels.interactive-tx.outputs-per-session", "Interactive tx outputs per session")
+ val LiquidityPurchaseAmount = Kamon.histogram("channels.interactive-tx.liquidity-purchase-amount", "Amount of liquidity purchased (if positive) or sold (if negative)")
+ val LiquidityPurchaseMiningFeeDiff = Kamon.histogram("channels.interactive-tx.liquidity-purchase-mining-fee-diff", "When selling liquidity, difference between the refunded mining fee and the actual mining fee paid")
val Splices = Kamon.histogram("channels.splices", "Splices")
val ProcessMessage = Kamon.timer("channels.messages-processed")
val HtlcDropped = Kamon.counter("channels.htlc-dropped")
@@ -56,6 +63,47 @@ object Monitoring {
}
}
+ def recordInteractiveTx(fundingParams: InteractiveTxParams, sharedTx: SharedTransaction, liquidityPurchase_opt: Option[LiquidityAds.Purchase]): Unit = {
+ // Global, not "per session". The goal is to measure the total number of inputs/outputs and distribution of amounts across all interactive-tx sessions.
+ sharedTx.sharedInput_opt.foreach(i => InteractiveTxInputs.withTag(Tags.InputType, "shared").record(i.txOut.amount.toLong))
+ sharedTx.localInputs.foreach(i => InteractiveTxInputs.withTag(Tags.InputType, "local").record(i.txOut.amount.toLong))
+ sharedTx.remoteInputs.foreach(i => InteractiveTxInputs.withTag(Tags.InputType, "remote").record(i.txOut.amount.toLong))
+ InteractiveTxOutputs.withTag(Tags.OutputType, "shared").record(sharedTx.sharedOutput.amount.toLong)
+ sharedTx.localOutputs.foreach(o => InteractiveTxOutputs.withTag(Tags.OutputType, "local").record(o.amount.toLong))
+ sharedTx.remoteOutputs.foreach(o => InteractiveTxOutputs.withTag(Tags.OutputType, "remote").record(o.amount.toLong))
+
+ // We measure the number of each non-shared input/output type per session.
+ if (sharedTx.localInputs.nonEmpty) InteractiveTxInputsPerSession.withTag(Tags.InputType, "local").record(sharedTx.localInputs.size)
+ if (sharedTx.remoteInputs.nonEmpty) InteractiveTxInputsPerSession.withTag(Tags.InputType, "remote").record(sharedTx.remoteInputs.size)
+ if (sharedTx.localOutputs.nonEmpty) InteractiveTxOutputsPerSession.withTag(Tags.OutputType, "local").record(sharedTx.localOutputs.size)
+ if (sharedTx.remoteOutputs.nonEmpty) InteractiveTxOutputsPerSession.withTag(Tags.OutputType, "remote").record(sharedTx.remoteOutputs.size)
+
+ // We measure the difference between the amount we want to fund and the resulting change.
+ if (fundingParams.localContribution >= 0.sat) {
+ InteractiveTxFundingTargetAmount.withTag(Tags.DiffSign, Tags.DiffSigns.plus).record(fundingParams.localContribution.toLong)
+ // Note that we explicitly want to record a 0 value when we don't have any change output: it lets us see how many sessions don't need change, which is the best outcome.
+ InteractiveTxFundingChangeAmount.withoutTags().record(sharedTx.localOutputs.collect { case o: InteractiveTxBuilder.Output.Local.Change => o.amount.toLong }.sum)
+ } else {
+ InteractiveTxFundingTargetAmount.withTag(Tags.DiffSign, Tags.DiffSigns.minus).record(-fundingParams.localContribution.toLong)
+ }
+ InteractiveTxLocalMiningFee.withoutTags().record(sharedTx.localFees.truncateToSatoshi.toLong)
+
+ // We record liquidity purchase details.
+ liquidityPurchase_opt.foreach(p => {
+ // If we initiate the interactive-tx session, we're the buyer: otherwise, we're the seller.
+ LiquidityPurchaseAmount.withTag(Tags.DiffSign, if (fundingParams.isInitiator) Tags.DiffSigns.plus else Tags.DiffSigns.minus).record(p.amount.toLong)
+ // If the actual mining fee we paid is greater than what the user refunds, we lose money.
+ if (!fundingParams.isInitiator) {
+ val miningFeeDiff = p.fees.miningFee - sharedTx.localFees.truncateToSatoshi
+ if (miningFeeDiff >= 0.sat) {
+ LiquidityPurchaseMiningFeeDiff.withTag(Tags.DiffSign, Tags.DiffSigns.plus).record(miningFeeDiff.toLong)
+ } else {
+ LiquidityPurchaseMiningFeeDiff.withTag(Tags.DiffSign, Tags.DiffSigns.minus).record(-miningFeeDiff.toLong)
+ }
+ }
+ })
+ }
+
/**
* This is best effort! It is not possible to attribute a type to a splice in all cases. For example, if remote provides
* both inputs and outputs, it could be a splice-in (with change), or a combined splice-in + splice-out.
@@ -122,6 +170,12 @@ object Monitoring {
val SpliceOut = "splice-out"
val SpliceCpfp = "splice-cpfp"
}
+
+ /** we can't chart negative amounts in Kamon */
+ object DiffSigns {
+ val plus = "plus"
+ val minus = "minus"
+ }
}
}
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 ce0db2a..bf468c9 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
@@ -783,22 +783,6 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
val localOutputs = session.localOutputs.collect { case o: Output.Local => o }
val remoteOutputs = session.remoteOutputs.collect { case o: Output.Remote => o }
- // Global, not "per session". The goal is to measure the total number of inputs/outputs and distribution of amounts across all interactive-tx sessions.
- sharedInputs.foreach(i => Monitoring.Metrics.InteractiveTxInputs.withTag(Monitoring.Tags.InputType, "shared").record(i.txOut.amount.toLong))
- localInputs.foreach(i => Monitoring.Metrics.InteractiveTxInputs.withTag(Monitoring.Tags.InputType, "local").record(i.txOut.amount.toLong))
- remoteInputs.foreach(i => Monitoring.Metrics.InteractiveTxInputs.withTag(Monitoring.Tags.InputType, "remote").record(i.txOut.amount.toLong))
- sharedOutputs.foreach(o => Monitoring.Metrics.InteractiveTxOutputs.withTag(Monitoring.Tags.OutputType, "shared").record(o.amount.toLong))
- localOutputs.foreach(o => Monitoring.Metrics.InteractiveTxOutputs.withTag(Monitoring.Tags.OutputType, "local").record(o.amount.toLong))
- remoteOutputs.foreach(o => Monitoring.Metrics.InteractiveTxOutputs.withTag(Monitoring.Tags.OutputType, "remote").record(o.amount.toLong))
-
- // We measure the number of each input/output type per session.
- if (sharedInputs.nonEmpty) Monitoring.Metrics.InteractiveTxInputsPerSession.withTag(Monitoring.Tags.InputType, "shared").record(sharedInputs.size)
- if (localInputs.nonEmpty) Monitoring.Metrics.InteractiveTxInputsPerSession.withTag(Monitoring.Tags.InputType, "local").record(localInputs.size)
- if (remoteInputs.nonEmpty) Monitoring.Metrics.InteractiveTxInputsPerSession.withTag(Monitoring.Tags.InputType, "remote").record(remoteInputs.size)
- if (sharedOutputs.nonEmpty) Monitoring.Metrics.InteractiveTxOutputsPerSession.withTag(Monitoring.Tags.OutputType, "shared").record(sharedOutputs.size)
- if (localOutputs.nonEmpty) Monitoring.Metrics.InteractiveTxOutputsPerSession.withTag(Monitoring.Tags.OutputType, "local").record(localOutputs.size)
- if (remoteOutputs.nonEmpty) Monitoring.Metrics.InteractiveTxOutputsPerSession.withTag(Monitoring.Tags.OutputType, "remote").record(remoteOutputs.size)
-
if (sharedOutputs.length > 1) {
log.warn("invalid interactive tx: funding script included multiple times")
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "funding script included multiple times"))
@@ -919,6 +903,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId, "RBF attempts must double-spend all previous transactions"))
}
+ Monitoring.Metrics.recordInteractiveTx(fundingParams, sharedTx, liquidityPurchase_opt)
Right(sharedTx)
}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.