Skip to content

Commit 473b46d

Browse files
authored
Rework channel lifecyle events (#3237)
We emit several events during the channel lifecycle, which have become a bit of a mess over the years, especially with the addition of 0-conf, splicing and on-the-fly funding. We now use the following events: - `ChannelCreated` once the funding transaction is created - `ChannelFundingConfirmed` once the funding transaction is confirmed, which is also emitted for splice transactions - `ChannelReadyForPayments` once the channel is ready for payments, after exchanging `channel_ready` for the channel creation or `splice_locked` for splice transactions The order between `ChannelFundingConfirmed` and `ChannelReadyForPayments` depends on whether 0-conf is used or not. We remove `ChannelOpened`, which was actually a subset of the existing `ChannelReadyForPayments` event (which was added afterwards). We add a few fields to existing channel events, which we don't yet store in the DB to avoid modifying it, but will store later when we modify the schema of the `AuditDb`. We now store an entry in the `AuditDb` whenever a splice transaction confirms, which allows tracking the full history of a channel's changes.
1 parent 7137eac commit 473b46d

File tree

20 files changed

+164
-94
lines changed

20 files changed

+164
-94
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@
99
We remove the code used to support legacy channels that don't use anchor outputs or taproot.
1010
If you still have such channels, eclair won't start: you will need to close those channels, and will only be able to update eclair once they have been successfully closed.
1111

12+
### Channel lifecyle events rework
13+
14+
Eclair emits several events during a channel lifecycle, which can be received by plugins or through the websocket.
15+
We reworked these events to be compatible with splicing and consistent with 0-conf:
16+
17+
- we removed the `channel-opened` event
18+
- we introduced a `channel-confirmed` event
19+
- we introduced a `channel-ready` event
20+
21+
The `channel-confirmed` event is emitted when the funding transaction or a splice transaction has enough confirmations.
22+
Listeners can use the `fundingTxIndex` to detect whether this is the initial channel funding (`fundingTxIndex = 0`) or a splice (`fundingTxIndex > 0`).
23+
24+
The `channel-ready` event is emitted when the channel is ready to process payments, which generally happens after the `channel-confirmed` event.
25+
However, when using zero-conf, this event may be emitted before the `channel-confirmed` event.
26+
27+
See #3237 for more details.
28+
1229
### Channel jamming accountability
1330

1431
We update our channel jamming mitigation to match the latest draft of the [spec](https://github.com/lightning/bolts/pull/1280).
@@ -29,6 +46,7 @@ eclair.relay.reserved-for-accountable = 0.0
2946
### API changes
3047

3148
- `findroute`, `findroutetonode` and `findroutebetweennodes` now include a `maxCltvExpiryDelta` parameter (#3234)
49+
- `channel-opened` was removed from the websocket in favor of `channel-confirmed` and `channel-ready` (#3237)
3250

3351
### Miscellaneous improvements and bug fixes
3452

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -765,14 +765,7 @@ object DATA_CLOSED {
765765
commitmentFormat = d.commitments.latest.commitmentFormat.toString,
766766
announced = d.commitments.latest.channelParams.announceChannel,
767767
capacity = d.commitments.latest.capacity,
768-
closingTxId = closingType match {
769-
case Closing.MutualClose(closingTx) => closingTx.tx.txid
770-
case Closing.LocalClose(_, localCommitPublished) => localCommitPublished.commitTx.txid
771-
case Closing.CurrentRemoteClose(_, remoteCommitPublished) => remoteCommitPublished.commitTx.txid
772-
case Closing.NextRemoteClose(_, remoteCommitPublished) => remoteCommitPublished.commitTx.txid
773-
case Closing.RecoveryClose(remoteCommitPublished) => remoteCommitPublished.commitTx.txid
774-
case Closing.RevokedClose(revokedCommitPublished) => revokedCommitPublished.commitTx.txid
775-
},
768+
closingTxId = closingType.closingTxId,
776769
closingType = closingType.toString,
777770
closingScript = d.finalScriptPubKey,
778771
localBalance = closingType match {

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import fr.acinq.eclair.{BlockHeight, Features, MilliSatoshi, RealShortChannelId,
3030

3131
trait ChannelEvent
3232

33+
/** This event is sent when a channel has been created: however, it may not be ready to process payments yet (see [[ChannelReadyForPayments]]). */
3334
case class ChannelCreated(channel: ActorRef, peer: ActorRef, remoteNodeId: PublicKey, isOpener: Boolean, temporaryChannelId: ByteVector32, commitTxFeerate: FeeratePerKw, fundingTxFeerate: Option[FeeratePerKw]) extends ChannelEvent
3435

3536
// This trait can be used by non-standard channels to inject themselves into Register actor and thus make them usable for routing
@@ -50,11 +51,11 @@ case class ShortChannelIdAssigned(channel: ActorRef, channelId: ByteVector32, an
5051
/** This event will be sent if a channel was aborted before completing the opening flow. */
5152
case class ChannelAborted(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32) extends ChannelEvent
5253

53-
/** This event will be sent once a channel has been successfully opened and is ready to process payments. */
54-
case class ChannelOpened(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32) extends ChannelEvent
54+
/** This event is sent once a funding transaction (channel creation or splice) has been confirmed. */
55+
case class ChannelFundingConfirmed(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, fundingTxId: TxId, fundingTxIndex: Long, blockHeight: BlockHeight, commitments: Commitments) extends ChannelEvent
5556

56-
/** This event is sent once channel_ready or splice_locked have been exchanged. */
57-
case class ChannelReadyForPayments(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32, fundingTxIndex: Long) extends ChannelEvent
57+
/** This event is sent once channel_ready or splice_locked have been exchanged: the channel is ready to process payments. */
58+
case class ChannelReadyForPayments(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32, fundingTxId: TxId, fundingTxIndex: Long) extends ChannelEvent
5859

5960
case class LocalChannelUpdate(channel: ActorRef, channelId: ByteVector32, aliases: ShortIdAliases, remoteNodeId: PublicKey, announcement_opt: Option[AnnouncedCommitment], channelUpdate: ChannelUpdate, commitments: Commitments) extends ChannelEvent {
6061
/**
@@ -103,7 +104,7 @@ case class ChannelPersisted(channel: ActorRef, remoteNodeId: PublicKey, channelI
103104

104105
case class LocalCommitConfirmed(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32, refundAtBlock: BlockHeight) extends ChannelEvent
105106

106-
case class ChannelClosed(channel: ActorRef, channelId: ByteVector32, closingType: ClosingType, commitments: Commitments) extends ChannelEvent
107+
case class ChannelClosed(channel: ActorRef, channelId: ByteVector32, closingType: ClosingType, closingTxId: TxId, commitments: Commitments) extends ChannelEvent
107108

108109
case class OutgoingHtlcAdded(add: UpdateAddHtlc, remoteNodeId: PublicKey, fee: MilliSatoshi)
109110

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -603,14 +603,30 @@ object Helpers {
603603
object Closing {
604604

605605
// @formatter:off
606-
sealed trait ClosingType
607-
case class MutualClose(tx: ClosingTx) extends ClosingType { override def toString: String = "mutual-close" }
608-
case class LocalClose(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished) extends ClosingType { override def toString: String = "local-close" }
609-
sealed trait RemoteClose extends ClosingType { def remoteCommit: RemoteCommit; def remoteCommitPublished: RemoteCommitPublished }
606+
sealed trait ClosingType { def closingTxId: TxId }
607+
case class MutualClose(tx: ClosingTx) extends ClosingType {
608+
override def closingTxId: TxId = tx.tx.txid
609+
override def toString: String = "mutual-close"
610+
}
611+
case class LocalClose(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished) extends ClosingType {
612+
override def closingTxId: TxId = localCommitPublished.commitTx.txid
613+
override def toString: String = "local-close"
614+
}
615+
sealed trait RemoteClose extends ClosingType {
616+
def remoteCommit: RemoteCommit
617+
def remoteCommitPublished: RemoteCommitPublished
618+
override def closingTxId: TxId = remoteCommitPublished.commitTx.txid
619+
}
610620
case class CurrentRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose { override def toString: String = "remote-close" }
611621
case class NextRemoteClose(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished) extends RemoteClose { override def toString: String = "next-remote-close" }
612-
case class RecoveryClose(remoteCommitPublished: RemoteCommitPublished) extends ClosingType { override def toString: String = "recovery-close" }
613-
case class RevokedClose(revokedCommitPublished: RevokedCommitPublished) extends ClosingType { override def toString: String = "revoked-close" }
622+
case class RecoveryClose(remoteCommitPublished: RemoteCommitPublished) extends ClosingType {
623+
override def closingTxId: TxId = remoteCommitPublished.commitTx.txid
624+
override def toString: String = "recovery-close"
625+
}
626+
case class RevokedClose(revokedCommitPublished: RevokedCommitPublished) extends ClosingType {
627+
override def closingTxId: TxId = revokedCommitPublished.commitTx.txid
628+
override def toString: String = "revoked-close"
629+
}
614630
// @formatter:on
615631

616632
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ object Monitoring {
9494

9595
object Events {
9696
val Created = "created"
97+
val Spliced = "spliced"
9798
val Closing = "closing"
9899
val Closed = "closed"
99100
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2281,7 +2281,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
22812281
Closing.isClosed(d1, Some(tx)) match {
22822282
case Some(closingType) =>
22832283
log.info("channel closed (type={})", EventType.Closed(closingType).label)
2284-
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments))
2284+
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, closingType.closingTxId, d.commitments))
22852285
goto(CLOSED) using DATA_CLOSED(d1, closingType)
22862286
case None =>
22872287
stay() using d1 storing()
@@ -2746,8 +2746,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
27462746

27472747
// We tell the peer that the channel is ready to process payments that may be queued.
27482748
if (!shutdownInProgress) {
2749-
val fundingTxIndex = commitments1.active.map(_.fundingTxIndex).min
2750-
peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, fundingTxIndex)
2749+
val c = commitments1.active.minBy(_.fundingTxIndex)
2750+
peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, c.fundingTxId, c.fundingTxIndex)
27512751
}
27522752

27532753
goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1, localShutdown = shutdown_opt) sending sendQueue
@@ -3033,7 +3033,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
30333033
val closingTx = d.findClosingTx(tx).get
30343034
val closingType = MutualClose(closingTx)
30353035
log.info("channel closed (type={})", EventType.Closed(closingType).label)
3036-
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments))
3036+
context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, closingType.closingTxId, d.commitments))
30373037
goto(CLOSED) using DATA_CLOSED(d, closingTx)
30383038

30393039
case Event(WatchFundingSpentTriggered(tx), d: ChannelDataWithCommitments) =>
@@ -3339,8 +3339,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
33393339
if (oldCommitments.active.size != newCommitments.active.size) {
33403340
// Some commitments have been deactivated, which means our available balance changed, which may allow forwarding
33413341
// payments that couldn't be forwarded before.
3342-
val fundingTxIndex = newCommitments.active.map(_.fundingTxIndex).min
3343-
peer ! ChannelReadyForPayments(self, remoteNodeId, newCommitments.channelId, fundingTxIndex)
3342+
val c = newCommitments.active.minBy(_.fundingTxIndex)
3343+
peer ! ChannelReadyForPayments(self, remoteNodeId, newCommitments.channelId, c.fundingTxId, c.fundingTxIndex)
33443344
}
33453345
}
33463346

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ trait CommonFundingHandlers extends CommonHandlers {
102102
// Children splice transactions may already spend that confirmed funding transaction.
103103
val spliceSpendingTxs = commitments1.all.collect { case c if c.fundingTxIndex == commitment.fundingTxIndex + 1 => c.fundingTxId }
104104
watchFundingSpent(commitment, additionalKnownSpendingTxs = spliceSpendingTxs.toSet, None)
105+
// We notify listeners that this funding transaction is now confirmed.
106+
context.system.eventStream.publish(ChannelFundingConfirmed(self, d.channelId, remoteNodeId, w.tx.txid, c.fundingTxIndex, w.blockHeight, commitments1))
105107
// We can unwatch the previous funding transaction(s), which have been spent by this splice transaction.
106108
d.commitments.all.collect { case c if c.fundingTxIndex < commitment.fundingTxIndex => blockchain ! UnwatchFundingSpent(c.fundingTxId, c.fundingInput.index.toInt) }
107109
// In the dual-funding/splicing case we can forget all other transactions (RBF attempts), they have been
@@ -144,7 +146,8 @@ trait CommonFundingHandlers extends CommonHandlers {
144146
aliases1.remoteAlias_opt.foreach(_ => context.system.eventStream.publish(ShortChannelIdAssigned(self, commitments.channelId, None, aliases1, remoteNodeId)))
145147
log.info("shortIds: real={} localAlias={} remoteAlias={}", commitments.latest.shortChannelId_opt.getOrElse("none"), aliases1.localAlias, aliases1.remoteAlias_opt.getOrElse("none"))
146148
// We notify that the channel is now ready to route payments.
147-
context.system.eventStream.publish(ChannelOpened(self, remoteNodeId, commitments.channelId))
149+
context.system.eventStream.publish(ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, commitments.latest.fundingTxId, fundingTxIndex = 0))
150+
peer ! ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, commitments.latest.fundingTxId, fundingTxIndex = 0)
148151
// We create a channel_update early so that we can use it to send payments through this channel, but it won't be propagated to other nodes since the channel is not yet announced.
149152
val scidForChannelUpdate = Helpers.scidForChannelUpdate(channelAnnouncement_opt = None, aliases1.localAlias)
150153
log.info("using shortChannelId={} for initial channel_update", scidForChannelUpdate)
@@ -161,7 +164,6 @@ trait CommonFundingHandlers extends CommonHandlers {
161164
remoteNextCommitInfo = Right(channelReady.nextPerCommitmentPoint)
162165
)
163166
channelReady.nextCommitNonce_opt.foreach(nonce => remoteNextCommitNonces = remoteNextCommitNonces + (commitments.latest.fundingTxId -> nonce))
164-
peer ! ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, fundingTxIndex = 0)
165167
DATA_NORMAL(commitments1, aliases1, None, initialChannelUpdate, SpliceStatus.NoSplice, None, None, None)
166168
}
167169

eclair-core/src/main/scala/fr/acinq/eclair/db/DbEventHandler.scala

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import akka.actor.typed.scaladsl.adapter.ClassicActorContextOps
2222
import akka.actor.{Actor, DiagnosticActorLogging, Props}
2323
import akka.event.Logging.MDC
2424
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
25-
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
25+
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, TxId}
2626
import fr.acinq.eclair.channel.Helpers.Closing._
2727
import fr.acinq.eclair.channel.Monitoring.{Metrics => ChannelMetrics, Tags => ChannelTags}
2828
import fr.acinq.eclair.channel._
@@ -51,6 +51,7 @@ class DbEventHandler(nodeParams: NodeParams) extends Actor with DiagnosticActorL
5151
context.system.eventStream.subscribe(self, classOf[TransactionConfirmed])
5252
context.system.eventStream.subscribe(self, classOf[ChannelErrorOccurred])
5353
context.system.eventStream.subscribe(self, classOf[ChannelStateChanged])
54+
context.system.eventStream.subscribe(self, classOf[ChannelFundingConfirmed])
5455
context.system.eventStream.subscribe(self, classOf[ChannelClosed])
5556
context.system.eventStream.subscribe(self, classOf[ChannelUpdateParametersChanged])
5657
context.system.eventStream.subscribe(self, classOf[PathFindingExperimentMetrics])
@@ -123,7 +124,7 @@ class DbEventHandler(nodeParams: NodeParams) extends Actor with DiagnosticActorL
123124
case ChannelStateChanged(_, channelId, _, remoteNodeId, WAIT_FOR_CHANNEL_READY | WAIT_FOR_DUAL_FUNDING_READY, NORMAL, Some(commitments)) =>
124125
ChannelMetrics.ChannelLifecycleEvents.withTag(ChannelTags.Event, ChannelTags.Events.Created).increment()
125126
val event = ChannelEvent.EventType.Created
126-
auditDb.add(ChannelEvent(channelId, remoteNodeId, commitments.latest.capacity, commitments.localChannelParams.isChannelOpener, !commitments.announceChannel, event))
127+
auditDb.add(ChannelEvent(channelId, remoteNodeId, commitments.latest.fundingTxId, commitments.latest.capacity, commitments.localChannelParams.isChannelOpener, !commitments.announceChannel, event))
127128
channelsDb.updateChannelMeta(channelId, event)
128129
case ChannelStateChanged(_, channelId, _, _, OFFLINE, SYNCING, _) =>
129130
channelsDb.updateChannelMeta(channelId, ChannelEvent.EventType.Connected)
@@ -132,11 +133,24 @@ class DbEventHandler(nodeParams: NodeParams) extends Actor with DiagnosticActorL
132133
case _ => ()
133134
}
134135

136+
case e: ChannelFundingConfirmed =>
137+
if (e.fundingTxIndex > 0) {
138+
ChannelMetrics.ChannelLifecycleEvents.withTag(ChannelTags.Event, ChannelTags.Events.Spliced).increment()
139+
}
140+
val event = e.fundingTxIndex match {
141+
case 0 => ChannelEvent.EventType.Confirmed
142+
case _ => ChannelEvent.EventType.Spliced
143+
}
144+
auditDb.add(ChannelEvent(e.channelId, e.remoteNodeId, e.fundingTxId, e.commitments.latest.capacity, e.commitments.localChannelParams.isChannelOpener, !e.commitments.announceChannel, event))
145+
135146
case e: ChannelClosed =>
136147
ChannelMetrics.ChannelLifecycleEvents.withTag(ChannelTags.Event, ChannelTags.Events.Closed).increment()
137148
val event = ChannelEvent.EventType.Closed(e.closingType)
149+
// We use the latest state of the channel (in case it has been spliced since it was opened), which is the state
150+
// spent by the closing transaction.
138151
val capacity = e.commitments.latest.capacity
139-
auditDb.add(ChannelEvent(e.channelId, e.commitments.remoteNodeId, capacity, e.commitments.localChannelParams.isChannelOpener, !e.commitments.announceChannel, event))
152+
val fundingTxId = e.commitments.latest.fundingTxId
153+
auditDb.add(ChannelEvent(e.channelId, e.commitments.remoteNodeId, fundingTxId, capacity, e.commitments.localChannelParams.isChannelOpener, !e.commitments.announceChannel, event))
140154
channelsDb.updateChannelMeta(e.channelId, event)
141155

142156
case u: ChannelUpdateParametersChanged =>
@@ -164,11 +178,13 @@ object DbEventHandler {
164178
def props(nodeParams: NodeParams): Props = Props(new DbEventHandler(nodeParams))
165179

166180
// @formatter:off
167-
case class ChannelEvent(channelId: ByteVector32, remoteNodeId: PublicKey, capacity: Satoshi, isChannelOpener: Boolean, isPrivate: Boolean, event: ChannelEvent.EventType)
181+
case class ChannelEvent(channelId: ByteVector32, remoteNodeId: PublicKey, fundingTxId: TxId, capacity: Satoshi, isChannelOpener: Boolean, isPrivate: Boolean, event: ChannelEvent.EventType)
168182
object ChannelEvent {
169183
sealed trait EventType { def label: String }
170184
object EventType {
171185
object Created extends EventType { override def label: String = "created" }
186+
object Confirmed extends EventType { override def label: String = "confirmed" }
187+
object Spliced extends EventType { override def label: String = "spliced" }
172188
object Connected extends EventType { override def label: String = "connected" }
173189
object PaymentSent extends EventType { override def label: String = "sent" }
174190
object PaymentReceived extends EventType { override def label: String = "received" }

eclair-core/src/main/scala/fr/acinq/eclair/io/IncomingConnectionsTracker.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
66
import akka.actor.typed.{ActorRef, Behavior}
77
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
88
import fr.acinq.eclair.Logs.LogCategory
9+
import fr.acinq.eclair.channel.ChannelReadyForPayments
910
import fr.acinq.eclair.{Logs, NodeParams}
10-
import fr.acinq.eclair.channel.ChannelOpened
1111
import fr.acinq.eclair.io.IncomingConnectionsTracker.Command
1212
import fr.acinq.eclair.io.Monitoring.Metrics
1313
import fr.acinq.eclair.io.Peer.{Disconnect, DisconnectResponse}
@@ -46,7 +46,7 @@ object IncomingConnectionsTracker {
4646
Behaviors.setup { context =>
4747
Behaviors.withMdc(Logs.mdc(category_opt = Some(LogCategory.CONNECTION))) {
4848
context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[PeerDisconnected](c => ForgetIncomingConnection(c.nodeId)))
49-
context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[ChannelOpened](c => ForgetIncomingConnection(c.remoteNodeId)))
49+
context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[ChannelReadyForPayments](c => ForgetIncomingConnection(c.remoteNodeId)))
5050
new IncomingConnectionsTracker(nodeParams, switchboard, context).tracking(Map.empty)
5151
}
5252
}

0 commit comments

Comments
 (0)