diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/FollowAmuletConversionRateFeedTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/FollowAmuletConversionRateFeedTimeBasedIntegrationTest.scala index 070e90be8d..c2a317cba5 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/FollowAmuletConversionRateFeedTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/FollowAmuletConversionRateFeedTimeBasedIntegrationTest.scala @@ -173,12 +173,12 @@ class FollowAmuletConversionRateFeedTimeBasedIntegrationTest loggerFactory.assertLogs( runTrigger, _.warningMessage should include( - "200.0000000000 which is outside of the configured accepted range" + "200.0000000000 which is outside of the configured accepted range RangeConfig(0.01,100.0), clamping to 100.0" ), ) BigDecimal( sv1Backend.listAmuletPriceVotes().loneElement.payload.amuletPrice.toScala.value - ) shouldBe BigDecimal(23.0) + ) shouldBe BigDecimal(100.0) }, ) // Advance below the configured bound @@ -206,12 +206,12 @@ class FollowAmuletConversionRateFeedTimeBasedIntegrationTest loggerFactory.assertLogs( runTrigger, _.warningMessage should include( - "0.0010000000 which is outside of the configured accepted range" + "0.0010000000 which is outside of the configured accepted range RangeConfig(0.01,100.0), clamping to 0.01" ), ) BigDecimal( sv1Backend.listAmuletPriceVotes().loneElement.payload.amuletPrice.toScala.value - ) shouldBe BigDecimal(23.0) + ) shouldBe BigDecimal(0.01) }, ) } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/FollowAmuletConversionRateFeedTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/FollowAmuletConversionRateFeedTrigger.scala index 9f7e1e08e1..fcbbb01d43 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/FollowAmuletConversionRateFeedTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/FollowAmuletConversionRateFeedTrigger.scala @@ -51,44 +51,49 @@ class FollowAmuletConversionRateFeedTrigger( logger.warn(s"No AmuletConversionRateFeed for publisher ${config.publisher}") Future.successful(Seq.empty) case Some(feed) => - val feedRate = BigDecimal(feed.payload.amuletConversionRate) - if (feedRate < config.acceptedRange.min || feedRate > config.acceptedRange.max) { - logger.warn( - s"Rate from publisher ${config.publisher} is ${feedRate} which is outside of the configured accepted range ${config.acceptedRange}" - ) - Future.successful(Seq.empty) - } else { - for { - existingVote <- store - .lookupAmuletPriceVoteByThisSv() - .map( - _.getOrElse( - // This can happen after a hard migration or when reingesting from - // ledger begin for other reasons so we don't make this INTERNAL. - throw Status.NOT_FOUND - .withDescription("No price vote for this SV found") - .asRuntimeException - ) + val followedFeedRate = BigDecimal(feed.payload.amuletConversionRate) + val publishedFeedRate = + if ( + followedFeedRate < config.acceptedRange.min || followedFeedRate > config.acceptedRange.max + ) { + val rate = + followedFeedRate.max(config.acceptedRange.min).min(config.acceptedRange.max) + logger.warn( + s"Rate from publisher ${config.publisher} is ${followedFeedRate} which is outside of the configured accepted range ${config.acceptedRange}, clamping to ${rate}" + ) + rate + } else followedFeedRate + for { + existingVote <- store + .lookupAmuletPriceVoteByThisSv() + .map( + _.getOrElse( + // This can happen after a hard migration or when reingesting from + // ledger begin for other reasons so we don't make this INTERNAL. + throw Status.NOT_FOUND + .withDescription("No price vote for this SV found") + .asRuntimeException ) - dsoRules <- store.getDsoRules() - voteCooldown = dsoRules.contract.payload.config.voteCooldownTime.toScala - .fold(Duration.ofMinutes(1))(t => Duration.ofNanos(t.microseconds * 1000)) - } yield { - val earliestVoteTimestamp = CantonTimestamp - .tryFromInstant(existingVote.payload.lastUpdatedAt.plus(voteCooldown)) - if ( - earliestVoteTimestamp < now && existingVote.payload.amuletPrice.toScala - .map(BigDecimal(_)) != Some(feedRate) - ) { - Seq( - Task( - existingVote, - feed, - ) + ) + dsoRules <- store.getDsoRules() + voteCooldown = dsoRules.contract.payload.config.voteCooldownTime.toScala + .fold(Duration.ofMinutes(1))(t => Duration.ofNanos(t.microseconds * 1000)) + } yield { + val earliestVoteTimestamp = CantonTimestamp + .tryFromInstant(existingVote.payload.lastUpdatedAt.plus(voteCooldown)) + if ( + earliestVoteTimestamp < now && existingVote.payload.amuletPrice.toScala + .map(BigDecimal(_)) != Some(publishedFeedRate) + ) { + Seq( + Task( + existingVote, + feed, + publishedFeedRate, ) - } else { - Seq.empty - } + ) + } else { + Seq.empty } } } @@ -103,7 +108,7 @@ class FollowAmuletConversionRateFeedTrigger( _.exerciseDsoRules_UpdateAmuletPriceVote( store.key.svParty.toProtoPrimitive, task.work.existingVote.contractId, - task.work.feed.payload.amuletConversionRate, + task.work.publishedFeedRate.bigDecimal, ) ) _ <- connection @@ -111,7 +116,7 @@ class FollowAmuletConversionRateFeedTrigger( .noDedup .yieldResult() } yield TaskSuccess( - s"Updated amulet conversion rate to ${task.work.feed.payload.amuletConversionRate}" + s"Updated amulet conversion rate to ${task.work.publishedFeedRate}" ) override protected def isStaleTask( @@ -125,12 +130,15 @@ class FollowAmuletConversionRateFeedTrigger( object FollowAmuletConversionRateFeedTrigger { final case class Task( existingVote: Contract[AmuletPriceVote.ContractId, AmuletPriceVote], + // mainly included for logging, the actual value to publish is publishedFeedRate which can be clamped to the boundaries. feed: Contract[AmuletConversionRateFeed.ContractId, AmuletConversionRateFeed], + publishedFeedRate: BigDecimal, ) extends PrettyPrinting { override protected def pretty: Pretty[this.type] = prettyOfClass( param("existingVote", _.existingVote), param("feed", _.feed), + param("publishedFeedRate", _.publishedFeedRate), ) } } diff --git a/docs/src/release_notes.rst b/docs/src/release_notes.rst index 576faab8c4..50ebc6c316 100644 --- a/docs/src/release_notes.rst +++ b/docs/src/release_notes.rst @@ -21,6 +21,11 @@ Upcoming - 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. + - SV app + + - Published conversion rates are now clamped to the configured range and the clamped value is published instead of + only logging a warning and not publishing an updated value for out of range values. + 0.4.20 ------ diff --git a/docs/src/sv_operator/sv_helm.rst b/docs/src/sv_operator/sv_helm.rst index e1753f9a12..cae04655ba 100644 --- a/docs/src/sv_operator/sv_helm.rst +++ b/docs/src/sv_operator/sv_helm.rst @@ -1134,8 +1134,7 @@ This will automatically pick up the conversion rate from ``#splice-amulet-name-service:Splice.Ans.AmuletConversionRateFeed:AmuletConversionRateFeed`` contracts published by the party ``publisher::namespace`` and set the SV's config to the latest rate from the publisher. If the published -rate falls outside of the accepted range, a warning is logged and no -change to the SV's published conversion rate is made. +rate falls outside of the accepted range, a warning is logged and the published rate is clamped to the configured range. Note that SVs must wait ``voteCooldownTime`` (a governance parameter that defaults to 1min) between updates to their rate. Therefore updates made