diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala
index 232d7113a8..c985cb6683 100644
--- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala
+++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala
@@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryReassign
import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment
import org.scalatest.concurrent.Eventually
import org.scalatest.matchers.should.Matchers
+import org.scalatest.time.{Millis, Seconds, Span}
import org.scalatest.{Inspectors, LoneElement}
import scala.annotation.tailrec
@@ -34,7 +35,16 @@ class EventHistorySanityCheckPlugin(
): Unit = {
val initializedScans = environment.scans.local.filter(_.is_initialized)
if (initializedScans.nonEmpty) {
- compareEventHistories(initializedScans)
+ // getEventHistory only serves events up to min(update, verdict) ingestion cursor
+ // (ScanEventStore.getCurrentMigrationCap), and verdict ingestion from the mediator lags
+ // behind update ingestion. At teardown this can hide even long-ingested updates, such as
+ // the DsoRules_AddSv exercise that compareEventHistories requires to appear in the founder
+ // history, so retry: each attempt re-fetches the histories until the cursors catch up.
+ eventually(compareEventHistories(initializedScans))(
+ PatienceConfig(timeout = Span(20, Seconds), interval = Span(500, Millis)),
+ implicitly[org.scalatest.enablers.Retrying[Unit]],
+ implicitly[org.scalactic.source.Position],
+ )
}
}
diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala
index 5591a73d02..1e39e21e7b 100644
--- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala
+++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala
@@ -63,7 +63,7 @@ class SvFrontendIntegrationTest
},
)(
"logged in in the sv ui",
- _ => find(id("app-title")).value.text should matchText("SUPER VALIDATOR OPERATIONS"),
+ _ => find(id("app-title")).value.text should matchText("Supervalidator Operations"),
)
}
}
@@ -1419,7 +1419,7 @@ class SvFrontendIntegrationTest
}
}
- "NEW UI: Grant and Revoke Featured App Right" in { implicit env =>
+ "NEW UI: Grant, Update and Revoke Featured App Right" in { implicit env =>
val providerParty = sv3Backend.getDsoInfo().svParty
val providerPartyId = providerParty.toProtoPrimitive
val activityWeight = BigDecimal("2.5")
@@ -1435,23 +1435,8 @@ class SvFrontendIntegrationTest
clue("vote the grant request to execution before creating revoke request") {
val grantTrackingCid = eventually() {
- val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend
- .listVoteRequests()
- .find { request =>
- val requestCid = request.contractId.contractId
- val trackingCid =
- if (request.payload.trackingCid.isPresent) {
- Some(request.payload.trackingCid.get.contractId)
- } else {
- None
- }
- requestCid == grantProposalContractId || trackingCid.contains(grantProposalContractId)
- }
- .getOrElse(
- fail(
- s"Could not find vote request for grant proposal contract id: $grantProposalContractId"
- )
- )
+ val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] =
+ getVoteRequestForProposal(grantProposalContractId)
if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get
else voteRequest.contractId
@@ -1474,6 +1459,38 @@ class SvFrontendIntegrationTest
}
}
+ val newActivityWeight = BigDecimal("3.0")
+
+ val updateProposalContractId = assertCreateProposal(
+ "SRARC_UpdateFeaturedAppRight",
+ "update-featured-app",
+ ) { implicit webDriver =>
+ fillOutTextField("update-featured-app-partyId", providerPartyId)
+ selectFirstMuiOption("update-featured-app-rightCid-dropdown")
+ fillOutTextField("update-featured-app-activityWeight", newActivityWeight.toString)
+ fillOutTextField("update-featured-app-reason", "increasing weight")
+ }
+
+ clue("vote the update request to execution") {
+ val updateTrackingCid = eventually() {
+ val voteRequest = getVoteRequestForProposal(updateProposalContractId)
+ if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get
+ else voteRequest.contractId
+ }
+
+ eventuallySucceeds() {
+ sv3Backend.castVote(updateTrackingCid, isAccepted = true, "", "")
+ }
+
+ eventually() {
+ val featuredAppRight = sv1ScanBackend.lookupFeaturedAppRight(providerParty)
+ featuredAppRight shouldBe a[Some[?]]
+ featuredAppRight.value.payload.activityWeight.toScala.map(
+ BigDecimal(_)
+ ) shouldBe Some(newActivityWeight)
+ }
+ }
+
// Now create a Revoke proposal by selecting a contract ID from the provider's dropdown.
assertCreateProposal("SRARC_RevokeFeaturedAppRight", "revoke-featured-app") {
implicit webDriver =>
@@ -1655,6 +1672,29 @@ class SvFrontendIntegrationTest
}
}
+ def getVoteRequestForProposal(
+ proposalContractId: String
+ )(implicit env: SpliceTestConsoleEnvironment) = {
+ val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend
+ .listVoteRequests()
+ .find { request =>
+ val requestCid = request.contractId.contractId
+ val trackingCid =
+ if (request.payload.trackingCid.isPresent) {
+ Some(request.payload.trackingCid.get.contractId)
+ } else {
+ None
+ }
+ requestCid == proposalContractId || trackingCid.contains(proposalContractId)
+ }
+ .getOrElse(
+ fail(
+ s"Could not find vote request for proposal contract id: $proposalContractId"
+ )
+ )
+ voteRequest
+ }
+
def changeAction(actionName: String)(implicit webDriver: WebDriverType) = {
eventually() {
find(
diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala
index 25be93015f..bff3b008fc 100644
--- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala
+++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala
@@ -13,6 +13,7 @@ import org.lfdecentralizedtrust.splice.sv.util.{SvOnboardingToken, SvUtil}
import scala.jdk.OptionConverters.*
import org.lfdecentralizedtrust.splice.sv.admin.api.client.commands.HttpSvPublicAppClient.SvOnboardingStatus
import org.lfdecentralizedtrust.splice.util.{SvTestUtil, WalletTestUtil}
+import com.digitalasset.canton.console.CommandFailure
import com.digitalasset.canton.logging.SuppressionRule
import com.digitalasset.canton.topology.transaction.ParticipantPermission
import org.slf4j.event.Level
@@ -349,7 +350,11 @@ class SvOnboardingAddlIntegrationTest
}
clue("create a amulet again with actAs = DSO") {
withCommandRetryPolicy(_ => _ => false) {
- assertThrowsAndLogsCommandFailures(
+ // Suppress at ERROR level only: background WARNs (e.g. the SV app failing to read the
+ // bft sequencers list while sv2's scan is unavailable) must not fail the log assertion.
+ loggerFactory.assertThrowsAndLogsSuppressing[CommandFailure](
+ SuppressionRule.LevelAndAbove(Level.ERROR)
+ )(
createAmulet(
sv1ValidatorBackend.participantClientWithAdminToken,
sv1UserId,
diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala
index f37c9c21d5..45d888648a 100644
--- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala
+++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala
@@ -34,6 +34,8 @@ import com.digitalasset.canton.discard.Implicits.DiscardOps
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes}
import org.apache.pekko.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken}
+import org.scalatest.concurrent.PatienceConfiguration
+import org.scalatest.time.{Seconds, Span}
import org.slf4j.event.Level
import java.time.Duration
@@ -225,9 +227,12 @@ class WalletIntegrationTest
val tapsAfter = Range(0, 3).map(_ => Future(Try(aliceWalletClient.tap(10))))
- // Wait for all futures to complete
- val successfulTaps = (tapsBefore ++ tapsAfter).map(_.futureValue).count(_.isSuccess)
- if (failedAcceptF.futureValue.isSuccess)
+ // Wait for all futures to complete. The stale accept forces the treasury to filter
+ // and retry batches, so under load this can exceed the default patience.
+ val patience = PatienceConfiguration.Timeout(Span(60, Seconds))
+ val successfulTaps =
+ (tapsBefore ++ tapsAfter).map(_.futureValue(patience)).count(_.isSuccess)
+ if (failedAcceptF.futureValue(patience).isSuccess)
fail("The AcceptTransferOffer action unexpectedly succeeded")
successfulTaps should be(
diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala
index 302df6e015..ee476d12a8 100644
--- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala
+++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala
@@ -243,30 +243,38 @@ class ScanVerdictIngestionService(
// Compute app activity records (before DB transaction).
// Records have verdictRowId = DUMMY_VERDICT_ROW_ID
// the store resolves actual row_ids during insertion.
- (appActivityRecords, lastArchivedRoundO) <- appActivityComputationO match {
- case Some(appActivityComputation) =>
- for {
- records <- appActivityComputation.computeActivities(summariesWithVerdicts).map {
- _.flatMap { case (summary, _, recordO) =>
- recordO.map(summary.sequencingTime -> _)
+ (appActivityRecords, firstActiveRoundO, lastArchivedRoundO) <-
+ appActivityComputationO match {
+ case Some(appActivityComputation) =>
+ val recordTimes =
+ verdicts.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime))
+ for {
+ records <- appActivityComputation.computeActivities(summariesWithVerdicts).map {
+ _.flatMap { case (summary, _, recordO) =>
+ recordO.map(summary.sequencingTime -> _)
+ }
}
- }
- lastArchivedRoundO <- verdicts
- .map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime))
- .maxOption match {
- case Some(maxRecordTime) =>
- appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime)
- case None => Future.successful(None)
- }
- } yield (records, lastArchivedRoundO)
- case None => Future.successful((Seq.empty, None))
- }
+ firstActiveRoundO <- recordTimes.minOption match {
+ case Some(minRecordTime) =>
+ appActivityComputation.lookupActiveOpenMiningRound(minRecordTime)
+ case None => Future.successful(None)
+ }
+ lastArchivedRoundO <- recordTimes.maxOption match {
+ case Some(maxRecordTime) =>
+ appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime)
+ case None => Future.successful(None)
+ }
+ } yield (records, firstActiveRoundO, lastArchivedRoundO)
+ case None => Future.successful((Seq.empty, None, None))
+ }
_ <- ensureVerdictsHaveTrafficSummaries(verdicts, summaryByTime)
_ <- store.insertVerdictsWithAppActivityRecords(
items,
appActivityRecords,
- lastArchivedRoundO,
+ hasTrafficSummaries = summaryByTime.nonEmpty,
+ firstActiveRoundO = firstActiveRoundO,
+ lastArchivedRoundO = lastArchivedRoundO,
)
} yield {
val lastRecordTime = verdicts.lastOption
diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala
index 30b4b9ede5..be99679eee 100644
--- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala
+++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala
@@ -49,6 +49,14 @@ class AppActivityComputation(
)(implicit tc: TraceContext): Future[Option[Long]] =
rewardsReferenceStore.lookupLatestArchivedOpenMiningRound(asOf)
+ /** The OpenMiningRound round number active at asOf, if the round data has been ingested. */
+ def lookupActiveOpenMiningRound(
+ asOf: CantonTimestamp
+ )(implicit tc: TraceContext): Future[Option[Long]] =
+ rewardsReferenceStore
+ .lookupActiveOpenMiningRounds(Seq(asOf))
+ .map(_.get(asOf).map { case (roundNumber, _) => roundNumber })
+
/** Compute app activity records for a batch of verdicts.
*
* Records are returned with verdictRowId = DUMMY_VERDICT_ROW_ID as a placeholder.
@@ -125,8 +133,12 @@ class AppActivityComputation(
}
case None =>
// Skip activity record computation as we don't have the necessary round data ingested.
- // This can happen for freshly onboarded SVs, but is not
- // expected to happen once the first activity record has been computed.
+ // This can happen for freshly onboarded SVs if the reward
+ // reference store does not have the data for any of the
+ // sequencingTime(s) in this batch.
+ // OTOH this cannot happen after ingestion starts because
+ // lookupActiveOpenMiningRounds blocks until the reference store
+ // has caught up to all the sequencingTime(s) in this batch.
logger.debug(
s"No round data found for sequencingTime=${summary.sequencingTime}, skipping activity record computation"
)
diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala
index a60594dc25..3016348a34 100644
--- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala
+++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala
@@ -300,13 +300,16 @@ class DbAppActivityRecordStore(
/** Insert activity records and ensure the meta row exists.
* Creates the meta row when enough information is available to
* determine which rounds have complete activity, even when no
- * activity records exist (e.g., no featured app providers).
+ * activity records exist (e.g., no featured app providers),
+ * but only if traffic-summaries could be obtained for this batch.
* On a fresh firstSV with no archived rounds, bootstraps round 0
* as complete.
*/
def insertAppActivityRecordsDBIO(
items: Seq[AppActivityRecordT],
firstRecordTimeMicros: Long,
+ hasTrafficSummaries: Boolean,
+ firstActiveRoundO: Option[Long] = None,
lastArchivedRoundO: Option[Long] = None,
)(implicit tc: TraceContext): DBIO[Unit] = {
val insertRecords =
@@ -316,15 +319,9 @@ class DbAppActivityRecordStore(
logger.info(s"Inserted ${items.size} app activity records.")
}
- // earliestRound: the lowest round covered by this ingestion batch.
- // - From activity records when present
- // - From lastArchivedRound when no featured apps produced records
- // - From bootstrap (-1) on a fresh firstSV with no archived rounds
- val earliestRound = items
- .map(_.roundNumber)
- .minOption
- .orElse(lastArchivedRoundO)
- .orElse(if (isFirstSv) Some(-1L) else None)
+ // earliestRound: the oldest round open at the earliest record_time of this batch.
+ // or (-1) on firstSV, as it is expected to have complete data for the first round.
+ val earliestRound = if (isFirstSv) Some(-1L) else firstActiveRoundO
// lastArchived: the highest round archived as of this verdict batch.
// - From the caller when available
@@ -337,10 +334,12 @@ class DbAppActivityRecordStore(
for {
_ <- insertRecords
ensureResult <- earliestRound match {
- case Some(earliest) =>
+ case Some(earliest) if hasTrafficSummaries =>
ensureMetaDBIO((firstRecordTimeMicros, earliest), lastArchived)
- case None =>
- // No archived rounds and not firstSV — skip meta creation.
+ case _ =>
+ // Either we have no rounds info and this is not firstSV,
+ // or we have not started obtaining the traffic summaries yet
+ // — skip meta creation.
// A later verdict batch will create it.
DBIO.successful(Resume: MetaCheckResult)
}
diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala
index 3e5cdf0646..38b7f2e6a1 100644
--- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala
+++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala
@@ -479,12 +479,17 @@ class DbScanVerdictStore(
*
* @param items verdicts with transaction view constructors
* @param appActivityRecords activity records with placeholder verdictRowIds
+ * @param hasTrafficSummaries whether traffic summaries were fetched for this batch
+ * @param firstActiveRoundO the OpenMiningRound round active at the earliest
+ * record time of the batch
* @param lastArchivedRoundO the highest archived OpenMiningRound round as of the
* max record time of the batch
*/
def insertVerdictsWithAppActivityRecords(
items: NonEmptyList[(VerdictT, Long => Seq[TransactionViewT])],
appActivityRecords: Seq[(CantonTimestamp, AppActivityRecordT)],
+ hasTrafficSummaries: Boolean,
+ firstActiveRoundO: Option[Long] = None,
lastArchivedRoundO: Option[Long] = None,
)(implicit tc: TraceContext): Future[Unit] = {
import profile.api.jdbcActionExtensionMethods
@@ -498,6 +503,8 @@ class DbScanVerdictStore(
_ <- insertAppActivityRecordsDBIO(
resolvedAppActivityRecords,
items.head._1.recordTime.toMicros,
+ hasTrafficSummaries,
+ firstActiveRoundO,
lastArchivedRoundO,
)
} yield ()
@@ -545,12 +552,20 @@ class DbScanVerdictStore(
private def insertAppActivityRecordsDBIO(
items: Seq[AppActivityRecordT],
firstRecordTimeMicros: Long,
+ hasTrafficSummaries: Boolean,
+ firstActiveRoundO: Option[Long],
lastArchivedRoundO: Option[Long],
)(implicit tc: TraceContext): DBIO[Unit] =
appActivityRecordStoreO match {
case None => DBIO.successful(())
case Some(s) =>
- s.insertAppActivityRecordsDBIO(items, firstRecordTimeMicros, lastArchivedRoundO)
+ s.insertAppActivityRecordsDBIO(
+ items,
+ firstRecordTimeMicros,
+ hasTrafficSummaries,
+ firstActiveRoundO,
+ lastArchivedRoundO,
+ )
}
private def afterFilters(
diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala
index b4b57c17c0..2ab1c4045c 100644
--- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala
+++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala
@@ -169,6 +169,8 @@ class DbAppActivityRecordStoreTest
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews),
appActivityRecords,
+ hasTrafficSummaries = true,
+ firstActiveRoundO = Some(10L),
lastArchivedRoundO = Some(9L),
)
@@ -209,6 +211,8 @@ class DbAppActivityRecordStoreTest
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(mkVerdict(verdictStore, "update-mono-1", baseTs) -> noViews),
Seq(baseTs -> mkRecord(0L, 10L, Seq("app1::provider"), Seq(100L))),
+ hasTrafficSummaries = true,
+ firstActiveRoundO = Some(10L),
lastArchivedRoundO = Some(9L),
)
// A later batch without activity records still advances the round
@@ -217,10 +221,13 @@ class DbAppActivityRecordStoreTest
mkVerdict(verdictStore, "update-mono-2", baseTs.plusSeconds(1L)) -> noViews
),
Seq.empty,
+ hasTrafficSummaries = true,
+ firstActiveRoundO = Some(11L),
lastArchivedRoundO = Some(10L),
)
meta <- appStore.lookupActivityRecordMeta(1, 0)
} yield {
+ meta.value.earliestIngestedRound shouldBe 10L
meta.value.lastArchivedRound shouldBe Some(10L)
}
}
@@ -233,40 +240,96 @@ class DbAppActivityRecordStoreTest
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews),
Seq.empty,
- lastArchivedRoundO = Some(7L),
+ hasTrafficSummaries = true,
+ firstActiveRoundO = Some(7L),
+ lastArchivedRoundO = None,
)
meta <- appStore.lookupActivityRecordMeta(1, 0)
} yield {
- // Meta row is created with earliestRound = lastArchivedRound
- // because verdict ingestion is active even without activity records
+ // Meta row is created using the firstActiveRoundO
+ // even though there are no activity records
meta shouldBe defined
meta.value.earliestIngestedRound shouldBe 7L
- meta.value.lastArchivedRound shouldBe Some(7L)
+ meta.value.lastArchivedRound shouldBe None
+ }
+ }
+
+ "Does not create meta row when traffic summaries are absent" in {
+ for {
+ (appStore, verdictStore) <- newStores()
+ baseTs = CantonTimestamp.now()
+
+ _ <- verdictStore.insertVerdictsWithAppActivityRecords(
+ NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews),
+ Seq.empty,
+ hasTrafficSummaries = false,
+ lastArchivedRoundO = Some(7L),
+ )
+ v <- verdictStore.getVerdictByUpdateId("update-no-meta")
+ countAfter <- countRecords()
+ meta <- appStore.lookupActivityRecordMeta(1, 0)
+ } yield {
+ v shouldBe defined
+ countAfter shouldBe 0L
+ meta shouldBe None
}
}
- "insert verdicts without activity records when appActivityRecords is empty" in {
+ "on a fresh firstSV, does not create meta row when traffic summaries are absent" in {
+ for {
+ (appStore, verdictStore) <- newStores(isFirstSv = true)
+ baseTs = CantonTimestamp.now()
+
+ _ <- verdictStore.insertVerdictsWithAppActivityRecords(
+ NonEmptyList.of(mkVerdict(verdictStore, "update-firstsv-2", baseTs) -> noViews),
+ Seq.empty,
+ hasTrafficSummaries = false,
+ )
+ // Even on firstSV, missing traffic summaries defer meta creation
+ // to a later batch.
+ metaBefore <- appStore.lookupActivityRecordMeta(1, 0)
+
+ // A later batch with traffic summaries creates the meta row.
+ _ <- verdictStore.insertVerdictsWithAppActivityRecords(
+ NonEmptyList.of(
+ mkVerdict(verdictStore, "update-firstsv-3", baseTs.plusSeconds(1L)) -> noViews
+ ),
+ Seq.empty,
+ hasTrafficSummaries = true,
+ )
+ metaAfter <- appStore.lookupActivityRecordMeta(1, 0)
+ } yield {
+ metaBefore shouldBe None
+
+ metaAfter shouldBe defined
+ metaAfter.value.earliestIngestedRound shouldBe -1L
+ metaAfter.value.lastArchivedRound shouldBe Some(0L)
+ }
+ }
+
+ "insert verdicts without activity records, when reward reference store does not have data asOf" in {
for {
(appStore, verdictStore) <- newStores()
baseTs = CantonTimestamp.now()
verdict = mkVerdict(verdictStore, "update-no-activity", baseTs)
+ // firstActiveRoundO is None, as reward reference store began ingestion after baseTx
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(verdict -> noViews),
Seq.empty,
+ hasTrafficSummaries = true,
+ firstActiveRoundO = None,
+ lastArchivedRoundO = None,
)
v <- verdictStore.getVerdictByUpdateId("update-no-activity")
countAfter <- countRecords()
- // No meta row should be created when there are no activity records
meta <- appStore.lookupActivityRecordMeta(1, 0)
} yield {
v shouldBe defined
countAfter shouldBe 0L
- // Non-firstSV with no lastArchivedRound: meta row is not created
- // because it would have last_archived_round = NULL, making no
- // rounds complete.
+ // Non-firstSV with no firstActiveRoundO: meta row is not created
meta shouldBe None
}
}
@@ -289,6 +352,7 @@ class DbAppActivityRecordStoreTest
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews, verdict3 -> noViews),
appActivityRecords,
+ hasTrafficSummaries = true,
)
v1 <- verdictStore.getVerdictByUpdateId("update-with-1")
@@ -335,6 +399,7 @@ class DbAppActivityRecordStoreTest
_ <- verdictStore.insertVerdictsWithAppActivityRecords(
NonEmptyList.of(verdict -> noViews),
appActivityRecords,
+ hasTrafficSummaries = true,
)
v <- verdictStore.getVerdictByUpdateId("update-mismatch")
@@ -1051,7 +1116,9 @@ class DbAppActivityRecordStoreTest
/** Creates both an app activity record store and a verdict store backed by
* the same UpdateHistory, for testing insertVerdictsWithAppActivityRecords.
*/
- private def newStores(): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = {
+ private def newStores(
+ isFirstSv: Boolean = false
+ ): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = {
val participantId = mkParticipantId("activity-test")
val updateHistory = new UpdateHistory(
storage.underlying,
@@ -1070,7 +1137,7 @@ class DbAppActivityRecordStoreTest
storage.underlying,
updateHistory,
DbAppActivityRecordStore.IngestionVersions(1, 0),
- isFirstSv = false,
+ isFirstSv,
loggerFactory,
)
val verdictStore = new DbScanVerdictStore(
diff --git a/apps/sv/frontend/index.html b/apps/sv/frontend/index.html
index c2f95b8233..32252600a0 100644
--- a/apps/sv/frontend/index.html
+++ b/apps/sv/frontend/index.html
@@ -7,7 +7,7 @@
diff --git a/apps/sv/frontend/src/App.tsx b/apps/sv/frontend/src/App.tsx
index 2ec65affcb..812a5385cf 100644
--- a/apps/sv/frontend/src/App.tsx
+++ b/apps/sv/frontend/src/App.tsx
@@ -39,6 +39,7 @@ import { useConfigPollInterval, useSvConfig } from './utils';
import { Governance } from './routes/governance';
import { VoteRequestDetails } from './routes/voteRequestDetails';
import { CreateProposal } from './routes/createProposal';
+import DelegateElection from './routes/delegateElection';
const Providers: React.FC = ({ children }) => {
const config = useSvConfig();
@@ -95,6 +96,7 @@ const App: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
@@ -111,8 +113,8 @@ const App: React.FC = () => {
- Super Validator Operations
-
+ Supervalidator Operations
+
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx
index 5c13445a01..67858a367d 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx
@@ -10,7 +10,11 @@ import { describe, expect, test } from 'vitest';
import App from '../../../App';
import { CreateUnallocatedUnclaimedActivityRecordForm } from '../../../components/forms/CreateUnallocatedUnclaimedActivityRecordForm';
import { SvConfigProvider } from '../../../utils';
-import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_REVIEW_TITLE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
import { Wrapper } from '../../helpers';
import { svPartyId } from '../../mocks/constants';
import { server, svUrl } from '../../setup/setup';
@@ -47,7 +51,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => {
expect(
screen.getByTestId('create-unallocated-unclaimed-activity-record-form')
).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('create-unallocated-unclaimed-activity-record-action');
expect(actionInput).toBeInTheDocument();
@@ -368,7 +372,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => {
await user.click(submitButton);
- expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
});
test('should show error on form if submission fails', async () => {
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx
index 4b8665866c..14e724e78f 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx
@@ -13,7 +13,11 @@ import dayjs from 'dayjs';
import { GrantRevokeFeaturedAppForm } from '../../../components/forms/GrantRevokeFeaturedAppForm';
import { server, svUrl } from '../../setup/setup';
import { http, HttpResponse } from 'msw';
-import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_REVIEW_TITLE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
describe('SV user can', () => {
test('login and see the SV party ID', async () => {
@@ -45,7 +49,7 @@ describe('Grant Featured App Form', () => {
);
expect(screen.getByTestId('grant-featured-app-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('grant-featured-app-action');
expect(actionInput).toBeInTheDocument();
@@ -212,7 +216,7 @@ describe('Grant Featured App Form', () => {
await user.click(submitButton);
- expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
});
test('activity weight is optional and is sent to backend as null when left blank', async () => {
@@ -389,7 +393,7 @@ describe('Revoke Featured App Form', () => {
);
expect(screen.getByTestId('revoke-featured-app-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('revoke-featured-app-action');
expect(actionInput).toBeInTheDocument();
@@ -542,7 +546,7 @@ describe('Revoke Featured App Form', () => {
await user.click(submitButton);
- expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID');
expect(screen.getByTestId('revokeProviderPartyId-field').textContent).toBe(
'a-party-id::1014912492'
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx
index 3d95c438be..2e32ba122f 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx
@@ -13,7 +13,11 @@ import dayjs from 'dayjs';
import { OffboardSvForm } from '../../../components/forms/OffboardSvForm';
import { server, svUrl } from '../../setup/setup';
import { http, HttpResponse } from 'msw';
-import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_REVIEW_TITLE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
describe('SV user can', () => {
test('login and see the SV party ID', async () => {
@@ -45,7 +49,7 @@ describe('Offboard SV Form', () => {
);
expect(screen.getByTestId('offboard-sv-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('offboard-sv-action');
expect(actionInput).toBeInTheDocument();
@@ -231,7 +235,7 @@ describe('Offboard SV Form', () => {
await user.click(submitButton);
- expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
});
test('should show error on form if submission fails', async () => {
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx
index ff010751fd..a94e95a967 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx
@@ -13,7 +13,11 @@ import { SetAmuletConfigRulesForm } from '../../../components/forms/SetAmuletCon
import dayjs from 'dayjs';
import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils';
import { server, svUrl } from '../../setup/setup';
-import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_REVIEW_TITLE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
describe('SV user can', () => {
test('login and see the SV party ID', async () => {
@@ -45,7 +49,7 @@ describe('Set Amulet Config Rules Form', () => {
);
expect(screen.getByTestId('set-amulet-config-rules-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('set-amulet-config-rules-action');
expect(actionInput).toBeInTheDocument();
@@ -278,7 +282,7 @@ describe('Set Amulet Config Rules Form', () => {
await user.click(submitButton);
- expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
expect(screen.queryByText('JSON')).not.toBeInTheDocument();
expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON');
});
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx
index 2825f1d132..644d582812 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx
@@ -12,6 +12,10 @@ import { http, HttpResponse } from 'msw';
import { describe, expect, test } from 'vitest';
import App from '../../../App';
import { SetDsoConfigRulesForm } from '../../../components/forms/SetDsoConfigRulesForm';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_REVIEW_TITLE,
+} from '../../../utils/constants';
import { SvConfigProvider } from '../../../utils';
import { Wrapper } from '../../helpers';
import { svPartyId } from '../../mocks/constants';
@@ -47,7 +51,7 @@ describe('Set DSO Config Rules Form', () => {
);
expect(screen.getByTestId('set-dso-config-rules-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('set-dso-config-rules-action');
expect(actionInput).toBeInTheDocument();
@@ -267,7 +271,7 @@ describe('Set DSO Config Rules Form', () => {
await user.click(submitButton);
- expect(screen.getByText('Proposal Summary')).toBeInTheDocument();
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
expect(screen.queryByText('JSON')).not.toBeInTheDocument();
expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON');
});
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx
new file mode 100644
index 0000000000..a6409b986f
--- /dev/null
+++ b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx
@@ -0,0 +1,348 @@
+// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils';
+import dayjs from 'dayjs';
+import { SvConfigProvider } from '../../../utils';
+import App from '../../../App';
+import { svPartyId } from '../../mocks/constants';
+import { Wrapper } from '../../helpers';
+import { UpdateFeaturedAppForm } from '../../../components/forms/UpdateFeaturedAppForm';
+import { server, svUrl } from '../../setup/setup';
+import { http, HttpResponse } from 'msw';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID,
+ PROPOSAL_REVIEW_TITLE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
+
+// Logs in once so the admin client has an access token for the rest of the file.
+describe('SV user can', () => {
+ test('login and see the SV party ID', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText('Log In')).toBeInTheDocument();
+
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'sv1');
+
+ const button = screen.getByRole('button', { name: 'Log In' });
+ await user.click(button);
+
+ expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]);
+ });
+});
+
+describe('Update Featured App Form', () => {
+ const fillOutUpdateForm = async (user: ReturnType) => {
+ const summaryInput = screen.getByTestId('update-featured-app-summary');
+ await user.type(summaryInput, 'Summary of the proposal');
+
+ const urlInput = screen.getByTestId('update-featured-app-url');
+ await user.type(urlInput, 'https://example.com');
+
+ const partyIdInput = screen.getByTestId('update-featured-app-partyId');
+ await user.type(partyIdInput, 'a-party-id::1014912492');
+ fireEvent.blur(partyIdInput);
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading featured app rights...')).not.toBeInTheDocument();
+ });
+
+ const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown');
+ await waitFor(
+ () => {
+ expect(rightCidDropdown).not.toBeDisabled();
+ },
+ { timeout: 3000 }
+ );
+ fireEvent.change(rightCidDropdown, { target: { value: 'rightCid123' } });
+ fireEvent.blur(rightCidDropdown);
+ await waitFor(() => {
+ expect(screen.getByTestId('update-featured-app-rightCid-error').textContent).toBeFalsy();
+ });
+
+ const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight');
+ await user.type(activityWeightInput, '2.5');
+
+ const reasonInput = screen.getByTestId('update-featured-app-reason');
+ await user.type(reasonInput, 'test');
+ };
+
+ test('should render all Form components', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('update-featured-app-form')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
+
+ const actionInput = screen.getByTestId('update-featured-app-action');
+ expect(actionInput).toBeInTheDocument();
+ expect(actionInput.textContent).toBe('Update Featured Application');
+
+ const summaryInput = screen.getByTestId('update-featured-app-summary');
+ expect(summaryInput).toBeInTheDocument();
+ expect(summaryInput.getAttribute('value')).toBeNull();
+
+ const summarySubtitle = screen.getByTestId('update-featured-app-summary-subtitle');
+ expect(summarySubtitle).toBeInTheDocument();
+ expect(summarySubtitle.textContent).toBe(PROPOSAL_SUMMARY_SUBTITLE);
+
+ const urlInput = screen.getByTestId('update-featured-app-url');
+ expect(urlInput).toBeInTheDocument();
+ expect(urlInput.getAttribute('value')).toBe('');
+
+ const partyIdInput = screen.getByTestId('update-featured-app-partyId');
+ expect(partyIdInput).toBeInTheDocument();
+ expect(partyIdInput.getAttribute('value')).toBe('');
+
+ const partyIdTitle = screen.getByTestId('update-featured-app-partyId-title');
+ expect(partyIdTitle).toBeInTheDocument();
+ expect(partyIdTitle.textContent).toBe(CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID);
+
+ const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown');
+ expect(rightCidDropdown).toBeInTheDocument();
+ expect(rightCidDropdown).toBeDisabled();
+
+ expect(screen.getByTestId('update-featured-app-activityWeight')).toBeInTheDocument();
+ expect(screen.getByTestId('update-featured-app-reason')).toBeInTheDocument();
+
+ expect(screen.getByText('Review Proposal')).toBeInTheDocument();
+ });
+
+ test('activity weight is required', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight');
+ const actionInput = screen.getByTestId('update-featured-app-action');
+
+ await user.click(activityWeightInput);
+ await user.click(actionInput); // blur to trigger validation
+
+ await waitFor(() => {
+ expect(screen.getByTestId('update-featured-app-activityWeight-error').textContent).toBe(
+ 'Weight is required'
+ );
+ });
+ });
+
+ test('should send new activity weight and reason to backend', async () => {
+ let requestBody = '';
+ server.use(
+ http.post(`${svUrl}/v0/admin/sv/voterequest/create`, async ({ request }) => {
+ requestBody = await request.text();
+ return HttpResponse.json({});
+ })
+ );
+
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await fillOutUpdateForm(user);
+
+ const actionInput = screen.getByTestId('update-featured-app-action');
+ await user.click(actionInput); // blur to trigger validation
+
+ const submitButton = screen.getByTestId('submit-button');
+ await waitFor(() => {
+ expect(submitButton).not.toBeDisabled();
+ });
+
+ await user.click(submitButton); // review proposal
+ await user.click(submitButton); // submit proposal
+
+ await waitFor(() => {
+ expect(requestBody).toContain('"newActivityWeight":"2.5"');
+ expect(requestBody).toContain('"reason":"test"');
+ });
+ });
+
+ test('activity weight rejects negative numbers and more than 10 decimal places', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight');
+ const activityWeightError = screen.getByTestId('update-featured-app-activityWeight-error');
+
+ await user.type(activityWeightInput, '-1');
+ await waitFor(() => {
+ expect(activityWeightError.textContent).toBe('Weight must be a valid non-negative number');
+ });
+
+ await user.clear(activityWeightInput);
+ await user.type(activityWeightInput, '1.1234567891');
+ await waitFor(() => {
+ expect(activityWeightError.textContent).toBe('');
+ });
+
+ await user.clear(activityWeightInput);
+ await user.type(activityWeightInput, '1.12345678912');
+ await waitFor(() => {
+ expect(activityWeightError.textContent).toBe('Weight can have at most 10 decimal places');
+ });
+ });
+
+ test('reason is required', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ const reasonInput = screen.getByTestId('update-featured-app-reason');
+ const actionInput = screen.getByTestId('update-featured-app-action');
+
+ await user.click(reasonInput);
+ await user.click(actionInput); // blur to trigger validation
+
+ await waitFor(() => {
+ expect(screen.getByTestId('update-featured-app-reason-error').textContent).toBe(
+ 'Reason is required'
+ );
+ });
+ });
+
+ test('communicates when the provider has no featured app rights to update', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ const partyIdInput = screen.getByTestId('update-featured-app-partyId');
+ await user.type(partyIdInput, 'no-rights-party::1014912492');
+ fireEvent.blur(partyIdInput);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('update-featured-app-rightCid')).toHaveTextContent(
+ 'No featured application rights found for this provider'
+ );
+ });
+
+ expect(screen.getByTestId('update-featured-app-rightCid-dropdown')).toBeDisabled();
+ });
+
+ test('expiry date must be in the future', async () => {
+ render(
+
+
+
+ );
+
+ const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field');
+ expect(expiryDateInput).toBeInTheDocument();
+
+ const thePast = dayjs().subtract(1, 'day').format(dateTimeFormatISO);
+ const theFuture = dayjs().add(1, 'day').format(dateTimeFormatISO);
+
+ fireEvent.change(expiryDateInput, { target: { value: thePast } });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Expiration must be in the future')).toBeInTheDocument();
+ });
+
+ fireEvent.change(expiryDateInput, { target: { value: theFuture } });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Expiration must be in the future')).not.toBeInTheDocument();
+ });
+ });
+
+ test('effective date must be after expiry date', async () => {
+ render(
+
+
+
+ );
+
+ const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field');
+ const effectiveDateInput = screen.getByTestId('update-featured-app-effective-date-field');
+
+ const expiryDate = dayjs().add(1, 'week');
+ const effectiveDate = expiryDate.subtract(1, 'day');
+
+ fireEvent.change(expiryDateInput, { target: { value: expiryDate.format(dateTimeFormatISO) } });
+ fireEvent.change(effectiveDateInput, {
+ target: { value: effectiveDate.format(dateTimeFormatISO) },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText('Effective Date must be after expiration date')
+ ).toBeInTheDocument();
+ });
+
+ const validEffectiveDate = expiryDate.add(1, 'day').format(dateTimeFormatISO);
+
+ fireEvent.change(effectiveDateInput, { target: { value: validEffectiveDate } });
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText('Effective Date must be after expiration date')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ test('should show proposal review page after form completion', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await fillOutUpdateForm(user);
+
+ const actionInput = screen.getByTestId('update-featured-app-action');
+ await user.click(actionInput); // blur to trigger validation
+
+ const submitButton = screen.getByTestId('submit-button');
+ await waitFor(() => {
+ expect(submitButton).not.toBeDisabled();
+ });
+
+ await user.click(submitButton); // review proposal
+
+ expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument();
+ expect(screen.getByTestId('updateProviderPartyId-field').textContent).toBe(
+ 'a-party-id::1014912492'
+ );
+ expect(screen.getByTestId('updateRight-field').textContent).toBe('rightCid123');
+ expect(screen.getByTestId('config-change-current-value').textContent).toBe('1.0');
+ expect(screen.getByTestId('config-change-new-value').textContent).toBe('2.5');
+ expect(screen.getByTestId('updateReason-field').textContent).toBe('test');
+ });
+});
diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx
index a9bd4985ee..2d6892d4bb 100644
--- a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx
@@ -13,7 +13,10 @@ import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'
import dayjs from 'dayjs';
import { server, svUrl } from '../../setup/setup';
import { http, HttpResponse } from 'msw';
-import { PROPOSAL_SUMMARY_SUBTITLE } from '../../../utils/constants';
+import {
+ CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE,
+ PROPOSAL_SUMMARY_SUBTITLE,
+} from '../../../utils/constants';
describe('SV user can', () => {
test('login and see the SV party ID', async () => {
@@ -45,7 +48,7 @@ describe('Update Super Validator Reward Weight Form', () => {
);
expect(screen.getByTestId('update-sv-reward-weight-form')).toBeInTheDocument();
- expect(screen.getByText('Proposal type')).toBeInTheDocument();
+ expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument();
const actionInput = screen.getByTestId('update-sv-reward-weight-action');
expect(actionInput).toBeInTheDocument();
diff --git a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx
index b4e44cba28..9ae19a7424 100644
--- a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx
@@ -138,7 +138,7 @@ describe('Governance Page', () => {
await user.click(viewDetailsLink);
- const proposalDetails = screen.getByTestId('proposal-details-title');
+ const proposalDetails = screen.getByTestId('proposal-details-proposal-details');
expect(proposalDetails).toBeInTheDocument();
});
@@ -156,7 +156,7 @@ describe('Governance Page', () => {
await user.click(viewDetailsLink);
- const proposalDetails = screen.getByTestId('proposal-details-title');
+ const proposalDetails = screen.getByTestId('proposal-details-proposal-details');
expect(proposalDetails).toBeInTheDocument();
const action = screen.getByTestId('proposal-details-action-value');
diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx
index b79df961b7..4c5e973667 100644
--- a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx
@@ -13,6 +13,7 @@ import {
ProposalVote,
ProposalVotingInformation,
UnclaimedActivityRecordProposal,
+ UpdateFeatureAppProposal,
UpdateSvRewardWeightProposal,
} from '../../utils/types';
import userEvent from '@testing-library/user-event';
@@ -158,8 +159,8 @@ describe('Proposal Details Content', () => {
);
- const pageTitle = screen.getByTestId('proposal-details-title');
- expect(pageTitle.textContent).toMatch(/Proposal Details/);
+ const pageTitle = screen.getByTestId('proposal-details-proposal-details');
+ expect(pageTitle).toBeInTheDocument();
const action = screen.getByTestId('proposal-details-action-value');
expect(action.textContent).toMatch(/Offboard Member/);
@@ -285,6 +286,94 @@ describe('Proposal Details Content', () => {
expect(rightContractIdValue.textContent).toMatch(/rightContractId/);
});
+ test('should render update featured app proposal details', async () => {
+ const updateFeaturedAppDetails = {
+ actionName: 'Update Featured Application',
+ action: 'SRARC_UpdateFeaturedAppRight',
+ proposal: {
+ rightContractId: 'rightCid123',
+ newActivityWeight: '2.5',
+ reason: 'boosting rewards',
+ } as UpdateFeatureAppProposal,
+ } as ProposalDetails;
+
+ render(
+
+
+
+ );
+
+ const action = screen.getByTestId('proposal-details-action-value');
+ expect(action.textContent).toMatch('Update Featured Application');
+
+ const updateFeaturedAppSection = screen.getByTestId(
+ 'proposal-details-update-feature-app-section'
+ );
+ expect(updateFeaturedAppSection).toBeInTheDocument();
+
+ const updateFeaturedAppLabel = screen.getByTestId('proposal-details-update-feature-app-label');
+ expect(updateFeaturedAppLabel.textContent).toMatch('Featured Application Contract ID');
+
+ const updateFeaturedAppValue = screen.getByTestId('proposal-details-update-feature-app-value');
+ expect(updateFeaturedAppValue.textContent).toMatch('rightCid123');
+
+ await waitFor(() => {
+ const currentFeaturedAppWeight = screen.getByTestId('config-change-current-value');
+ expect(currentFeaturedAppWeight.textContent).toMatch('1.0');
+ });
+
+ const newFeaturedAppWeight = screen.getByTestId('config-change-new-value');
+ expect(newFeaturedAppWeight.textContent).toMatch('2.5');
+
+ const updateFeaturedReasonLabel = screen.getByTestId(
+ 'proposal-details-update-feature-reason-label'
+ );
+ expect(updateFeaturedReasonLabel.textContent).toMatch('Reason');
+
+ const updateFeaturedReasonValue = screen.getByTestId(
+ 'proposal-details-update-feature-reason-value'
+ );
+ expect(updateFeaturedReasonValue.textContent).toMatch('boosting rewards');
+ });
+
+ test('should show only new weight when featured app right is not found', async () => {
+ const updateFeaturedAppDetails = {
+ actionName: 'Update Featured Application',
+ action: 'SRARC_UpdateFeaturedAppRight',
+ proposal: {
+ rightContractId: 'archivedRightCid', // <- not 'rightCid123', so the mock returns not-found
+ newActivityWeight: '2.5',
+ reason: 'boosting rewards',
+ } as UpdateFeatureAppProposal,
+ } as ProposalDetails;
+
+ render(
+
+
+
+ );
+
+ // new value still shows
+ await waitFor(() => {
+ const newFeaturedAppWeight = screen.getByTestId('config-change-new-value');
+ expect(newFeaturedAppWeight.textContent).toMatch('2.5');
+ });
+ // ...but there's no current-value box (contract archived → currentWeight '')
+ expect(screen.queryByTestId('config-change-current-value')).toBeNull();
+ });
+
test('should render update sv reward weight proposal details', () => {
const svToUpdate = 'sv2';
const updateSvRewardWeightDetails = {
diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx
index cbbd9b3f9e..d497aabb74 100644
--- a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx
+++ b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx
@@ -29,22 +29,22 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
- expect(screen.getByTestId('offboardMember-title').textContent).toBe('Offboard Member');
+ expect(screen.getByTestId('offboardMember-title').textContent).toBe('Member');
expect(screen.getByTestId('offboardMember-field').textContent).toBe(offboardMember);
});
@@ -66,13 +66,12 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe('Threshold');
});
test('should render review proposal component for sv reward weight', () => {
const actionName = 'Update Super Validator Reward Weight';
- const title = 'Super Validator Reward Weight';
const svRewardWeightMember = 'Digital-Asset-Eng-2';
const currentWeight = '1000';
const svRewardWeight = '99';
@@ -93,22 +92,22 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
- expect(screen.getByTestId('config-change-field-label').textContent).toBe(title);
+ expect(screen.getByTestId('config-change-field-label').textContent).toBe('Weight');
expect(screen.getByTestId('config-change-current-value').textContent).toBe(currentWeight);
expect(screen.getByTestId('config-change-new-value').textContent).toBe(svRewardWeight);
});
@@ -133,19 +132,19 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
expect(screen.getByTestId('grantRight-title').textContent).toBe('Provider Party ID');
@@ -177,19 +176,19 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID');
@@ -201,6 +200,64 @@ describe('Review Proposal Component', () => {
expect(screen.getByTestId('revokeRight-field').textContent).toBe(contractId);
});
+ test('should render review proposal component for update feature application', () => {
+ const actionName = 'Update Featured Application';
+ const providerPartyId = 'a-party-id::1014912492';
+ const rightCid = 'bcde123456';
+ const currentActivityWeight = '1.0';
+ const newActivityWeight = '2.5';
+ const reason = 'boosting rewards';
+
+ render(
+ {}}
+ onSubmit={() => {}}
+ />
+ );
+
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
+ expect(screen.getByTestId('action-field').textContent).toBe(actionName);
+
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
+ expect(screen.getByTestId('url-field').textContent).toBe(url);
+
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
+ expect(screen.getByTestId('summary-field').textContent).toBe(summary);
+
+ expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
+ expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
+
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
+ expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
+
+ expect(screen.getByTestId('updateProviderPartyId-title').textContent).toBe('Provider Party ID');
+ expect(screen.getByTestId('updateProviderPartyId-field').textContent).toBe(providerPartyId);
+
+ expect(screen.getByTestId('updateRight-title').textContent).toBe(
+ 'Featured Application Contract ID'
+ );
+ expect(screen.getByTestId('updateRight-field').textContent).toBe(rightCid);
+
+ expect(screen.getByTestId('config-change-current-value').textContent).toBe(
+ currentActivityWeight
+ );
+ expect(screen.getByTestId('config-change-new-value').textContent).toBe(newActivityWeight);
+
+ expect(screen.getByTestId('updateReason-title').textContent).toBe('Reason');
+ expect(screen.getByTestId('updateReason-field').textContent).toBe(reason);
+ });
+
test('should render review proposal component for dso rules config', () => {
const actionName = 'Set DSO Rules Configuration';
const numThresholdTitle = 'Number of Unclaimed Rewards Threshold';
@@ -235,22 +292,22 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
- expect(screen.getByText('Proposed Changes')).toBeDefined();
+ expect(screen.getByText('Proposed Configuration Changes')).toBeDefined();
expect(screen.getByText(numThresholdTitle)).toBeDefined();
expect(screen.getByText(voteCooldownTitle)).toBeDefined();
@@ -307,22 +364,22 @@ describe('Review Proposal Component', () => {
/>
);
- expect(screen.getByTestId('action-title').textContent).toBe('Action');
+ expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type');
expect(screen.getByTestId('action-field').textContent).toBe(actionName);
- expect(screen.getByTestId('url-title').textContent).toBe('URL');
+ expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL');
expect(screen.getByTestId('url-field').textContent).toBe(url);
- expect(screen.getByTestId('summary-title').textContent).toBe('Summary');
+ expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary');
expect(screen.getByTestId('summary-field').textContent).toBe(summary);
expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline');
expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate);
- expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date');
+ expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At');
expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate);
- expect(screen.getByText('Proposed Changes')).toBeDefined();
+ expect(screen.getByText('Proposed Configuration Changes')).toBeDefined();
expect(screen.getByText(feeTitle)).toBeDefined();
expect(screen.getByText(feeRateTitle)).toBeDefined();
diff --git a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts
index d184170e42..e335af9bf9 100644
--- a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts
+++ b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts
@@ -273,7 +273,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [
{
template_id: 'featured-app-right-template-id',
contract_id: 'rightCid123',
- payload: {},
+ payload: { activityWeight: '1.0' },
created_event_blob: '',
created_at: '2026-02-26T13:00:00.000000Z',
},
@@ -293,7 +293,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [
? {
template_id: 'featured-app-right-template-id',
contract_id: 'rightCid123',
- payload: { provider: 'a-party-id::1014912492' },
+ payload: { provider: 'a-party-id::1014912492', activityWeight: '1.0' },
created_event_blob: '',
created_at: '2026-02-26T13:00:00.000000Z',
}
diff --git a/apps/sv/frontend/src/__tests__/sv.test.tsx b/apps/sv/frontend/src/__tests__/sv.test.tsx
index dc1f618048..fa6a398bc1 100644
--- a/apps/sv/frontend/src/__tests__/sv.test.tsx
+++ b/apps/sv/frontend/src/__tests__/sv.test.tsx
@@ -41,19 +41,12 @@ describe('SV user can', () => {
expect(await screen.findAllByDisplayValue(svPartyId)).toBeDefined();
});
- test('can see the network name banner', async () => {
- userEvent.setup();
- render();
-
- await screen.findByText('You are on ScratchNet');
- });
-
test('browse to the validator onboarding tab', async () => {
const user = userEvent.setup();
render();
- expect(await screen.findByText('Validator Onboarding')).toBeDefined();
- await user.click(screen.getByText('Validator Onboarding'));
+ expect(await screen.findByText('Validators')).toBeDefined();
+ await user.click(screen.getByText('Validators'));
expect(await screen.findByText('Validator Onboarding Secrets')).toBeDefined();
});
@@ -62,8 +55,8 @@ describe('SV user can', () => {
const user = userEvent.setup();
render();
- expect(await screen.findByText('Validator Onboarding')).toBeDefined();
- await user.click(screen.getByText('Validator Onboarding'));
+ expect(await screen.findByText('Validators')).toBeDefined();
+ await user.click(screen.getByText('Validators'));
const partyHintInput = screen.getByTestId('create-party-hint');
await user.type(partyHintInput, 'wrong-input');
@@ -306,8 +299,8 @@ describe('An AddFutureAmuletConfigSchedule request', () => {
const user = userEvent.setup();
render();
- expect(await screen.findByText('Validator Onboarding')).toBeDefined();
- await user.click(screen.getByText('Validator Onboarding'));
+ expect(await screen.findByText('Validators')).toBeDefined();
+ await user.click(screen.getByText('Validators'));
expect(await screen.findByText('Validator Licenses')).toBeDefined();
diff --git a/apps/sv/frontend/src/components/Layout.tsx b/apps/sv/frontend/src/components/Layout.tsx
index 80c3facaea..8604f5af56 100644
--- a/apps/sv/frontend/src/components/Layout.tsx
+++ b/apps/sv/frontend/src/components/Layout.tsx
@@ -1,34 +1,47 @@
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
-import {
- Header,
- Loading,
- useUserState,
- useVotesHooks,
-} from '@canton-network/splice-common-frontend';
+import { Loading, useUserState, useVotesHooks } from '@canton-network/splice-common-frontend';
-import { Logout } from '@mui/icons-material';
-import { Box, Button, Divider, GlobalStyles, Stack, Typography } from '@mui/material';
-import Container from '@mui/material/Container';
-import Link from '@mui/material/Link';
+import { Box, Container, GlobalStyles } from '@mui/material';
+import { useLocation } from 'react-router';
import { partyIdScrollGlobalStyles } from './beta/identifierStyles';
import PartyIdScrollTracks from './PartyIdScrollTracks';
-
+import SvNavigationShell from './layout/SvNavigationShell';
+import { SvNavLinkItem } from './layout/SvNavLink';
import { useFeatureSupport } from '../contexts/SvContext';
-import { useNetworkInstanceName } from '../hooks/index';
+import { CONTENT_MAX_WIDTH, layoutTokens, PAGE_PX } from '../theme/tokens';
import { useSvConfig } from '../utils';
interface LayoutProps {
children: React.ReactNode;
}
-const Layout: React.FC = (props: LayoutProps) => {
+const pathnameToPageName = (pathname: string, amuletName: string): string => {
+ if (pathname.startsWith('/governance')) {
+ return 'Governance';
+ }
+ if (pathname.startsWith('/validator-onboarding')) {
+ return 'Validators';
+ }
+ if (pathname.startsWith('/amulet-price')) {
+ return `${amuletName} Price`;
+ }
+ return 'Global Synchronizer Information';
+};
+
+const contentShellSx = {
+ maxWidth: CONTENT_MAX_WIDTH,
+ mx: 'auto',
+ px: PAGE_PX,
+ width: '100%',
+};
+
+const Layout: React.FC = ({ children }) => {
const config = useSvConfig();
const { logout } = useUserState();
- const networkInstanceName = useNetworkInstanceName();
- const networkInstanceNameColor = `colors.${networkInstanceName?.toLowerCase()}`;
+ const location = useLocation();
const featureSupport = useFeatureSupport();
const votesHooks = useVotesHooks();
@@ -43,57 +56,30 @@ const Layout: React.FC = (props: LayoutProps) => {
return ;
}
- const navLinks = [
- { name: 'Information', path: 'dso' },
- { name: 'Validator Onboarding', path: 'validator-onboarding' },
- { name: `${config.spliceInstanceNames.amuletName} Price`, path: 'amulet-price' },
- { name: 'Governance', path: 'governance', badgeCount: actionsPending?.length },
+ const navLinks: SvNavLinkItem[] = [
+ { name: 'Global Synchronizer Information', path: '/dso' },
+ {
+ name: 'Governance',
+ path: '/governance',
+ end: false,
+ badgeCount: actionsPending?.length,
+ },
+ { name: `${config.spliceInstanceNames.amuletName} Price`, path: '/amulet-price' },
+ { name: 'Validators', path: '/validator-onboarding' },
];
+ const pageName = pathnameToPageName(location.pathname, config.spliceInstanceNames.amuletName);
+
return (
-
+
- {networkInstanceName === undefined ? (
- <>>
- ) : (
-
-
- You are on {networkInstanceName}
-
-
- )}
-
-
-
-
-
-
-
-
+
-
- {props.children}
+
+
+ {children}
+
);
diff --git a/apps/sv/frontend/src/components/form-components/ConfigField.tsx b/apps/sv/frontend/src/components/form-components/ConfigField.tsx
index 61a0216eaa..043883678f 100644
--- a/apps/sv/frontend/src/components/form-components/ConfigField.tsx
+++ b/apps/sv/frontend/src/components/form-components/ConfigField.tsx
@@ -8,6 +8,12 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { useFieldContext } from '../../hooks/formContext';
import type { ConfigChange, PendingConfigFieldInfo } from '../../utils/types';
import { nextScheduledSynchronizerUpgradeFormat } from '@canton-network/splice-common-frontend-utils';
+import {
+ configFieldFieldSx,
+ configFieldInputSx,
+ configFieldNameSx,
+ configFieldRowSx,
+} from '../../themes/fieldStyles';
dayjs.extend(relativeTime);
@@ -55,12 +61,16 @@ export const ConfigField: React.FC = props => {
const textFieldProps = {
variant: 'outlined' as const,
- size: 'small' as const,
color: field.state.meta.isDefaultValue ? ('primary' as const) : ('secondary' as const),
focused: !field.state.meta.isDefaultValue,
autoComplete: 'off' as const,
+ sx: configFieldFieldSx,
+ slotProps: {
+ input: {
+ sx: configFieldInputSx,
+ },
+ },
inputProps: {
- sx: { textAlign: 'right' },
'data-testid': `config-field-${configChange.fieldName}`,
},
disabled: isDisabled,
@@ -68,28 +78,20 @@ export const ConfigField: React.FC = props => {
return (
<>
-
-
+
+
{configChange.label}
{configChange.fieldName}
-
+
= {
+ thresholdDeadline: CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE,
+ mustMintBefore: CREATE_PROPOSAL_LABEL_MUST_MINT_BEFORE,
+};
export interface DateFieldProps {
title?: string;
+ fieldLabel?: DateFieldLabel;
description?: string;
minDate?: Dayjs;
id: string;
}
export const DateField: React.FC = props => {
- const { title, description, minDate, id } = props;
+ const { fieldLabel, minDate, id } = props;
+ const title = props.title ?? (fieldLabel ? DATE_FIELD_LABELS[fieldLabel] : undefined);
+ const description =
+ props.description ??
+ (fieldLabel === 'thresholdDeadline' ? THRESHOLD_DEADLINE_SUBTITLE : undefined);
const field = useFieldContext();
const dateValue = useMemo(() => dayjs(field.state.value), [field.state.value]);
return (
-
- {title && (
-
- {title}
-
- )}
-
- {description && (
-
- {description}
-
- )}
+
+ {title && {title}}
+
+ {description && {description}}
= props => {
format={dateTimeFormatISO}
minDateTime={minDate || dayjs()}
ampm={false}
+ onClose={() => field.handleBlur()}
onChange={newDate => field.handleChange(newDate?.format(dateTimeFormatISO)!)}
enableAccessibleFieldDOMStructure={false}
+ slots={{
+ openPickerIcon: KeyboardArrowDown,
+ }}
slotProps={{
textField: {
fullWidth: true,
variant: 'outlined',
id: `${id}-field`,
+ error: !field.state.meta.isValid,
helperText: field.state.meta.errors?.[0],
onBlur: field.handleBlur,
+ sx: datePickerFieldSx,
inputProps: {
'data-testid': `${id}-field`,
},
},
+ openPickerButton: {
+ sx: {
+ color: 'text.light',
+ marginRight: 0,
+ padding: 0,
+ cursor: 'pointer',
+ '& .MuiSvgIcon-root': {
+ fontSize: 16,
+ },
+ },
+ },
}}
/>
diff --git a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx
index 26c6b0216c..39c93af27d 100644
--- a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx
+++ b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx
@@ -9,7 +9,9 @@ import dayjs from 'dayjs';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { EffectivityType } from '../../utils/types';
import React, { useMemo } from 'react';
+import { CREATE_PROPOSAL_LABEL_EFFECTIVE_AT } from '../../utils/constants';
import { RadioSelector } from './RadioSelector';
+import { datePickerFieldSx } from '../../themes/fieldStyles';
const effectiveAtDisplayFormat = 'YYYY-MM-DD HH:mm';
@@ -22,7 +24,7 @@ export interface EffectiveDateFieldProps {
export const EffectiveDateField: React.FC = props => {
const { initialEffectiveDate, id } = props;
- const title = props.title ?? 'Effective At';
+ const title = props.title ?? CREATE_PROPOSAL_LABEL_EFFECTIVE_AT;
const dateDescription =
props.description ?? 'Select the block at which the proposal will take effect';
@@ -106,46 +108,10 @@ export const EffectiveDateField: React.FC = props => {
error: !field.state.meta.isValid,
helperText: field.state.meta.errors?.[0],
onBlur: field.handleBlur,
+ sx: datePickerFieldSx,
inputProps: {
'data-testid': `${id}-field`,
},
- sx: theme => ({
- width: '100%',
- '& .MuiOutlinedInput-root': {
- backgroundColor: '#363636',
- borderRadius: '4px',
- display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center',
- alignSelf: 'stretch',
- flexWrap: 'nowrap',
- padding: '13px 16px',
- overflow: 'hidden',
- minHeight: 'unset',
- '& fieldset': {
- border: 'none',
- borderRadius: '4px',
- },
- },
- '& .MuiOutlinedInput-input': {
- ...theme.typography.body2,
- flex: 1,
- minWidth: 0,
- padding: 0,
- lineHeight: '22px',
- color: theme.palette.text.light,
- backgroundColor: 'transparent',
- borderRadius: 0,
- WebkitBoxShadow: 'none',
- },
- '& .MuiInputAdornment-root': {
- flexShrink: 0,
- marginLeft: theme.spacing(1.25),
- },
- '& .MuiFormHelperText-root': {
- mx: 0,
- },
- }),
},
}}
/>
diff --git a/apps/sv/frontend/src/components/form-components/FormControls.tsx b/apps/sv/frontend/src/components/form-components/FormControls.tsx
index e661a7ba86..f409fdcf3d 100644
--- a/apps/sv/frontend/src/components/form-components/FormControls.tsx
+++ b/apps/sv/frontend/src/components/form-components/FormControls.tsx
@@ -2,8 +2,14 @@
// SPDX-License-Identifier: Apache-2.0
import { Box, Button } from '@mui/material';
+import { useState } from 'react';
+import {
+ createProposalCancelButtonSx,
+ createProposalSubmitButtonSx,
+} from '../../constants/formButtonStyles';
import { useFormContext } from '../../hooks/formContext';
import { useNavigate } from 'react-router';
+import { CancelProposalDialog } from '../governance/CancelProposalDialog';
export interface FormControlsProps {
showConfirmation?: boolean;
@@ -14,51 +20,68 @@ export const FormControls: React.FC = props => {
const { showConfirmation, onEdit } = props;
const form = useFormContext();
const navigate = useNavigate();
+ const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
+
const submitTitle = showConfirmation ? 'Submit Proposal' : 'Review Proposal';
const cancelTitle = showConfirmation ? 'Edit Proposal' : 'Cancel';
const handleCancel = () => {
if (showConfirmation) {
onEdit();
- } else {
- navigate('/governance/proposals/create');
+ return;
}
+ setCancelDialogOpen(true);
+ };
+
+ const handleConfirmCancel = () => {
+ setCancelDialogOpen(false);
+ navigate('/governance/proposals');
};
return (
-
-
+
+
+ [state.canSubmit, state.isSubmitting]}
+ children={([canSubmit, isSubmitting]) => (
+
+ )}
+ />
+
- [state.canSubmit, state.isSubmitting]}
- children={([canSubmit, isSubmitting]) => (
-
- )}
+ setCancelDialogOpen(false)}
+ onConfirm={handleConfirmCancel}
/>
-
+ >
);
};
diff --git a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx
index f6a7a83e53..659cb94869 100644
--- a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx
+++ b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx
@@ -6,9 +6,15 @@ import { useFieldContext } from '../../hooks/formContext';
import { useDsoInfos } from '../../contexts/SvContext';
import {
DEFAULT_PROPOSAL_SUMMARY_MAX_LENGTH,
+ CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY,
PROPOSAL_SUMMARY_SUBTITLE,
- PROPOSAL_SUMMARY_TITLE,
} from '../../utils/constants';
+import {
+ fieldDescriptionSx,
+ fieldSectionSx,
+ fieldSectionTitleSx,
+ proposalSummaryFieldSx,
+} from '../../themes/fieldStyles';
export interface ProposalSummaryFieldProps {
id: string;
@@ -27,11 +33,17 @@ export const ProposalSummaryField: React.FC = props =
const currentLength = field.state.value.length;
return (
-
-
- {title || PROPOSAL_SUMMARY_TITLE}
+
+
+ {title || CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY}
{optional && (
-
+
optional
)}
@@ -39,32 +51,32 @@ export const ProposalSummaryField: React.FC = props =
field.handleChange(e.target.value)}
error={!field.state.meta.isValid}
helperText={field.state.meta.errors?.[0]}
inputProps={{ 'data-testid': id, maxLength }}
- id={id}
/>
-
+
{subtitle || PROPOSAL_SUMMARY_SUBTITLE}
diff --git a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx
index d7c0c06dd0..6e70e5077d 100644
--- a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx
+++ b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx
@@ -2,7 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
import { Box, Typography } from '@mui/material';
+import { fieldBodySx, fieldSectionTitleSx } from '../../themes/fieldStyles';
import { useFieldContext } from '../../hooks/formContext';
+import { CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE } from '../../utils/constants';
export interface ProposalTypeFieldProps {
id: string;
@@ -10,16 +12,21 @@ export interface ProposalTypeFieldProps {
}
export const ProposalTypeField: React.FC = props => {
- const { id, title = 'Proposal type' } = props;
+ const { id, title = CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE } = props;
const field = useFieldContext();
return (
-
+
{title}
-
+
{field.state.value}
diff --git a/apps/sv/frontend/src/components/form-components/SelectField.tsx b/apps/sv/frontend/src/components/form-components/SelectField.tsx
index 95bb6d8fb5..354e4350d9 100644
--- a/apps/sv/frontend/src/components/form-components/SelectField.tsx
+++ b/apps/sv/frontend/src/components/form-components/SelectField.tsx
@@ -1,6 +1,7 @@
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import { KeyboardArrowDown } from '@mui/icons-material';
import {
Box,
FormControl,
@@ -13,6 +14,7 @@ import {
import type { FormEvent } from 'react';
import { useFieldContext } from '../../hooks/formContext';
import { scrollableSelectFieldSx } from '../beta/identifierStyles';
+import { fieldSectionSx, fieldSectionTitleSx, selectFieldSx } from '../../themes/fieldStyles';
export type Option = { key: string; value: string };
export interface SelectFieldProps {
@@ -38,16 +40,19 @@ export const SelectField: React.FC = props => {
const isError = !field.state.meta.isValid && !showPlaceholder;
return (
-
-
- {title}
-
+
+ {title}
-
+