From b0ad14a7a53db286d14b211fbde09de734fb3a01 Mon Sep 17 00:00:00 2001 From: Haiko Schol Date: Wed, 30 Sep 2026 11:00:07 +0000 Subject: [PATCH 1/4] feat: look up a synchronizer registration by contract id [ci] Offboard and set-parameters votes pin a RegisteredSynchronizer by contract id, which is all their payload carries. Scan now serves an active registration by contract id, and the SV app forwards it, so the SV UI can show what such a vote targets, flag one whose registration was archived, and let an SV offboard the second of two duplicate registrations, which the lookup by synchronizer id does not reach. Empty means archived: offboarded, or replaced by a parameters change. Signed-off-by: Haiko Schol --- .../splice/console/ScanAppReference.scala | 8 +++++ .../SyncOperatorTrafficIntegrationTest.scala | 10 +++++++ apps/scan/src/main/openapi/scan.yaml | 30 +++++++++++++++++++ .../admin/api/client/BftScanConnection.scala | 9 ++++++ .../admin/api/client/ScanConnection.scala | 6 ++++ .../api/client/SingleScanConnection.scala | 9 ++++++ .../client/commands/HttpScanAppClient.scala | 20 +++++++++++++ .../scan/admin/http/HttpScanHandler.scala | 22 ++++++++++++++ apps/sv/src/main/openapi/sv-internal.yaml | 27 +++++++++++++++-- .../sv/admin/http/HttpSvOperatorHandler.scala | 24 ++++++++++++++- 10 files changed, 162 insertions(+), 3 deletions(-) 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 6a647efc590..70ac79da41a 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 @@ -205,6 +205,14 @@ abstract class ScanAppReference( httpCommand(HttpScanAppClient.LookupSynchronizerRegistration(synchronizerId)) } + @Help.Summary("Look up an active RegisteredSynchronizer by contract ID") + def lookupSynchronizerRegistrationByContractId( + contractId: String + ): Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]] = + consoleEnvironment.run { + httpCommand(HttpScanAppClient.LookupSynchronizerRegistrationByContractId(contractId)) + } + @Help.Summary("Lookup a TransferCommandCounter by the receiver party") def lookupTransferCommandCounterByParty( party: PartyId diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala index 0f54cc1ef10..28a8a9db855 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala @@ -147,6 +147,16 @@ class SyncOperatorTrafficIntegrationTest sv1ScanBackend.lookupSynchronizerRegistration(synchronizerId.toProtoPrimitive).value } + // Offboard and set-parameters votes pin the registration by contract id. + clue("scan looks the registration up by contract id") { + sv1ScanBackend + .lookupSynchronizerRegistrationByContractId(registration.contractId.contractId) + .value + .payload + .synchronizerId shouldBe synchronizerId.toProtoPrimitive + sv1ScanBackend.lookupSynchronizerRegistrationByContractId("00" * 33) shouldBe None + } + clue("the validator serves the registration to its wallet clients through the scan proxy") { aliceValidatorBackend.scanProxy .lookupSynchronizerRegistration(synchronizerId.toProtoPrimitive) diff --git a/apps/scan/src/main/openapi/scan.yaml b/apps/scan/src/main/openapi/scan.yaml index 9ef5c0775b4..b3296b66587 100644 --- a/apps/scan/src/main/openapi/scan.yaml +++ b/apps/scan/src/main/openapi/scan.yaml @@ -1153,6 +1153,29 @@ paths: "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + /v0/synchronizers/registrations/by-contract-id/{contract_id}: + get: + tags: [internal, scan] + x-jvm-package: scan + operationId: "lookupSynchronizerRegistrationByContractId" + description: | + Look up an active RegisteredSynchronizer by its contract ID. + Returns `registration` if the contract is active, empty otherwise: an archived + registration (offboarded, or replaced by a governance-parameters change) is not returned. + parameters: + - name: "contract_id" + in: "path" + required: true + schema: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/LookupSynchronizerRegistrationByContractIdResponse" + /v0/transfer-command-counter/{party}: get: tags: [internal, scan] @@ -3545,6 +3568,13 @@ components: properties: registration: "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/ContractWithState" + LookupSynchronizerRegistrationByContractIdResponse: + description: | + If defined, an active contract of Daml template `Splice.DecentralizedSynchronizer:RegisteredSynchronizer`. + type: object + properties: + registration: + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" LookupTransferCommandCounterByPartyResponse: description: A Daml contract of template `Splice.ExternalPartyAmuletRules:TransferCommandCounter`. 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 904692c4b1a..00ecb4dfcf7 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 @@ -495,6 +495,15 @@ class BftScanConnection( ): Future[Option[ContractWithState[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = bftCall(_.lookupSynchronizerRegistration(synchronizerId), "lookupSynchronizerRegistration") + override def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = + bftCall( + _.lookupSynchronizerRegistrationByContractId(contractId), + "lookupSynchronizerRegistrationByContractId", + ) + override def lookupTransferPreapprovalByParty(receiver: PartyId)(implicit ec: ExecutionContext, tc: TraceContext, 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 6ad82f6c8b3..683e27e8e14 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 @@ -314,6 +314,12 @@ trait ScanConnection tc: TraceContext, ): Future[Option[ContractWithState[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] + /** Only active registrations: None for an archived (offboarded or re-parameterized) one. */ + def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] + def listVoteRequestResults( filters: VoteResultsFilters, limit: Int, 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 75f3d986a79..dde4bec54d1 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 @@ -500,6 +500,15 @@ class SingleScanConnection private[client] ( HttpScanAppClient.LookupSynchronizerRegistration(synchronizerId), ) + override def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = + runHttpCmd( + config.adminApi.url, + HttpScanAppClient.LookupSynchronizerRegistrationByContractId(contractId), + ) + override def lookupTransferPreapprovalByParty(receiver: PartyId)(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 a5550320cd7..bab51e0381c 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 @@ -627,6 +627,26 @@ object HttpScanAppClient { } } + case class LookupSynchronizerRegistrationByContractId( + contractId: String + ) extends InternalBaseCommand[http.LookupSynchronizerRegistrationByContractIdResponse, Option[ + Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer] + ]] { + + override def submitRequest( + client: ScanClient, + headers: List[HttpHeader], + ) = client.lookupSynchronizerRegistrationByContractId(contractId, headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.LookupSynchronizerRegistrationByContractIdResponse.OK(response) => + response.registration + .traverse(co => Contract.fromHttp(RegisteredSynchronizer.COMPANION)(co)) + .leftMap(_.toString) + } + } + case class LookupTransferCommandCounterByParty( party: PartyId ) extends InternalBaseCommand[http.LookupTransferCommandCounterByPartyResponse, Option[ 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 f21c6b3486c..14202c16353 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 @@ -34,6 +34,7 @@ import org.apache.pekko.http.scaladsl.model.Uri import org.lfdecentralizedtrust.splice.admin.http.HttpErrorHandler import org.lfdecentralizedtrust.splice.codegen.java.splice.{amulet, ans as ansCodegen} import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules +import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.RegisteredSynchronizer import org.lfdecentralizedtrust.splice.codegen.java.splice.dso.decentralizedsynchronizer.SynchronizerNodeConfig import org.lfdecentralizedtrust.splice.codegen.java.splice.dso.svstate.SvNodeState import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletrules.{ @@ -1236,6 +1237,27 @@ class HttpScanHandler( } } + override def lookupSynchronizerRegistrationByContractId( + respond: ScanResource.LookupSynchronizerRegistrationByContractIdResponse.type + )( + contractId: String + )(extracted: TraceContext): Future[ + ScanResource.LookupSynchronizerRegistrationByContractIdResponse + ] = { + implicit val tc = extracted + withSpan(s"$workflowId.lookupSynchronizerRegistrationByContractId") { _ => _ => + for { + registration <- store.multiDomainAcsStore.lookupContractById( + RegisteredSynchronizer.COMPANION + )(new RegisteredSynchronizer.ContractId(contractId)) + } yield { + definitions.LookupSynchronizerRegistrationByContractIdResponse( + registration.map(_.contract.toHttp) + ) + } + } + } + override def lookupTransferCommandCounterByParty( respond: ScanResource.LookupTransferCommandCounterByPartyResponse.type )( diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 3a3d8058bf3..25a615010c1 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -241,8 +241,9 @@ paths: operationId: "lookupSynchronizerRegistration" description: | Look up the registration of a dedicated synchronizer, so the SV UI can tell a - proposer that a synchronizer id is already registered. Forwarded to Scan, which - holds the registry; the SV app does not ingest it. + proposer that a synchronizer id is already registered, and resolve the registration + an offboard or set-parameters proposal targets. Forwarded to Scan, which holds the + registry; the SV app does not ingest it. parameters: - name: "synchronizer_id" in: "path" @@ -258,6 +259,28 @@ paths: "$ref": "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/LookupSynchronizerRegistrationResponse" "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + /v0/admin/sv/synchronizers/registrations/by-contract-id/{contract_id}: + get: + tags: [ sv ] + x-jvm-package: sv_operator + operationId: "lookupSynchronizerRegistrationByContractId" + description: | + Look up an active synchronizer registration by contract id, so the SV UI can show + what an offboard or set-parameters vote targets and flag a vote whose registration + is no longer active. Empty when the registration was archived. Forwarded to Scan. + parameters: + - name: "contract_id" + in: "path" + required: true + schema: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/LookupSynchronizerRegistrationByContractIdResponse" /v0/admin/sv/voteresults/count: post: tags: [ sv ] diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index e6f78fea167..5371dea5f30 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -250,7 +250,8 @@ class HttpSvOperatorHandler( } /** Intended use: the SV app UI, to warn a proposer that a synchronizer id is already - * registered. Forwarded to Scan, which holds the registry; the SV app does not ingest it. + * registered, and to resolve the registration an offboard or set-parameters proposal + * targets. Forwarded to Scan, which holds the registry; the SV app does not ingest it. */ override def lookupSynchronizerRegistration( respond: r0.LookupSynchronizerRegistrationResponse.type @@ -277,6 +278,27 @@ class HttpSvOperatorHandler( } } + /** Intended use: the SV app UI, to show what an offboard or set-parameters vote targets and + * to flag a vote whose pinned registration is no longer active. Forwarded to Scan. + */ + override def lookupSynchronizerRegistrationByContractId( + respond: r0.LookupSynchronizerRegistrationByContractIdResponse.type + )(contractId: String)( + extracted: ActAsKnownUserRequest + ): Future[r0.LookupSynchronizerRegistrationByContractIdResponse] = { + implicit val ActAsKnownUserRequest(traceContext) = extracted + withSpan(s"$workflowId.lookupSynchronizerRegistrationByContractId") { _ => _ => + for { + scanConnection <- scanConnectionF + registration <- scanConnection.lookupSynchronizerRegistrationByContractId(contractId) + } yield respond.OK( + definitions.LookupSynchronizerRegistrationByContractIdResponse( + registration.map(_.toHttp) + ) + ) + } + } + override def listValidatorLicenses( respond: r0.ListValidatorLicensesResponse.type )(after: Option[Long], limit: Option[Int])( From 73da04e4d7781bea6c99cd2a77d73a7b8925d842 Mon Sep 17 00:00:00 2001 From: Haiko Schol Date: Fri, 2 Oct 2026 10:24:01 +0000 Subject: [PATCH 2/4] fix: serve the registration lookup by contract id from the SV store [ci] The SV app forwarded the lookup to Scan. The DSO party signs every RegisteredSynchronizer, so the SV app's own DSO store can hold them: ingest them there and look the contract up locally, which avoids a BFT call across all scans for an SV-local read and the dependency on Scan being reachable. Nothing else uses the Scan endpoint added for this, so it is removed again, along with its client chain, console method and integration-test assertion. The SV endpoint keeps its path and response schema, now defined in sv-internal.yaml. Signed-off-by: Haiko Schol --- .../splice/console/ScanAppReference.scala | 8 ---- .../SyncOperatorTrafficIntegrationTest.scala | 10 ---- apps/scan/src/main/openapi/scan.yaml | 30 ------------ .../admin/api/client/BftScanConnection.scala | 9 ---- .../admin/api/client/ScanConnection.scala | 6 --- .../api/client/SingleScanConnection.scala | 9 ---- .../client/commands/HttpScanAppClient.scala | 20 -------- .../scan/admin/http/HttpScanHandler.scala | 22 --------- apps/sv/src/main/openapi/sv-internal.yaml | 14 +++++- .../sv/admin/http/HttpSvOperatorHandler.scala | 10 ++-- .../splice/sv/store/SvDsoStore.scala | 5 ++ .../splice/store/db/SvDsoStoreTest.scala | 46 ++++++++++++++++++- 12 files changed, 68 insertions(+), 121 deletions(-) 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 70ac79da41a..6a647efc590 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 @@ -205,14 +205,6 @@ abstract class ScanAppReference( httpCommand(HttpScanAppClient.LookupSynchronizerRegistration(synchronizerId)) } - @Help.Summary("Look up an active RegisteredSynchronizer by contract ID") - def lookupSynchronizerRegistrationByContractId( - contractId: String - ): Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]] = - consoleEnvironment.run { - httpCommand(HttpScanAppClient.LookupSynchronizerRegistrationByContractId(contractId)) - } - @Help.Summary("Lookup a TransferCommandCounter by the receiver party") def lookupTransferCommandCounterByParty( party: PartyId diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala index 28a8a9db855..0f54cc1ef10 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SyncOperatorTrafficIntegrationTest.scala @@ -147,16 +147,6 @@ class SyncOperatorTrafficIntegrationTest sv1ScanBackend.lookupSynchronizerRegistration(synchronizerId.toProtoPrimitive).value } - // Offboard and set-parameters votes pin the registration by contract id. - clue("scan looks the registration up by contract id") { - sv1ScanBackend - .lookupSynchronizerRegistrationByContractId(registration.contractId.contractId) - .value - .payload - .synchronizerId shouldBe synchronizerId.toProtoPrimitive - sv1ScanBackend.lookupSynchronizerRegistrationByContractId("00" * 33) shouldBe None - } - clue("the validator serves the registration to its wallet clients through the scan proxy") { aliceValidatorBackend.scanProxy .lookupSynchronizerRegistration(synchronizerId.toProtoPrimitive) diff --git a/apps/scan/src/main/openapi/scan.yaml b/apps/scan/src/main/openapi/scan.yaml index b3296b66587..9ef5c0775b4 100644 --- a/apps/scan/src/main/openapi/scan.yaml +++ b/apps/scan/src/main/openapi/scan.yaml @@ -1153,29 +1153,6 @@ paths: "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" - /v0/synchronizers/registrations/by-contract-id/{contract_id}: - get: - tags: [internal, scan] - x-jvm-package: scan - operationId: "lookupSynchronizerRegistrationByContractId" - description: | - Look up an active RegisteredSynchronizer by its contract ID. - Returns `registration` if the contract is active, empty otherwise: an archived - registration (offboarded, or replaced by a governance-parameters change) is not returned. - parameters: - - name: "contract_id" - in: "path" - required: true - schema: - type: string - responses: - "200": - description: ok - content: - application/json: - schema: - "$ref": "#/components/schemas/LookupSynchronizerRegistrationByContractIdResponse" - /v0/transfer-command-counter/{party}: get: tags: [internal, scan] @@ -3568,13 +3545,6 @@ components: properties: registration: "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/ContractWithState" - LookupSynchronizerRegistrationByContractIdResponse: - description: | - If defined, an active contract of Daml template `Splice.DecentralizedSynchronizer:RegisteredSynchronizer`. - type: object - properties: - registration: - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" LookupTransferCommandCounterByPartyResponse: description: A Daml contract of template `Splice.ExternalPartyAmuletRules:TransferCommandCounter`. 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 00ecb4dfcf7..904692c4b1a 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 @@ -495,15 +495,6 @@ class BftScanConnection( ): Future[Option[ContractWithState[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = bftCall(_.lookupSynchronizerRegistration(synchronizerId), "lookupSynchronizerRegistration") - override def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit - ec: ExecutionContext, - tc: TraceContext, - ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = - bftCall( - _.lookupSynchronizerRegistrationByContractId(contractId), - "lookupSynchronizerRegistrationByContractId", - ) - override def lookupTransferPreapprovalByParty(receiver: PartyId)(implicit ec: ExecutionContext, tc: TraceContext, 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 683e27e8e14..6ad82f6c8b3 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 @@ -314,12 +314,6 @@ trait ScanConnection tc: TraceContext, ): Future[Option[ContractWithState[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] - /** Only active registrations: None for an archived (offboarded or re-parameterized) one. */ - def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit - ec: ExecutionContext, - tc: TraceContext, - ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] - def listVoteRequestResults( filters: VoteResultsFilters, limit: Int, 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 dde4bec54d1..75f3d986a79 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 @@ -500,15 +500,6 @@ class SingleScanConnection private[client] ( HttpScanAppClient.LookupSynchronizerRegistration(synchronizerId), ) - override def lookupSynchronizerRegistrationByContractId(contractId: String)(implicit - ec: ExecutionContext, - tc: TraceContext, - ): Future[Option[Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = - runHttpCmd( - config.adminApi.url, - HttpScanAppClient.LookupSynchronizerRegistrationByContractId(contractId), - ) - override def lookupTransferPreapprovalByParty(receiver: PartyId)(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 bab51e0381c..a5550320cd7 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 @@ -627,26 +627,6 @@ object HttpScanAppClient { } } - case class LookupSynchronizerRegistrationByContractId( - contractId: String - ) extends InternalBaseCommand[http.LookupSynchronizerRegistrationByContractIdResponse, Option[ - Contract[RegisteredSynchronizer.ContractId, RegisteredSynchronizer] - ]] { - - override def submitRequest( - client: ScanClient, - headers: List[HttpHeader], - ) = client.lookupSynchronizerRegistrationByContractId(contractId, headers) - - override def handleOk()(implicit - decoder: TemplateJsonDecoder - ) = { case http.LookupSynchronizerRegistrationByContractIdResponse.OK(response) => - response.registration - .traverse(co => Contract.fromHttp(RegisteredSynchronizer.COMPANION)(co)) - .leftMap(_.toString) - } - } - case class LookupTransferCommandCounterByParty( party: PartyId ) extends InternalBaseCommand[http.LookupTransferCommandCounterByPartyResponse, Option[ 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 14202c16353..f21c6b3486c 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 @@ -34,7 +34,6 @@ import org.apache.pekko.http.scaladsl.model.Uri import org.lfdecentralizedtrust.splice.admin.http.HttpErrorHandler import org.lfdecentralizedtrust.splice.codegen.java.splice.{amulet, ans as ansCodegen} import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules -import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.RegisteredSynchronizer import org.lfdecentralizedtrust.splice.codegen.java.splice.dso.decentralizedsynchronizer.SynchronizerNodeConfig import org.lfdecentralizedtrust.splice.codegen.java.splice.dso.svstate.SvNodeState import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletrules.{ @@ -1237,27 +1236,6 @@ class HttpScanHandler( } } - override def lookupSynchronizerRegistrationByContractId( - respond: ScanResource.LookupSynchronizerRegistrationByContractIdResponse.type - )( - contractId: String - )(extracted: TraceContext): Future[ - ScanResource.LookupSynchronizerRegistrationByContractIdResponse - ] = { - implicit val tc = extracted - withSpan(s"$workflowId.lookupSynchronizerRegistrationByContractId") { _ => _ => - for { - registration <- store.multiDomainAcsStore.lookupContractById( - RegisteredSynchronizer.COMPANION - )(new RegisteredSynchronizer.ContractId(contractId)) - } yield { - definitions.LookupSynchronizerRegistrationByContractIdResponse( - registration.map(_.contract.toHttp) - ) - } - } - } - override def lookupTransferCommandCounterByParty( respond: ScanResource.LookupTransferCommandCounterByPartyResponse.type )( diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 25a615010c1..0c6b1f95cbb 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -267,7 +267,8 @@ paths: description: | Look up an active synchronizer registration by contract id, so the SV UI can show what an offboard or set-parameters vote targets and flag a vote whose registration - is no longer active. Empty when the registration was archived. Forwarded to Scan. + is no longer active. Empty when the registration was archived. Served from the SV + app's DSO store. parameters: - name: "contract_id" in: "path" @@ -280,7 +281,7 @@ paths: content: application/json: schema: - "$ref": "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/LookupSynchronizerRegistrationByContractIdResponse" + $ref: "#/components/schemas/LookupSynchronizerRegistrationByContractIdResponse" /v0/admin/sv/voteresults/count: post: tags: [ sv ] @@ -1257,6 +1258,15 @@ components: featured_app_right: $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + LookupSynchronizerRegistrationByContractIdResponse: + description: | + An active `Splice.DecentralizedSynchronizer:RegisteredSynchronizer` contract looked up by + contract ID, if it exists. + type: object + properties: + registration: + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + ListFeaturedAppRightsByProviderResponse: description: | The `Splice.Amulet.FeaturedAppRight` contract for a specific provider, if it exists. diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index 5371dea5f30..5a8df3b906d 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -279,7 +279,8 @@ class HttpSvOperatorHandler( } /** Intended use: the SV app UI, to show what an offboard or set-parameters vote targets and - * to flag a vote whose pinned registration is no longer active. Forwarded to Scan. + * to flag a vote whose pinned registration is no longer active. The DSO party signs every + * registration, so the SV app's own DSO store holds them. */ override def lookupSynchronizerRegistrationByContractId( respond: r0.LookupSynchronizerRegistrationByContractIdResponse.type @@ -289,11 +290,12 @@ class HttpSvOperatorHandler( implicit val ActAsKnownUserRequest(traceContext) = extracted withSpan(s"$workflowId.lookupSynchronizerRegistrationByContractId") { _ => _ => for { - scanConnection <- scanConnectionF - registration <- scanConnection.lookupSynchronizerRegistrationByContractId(contractId) + registration <- dsoStore.multiDomainAcsStore.lookupContractById( + spliceCodegen.decentralizedsynchronizer.RegisteredSynchronizer.COMPANION + )(new spliceCodegen.decentralizedsynchronizer.RegisteredSynchronizer.ContractId(contractId)) } yield respond.OK( definitions.LookupSynchronizerRegistrationByContractIdResponse( - registration.map(_.toHttp) + registration.map(_.contract.toHttp) ) ) } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala index aae1b0bc49e..44f45aca1ac 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala @@ -1507,6 +1507,11 @@ object SvDsoStore { totalTrafficPurchased = Some(contract.payload.totalPurchased), ) }, + // Offboard and set-parameters votes pin a registration by contract id; the SV UI looks it + // up here to show what such a vote targets. + mkFilter(splice.decentralizedsynchronizer.RegisteredSynchronizer.COMPANION)(co => + co.payload.dso == dso + )(DsoAcsStoreRowData(_)), mkFilter(splice.ans.AnsRules.COMPANION)(co => co.payload.dso == dso)(DsoAcsStoreRowData(_)), mkFilter(splice.ans.AnsEntry.COMPANION)(co => co.payload.dso == dso) { contract => DsoAcsStoreRowData( diff --git a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala index a5187f434c9..4808775c7e2 100644 --- a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala +++ b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala @@ -21,7 +21,11 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.{ } import org.lfdecentralizedtrust.splice.codegen.java.splice.ans.* import org.lfdecentralizedtrust.splice.codegen.java.splice.cometbft.CometBftConfigLimits -import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.MemberTraffic +import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.{ + GovernanceParameters, + MemberTraffic, + RegisteredSynchronizer, +} import org.lfdecentralizedtrust.splice.codegen.java.splice.dso.decentralizedsynchronizer.{ DsoDecentralizedSynchronizerConfig, SynchronizerNodeConfigLimits, @@ -209,6 +213,34 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { _.lookupFeaturedAppRightWithOffset(userParty(1)) ) + "RegisteredSynchronizer" should { + def lookup(store: SvDsoStore, cid: RegisteredSynchronizer.ContractId) = + store.multiDomainAcsStore.lookupContractById(RegisteredSynchronizer.COMPANION)(cid) + + "be found by contract id" in { + val wanted = registeredSynchronizer(userParty(1), "dedicated::1220aa") + val other = registeredSynchronizer(userParty(2), "dedicated::1220bb") + for { + store <- mkStore() + _ <- dummyDomain.create(wanted)(store.multiDomainAcsStore) + _ <- dummyDomain.create(other)(store.multiDomainAcsStore) + result <- lookup(store, wanted.contractId) + } yield result.map(_.contract) should be(Some(wanted)) + } + + // An offboard or set-parameters vote archives the registration it pins, which leaves any + // other vote pinning it stale. + "not be found once archived" in { + val registration = registeredSynchronizer(userParty(1), "dedicated::1220aa") + for { + store <- mkStore() + _ <- dummyDomain.create(registration)(store.multiDomainAcsStore) + _ <- dummyDomain.archive(registration)(store.multiDomainAcsStore) + result <- lookup(store, registration.contractId) + } yield result should be(None) + } + } + "getOpenMiningRoundTriple" should { "return the oldest, middle, newest mining rounds" in { @@ -2286,6 +2318,18 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { ) } + private def registeredSynchronizer(operator: PartyId, synchronizerId: String) = + contract( + RegisteredSynchronizer.TEMPLATE_ID_WITH_PACKAGE_ID, + new RegisteredSynchronizer.ContractId(nextCid()), + new RegisteredSynchronizer( + dsoParty.toProtoPrimitive, + synchronizerId, + operator.toProtoPrimitive, + new GovernanceParameters(java.math.BigDecimal.ONE.setScale(10)), + ), + ) + private def memberTraffic( member: Member, synchronizerId: SynchronizerId, From 2ce6201b6041b0eb42e0689677b822098d3386db Mon Sep 17 00:00:00 2001 From: Haiko Schol Date: Mon, 5 Oct 2026 07:18:28 +0000 Subject: [PATCH 3/4] fix: serve the registration lookup by synchronizer id from the SV store [ci] The SV app forwarded the lookup by synchronizer id, which the SV UI uses to reject a duplicate registration, to Scan. The SV app's DSO store now ingests every RegisteredSynchronizer, so look it up there instead: an SV-local read no longer needs a BFT call across all scans or a reachable Scan. The lookup filters on a new dso_acs_store.registered_synchronizer_id column, populated at ingestion, the way Scan serves the same query: V077 adds the column and SqlIndexInitializationTrigger adds the concurrent index. order by contract_id keeps the answer deterministic when an id is registered twice, which the ledger does not prevent. Scan keeps its endpoint: the validator, the wallet and the scan proxy still use it. The SV endpoint keeps its path and response shape, now defined in sv-internal.yaml. Signed-off-by: Haiko Schol --- ..._dso_acs_store_registered_synchronizer.sql | 4 ++ .../SqlIndexInitializationTrigger.scala | 9 ++++ ...lIndexInitializationTriggerStoreTest.scala | 1 + apps/sv/src/main/openapi/sv-internal.yaml | 15 +++++-- .../sv/admin/http/HttpSvOperatorHandler.scala | 5 +-- .../splice/sv/store/SvDsoStore.scala | 22 ++++++++-- .../splice/sv/store/db/DbSvDsoStore.scala | 30 ++++++++++++- .../splice/sv/store/db/DsoTables.scala | 5 +++ .../splice/store/db/SvDsoStoreTest.scala | 43 +++++++++++++++++++ 9 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V077__dso_acs_store_registered_synchronizer.sql diff --git a/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V077__dso_acs_store_registered_synchronizer.sql b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V077__dso_acs_store_registered_synchronizer.sql new file mode 100644 index 00000000000..0ba996022f6 --- /dev/null +++ b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V077__dso_acs_store_registered_synchronizer.sql @@ -0,0 +1,4 @@ +-- The synchronizer id from a RegisteredSynchronizer, so the SV app can serve a registration by +-- synchronizer id from its DSO store without a JSON extraction on every candidate row. +alter table dso_acs_store + add column registered_synchronizer_id text; diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala index 0cf497a95e6..94f65db68e9 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala @@ -273,6 +273,15 @@ object SqlIndexInitializationTrigger { where registered_synchronizer_id is not null """, ), + IndexAction + .Create( + indexName = "dso_acs_store_sid_mid_pn_tid_rsid", + createAction = sqlu""" + create index concurrently if not exists dso_acs_store_sid_mid_pn_tid_rsid + on dso_acs_store (store_id, migration_id, package_name, template_id_qualified_name, registered_synchronizer_id) + where registered_synchronizer_id is not null + """, + ), IndexAction .Create( indexName = "scan_txlog_store_sid_effat_en_vot", diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala index dec965b8925..e9f9acb37a8 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala @@ -43,6 +43,7 @@ class SqlIndexInitializationTriggerStoreTest "updt_hist_tran_hi_eth", "dso_acs_store_sid_mid_pn_tid_rbio", "scan_acs_store_sid_mid_pn_tid_rsid", + "dso_acs_store_sid_mid_pn_tid_rsid", "scan_txlog_store_sid_effat_en_vot", ) diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 0c6b1f95cbb..8e190c95338 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -242,8 +242,7 @@ paths: description: | Look up the registration of a dedicated synchronizer, so the SV UI can tell a proposer that a synchronizer id is already registered, and resolve the registration - an offboard or set-parameters proposal targets. Forwarded to Scan, which holds the - registry; the SV app does not ingest it. + an offboard or set-parameters proposal targets. Served from the SV app's DSO store. parameters: - name: "synchronizer_id" in: "path" @@ -256,7 +255,7 @@ paths: content: application/json: schema: - "$ref": "../../../../scan/src/main/openapi/scan.yaml#/components/schemas/LookupSynchronizerRegistrationResponse" + $ref: "#/components/schemas/LookupSynchronizerRegistrationResponse" "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" /v0/admin/sv/synchronizers/registrations/by-contract-id/{contract_id}: @@ -1258,6 +1257,16 @@ components: featured_app_right: $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + LookupSynchronizerRegistrationResponse: + description: | + An active `Splice.DecentralizedSynchronizer:RegisteredSynchronizer` contract for a + synchronizer id. + type: object + required: ["registration"] + properties: + registration: + $ref: "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/ContractWithState" + LookupSynchronizerRegistrationByContractIdResponse: description: | An active `Splice.DecentralizedSynchronizer:RegisteredSynchronizer` contract looked up by diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index 5a8df3b906d..b26e9dcd062 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -251,7 +251,7 @@ class HttpSvOperatorHandler( /** Intended use: the SV app UI, to warn a proposer that a synchronizer id is already * registered, and to resolve the registration an offboard or set-parameters proposal - * targets. Forwarded to Scan, which holds the registry; the SV app does not ingest it. + * targets. The DSO party signs every registration, so the SV app's own DSO store holds them. */ override def lookupSynchronizerRegistration( respond: r0.LookupSynchronizerRegistrationResponse.type @@ -261,8 +261,7 @@ class HttpSvOperatorHandler( implicit val ActAsKnownUserRequest(traceContext) = extracted withSpan(s"$workflowId.lookupSynchronizerRegistration") { _ => _ => for { - scanConnection <- scanConnectionF - registrationOpt <- scanConnection.lookupSynchronizerRegistration(synchronizerId) + registrationOpt <- dsoStore.lookupSynchronizerRegistration(synchronizerId) } yield registrationOpt match { case None => r0.LookupSynchronizerRegistrationResponse.NotFound( diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala index 44f45aca1ac..39c908d657b 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala @@ -1039,6 +1039,14 @@ trait SvDsoStore ] ] + /** The active registration for a dedicated synchronizer id, if any. */ + def lookupSynchronizerRegistration(synchronizerId: String)(implicit + tc: TraceContext + ): Future[Option[ContractWithState[ + splice.decentralizedsynchronizer.RegisteredSynchronizer.ContractId, + splice.decentralizedsynchronizer.RegisteredSynchronizer, + ]]] + def lookupFeaturedAppRight( providerPartyId: PartyId )(implicit @@ -1507,11 +1515,19 @@ object SvDsoStore { totalTrafficPurchased = Some(contract.payload.totalPurchased), ) }, - // Offboard and set-parameters votes pin a registration by contract id; the SV UI looks it - // up here to show what such a vote targets. + // The SV UI looks a registration up here by contract id, to show what an offboard or + // set-parameters vote targets, and by synchronizer id, to reject a duplicate registration. mkFilter(splice.decentralizedsynchronizer.RegisteredSynchronizer.COMPANION)(co => co.payload.dso == dso - )(DsoAcsStoreRowData(_)), + )(contract => + // A String, not a SynchronizerId: DsoRules_RegisterSynchronizer only checks the id is + // non-empty, so tryFromString here would throw on a governance typo and take down the + // ingestion pipeline. The query compares it as text. + DsoAcsStoreRowData( + contract, + registeredSynchronizerId = Some(contract.payload.synchronizerId), + ) + ), mkFilter(splice.ans.AnsRules.COMPANION)(co => co.payload.dso == dso)(DsoAcsStoreRowData(_)), mkFilter(splice.ans.AnsEntry.COMPANION)(co => co.payload.dso == dso) { contract => DsoAcsStoreRowData( diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index b2f01114145..68779def224 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -11,7 +11,10 @@ import com.daml.nonempty.NonEmpty import org.lfdecentralizedtrust.splice.automation.MultiDomainExpiredContractTrigger.ListExpiredContracts import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.* -import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.MemberTraffic +import org.lfdecentralizedtrust.splice.codegen.java.splice.decentralizedsynchronizer.{ + MemberTraffic, + RegisteredSynchronizer, +} import org.lfdecentralizedtrust.splice.codegen.java.splice.round.{ ClosedMiningRound, SummarizingMiningRound, @@ -1956,6 +1959,31 @@ class DbSvDsoStore( } yield row.map(contractWithStateFromRow(AnsEntryContext.COMPANION)(_)) } + override def lookupSynchronizerRegistration(synchronizerId: String)(implicit + tc: TraceContext + ): Future[Option[ContractWithState[RegisteredSynchronizer.ContractId, RegisteredSynchronizer]]] = + waitUntilAcsIngested { + for { + row <- storage + .querySingle( + selectFromAcsTableWithState( + DsoTables.acsTableName, + acsStoreId, + domainMigrationId, + RegisteredSynchronizer.COMPANION, + additionalWhere = sql"""and registered_synchronizer_id = ${lengthLimited( + synchronizerId + )}""", + // Uniqueness is not enforced on-ledger, so pick a total order: a duplicate + // registration then always resolves to the same row. + orderLimit = sql"""order by contract_id limit 1""", + ).headOption, + "lookupSynchronizerRegistration", + ) + .value + } yield row.map(contractWithStateFromRow(RegisteredSynchronizer.COMPANION)(_)) + } + override def listClosedRounds( roundNumbers: Set[Long], synchronizerId: SynchronizerId, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DsoTables.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DsoTables.scala index 53f2358a102..a87340e9527 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DsoTables.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DsoTables.scala @@ -56,6 +56,7 @@ object DsoTables extends AcsTables with NamedLogging { walletParty: Option[PartyId] = None, conversionRateFeedPublisher: Option[PartyId] = None, rewardBeneficiaryIsObserver: Option[Boolean] = None, + registeredSynchronizerId: Option[String] = None, ) extends AcsRowData.AcsRowDataFromContract { override def indexColumns: Seq[(String, IndexColumnValue[?])] = Seq( DsoAcsStoreRowData.IndexColumns.amulet_round_of_expiry -> amuletRoundOfExpiry, @@ -91,6 +92,8 @@ object DsoTables extends AcsTables with NamedLogging { DsoAcsStoreRowData.IndexColumns.wallet_party -> walletParty, DsoAcsStoreRowData.IndexColumns.conversion_rate_feed_publisher -> conversionRateFeedPublisher, DsoAcsStoreRowData.IndexColumns.reward_beneficiary_is_observer -> rewardBeneficiaryIsObserver, + DsoAcsStoreRowData.IndexColumns.registered_synchronizer_id -> registeredSynchronizerId + .map(lengthLimited), ) } object DsoAcsStoreRowData { @@ -133,6 +136,7 @@ object DsoTables extends AcsTables with NamedLogging { val wallet_party = "wallet_party" val conversion_rate_feed_publisher = "conversion_rate_feed_publisher" val reward_beneficiary_is_observer = "reward_beneficiary_is_observer" + val registered_synchronizer_id = "registered_synchronizer_id" val All = Seq( amulet_round_of_expiry, reward_round, @@ -166,6 +170,7 @@ object DsoTables extends AcsTables with NamedLogging { wallet_party, conversion_rate_feed_publisher, reward_beneficiary_is_observer, + registered_synchronizer_id, ) } } diff --git a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala index 4808775c7e2..4d0d4b12b09 100644 --- a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala +++ b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala @@ -55,6 +55,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.payment.{Payme import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions.* import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider} +import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.ContractState.Assigned import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult import org.lfdecentralizedtrust.splice.store.{ HardLimit, @@ -71,6 +72,7 @@ import org.lfdecentralizedtrust.splice.sv.util.SvUtil import org.lfdecentralizedtrust.splice.util.{ AssignedContract, Contract, + ContractWithState, ResourceTemplateDecoder, TemplateJsonDecoder, } @@ -239,6 +241,47 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { result <- lookup(store, registration.contractId) } yield result should be(None) } + + // The SV UI rejects a proposal to register a synchronizer id that already has one. + "be found by synchronizer id" in { + val wanted = registeredSynchronizer(userParty(1), "dedicated::1220aa") + val other = registeredSynchronizer(userParty(2), "dedicated::1220bb") + for { + store <- mkStore() + _ <- dummyDomain.create(wanted)(store.multiDomainAcsStore) + _ <- dummyDomain.create(other)(store.multiDomainAcsStore) + found <- store.lookupSynchronizerRegistration("dedicated::1220aa") + missing <- store.lookupSynchronizerRegistration("dedicated::1220zz") + } yield { + found should be(Some(ContractWithState(wanted, Assigned(dummyDomain)))) + missing should be(None) + } + } + + "not be found by synchronizer id once archived" in { + val registration = registeredSynchronizer(userParty(1), "dedicated::1220aa") + for { + store <- mkStore() + _ <- dummyDomain.create(registration)(store.multiDomainAcsStore) + _ <- dummyDomain.archive(registration)(store.multiDomainAcsStore) + result <- store.lookupSynchronizerRegistration("dedicated::1220aa") + } yield result should be(None) + } + + // Governance can create two registrations for one synchronizer id: the template has no + // key and DsoRules_RegisterSynchronizer creates unconditionally. + "pick deterministically when a synchronizer id has more than one registration" in { + // Ingest `lower` second so insertion order and contract-id order disagree. + val lower = registeredSynchronizer(userParty(1), "dedicated::1220aa") + val higher = registeredSynchronizer(userParty(2), "dedicated::1220aa") + lower.contractId.contractId should be < higher.contractId.contractId + for { + store <- mkStore() + _ <- dummyDomain.create(higher)(store.multiDomainAcsStore) + _ <- dummyDomain.create(lower)(store.multiDomainAcsStore) + result <- store.lookupSynchronizerRegistration("dedicated::1220aa") + } yield result should be(Some(ContractWithState(lower, Assigned(dummyDomain)))) + } } "getOpenMiningRoundTriple" should { From 297d103d95cdaf724650b939ec0e54a028ed4295 Mon Sep 17 00:00:00 2001 From: Haiko Schol Date: Thu, 8 Oct 2026 01:57:52 +0000 Subject: [PATCH 4/4] fix: guard the SV store's RegisteredSynchronizer filter on package version [ci] The SV app's DSO store subscribed to RegisteredSynchronizer unconditionally. On a network whose package config still runs a splice-amulet that predates the template, the ledger API rejects the whole ACS stream with NO_TEMPLATES_FOR_PACKAGE_NAME_AND_QUALIFIED_NAME, the store retries forever and the SV app never finishes setup. The BootstrapPackageConfigDarUpload* integration tests boot on such a config, hung on it, and left their environment's ports bound, which failed every later suite in their shards. Guard the filter with supportsDedicatedSynchronizers, as Scan already does for the same template. Until a package config that carries RegisteredSynchronizer is active, nothing is ingested and both registration lookups return not found, which is correct: no registration can exist before then. Signed-off-by: Haiko Schol --- .../lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala index 39c908d657b..5084a38c9ff 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala @@ -1517,8 +1517,11 @@ object SvDsoStore { }, // The SV UI looks a registration up here by contract id, to show what an offboard or // set-parameters vote targets, and by synchronizer id, to reject a duplicate registration. - mkFilter(splice.decentralizedsynchronizer.RegisteredSynchronizer.COMPANION)(co => - co.payload.dso == dso + mkFilter(splice.decentralizedsynchronizer.RegisteredSynchronizer.COMPANION)( + co => co.payload.dso == dso, + versionGuard = { case (pkgVersionSupport, now) => + (tc) => pkgVersionSupport.supportsDedicatedSynchronizers(Seq(dsoParty), now)(tc) + }, )(contract => // A String, not a SynchronizerId: DsoRules_RegisterSynchronizer only checks the id is // non-empty, so tryFromString here would throw on a governance typo and take down the