diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala index c9daf5958e..ea55480895 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala @@ -582,6 +582,25 @@ abstract class ScanAppReference( ) } + def getHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId] = Vector.empty, + recordTimeMatch: Option[definitions.HoldingsSummaryRequestV1.RecordTimeMatch] = Some( + definitions.HoldingsSummaryRequestV1.RecordTimeMatch.Exact + ), + ) = + consoleEnvironment.run { + httpCommand( + HttpScanAppClient.GetHoldingsSummaryAtV1( + at.toInstant.atOffset(java.time.ZoneOffset.UTC), + migrationId, + ownerPartyIds, + recordTimeMatch, + ) + ) + } + def getAggregatedRounds(): Option[ScanAggregator.RoundRange] = consoleEnvironment.run { httpCommand( diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ValidatorAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ValidatorAppReference.scala index e42d2b5dc3..457e438075 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ValidatorAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ValidatorAppReference.scala @@ -360,6 +360,24 @@ abstract class ValidatorAppReference( } } + def getHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[definitions.HoldingsSummaryRequestV1.RecordTimeMatch] = None, + ): Option[definitions.HoldingsSummaryResponseV1] = { + consoleEnvironment.run { + httpCommand( + HttpScanProxyAppClient.GetHoldingsSummaryAtV1( + at, + migrationId, + ownerPartyIds, + recordTimeMatch, + ) + ) + } + } + def getDsoInfo(): definitions.GetDsoInfoResponse = { consoleEnvironment.run { httpCommand( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala index 200e8e175c..412e943054 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala @@ -418,7 +418,7 @@ class ScanTimeBasedIntegrationTest } "snapshotting" in { implicit env => - val (aliceUserParty, _) = onboardAliceAndBob() + val (aliceUserParty, bobUserParty) = onboardAliceAndBob() val migrationId = sv1ScanBackend.config.domainMigrationId clue( @@ -566,6 +566,50 @@ class ScanTimeBasedIntegrationTest res.summaries.map(_.partyId).distinct shouldBe (Vector(aliceUserParty.toProtoPrimitive)) } + val holdingsSummaryV1 = sv1ScanBackend.getHoldingsSummaryAtV1( + snapshotAfterCts, + migrationId, + ownerPartyIds = Vector(aliceUserParty), + ) + + inside(holdingsSummaryV1) { case Some(res) => + res.migrationId should be(migrationId) + res.recordTime should be(snapshotAfter.value) + res.summaries.map(_.partyId).distinct shouldBe (Vector(aliceUserParty.toProtoPrimitive)) + forAll(res.summaries) { summary => + // V1 response should contain non-zero coin totals + BigDecimal(summary.totalCoinHoldings) should be > BigDecimal(0) + BigDecimal(summary.totalCoinHoldings) shouldBe BigDecimal( + summary.totalUnlockedCoin + ) + BigDecimal(summary.totalLockedCoin) + } + } + + // V1 coin totals should match V0 coin totals + inside((holdingsSummary, holdingsSummaryV1)) { case (Some(v0Res), Some(v1Res)) => + v0Res.summaries.zip(v1Res.summaries).foreach { case (v0s, v1s) => + v0s.totalUnlockedCoin shouldBe v1s.totalUnlockedCoin + v0s.totalLockedCoin shouldBe v1s.totalLockedCoin + v0s.totalCoinHoldings shouldBe v1s.totalCoinHoldings + } + } + + // V1 multi-party query: querying with both alice and bob exercises + // multi-party support at the HTTP endpoint level + val holdingsSummaryV1MultiParty = sv1ScanBackend.getHoldingsSummaryAtV1( + snapshotAfterCts, + migrationId, + ownerPartyIds = Vector(aliceUserParty, bobUserParty), + ) + inside(holdingsSummaryV1MultiParty) { case Some(res) => + // Alice should appear in the multi-party result with the same values + val aliceSummaries = res.summaries.filter(_.partyId == aliceUserParty.toProtoPrimitive) + aliceSummaries should not be empty + inside(holdingsSummaryV1) { case Some(singleRes) => + aliceSummaries shouldBe singleRes.summaries + } + } + // afOrBefore should return the same holdingsState and holdingsSummary as the exact time given by snapshotAfter advanceTime(java.time.Duration.ofMinutes(10)) val atOrBefore = getLedgerTime @@ -602,6 +646,21 @@ class ScanTimeBasedIntegrationTest ownerPartyIds = Vector(aliceUserParty), recordTimeMatch = Some(definitions.HoldingsSummaryRequest.RecordTimeMatch.Exact), ) shouldBe None + + val holdingsSummaryV1AtOrBefore = sv1ScanBackend.getHoldingsSummaryAtV1( + atOrBeforeCts, + migrationId, + ownerPartyIds = Vector(aliceUserParty), + recordTimeMatch = Some(definitions.HoldingsSummaryRequestV1.RecordTimeMatch.AtOrBefore), + ) + holdingsSummaryV1AtOrBefore shouldBe holdingsSummaryV1 + + sv1ScanBackend.getHoldingsSummaryAtV1( + atOrBeforeCts, + migrationId, + ownerPartyIds = Vector(aliceUserParty), + recordTimeMatch = Some(definitions.HoldingsSummaryRequestV1.RecordTimeMatch.Exact), + ) shouldBe None } advanceTime(java.time.Duration.ofHours(24)) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorIntegrationTest.scala index d448f230b2..1c22b846c5 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorIntegrationTest.scala @@ -124,6 +124,16 @@ class ValidatorIntegrationTest extends IntegrationTestWithIsolatedEnvironment wi Vector(aliceValidatorParty), ) + aliceValidatorBackend.scanProxy.getHoldingsSummaryAtV1( + now, + 0L, + Vector(aliceValidatorParty), + ) shouldBe sv1ScanBackend.getHoldingsSummaryAtV1( + now, + 0L, + Vector(aliceValidatorParty), + ) + // check that the dsoGovernance are not vetted aliceValidatorBackend.participantClient.topology.vetted_packages .list(filterParticipant = aliceValidatorBackend.participantClient.id.toProtoPrimitive) diff --git a/apps/scan/src/main/openapi/scan.yaml b/apps/scan/src/main/openapi/scan.yaml index 7927802fea..c04846d607 100644 --- a/apps/scan/src/main/openapi/scan.yaml +++ b/apps/scan/src/main/openapi/scan.yaml @@ -731,11 +731,12 @@ paths: /v0/holdings/summary: post: - tags: [external, scan] + deprecated: true + tags: [deprecated] x-jvm-package: scan operationId: "getHoldingsSummaryAt" description: | - Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. + Deprecated. Please use /v1/holdings/summary instead. Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. This is an aggregate of `/v0/holdings/state` by owner party ID with better performance than client-side computation. requestBody: required: true @@ -757,6 +758,36 @@ paths: "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v1/holdings/summary: + post: + tags: [external, scan] + x-jvm-package: scan + operationId: "getHoldingsSummaryAtV1" + description: | + Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. + This is an aggregate of `/v0/holdings/state` by owner party ID with better performance than client-side computation. + Unlike /v0/holdings/summary, this version does not include holding fee fields + as they do not express a meaningful aggregate value. + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/HoldingsSummaryRequestV1" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/HoldingsSummaryResponseV1" + "400": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v0/ans-entries: get: tags: [external, scan] @@ -3229,6 +3260,43 @@ components: description: | Compute holding fees as of this round. Defaults to the earliest open mining round. + HoldingsSummaryRequestV1: + type: object + required: + - migration_id + - record_time + - owner_party_ids + properties: + migration_id: + type: integer + format: int64 + description: | + The migration id for which to return the summary. + record_time: + type: string + format: date-time + description: | + The timestamp at which the contract set was active. + This needs to be an exact timestamp, i.e., + needs to correspond to a timestamp reported by `/v0/state/acs/snapshot-timestamp` if `record_time_match` is set to `exact` (which is the default). + If `record_time_match` is set to `at_or_before`, this can be any timestamp, and the most recent snapshot at or before the given `record_time` will be returned. + record_time_match: + type: string + description: | + How to match the record_time. "exact" requires the record_time to match exactly. + "at_or_before" finds the most recent snapshot at or before the given record_time. + enum: + - "exact" + - "at_or_before" + default: "exact" + owner_party_ids: + type: array + items: + type: string + minItems: 1 + description: | + The owners for which to compute the summary. + ForceAcsSnapshotResponse: type: object required: @@ -3379,6 +3447,53 @@ components: Same as `total_unlocked_coin` - `accumulated_holding_fees_unlocked`. type: string + HoldingsSummaryResponseV1: + type: object + required: + - record_time + - migration_id + - summaries + properties: + record_time: + description: The same `record_time` as in the request. + type: string + format: date-time + migration_id: + description: The same `migration_id` as in the request. + type: integer + format: int64 + summaries: + type: array + items: + $ref: "#/components/schemas/HoldingsSummaryV1" + HoldingsSummaryV1: + description: Aggregate Amulet totals for a particular owner party ID. + type: object + required: + - party_id + - total_unlocked_coin + - total_locked_coin + - total_coin_holdings + properties: + party_id: + description: | + Owner party ID of the amulet. Guaranteed to be unique among `summaries`. + type: string + total_unlocked_coin: + description: | + Sum of unlocked amulet initial amounts, not counting holding + fees deducted since. + type: string + total_locked_coin: + description: | + Sum of locked amulet initial amounts, not + counting holding fees deducted since. + type: string + total_coin_holdings: + description: | + `total_unlocked_coin` + `total_locked_coin`. + type: string + ListActivityRequest: type: object required: diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala index 62445e6da7..3ca3ca927b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala @@ -36,7 +36,9 @@ import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ AnsEntry, GetDsoInfoResponse, + HoldingsSummaryRequestV1, HoldingsSummaryResponse, + HoldingsSummaryResponseV1, LookupTransferCommandStatusResponse, MigrationSchedule, } @@ -185,6 +187,15 @@ class BftScanConnection( bftCall(_.getHoldingsSummaryAt(at, migrationId, ownerPartyIds, recordTimeMatch, asOfRound)) } + override def getHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[HoldingsSummaryRequestV1.RecordTimeMatch], + )(implicit tc: TraceContext): Future[Option[HoldingsSummaryResponseV1]] = { + bftCall(_.getHoldingsSummaryAtV1(at, migrationId, ownerPartyIds, recordTimeMatch)) + } + override protected def runGetAmuletRulesWithState( cachedAmuletRules: Option[ContractWithState[AmuletRules.ContractId, AmuletRules]] )(implicit tc: TraceContext): Future[ContractWithState[AmuletRules.ContractId, AmuletRules]] = diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala index 5cfa40cdab..11bd2d6798 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala @@ -26,6 +26,7 @@ import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ GetDsoInfoResponse, HoldingsSummaryResponse, + HoldingsSummaryResponseV1, LookupTransferCommandStatusResponse, MigrationSchedule, } @@ -49,6 +50,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ VoteRequest, } import org.lfdecentralizedtrust.splice.http.v0.definitions.HoldingsSummaryRequest.RecordTimeMatch +import org.lfdecentralizedtrust.splice.http.v0.definitions.HoldingsSummaryRequestV1 import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} import scala.jdk.OptionConverters.* @@ -89,6 +91,13 @@ trait ScanConnection asOfRound: Option[Long], )(implicit tc: TraceContext): Future[Option[HoldingsSummaryResponse]] + def getHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[HoldingsSummaryRequestV1.RecordTimeMatch], + )(implicit tc: TraceContext): Future[Option[HoldingsSummaryResponseV1]] + def getAmuletRulesWithState()(implicit ec: ExecutionContext, tc: TraceContext, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala index a5e34afe7d..b5a38b8a42 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala @@ -31,7 +31,9 @@ import org.lfdecentralizedtrust.splice.environment.{ } import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ + HoldingsSummaryRequestV1, HoldingsSummaryResponse, + HoldingsSummaryResponseV1, LookupTransferCommandStatusResponse, MigrationSchedule, } @@ -156,6 +158,23 @@ class SingleScanConnection private[client] ( ) } + override def getHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[HoldingsSummaryRequestV1.RecordTimeMatch], + )(implicit tc: TraceContext): Future[Option[HoldingsSummaryResponseV1]] = { + runHttpCmd( + config.adminApi.url, + HttpScanAppClient.GetHoldingsSummaryAtV1( + at.toInstant.atOffset(java.time.ZoneOffset.UTC), + migrationId, + ownerPartyIds, + recordTimeMatch, + ), + ) + } + override def getAmuletRulesWithState()(implicit ec: ExecutionContext, tc: TraceContext, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala index 472b854dc5..f21795af43 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala @@ -1325,6 +1325,42 @@ object HttpScanAppClient { } } + case class GetHoldingsSummaryAtV1( + at: java.time.OffsetDateTime, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[definitions.HoldingsSummaryRequestV1.RecordTimeMatch], + ) extends InternalBaseCommand[ + http.GetHoldingsSummaryAtV1Response, + Option[definitions.HoldingsSummaryResponseV1], + ] { + override def submitRequest( + client: ScanClient, + headers: List[HttpHeader], + ): EitherT[Future, Either[Throwable, HttpResponse], http.GetHoldingsSummaryAtV1Response] = + client.getHoldingsSummaryAtV1( + definitions.HoldingsSummaryRequestV1( + migrationId, + at, + recordTimeMatch, + ownerPartyIds.map(_.toProtoPrimitive), + ), + headers, + ) + + override protected def handleOk()(implicit + decoder: TemplateJsonDecoder + ): PartialFunction[http.GetHoldingsSummaryAtV1Response, Either[ + String, + Option[definitions.HoldingsSummaryResponseV1], + ]] = { + case http.GetHoldingsSummaryAtV1Response.OK(value) => + Right(Some(value)) + case http.GetHoldingsSummaryAtV1Response.NotFound(_) => + Right(None) + } + } + object GetAggregatedRounds extends InternalBaseCommand[http.GetAggregatedRoundsResponse, Option[ ScanAggregator.RoundRange diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala index ddab58b723..9b9616ec53 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala @@ -69,6 +69,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ EventHistoryRequest, HoldingsStateRequest, HoldingsSummaryRequest, + HoldingsSummaryRequestV1, ListBulkUpdateHistoryObjectsRequest, ListVoteResultsRequest, MaybeCachedContractWithState, @@ -2020,6 +2021,63 @@ class HttpScanHandler( } } + override def getHoldingsSummaryAtV1( + respond: ScanResource.GetHoldingsSummaryAtV1Response.type + )( + body: HoldingsSummaryRequestV1 + )(extracted: TraceContext): Future[ScanResource.GetHoldingsSummaryAtV1Response] = { + implicit val tc: TraceContext = extracted + withSpan(s"$workflowId.getHoldingsSummaryAtV1") { _ => _ => + val HoldingsSummaryRequestV1( + migrationId, + recordTime, + recordTimeMatch, + partyIds, + ) = body + + // Round 0 is used as a placeholder since the v1 endpoint does not compute holding fees. + // The totalUnlockedCoin, totalLockedCoin, and totalCoinHoldings fields are round-independent. + def exactQuery(recordTimeTs: CantonTimestamp) = + snapshotStore + .getHoldingsSummary( + migrationId, + recordTimeTs, + nonEmptyOrFail("partyIds", partyIds).map(PartyId.tryFromProtoPrimitive), + 0L, + ) + + def toResponse(result: AcsSnapshotStore.HoldingsSummaryResult) = + ScanResource.GetHoldingsSummaryAtV1Response.OK( + definitions.HoldingsSummaryResponseV1( + Codec.encode(result.recordTime), + result.migrationId, + result.summaries.map { case (partyId, holdings) => + definitions.HoldingsSummaryV1( + partyId = Codec.encode(partyId), + totalUnlockedCoin = Codec.encode(holdings.totalUnlockedCoin), + totalLockedCoin = Codec.encode(holdings.totalLockedCoin), + totalCoinHoldings = Codec.encode(holdings.totalCoinHoldings), + ) + }.toVector, + ) + ) + + queryWithOptionalAtOrBefore( + migrationId, + recordTime, + recordTimeMatch.contains(HoldingsSummaryRequestV1.RecordTimeMatch.AtOrBefore), + exactQuery, + toResponse, + ).map { + case Right(response) => response + case Left(errorMessage) => + ScanResource.GetHoldingsSummaryAtV1ResponseNotFound( + ErrorResponse(errorMessage) + ) + } + } + } + private def nonEmptyOrFail[A](fieldName: String, vec: Vector[A]): NonEmptyVector[A] = { NonEmptyVector .fromVector(vec) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala index cf9b9667e8..1e08b14ff4 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala @@ -694,6 +694,12 @@ class AcsSnapshotStoreTest ) } _ <- store.insertNewSnapshot(None, DefaultMigrationId, timestamp1) + summaryAtRound0 <- store.getHoldingsSummary( + DefaultMigrationId, + timestamp1, + NonEmptyVector.of(wantedParty1, wantedParty2), + asOfRound = 0L, + ) summaryAtRound3 <- store.getHoldingsSummary( DefaultMigrationId, timestamp1, @@ -713,6 +719,17 @@ class AcsSnapshotStoreTest asOfRound = 100L, ) } yield { + // asOfRound=0: coin totals are round-independent (relied on by the v1 endpoint + // which hardcodes round 0). Fee-related fields become negative when + // asOfRound < createdAtRound, but the v1 response never exposes them. + summaryAtRound0.summaries.foreach { case (partyId, h0) => + val h3 = summaryAtRound3.summaries(partyId) + h0.totalUnlockedCoin shouldBe h3.totalUnlockedCoin + h0.totalLockedCoin shouldBe h3.totalLockedCoin + h0.totalCoinHoldings shouldBe h3.totalCoinHoldings + h0.accumulatedHoldingFeesUnlocked should be < BigDecimal(0) + h0.accumulatedHoldingFeesLocked should be < BigDecimal(0) + } summaryAtRound3 should be( AcsSnapshotStore.HoldingsSummaryResult( DefaultMigrationId, @@ -799,6 +816,31 @@ class AcsSnapshotStoreTest } } + "return empty summaries for a party with no holdings" in { + val partyWithHoldings = providerParty(1) + val partyWithNoHoldings = providerParty(4) + val amulet1 = amulet(partyWithHoldings, 10, 1L, 1.0) + for { + updateHistory <- mkUpdateHistory() + store = mkStore(updateHistory) + _ <- ingestCreate( + updateHistory, + amulet1, + timestamp1.minusSeconds(10L), + Seq(partyWithHoldings), + ) + _ <- store.insertNewSnapshot(None, DefaultMigrationId, timestamp1) + summary <- store.getHoldingsSummary( + DefaultMigrationId, + timestamp1, + NonEmptyVector.of(partyWithNoHoldings), + asOfRound = 3L, + ) + } yield { + summary.summaries shouldBe empty + } + } + } "fix for corrupt snapshots" should { diff --git a/apps/validator/src/main/openapi/scan-proxy.yaml b/apps/validator/src/main/openapi/scan-proxy.yaml index e4dba193d2..3318b5e9df 100644 --- a/apps/validator/src/main/openapi/scan-proxy.yaml +++ b/apps/validator/src/main/openapi/scan-proxy.yaml @@ -225,11 +225,12 @@ paths: $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" /v0/scan-proxy/holdings/summary: post: - tags: [ scan-proxy ] + deprecated: true + tags: [ deprecated ] x-jvm-package: scanproxy operationId: "getHoldingsSummaryAt" description: | - Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. + Deprecated. Please use /v1/scan-proxy/holdings/summary instead. Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. This is an aggregate of `/v0/holdings/state` by owner party ID with better performance than client-side computation. requestBody: required: true @@ -250,6 +251,35 @@ paths: $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v1/scan-proxy/holdings/summary: + post: + tags: [ scan-proxy ] + x-jvm-package: scanproxy + operationId: "getHoldingsSummaryAtV1" + description: | + Returns the summary of active amulet contracts for a given migration id and record time, for the given parties. + This is an aggregate of `/v0/holdings/state` by owner party ID with better performance than client-side computation. + Unlike /v0/scan-proxy/holdings/summary, this version does not include holding fee fields + as they do not express a meaningful aggregate value. + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/HoldingsSummaryRequestV1" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/HoldingsSummaryResponseV1" + "400": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" /v0/scan-proxy/unclaimed-development-fund-coupons: get: tags: [ scan-proxy ] diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/api/client/commands/HttpScanProxyAppClient.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/api/client/commands/HttpScanProxyAppClient.scala index c37a5307f8..23ef4182b0 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/api/client/commands/HttpScanProxyAppClient.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/api/client/commands/HttpScanProxyAppClient.scala @@ -88,6 +88,32 @@ object HttpScanProxyAppClient { } } + case class GetHoldingsSummaryAtV1( + at: CantonTimestamp, + migrationId: Long, + ownerPartyIds: Vector[PartyId], + recordTimeMatch: Option[definitions.HoldingsSummaryRequestV1.RecordTimeMatch], + ) extends ScanProxyBaseCommand[scanProxy.GetHoldingsSummaryAtV1Response, Option[ + definitions.HoldingsSummaryResponseV1 + ]] { + + override def submitRequest(client: ScanproxyClient, headers: List[HttpHeader]) = + client.getHoldingsSummaryAtV1( + definitions.HoldingsSummaryRequestV1( + migrationId, + at.toInstant.atOffset(java.time.ZoneOffset.UTC), + recordTimeMatch, + ownerPartyIds.map(_.toProtoPrimitive), + ), + headers, + ) + + override def handleOk()(implicit decoder: TemplateJsonDecoder) = { + case scanProxy.GetHoldingsSummaryAtV1Response.OK(response) => Right(Some(response)) + case scanProxy.GetHoldingsSummaryAtV1Response.NotFound(_) => Right(None) + } + } + case object GetAnsRules extends ScanProxyBaseCommand[ scanProxy.GetAnsRulesResponse, diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpScanProxyHandler.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpScanProxyHandler.scala index 0a0f366e6e..8cda18e9b4 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpScanProxyHandler.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpScanProxyHandler.scala @@ -125,6 +125,30 @@ class HttpScanProxyHandler( } } + override def getHoldingsSummaryAtV1( + respond: v0.ScanproxyResource.GetHoldingsSummaryAtV1Response.type + )( + body: definitions.HoldingsSummaryRequestV1 + )(tUser: AuthenticatedRequest): Future[v0.ScanproxyResource.GetHoldingsSummaryAtV1Response] = { + implicit val AuthenticatedRequest(_, traceContext) = tUser + withSpan(s"$workflowId.getHoldingsSummaryAtV1") { implicit traceContext => _ => + for { + summaryOpt <- scanConnection.getHoldingsSummaryAtV1( + CantonTimestamp.assertFromInstant(body.recordTime.toInstant), + body.migrationId, + body.ownerPartyIds.map(PartyId.tryFromProtoPrimitive), + body.recordTimeMatch, + ) + } yield { + summaryOpt match { + case Some(summary) => respond.OK(summary) + case None => + respond.NotFound(definitions.ErrorResponse("Summary not found for given parameters")) + } + } + } + } + override def getAmuletRules(respond: v0.ScanproxyResource.GetAmuletRulesResponse.type)()( tUser: AuthenticatedRequest ): Future[v0.ScanproxyResource.GetAmuletRulesResponse] = {