Skip to content

Commit 33d15a6

Browse files
committed
Reputation is recorded from channel events
1 parent a357376 commit 33d15a6

File tree

13 files changed

+136
-112
lines changed

13 files changed

+136
-112
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ class Setup(val datadir: File,
362362
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
363363
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
364364
reputationRecorder_opt = if (nodeParams.relayParams.peerReputationConfig.enabled) {
365-
Some(system.spawn(Behaviors.supervise(ReputationRecorder(nodeParams.relayParams.peerReputationConfig, Map.empty)).onFailure(typed.SupervisorStrategy.resume), name = "reputation-recorder"))
365+
Some(system.spawn(Behaviors.supervise(ReputationRecorder(nodeParams.relayParams.peerReputationConfig)).onFailure(typed.SupervisorStrategy.resume), name = "reputation-recorder"))
366366
} else {
367367
None
368368
}

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningS
2626
import fr.acinq.eclair.io.Peer
2727
import fr.acinq.eclair.transactions.CommitmentSpec
2828
import fr.acinq.eclair.transactions.Transactions._
29-
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
29+
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, HtlcFailureMessage, Init, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
3030
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, TimestampMilli, UInt64}
3131
import scodec.bits.ByteVector
3232

@@ -228,6 +228,10 @@ final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToComm
228228
final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand
229229
final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command
230230

231+
case class OutgoingHtlcAdded(add: UpdateAddHtlc, upstream: Upstream.Hot, fee: MilliSatoshi)
232+
case class OutgoingHtlcFailed(fail: HtlcFailureMessage)
233+
case class OutgoingHtlcFulfilled(fulfill: UpdateFulfillHtlc)
234+
231235
/*
232236
88888888b. 8888888888 .d8888b. 88888888b. ,ad8888ba, 888b 88 .d8888b. 8888888888 .d8888b.
233237
88 "8b 88 d88P Y88b 88 "8b d8"' `"8b 8888b 88 d88P Y88b 88 d88P Y88b

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
439439
case Right((commitments1, add)) =>
440440
if (c.commit) self ! CMD_SIGN()
441441
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.shortIds, commitments1))
442+
context.system.eventStream.publish(OutgoingHtlcAdded(add, c.origin.upstream, nodeFee(d.channelUpdate.relayFees, add.amountMsat)))
442443
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending add
443444
case Left(cause) => handleAddHtlcCommandError(c, cause, Some(d.channelUpdate))
444445
}
@@ -465,6 +466,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
465466
case Right((commitments1, origin, htlc)) =>
466467
// we forward preimages as soon as possible to the upstream channel because it allows us to pull funds
467468
relayer ! RES_ADD_SETTLED(origin, htlc, HtlcResult.RemoteFulfill(fulfill))
469+
context.system.eventStream.publish(OutgoingHtlcFulfilled(fulfill))
468470
stay() using d.copy(commitments = commitments1)
469471
case Left(cause) => handleLocalError(cause, d, Some(fulfill))
470472
}
@@ -498,12 +500,14 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
498500
}
499501

500502
case Event(fail: UpdateFailHtlc, d: DATA_NORMAL) =>
503+
context.system.eventStream.publish(OutgoingHtlcFailed(fail))
501504
d.commitments.receiveFail(fail) match {
502505
case Right((commitments1, _, _)) => stay() using d.copy(commitments = commitments1)
503506
case Left(cause) => handleLocalError(cause, d, Some(fail))
504507
}
505508

506509
case Event(fail: UpdateFailMalformedHtlc, d: DATA_NORMAL) =>
510+
context.system.eventStream.publish(OutgoingHtlcFailed(fail))
507511
d.commitments.receiveFailMalformed(fail) match {
508512
case Right((commitments1, _, _)) => stay() using d.copy(commitments = commitments1)
509513
case Left(cause) => handleLocalError(cause, d, Some(fail))

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags}
3030
import fr.acinq.eclair.payment.relay.Relayer.{OutgoingChannel, OutgoingChannelParams}
3131
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket}
3232
import fr.acinq.eclair.reputation.ReputationRecorder
33-
import fr.acinq.eclair.reputation.ReputationRecorder.{CancelRelay, GetConfidence, RecordResult}
33+
import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence
3434
import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure
3535
import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload
3636
import fr.acinq.eclair.wire.protocol._
@@ -59,7 +59,7 @@ object ChannelRelay {
5959

6060
def apply(nodeParams: NodeParams,
6161
register: ActorRef,
62-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.ChannelRelayCommand]],
62+
reputationRecorder_opt: Option[typed.ActorRef[GetConfidence]],
6363
channels: Map[ByteVector32, Relayer.OutgoingChannel],
6464
originNode: PublicKey,
6565
relayId: UUID,
@@ -73,15 +73,15 @@ object ChannelRelay {
7373
val upstream = Upstream.Hot.Channel(r.add.removeUnknownTlvs(), TimestampMilli.now(), originNode)
7474
reputationRecorder_opt match {
7575
case Some(reputationRecorder) =>
76-
reputationRecorder ! GetConfidence(context.messageAdapter[ReputationRecorder.Confidence](confidence => WrappedConfidence(confidence.value)), originNode, r.add.endorsement, relayId, r.relayFeeMsat)
76+
reputationRecorder ! GetConfidence(context.messageAdapter[ReputationRecorder.Confidence](confidence => WrappedConfidence(confidence.value)), upstream, r.relayFeeMsat)
7777
case None =>
7878
val confidence = (r.add.endorsement + 0.5) / 8
7979
context.self ! WrappedConfidence(confidence)
8080
}
8181
Behaviors.receiveMessagePartial {
8282
case WrappedConfidence(confidence) =>
8383
context.self ! DoRelay
84-
new ChannelRelay(nodeParams, register, reputationRecorder_opt, channels, r, upstream, confidence, context, relayId).relay(Seq.empty)
84+
new ChannelRelay(nodeParams, register, channels, r, upstream, confidence, context).relay(Seq.empty)
8585
}
8686
}
8787
}
@@ -123,13 +123,11 @@ object ChannelRelay {
123123
*/
124124
class ChannelRelay private(nodeParams: NodeParams,
125125
register: ActorRef,
126-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.ChannelRelayCommand]],
127126
channels: Map[ByteVector32, Relayer.OutgoingChannel],
128127
r: IncomingPaymentPacket.ChannelRelayPacket,
129128
upstream: Upstream.Hot.Channel,
130129
confidence: Double,
131-
context: ActorContext[ChannelRelay.Command],
132-
relayId: UUID) {
130+
context: ActorContext[ChannelRelay.Command]) {
133131

134132
import ChannelRelay._
135133

@@ -149,7 +147,6 @@ class ChannelRelay private(nodeParams: NodeParams,
149147
case RelayFailure(cmdFail) =>
150148
Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel)
151149
context.log.info("rejecting htlc reason={}", cmdFail.reason)
152-
reputationRecorder_opt.foreach(_ ! CancelRelay(upstream.receivedFrom, r.add.endorsement, relayId))
153150
safeSendAndStop(r.add.channelId, cmdFail)
154151
case RelaySuccess(selectedChannelId, cmdAdd) =>
155152
context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId)
@@ -165,7 +162,6 @@ class ChannelRelay private(nodeParams: NodeParams,
165162
context.log.warn(s"couldn't resolve downstream channel $channelId, failing htlc #${upstream.add.id}")
166163
val cmdFail = CMD_FAIL_HTLC(upstream.add.id, Right(UnknownNextPeer()), commit = true)
167164
Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel)
168-
reputationRecorder_opt.foreach(_ ! CancelRelay(upstream.receivedFrom, r.add.endorsement, relayId))
169165
safeSendAndStop(upstream.add.channelId, cmdFail)
170166

171167
case WrappedAddResponse(addFailed: RES_ADD_FAILED[_]) =>
@@ -342,11 +338,9 @@ class ChannelRelay private(nodeParams: NodeParams,
342338
}
343339
}
344340

345-
private def recordRelayDuration(isSuccess: Boolean): Unit = {
346-
reputationRecorder_opt.foreach(_ ! RecordResult(upstream.receivedFrom, r.add.endorsement, relayId, isSuccess))
341+
private def recordRelayDuration(isSuccess: Boolean): Unit =
347342
Metrics.RelayedPaymentDuration
348343
.withTag(Tags.Relay, Tags.RelayType.Channel)
349344
.withTag(Tags.Success, isSuccess)
350345
.record((TimestampMilli.now() - upstream.receivedAt).toMillis, TimeUnit.MILLISECONDS)
351-
}
352346
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ object ChannelRelayer {
5959

6060
def apply(nodeParams: NodeParams,
6161
register: ActorRef,
62-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.ChannelRelayCommand]],
62+
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.GetConfidence]],
6363
channels: Map[ByteVector32, Relayer.OutgoingChannel] = Map.empty,
6464
scid2channels: Map[ShortChannelId, ByteVector32] = Map.empty,
6565
node2channels: mutable.MultiDict[PublicKey, ByteVector32] = mutable.MultiDict.empty): Behavior[Command] =

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.{PreimageReceived,
3636
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig
3737
import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPaymentToNode
3838
import fr.acinq.eclair.payment.send._
39-
import fr.acinq.eclair.reputation.ReputationRecorder
39+
import fr.acinq.eclair.reputation.ReputationRecorder.GetTrampolineConfidence
4040
import fr.acinq.eclair.router.Router.RouteParams
4141
import fr.acinq.eclair.router.{BalanceTooLow, RouteNotFound}
4242
import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload
@@ -87,7 +87,7 @@ object NodeRelay {
8787
def apply(nodeParams: NodeParams,
8888
parent: typed.ActorRef[NodeRelayer.Command],
8989
register: ActorRef,
90-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.TrampolineRelayCommand]],
90+
reputationRecorder_opt: Option[typed.ActorRef[GetTrampolineConfidence]],
9191
relayId: UUID,
9292
nodeRelayPacket: NodeRelayPacket,
9393
outgoingPaymentFactory: OutgoingPaymentFactory,
@@ -186,7 +186,7 @@ object NodeRelay {
186186
class NodeRelay private(nodeParams: NodeParams,
187187
parent: akka.actor.typed.ActorRef[NodeRelayer.Command],
188188
register: ActorRef,
189-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.TrampolineRelayCommand]],
189+
reputationRecorder_opt: Option[typed.ActorRef[GetTrampolineConfidence]],
190190
relayId: UUID,
191191
paymentHash: ByteVector32,
192192
paymentSecret: ByteVector32,
@@ -264,11 +264,8 @@ class NodeRelay private(nodeParams: NodeParams,
264264
private def doSend(upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = {
265265
context.log.debug(s"relaying trampoline payment (amountIn=${upstream.amountIn} expiryIn=${upstream.expiryIn} amountOut=${nextPayload.amountToForward} expiryOut=${nextPayload.outgoingCltv})")
266266
val totalFee = upstream.amountIn - nextPayload.amountToForward
267-
val fees = upstream.received.foldLeft(Map.empty[ReputationRecorder.PeerEndorsement, MilliSatoshi])((fees, r) =>
268-
fees.updatedWith(ReputationRecorder.PeerEndorsement(r.receivedFrom, r.add.endorsement))(fee =>
269-
Some(fee.getOrElse(MilliSatoshi(0)) + r.add.amountMsat * totalFee.toLong / upstream.amountIn.toLong)))
270267
reputationRecorder_opt match {
271-
case Some(reputationRecorder) => reputationRecorder ! ReputationRecorder.GetTrampolineConfidence(context.messageAdapter[ReputationRecorder.Confidence](confidence => WrappedConfidence(confidence.value)), fees, relayId)
268+
case Some(reputationRecorder) => reputationRecorder ! GetTrampolineConfidence(context.messageAdapter(confidence => WrappedConfidence(confidence.value)), upstream, totalFee)
272269
case None => context.self ! WrappedConfidence((upstream.received.map(_.add.endorsement).min + 0.5) / 8)
273270
}
274271
Behaviors.receiveMessagePartial {
@@ -303,19 +300,13 @@ class NodeRelay private(nodeParams: NodeParams,
303300
case WrappedPaymentSent(paymentSent) =>
304301
context.log.debug("trampoline payment fully resolved downstream")
305302
success(upstream, fulfilledUpstream, paymentSent)
306-
val totalFee = upstream.amountIn - paymentSent.amountWithFees
307-
val fees = upstream.received.foldLeft(Map.empty[ReputationRecorder.PeerEndorsement, MilliSatoshi])((fees, r) =>
308-
fees.updatedWith(ReputationRecorder.PeerEndorsement(r.receivedFrom, r.add.endorsement))(fee =>
309-
Some(fee.getOrElse(MilliSatoshi(0)) + r.add.amountMsat * totalFee.toLong / upstream.amountIn.toLong)))
310-
reputationRecorder_opt.foreach(_ ! ReputationRecorder.RecordTrampolineSuccess(fees, relayId))
311303
recordRelayDuration(startedAt, isSuccess = true)
312304
stopping()
313305
case WrappedPaymentFailed(PaymentFailed(_, _, failures, _)) =>
314306
context.log.debug(s"trampoline payment failed downstream")
315307
if (!fulfilledUpstream) {
316308
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload))
317309
}
318-
reputationRecorder_opt.foreach(_ ! ReputationRecorder.RecordTrampolineFailure(upstream.received.map(r => ReputationRecorder.PeerEndorsement(r.receivedFrom, r.add.endorsement)).toSet, relayId))
319310
recordRelayDuration(startedAt, isSuccess = fulfilledUpstream)
320311
stopping()
321312
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelayer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ object NodeRelayer {
6161
*/
6262
def apply(nodeParams: NodeParams,
6363
register: akka.actor.ActorRef,
64-
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.TrampolineRelayCommand]],
64+
reputationRecorder_opt: Option[typed.ActorRef[ReputationRecorder.GetTrampolineConfidence]],
6565
outgoingPaymentFactory: NodeRelay.OutgoingPaymentFactory,
6666
triggerer: typed.ActorRef[AsyncPaymentTriggerer.Command],
6767
router: akka.actor.ActorRef,

eclair-core/src/main/scala/fr/acinq/eclair/reputation/Reputation.scala

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@
1616

1717
package fr.acinq.eclair.reputation
1818

19+
import fr.acinq.bitcoin.scalacompat.ByteVector32
20+
import fr.acinq.eclair.reputation.Reputation.HtlcId
1921
import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
2022

21-
import java.util.UUID
2223
import scala.concurrent.duration.FiniteDuration
2324

2425
/**
2526
* Created by thomash on 21/07/2023.
2627
*/
2728

2829
/**
29-
* Local reputation for a given incoming node, that should be track for each incoming endorsement level.
30+
* Local reputation for a given incoming node, that should be tracked for each incoming endorsement level.
3031
*
3132
* @param pastWeight How much fees we would have collected in the past if all payments had succeeded (exponential moving average).
3233
* @param pastScore How much fees we have collected in the past (exponential moving average).
@@ -36,51 +37,47 @@ import scala.concurrent.duration.FiniteDuration
3637
* @param maxRelayDuration Duration after which payments are penalized for staying pending too long.
3738
* @param pendingMultiplier How much to penalize pending payments.
3839
*/
39-
case class Reputation(pastWeight: Double, pastScore: Double, lastSettlementAt: TimestampMilli, pending: Map[UUID, Reputation.PendingPayment], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double) {
40+
case class Reputation(pastWeight: Double, pastScore: Double, lastSettlementAt: TimestampMilli, pending: Map[HtlcId, Reputation.PendingPayment], halfLife: FiniteDuration, maxRelayDuration: FiniteDuration, pendingMultiplier: Double) {
4041
private def decay(now: TimestampMilli): Double = scala.math.pow(0.5, (now - lastSettlementAt) / halfLife)
4142

4243
private def pendingWeight(now: TimestampMilli): Double = pending.values.map(_.weight(now, maxRelayDuration, pendingMultiplier)).sum
4344

4445
/**
45-
* Register a payment to relay and estimate the confidence that it will succeed.
46-
*
47-
* @return (updated reputation, confidence)
46+
* Estimate the confidence that a payment will succeed.
4847
*/
49-
def attempt(relayId: UUID, fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): (Reputation, Double) = {
48+
def getConfidence(fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): Double = {
5049
val d = decay(now)
51-
val newReputation = copy(pending = pending + (relayId -> Reputation.PendingPayment(fee, now)))
52-
val confidence = d * pastScore / (d * pastWeight + newReputation.pendingWeight(now))
53-
(newReputation, confidence)
50+
d * pastScore / (d * pastWeight + pendingWeight(now) + fee.toLong.toDouble * pendingMultiplier)
5451
}
5552

5653
/**
57-
* Mark a previously registered payment as failed without trying to relay it (usually because its confidence was too low).
54+
* Register a pending relay.
5855
*
5956
* @return updated reputation
6057
*/
61-
def cancel(relayId: UUID): Reputation = copy(pending = pending - relayId)
58+
def attempt(htlcId: HtlcId, fee: MilliSatoshi, now: TimestampMilli = TimestampMilli.now()): Reputation =
59+
copy(pending = pending + (htlcId -> Reputation.PendingPayment(fee, now)))
6260

6361
/**
6462
* When a payment is settled, we record whether it succeeded and how long it took.
6563
*
66-
* @param feeOverride When relaying trampoline payments, the actual fee is only known when the payment succeeds. This
67-
* is used instead of the fee upper bound that was known when first attempting the relay.
6864
* @return updated reputation
6965
*/
70-
def record(relayId: UUID, isSuccess: Boolean, feeOverride: Option[MilliSatoshi] = None, now: TimestampMilli = TimestampMilli.now()): Reputation = {
71-
pending.get(relayId) match {
66+
def record(htlcId: HtlcId, isSuccess: Boolean, now: TimestampMilli = TimestampMilli.now()): Reputation = {
67+
pending.get(htlcId) match {
7268
case Some(p) =>
7369
val d = decay(now)
74-
val p1 = p.copy(fee = feeOverride.getOrElse(p.fee))
75-
val newWeight = d * pastWeight + p1.weight(now, maxRelayDuration, 1.0)
76-
val newScore = d * pastScore + (if (isSuccess) p1.fee.toLong.toDouble else 0)
77-
Reputation(newWeight, newScore, now, pending - relayId, halfLife, maxRelayDuration, pendingMultiplier)
70+
val newWeight = d * pastWeight + p.weight(now, maxRelayDuration, if (isSuccess) 1.0 else 0.0)
71+
val newScore = d * pastScore + (if (isSuccess) p.fee.toLong.toDouble else 0)
72+
Reputation(newWeight, newScore, now, pending - htlcId, halfLife, maxRelayDuration, pendingMultiplier)
7873
case None => this
7974
}
8075
}
8176
}
8277

8378
object Reputation {
79+
case class HtlcId(channelId: ByteVector32, id: Long)
80+
8481
/** We're relaying that payment and are waiting for it to settle. */
8582
case class PendingPayment(fee: MilliSatoshi, startedAt: TimestampMilli) {
8683
def weight(now: TimestampMilli, minDuration: FiniteDuration, multiplier: Double): Double = {

0 commit comments

Comments
 (0)