Skip to content

Commit f9aa0a8

Browse files
committed
Clamp price feed instead of ignoring out of bounds values
[ci] Signed-off-by: Moritz Kiefer <moritz.kiefer@purelyfunctional.org>
1 parent 286badb commit f9aa0a8

File tree

4 files changed

+56
-44
lines changed

4 files changed

+56
-44
lines changed

apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/FollowAmuletConversionRateFeedTimeBasedIntegrationTest.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,12 @@ class FollowAmuletConversionRateFeedTimeBasedIntegrationTest
173173
loggerFactory.assertLogs(
174174
runTrigger,
175175
_.warningMessage should include(
176-
"200.0000000000 which is outside of the configured accepted range"
176+
"200.0000000000 which is outside of the configured accepted range RangeConfig(0.01,100.0), clamping to 100.0"
177177
),
178178
)
179179
BigDecimal(
180180
sv1Backend.listAmuletPriceVotes().loneElement.payload.amuletPrice.toScala.value
181-
) shouldBe BigDecimal(23.0)
181+
) shouldBe BigDecimal(100.0)
182182
},
183183
)
184184
// Advance below the configured bound
@@ -206,12 +206,12 @@ class FollowAmuletConversionRateFeedTimeBasedIntegrationTest
206206
loggerFactory.assertLogs(
207207
runTrigger,
208208
_.warningMessage should include(
209-
"0.0010000000 which is outside of the configured accepted range"
209+
"0.0010000000 which is outside of the configured accepted range RangeConfig(0.01,100.0), clamping to 0.01"
210210
),
211211
)
212212
BigDecimal(
213213
sv1Backend.listAmuletPriceVotes().loneElement.payload.amuletPrice.toScala.value
214-
) shouldBe BigDecimal(23.0)
214+
) shouldBe BigDecimal(0.01)
215215
},
216216
)
217217
}

apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/FollowAmuletConversionRateFeedTrigger.scala

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -51,44 +51,49 @@ class FollowAmuletConversionRateFeedTrigger(
5151
logger.warn(s"No AmuletConversionRateFeed for publisher ${config.publisher}")
5252
Future.successful(Seq.empty)
5353
case Some(feed) =>
54-
val feedRate = BigDecimal(feed.payload.amuletConversionRate)
55-
if (feedRate < config.acceptedRange.min || feedRate > config.acceptedRange.max) {
56-
logger.warn(
57-
s"Rate from publisher ${config.publisher} is ${feedRate} which is outside of the configured accepted range ${config.acceptedRange}"
58-
)
59-
Future.successful(Seq.empty)
60-
} else {
61-
for {
62-
existingVote <- store
63-
.lookupAmuletPriceVoteByThisSv()
64-
.map(
65-
_.getOrElse(
66-
// This can happen after a hard migration or when reingesting from
67-
// ledger begin for other reasons so we don't make this INTERNAL.
68-
throw Status.NOT_FOUND
69-
.withDescription("No price vote for this SV found")
70-
.asRuntimeException
71-
)
54+
val followedFeedRate = BigDecimal(feed.payload.amuletConversionRate)
55+
val publishedFeedRate =
56+
if (
57+
followedFeedRate < config.acceptedRange.min || followedFeedRate > config.acceptedRange.max
58+
) {
59+
val rate =
60+
followedFeedRate.max(config.acceptedRange.min).min(config.acceptedRange.max)
61+
logger.warn(
62+
s"Rate from publisher ${config.publisher} is ${followedFeedRate} which is outside of the configured accepted range ${config.acceptedRange}, clamping to ${rate}"
63+
)
64+
rate
65+
} else followedFeedRate
66+
for {
67+
existingVote <- store
68+
.lookupAmuletPriceVoteByThisSv()
69+
.map(
70+
_.getOrElse(
71+
// This can happen after a hard migration or when reingesting from
72+
// ledger begin for other reasons so we don't make this INTERNAL.
73+
throw Status.NOT_FOUND
74+
.withDescription("No price vote for this SV found")
75+
.asRuntimeException
7276
)
73-
dsoRules <- store.getDsoRules()
74-
voteCooldown = dsoRules.contract.payload.config.voteCooldownTime.toScala
75-
.fold(Duration.ofMinutes(1))(t => Duration.ofNanos(t.microseconds * 1000))
76-
} yield {
77-
val earliestVoteTimestamp = CantonTimestamp
78-
.tryFromInstant(existingVote.payload.lastUpdatedAt.plus(voteCooldown))
79-
if (
80-
earliestVoteTimestamp < now && existingVote.payload.amuletPrice.toScala
81-
.map(BigDecimal(_)) != Some(feedRate)
82-
) {
83-
Seq(
84-
Task(
85-
existingVote,
86-
feed,
87-
)
77+
)
78+
dsoRules <- store.getDsoRules()
79+
voteCooldown = dsoRules.contract.payload.config.voteCooldownTime.toScala
80+
.fold(Duration.ofMinutes(1))(t => Duration.ofNanos(t.microseconds * 1000))
81+
} yield {
82+
val earliestVoteTimestamp = CantonTimestamp
83+
.tryFromInstant(existingVote.payload.lastUpdatedAt.plus(voteCooldown))
84+
if (
85+
earliestVoteTimestamp < now && existingVote.payload.amuletPrice.toScala
86+
.map(BigDecimal(_)) != Some(publishedFeedRate)
87+
) {
88+
Seq(
89+
Task(
90+
existingVote,
91+
feed,
92+
publishedFeedRate,
8893
)
89-
} else {
90-
Seq.empty
91-
}
94+
)
95+
} else {
96+
Seq.empty
9297
}
9398
}
9499
}
@@ -103,15 +108,15 @@ class FollowAmuletConversionRateFeedTrigger(
103108
_.exerciseDsoRules_UpdateAmuletPriceVote(
104109
store.key.svParty.toProtoPrimitive,
105110
task.work.existingVote.contractId,
106-
task.work.feed.payload.amuletConversionRate,
111+
task.work.publishedFeedRate.bigDecimal,
107112
)
108113
)
109114
_ <- connection
110115
.submit(Seq(store.key.svParty), Seq(store.key.dsoParty), cmd)
111116
.noDedup
112117
.yieldResult()
113118
} yield TaskSuccess(
114-
s"Updated amulet conversion rate to ${task.work.feed.payload.amuletConversionRate}"
119+
s"Updated amulet conversion rate to ${task.work.publishedFeedRate}"
115120
)
116121

117122
override protected def isStaleTask(
@@ -125,12 +130,15 @@ class FollowAmuletConversionRateFeedTrigger(
125130
object FollowAmuletConversionRateFeedTrigger {
126131
final case class Task(
127132
existingVote: Contract[AmuletPriceVote.ContractId, AmuletPriceVote],
133+
// mainly included for logging, the actual value to publish is publishedFeedRate which can be clamped to the boundaries.
128134
feed: Contract[AmuletConversionRateFeed.ContractId, AmuletConversionRateFeed],
135+
publishedFeedRate: BigDecimal,
129136
) extends PrettyPrinting {
130137

131138
override protected def pretty: Pretty[this.type] = prettyOfClass(
132139
param("existingVote", _.existingVote),
133140
param("feed", _.feed),
141+
param("publishedFeedRate", _.publishedFeedRate),
134142
)
135143
}
136144
}

docs/src/release_notes.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ Upcoming
2121

2222
- Changed the behavior of automation around rewards and coupons to run for the first time in the interval of ``round open time`` -> ``round open time + tick duration``. This might increase the observed duration between rewards and coupons being issued and until they are collected. Once the first tick elapses retries will happen more aggressively.
2323

24+
- SV app
25+
26+
- Published conversion rates are now clamped to the configured range and the clamped value is published instead of
27+
only logging a warning and not publishing an updated value for out of range values.
28+
2429
0.4.20
2530
------
2631

docs/src/sv_operator/sv_helm.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,8 +1134,7 @@ This will automatically pick up the conversion rate from
11341134
``#splice-amulet-name-service:Splice.Ans.AmuletConversionRateFeed:AmuletConversionRateFeed``
11351135
contracts published by the party ``publisher::namespace`` and set the
11361136
SV's config to the latest rate from the publisher. If the published
1137-
rate falls outside of the accepted range, a warning is logged and no
1138-
change to the SV's published conversion rate is made.
1137+
rate falls outside of the accepted range, a warning is logged and the published rate is clamped to the configured range.
11391138

11401139
Note that SVs must wait ``voteCooldownTime`` (a governance parameter
11411140
that defaults to 1min) between updates to their rate. Therefore updates made

0 commit comments

Comments
 (0)