diff --git a/build.sbt b/build.sbt index 9ba810a7b8..92df79f647 100644 --- a/build.sbt +++ b/build.sbt @@ -114,6 +114,8 @@ lazy val root: Project = (project in file(".")) `splice-dso-governance-test-daml`, `splice-validator-lifecycle-daml`, `splice-validator-lifecycle-test-daml`, + `splice-api-featured-app-v1-daml`, + `splice-api-credential-registry-v1-daml`, `splice-api-token-metadata-v1-daml`, `splice-api-token-holding-v1-daml`, `splice-api-token-transfer-instruction-v1-daml`, @@ -660,7 +662,7 @@ lazy val `splice-util-daml` = `canton-bindings-java` ) -lazy val `splice-featured-app-api-v1-daml` = +lazy val `splice-api-featured-app-v1-daml` = project .in(file("daml/splice-api-featured-app-v1")) .enablePlugins(DamlPlugin) @@ -671,6 +673,21 @@ lazy val `splice-featured-app-api-v1-daml` = `canton-bindings-java` ) +lazy val `splice-api-credential-registry-v1-daml` = + project + .in(file("daml/splice-api-credential-registry-v1")) + .enablePlugins(DamlPlugin) + .settings( + BuildCommon.damlSettings, + Compile / damlDependencies := + (`splice-util-daml` / Compile / damlBuild).value ++ + (`splice-api-token-metadata-v1-daml` / Compile / damlBuild).value, + ) + .dependsOn( + `canton-bindings-java` + ) + + lazy val `splice-amulet-daml` = project .in(file("daml/splice-amulet")) @@ -685,7 +702,8 @@ lazy val `splice-amulet-daml` = (`splice-api-token-allocation-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-request-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value ++ + (`splice-api-credential-registry-v1-daml` / Compile / damlBuild).value, ) .dependsOn(`canton-bindings-java`) @@ -796,7 +814,7 @@ lazy val `splice-util-featured-app-proxies-daml` = (`splice-api-token-transfer-instruction-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value, ) .dependsOn(`canton-bindings-java`) @@ -810,7 +828,7 @@ lazy val `splice-util-token-standard-wallet-daml` = (`splice-api-token-holding-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-metadata-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-transfer-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value, ) .dependsOn(`canton-bindings-java`) @@ -857,7 +875,9 @@ lazy val `splice-amulet-name-service-daml` = .enablePlugins(DamlPlugin) .settings( BuildCommon.damlSettings, - Compile / damlDependencies := (`splice-wallet-payments-daml` / Compile / damlBuild).value, + Compile / damlDependencies := + (`splice-wallet-payments-daml` / Compile / damlBuild).value ++ + (`splice-api-credential-registry-v1-daml` / Compile / damlBuild).value, ) .dependsOn(`canton-bindings-java`) @@ -922,7 +942,7 @@ lazy val `apps-common` = `splice-api-token-allocation-instruction-v1-daml`, `splice-token-test-dummy-holding-daml`, `splice-token-test-trading-app-daml`, - `splice-featured-app-api-v1-daml`, + `splice-api-featured-app-v1-daml`, ) .enablePlugins(BuildInfoPlugin) .settings( diff --git a/daml/splice-amulet-name-service/daml.yaml b/daml/splice-amulet-name-service/daml.yaml index 83452c24b4..76462f285c 100644 --- a/daml/splice-amulet-name-service/daml.yaml +++ b/daml/splice-amulet-name-service/daml.yaml @@ -9,7 +9,10 @@ data-dependencies: - ../splice-amulet/.daml/dist/splice-amulet-current.dar - ../splice-util/.daml/dist/splice-util-current.dar - ../splice-wallet-payments/.daml/dist/splice-wallet-payments-current.dar + - ../splice-api-featured-app-v1/.daml/dist/splice-api-featured-app-v1-current.dar +- ../splice-api-credential-registry-v1/.daml/dist/splice-api-credential-registry-v1-current.dar +- ../../token-standard/splice-api-token-metadata-v1/.daml/dist/splice-api-token-metadata-v1-current.dar build-options: - --ghc-option=-Wunused-binds - --ghc-option=-Wunused-matches diff --git a/daml/splice-amulet-name-service/daml/Splice/Ans.daml b/daml/splice-amulet-name-service/daml/Splice/Ans.daml index 43564af0c6..454fbb5a30 100644 --- a/daml/splice-amulet-name-service/daml/Splice/Ans.daml +++ b/daml/splice-amulet-name-service/daml/Splice/Ans.daml @@ -4,6 +4,7 @@ module Splice.Ans where import DA.Action (void) +import DA.TextMap qualified as TM import DA.Time import Splice.Amulet @@ -13,6 +14,9 @@ import Splice.Wallet.Payment import Splice.Wallet.Subscriptions import Splice.Util +import Splice.Api.Token.MetadataV1 (emptyMetadata) +import Splice.Api.Credential.RegistryV1 qualified as RegistryV1 + data AnsRules_RequestEntryResult = AnsRules_RequestEntryResult with entryCid : ContractId AnsEntryContext @@ -297,6 +301,54 @@ template AnsEntry where signatory user, dso + -- TODO: factor this instance out into a separate PR for the CIP that builds ANS + -- on top of the DSO credential registry. + -- + -- This instance is only provided here to test-drive the credential standard APIs. + -- + interface instance RegistryV1.Credential for AnsEntry where + view = RegistryV1.CredentialView with + admin = dso + issuer = dso + holder = user + claims = RegistryV1.Claims with + values = TM.fromList [ + -- TODO: use the right CIP number once it is assigned; factor it out into a constant + + ("cip-TBD/cns.name!" <> name, partyToText user), + -- This claim translates to (subject="name", property="cip-TBD/cns.name", value=holder). + -- + -- A name lookup matching ANS 1.0 lookup semantics + -- can be performed by searching for credentials with + -- key = "cip-TBD/cns.name#" and issuer=dso + -- + -- TODO: consider saving space and defining the default rule that an empty value + -- means that the value is the holder party. It also obviates the need to deal + -- with credentials where the holder is different from the value of this claim. + -- + -- TODO: consider using reverse DNS style for the name, so that the prefix search + -- allows listing all names under a domain, e.g., "com.example.myname". + -- + ("cip-TBD/cns.url!" <> name, url), + ("cip-TBD/cns.description!" <> name, description) + -- Note that name and description are associated with the ans.name and not + -- the holder directly; i.e., the holder can have different attributes for different names. + + ] + validFrom = None + validUntil = Some expiresAt + meta = emptyMetadata + createdAt = None + expiresAt = Some expiresAt + meta = emptyMetadata + + credential_publicFetchImpl _self _arg = pure (view (toInterface @RegistryV1.Credential this)) + + credential_archiveAsHolderImpl _self _arg = do + pure RegistryV1.Credential_ArchiveAsHolderResult with + archivedCredential = view (toInterface @RegistryV1.Credential this) + meta = emptyMetadata + choice AnsEntry_Expire : AnsEntry_ExpireResult with actor : Party diff --git a/daml/splice-amulet-name-service/daml/Splice/Ans/CredentialRegistry.daml b/daml/splice-amulet-name-service/daml/Splice/Ans/CredentialRegistry.daml new file mode 100644 index 0000000000..b9b8497c65 --- /dev/null +++ b/daml/splice-amulet-name-service/daml/Splice/Ans/CredentialRegistry.daml @@ -0,0 +1,183 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Credential registry implementation provided as part of the Amulet Name Service (ANS) +-- run as port of the Decentralized Synchronizer Operations (DSO). +module Splice.Ans.CredentialRegistry where + +import DA.Assert +import DA.Foldable (forA_) +import DA.Time +import DA.Text as T +import DA.TextMap as TextMap + +import Splice.Types +import Splice.Util + +import Splice.Api.Token.MetadataV1 (emptyMetadata) +import Splice.Api.Credential.RegistryV1 qualified as RegistryV1 + + +-- | Credential registry factory contract. +template AnsCredentialRegistry with + dso : Party + where + signatory dso + + interface instance RegistryV1.CredentialFactory for AnsCredentialRegistry where + view = RegistryV1.CredentialFactoryView with + admin = dso + meta = emptyMetadata + + credentialFactory_publicFetchImpl _self _arg = pure (view (toInterface @RegistryV1.CredentialFactory this)) + + credentialFactory_updateCredentialsImpl _self RegistryV1.CredentialFactory_UpdateCredentials{..} = do + assertDeadlineExceeded "newCreatedAt" newCreatedAt + -- TODO: make configurable + let defaultExpiresAt = addRelTime newCreatedAt (days 90) -- default to 90 day expiry + + -- check admin + require "admin matches" (admin == dso) + + -- archive old credential records + forA_ oldCredentials \oldCredentialCid -> do + let ansCredentialCid = fromInterfaceContractId @AnsCredentialRecord oldCredentialCid + oldCredential <- fetchAndArchive (ForOwner with dso; owner = holder) ansCredentialCid + require "issuer matches" (oldCredential.issuer == issuer) + + -- create new records + newCredentials <- forA newCredentialClaims \claims -> do + toInterfaceContractId <$> create AnsCredentialRecord with + dso + issuer + holder + claims -- TODO: normalize claims by dropping redundant '#holder' suffixes + createdAt = newCreatedAt + expiresAt = optional defaultExpiresAt (min defaultExpiresAt) claims.validUntil + + pure RegistryV1.CredentialFactory_UpdateCredentialsResult with + newCredentials + meta = emptyMetadata + +-- | A credential record visible to the DSO and served by its Scan service. +template AnsCredentialRecord + with + dso : Party + issuer : Party + holder : Party + claims : RegistryV1.Claims + createdAt : Time + expiresAt : Time + where + ensure validCredentialRecord this + + signatory issuer, holder + observer dso + + interface instance RegistryV1.Credential for AnsCredentialRecord where + view = RegistryV1.CredentialView with + admin = dso + issuer = issuer + holder = holder + claims = claims + createdAt = Some createdAt + expiresAt = Some expiresAt + meta = emptyMetadata + + credential_publicFetchImpl _self _arg = pure (view (toInterface @RegistryV1.Credential this)) + + credential_archiveAsHolderImpl _self _arg = do + pure RegistryV1.Credential_ArchiveAsHolderResult with + archivedCredential = view (toInterface @RegistryV1.Credential this) + meta = emptyMetadata + + choice AnsCredential_Expire : () + with + actor : Party + controller actor + do + require "actor is stakeholder" (actor == holder || actor == issuer || actor == dso) + assertDeadlineExceeded "expiresAt" expiresAt + + choice AnsCredential_DsoArchiveInvalid : () + -- ^ Archive this credential record if it is invalid according to the DSO rules. + -- Required as the DSO party is only an observer and thus cannot control the creation of records. + controller dso + do + -- created at cannot be in the future + assertWithinDeadline "createdAt" createdAt + -- TODO: add check that expiresAt is not too far in the future + -- TODO: consider how to check that reliably when the allowed TTL changes + pure () + + +-- Credential record validation +------------------------------- + +-- | Credential record validation to ensure that the credentials are not too large and +-- can be efficiently indexed and processed by the Scan service. +-- +-- No content-level validation is performed, as that is application-specific and would +-- bind the registry to specific use cases. +validCredentialRecord : AnsCredentialRecord -> Bool +validCredentialRecord credential = + validClaims credential.createdAt credential.claims + -- no validation other than for the claims + +validClaims : Time -> RegistryV1.Claims -> Bool +validClaims createdAt claims = + -- at most 32 claims per credential to constrain indexing overhead + TextMap.size claims.values <= 32 && + -- claim values are valid + all validClaimValue (TextMap.toList claims.values) && + -- validity dates are consistent + case (claims.validFrom, claims.validUntil) of + (Some from, Some until) -> createdAt <= from && from < until + (Some from, None) -> createdAt <= from + (None, Some until) -> createdAt < until + (None, None) -> True + +validClaimValue : (Text, Text) -> Bool +validClaimValue (k, v) = + validKey k && + T.length v < 2048 + +-- | Keys are constrained to be < 512 characters, as they need to be indexed efficiently. +validKey : Text -> Bool +validKey k = + case T.splitOn "#" k of + [property] -> validProperty property + [property, subject] -> validProperty property && validSubject subject + _ -> False + +validProperty : Text -> Bool +validProperty property = + -- 254 chosen to ensure total key length is below 512 with subject suffixes + T.length property <= 254 && all validPropertyCodepoint (T.toCodePoints property) + +validSubject : Text -> Bool +validSubject subject = + -- 255 chosen to allow full party-ids + T.length subject <= 255 && all validPropertyCodepoint (T.toCodePoints subject) + +validPropertyCodepoint : Int -> Bool +validPropertyCodepoint c = + -- yeah, Daml does not have rgex support yet + (c >= 0x30 && c <= 0x39) -- '0' - '9' + || (c >= 0x41 && c <= 0x5A) -- 'A' - 'Z' + || (c >= 0x61 && c <= 0x7A) -- 'a' - 'z' + || c == 0x2E -- '.' + || c == 0x5F -- '_' + || c == 0x3A -- ':' + || c == 0x2D -- '-' + || c == 0x2F -- '/' + + +-- checked fetches +------------------ + +instance HasCheckedFetch AnsCredentialRegistry ForDso where + contractGroupId AnsCredentialRegistry{..} = ForDso with dso + +instance HasCheckedFetch AnsCredentialRecord ForOwner where + contractGroupId AnsCredentialRecord{..} = ForOwner with dso; owner = holder diff --git a/daml/splice-amulet/daml.yaml b/daml/splice-amulet/daml.yaml index d0edcb5cbb..ef8d1e6f4c 100644 --- a/daml/splice-amulet/daml.yaml +++ b/daml/splice-amulet/daml.yaml @@ -18,6 +18,7 @@ data-dependencies: - ../../token-standard/splice-api-token-allocation-instruction-v1/.daml/dist/splice-api-token-allocation-instruction-v1-current.dar - ../splice-util/.daml/dist/splice-util-current.dar - ../splice-api-featured-app-v1/.daml/dist/splice-api-featured-app-v1-current.dar + - ../splice-api-credential-registry-v1/.daml/dist/splice-api-credential-registry-v1-current.dar build-options: - --ghc-option=-Wunused-binds - --ghc-option=-Wunused-matches diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index d3366d4190..2b0df20e82 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -13,6 +13,7 @@ import DA.Optional (fromOptional) import Splice.Api.Token.MetadataV1 qualified as Api.Token.MetadataV1 import Splice.Api.Token.HoldingV1 qualified as Api.Token.HoldingV1 +import Splice.Api.Credential.RegistryV1 qualified as Api.Credential.RegistryV1 import Splice.Amulet.TokenApiUtils import Splice.Expiry @@ -81,7 +82,7 @@ data SvRewardCoupon_ArchiveAsBeneficiaryResult = SvRewardCoupon_ArchiveAsBenefic data UnclaimedActivityRecord_ArchiveAsBeneficiaryResult = UnclaimedActivityRecord_ArchiveAsBeneficiaryResult -data UnclaimedActivityRecord_DsoExpireResult = UnclaimedActivityRecord_DsoExpireResult with +data UnclaimedActivityRecord_DsoExpireResult = UnclaimedActivityRecord_DsoExpireResult with unclaimedRewardCid : ContractId UnclaimedReward -- | A amulet, which can be locked and whose amount expires over time. @@ -246,6 +247,30 @@ template FeaturedAppRight with weight = weight pure (Splice.Api.FeaturedAppRightV1.FeaturedAppRight_CreateActivityMarkerResult $ map toInterfaceContractId cids) + interface instance Api.Credential.RegistryV1.Credential for FeaturedAppRight where + view = Api.Credential.RegistryV1.CredentialView with + admin = dso + issuer = dso + holder = provider + claims = Api.Credential.RegistryV1.Claims with + values = TextMap.fromList [ + ("cip-TBD/is-featured-app", "") + -- TODO: replace TBD with the actual CIP number once it is assigned; factor it out into a constant + ] + validFrom = None + validUntil = None + meta = Api.Token.MetadataV1.emptyMetadata + createdAt = None + expiresAt = None + meta = Api.Token.MetadataV1.emptyMetadata + + credential_publicFetchImpl _self _arg = pure (view (toInterface @Api.Credential.RegistryV1.Credential this)) + + credential_archiveAsHolderImpl _self _arg = do + pure Api.Credential.RegistryV1.Credential_ArchiveAsHolderResult with + archivedCredential = view (toInterface @Api.Credential.RegistryV1.Credential this) + meta = Api.Token.MetadataV1.emptyMetadata + validateAppRewardBeneficiaries : [Splice.Api.FeaturedAppRightV1.AppRewardBeneficiary] -> Update () validateAppRewardBeneficiaries beneficiaries = do -- Note: we limit the minimal weight to limit the management overhead of the SVs @@ -392,29 +417,29 @@ template UnclaimedReward with signatory dso --- | A record of activity that can be minted by the beneficiary. --- Note that these do not come out of the per-round issuance but are instead created by burning --- UnclaimedRewardCoupon as defined through a vote by the SVs. That's also why expiry is a separate +-- | A record of activity that can be minted by the beneficiary. +-- Note that these do not come out of the per-round issuance but are instead created by burning +-- UnclaimedRewardCoupon as defined through a vote by the SVs. That's also why expiry is a separate -- time-based expiry instead of being tied to a round like the other activity records. template UnclaimedActivityRecord with dso : Party beneficiary : Party -- ^ The owner of the `Amulet` to be minted. amount : Decimal -- ^ The amount of `Amulet` to be minted. - reason : Text -- ^ A reason to mint the `Amulet`. - expiresAt : Time -- ^ Selected timestamp defining the lifetime of the contract. - where + reason : Text -- ^ A reason to mint the `Amulet`. + expiresAt : Time -- ^ Selected timestamp defining the lifetime of the contract. + where signatory dso observer beneficiary ensure amount > 0.0 choice UnclaimedActivityRecord_DsoExpire : UnclaimedActivityRecord_DsoExpireResult controller dso - do + do assertDeadlineExceeded "UnclaimedActivityRecord.expiresAt" expiresAt unclaimedRewardCid <- create UnclaimedReward with dso; amount pure UnclaimedActivityRecord_DsoExpireResult with unclaimedRewardCid - + requireAmuletExpiredForAllOpenRounds : ContractId OpenMiningRound -> Amulet -> Update () requireAmuletExpiredForAllOpenRounds roundCid amulet = do @@ -456,4 +481,4 @@ instance HasCheckedFetch FeaturedAppActivityMarker ForDso where contractGroupId FeaturedAppActivityMarker {..} = ForDso with dso instance HasCheckedFetch UnclaimedActivityRecord ForOwner where - contractGroupId UnclaimedActivityRecord{..} = ForOwner with dso; owner = beneficiary \ No newline at end of file + contractGroupId UnclaimedActivityRecord{..} = ForOwner with dso; owner = beneficiary diff --git a/daml/splice-api-credential-registry-v1/daml.yaml b/daml/splice-api-credential-registry-v1/daml.yaml new file mode 100644 index 0000000000..ea7b4aadab --- /dev/null +++ b/daml/splice-api-credential-registry-v1/daml.yaml @@ -0,0 +1,18 @@ +sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 +name: splice-api-credential-registry-v1 +source: daml +version: 1.0.0 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + - ../../token-standard/splice-api-token-metadata-v1/.daml/dist/splice-api-token-metadata-v1-current.dar +build-options: + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches + - --target=2.1 +codegen: + java: + package-prefix: org.lfdecentralizedtrust.splice.codegen.java + decoderClass: org.lfdecentralizedtrust.splice.codegen.java.DecoderSpliceCredentialRegistryV1 + output-directory: target/daml-codegen-java diff --git a/daml/splice-api-credential-registry-v1/daml/Splice/Api/Credential/RegistryV1.daml b/daml/splice-api-credential-registry-v1/daml/Splice/Api/Credential/RegistryV1.daml new file mode 100644 index 0000000000..fa8af4c7d3 --- /dev/null +++ b/daml/splice-api-credential-registry-v1/daml/Splice/Api/Credential/RegistryV1.daml @@ -0,0 +1,192 @@ + +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Credential Registry API that defines both the schema of credentials and +-- the interface for managing credentials in a registry. +module Splice.Api.Credential.RegistryV1 where + +import DA.TextMap (TextMap) + +import Splice.Api.Token.MetadataV1 + + +-- | A set of claims that define a credential analogous to W3C Verifiable Credentials. +data Claims = Claims with + values : TextMap Text + -- ^ The values of the claims are encoded as key-value pairs to align with the + -- common use of key-value pairs in ENS, DNS, k8s, and similar systems. A + -- W3C claim of the form (subject, property, value) is represented as a + -- key-value pair with the key formed as "property!subject" and value as + -- "value". + -- + -- When no '!subject' suffix is present in the key, the claim pertains + -- to the holder of the credential. Thus the key value pair + -- + -- "cip-TBD/displayName" : "Alice" + -- + -- corresponds to the W3C claim + -- + -- (subject=holder, property="cip-TBD/displayName", value="Alice"). + -- + -- Implementations SHOULD ensure that claims are stored in canonical form without + -- redundant '!holder' suffixes in keys. + -- + -- Keys MUST only contain characters from [a-zA-Z0-9._:/!-] + -- and the '!' is only allowed to be used to separate property from subject. + -- + -- All keys MUST be namespaced in the form `namespace/property' to avoid collisions. + -- The namespace `cip-` is reserved for CIP-defined properties. + -- All other applications MUST use a Java-style reverse DNS name for + -- a domain under their control. + -- + -- For example, a key for a property `prop` defined by the domain + -- `example.com` would be `com.example/prop`. + validFrom : Optional Time + -- ^ The time from which this credential is valid. + validUntil : Optional Time + -- ^ The time until which this credential is valid. + meta : Metadata + -- ^ Metadata associated with these claims. Used for extensibility. + deriving (Eq, Show) + +-- | A view of a credential record stored in a credential registry. +data CredentialView = CredentialView with + admin : Party + -- ^ The party that administers this credential registry. + issuer : Party + -- ^ The party that issued the credential. + holder : Party + -- ^ The party that holds the credential. + claims : Claims + -- ^ The credential associated with this record. + createdAt : Optional Time + -- ^ The time at which this credential record was created. + expiresAt : Optional Time + -- ^ The time at which this credential record expires in the registry. + -- + -- The registry MAY archive the record after this time. + -- + -- Separate from the `validUntil` field in `Claims`, as the expiry time of the + -- credential record in the registry is determined by the registry policy and + -- may differ from the validity period of the credential itself. + meta : Metadata + -- ^ Metadata associated with this credential record. Used for extensibility. + deriving (Eq, Show) + +-- | A credential record stored in a credential registry. +interface Credential where + viewtype CredentialView + + credential_archiveAsHolderImpl : ContractId Credential -> Credential_ArchiveAsHolder -> Update Credential_ArchiveAsHolderResult + credential_publicFetchImpl : ContractId Credential -> Credential_PublicFetch -> Update CredentialView + + choice Credential_ArchiveAsHolder : Credential_ArchiveAsHolderResult + -- ^ Archive this credential record as the holder. + -- + -- This is always allowed for the holder of the credential and matches the real-world analogue + -- of them destroying their copy of the credential. + -- + -- The view is returned for convenience so that the caller does not need to fetch it ahead of time. + controller (view this).holder + do credential_archiveAsHolderImpl this self arg + + nonconsuming choice Credential_PublicFetch : CredentialView + -- ^ Fetch the view of the credential. + -- + -- Registries MAY may restrict the actor in case the credential is not public. + with + expectedAdmin : Party + -- ^ The expected admin party storing the credential. Implementations MUST validate that this matches + -- the admin of the factory. + -- + -- Callers SHOULD ensure they get `expectedAdmin` from a trusted source, e.g., a read against + -- their own participant. That way they can ensure that it is safe to exercise a choice + -- on a factory contract acquired from an untrusted source *provided* + -- all vetted Daml packages only contain interface implementations + -- that check the expected admin party. + actor : Party + -- ^ The party fetching the contract. + controller actor + do credential_publicFetchImpl this self arg + +data Credential_ArchiveAsHolderResult = Credential_ArchiveAsHolderResult with + archivedCredential : CredentialView + -- ^ The view of the archived credential. + meta : Metadata + -- ^ Additional metadata specific to the archive operation, used for extensibility. + deriving (Eq, Show) + + +-- Registry interface +--------------------- + +-- | Credential factory interface to create, update, and archive credentials in the registry. +interface CredentialFactory where + viewtype CredentialFactoryView + + credentialFactory_publicFetchImpl : ContractId CredentialFactory -> CredentialFactory_PublicFetch -> Update CredentialFactoryView + credentialFactory_updateCredentialsImpl : ContractId CredentialFactory -> CredentialFactory_UpdateCredentials -> Update CredentialFactory_UpdateCredentialsResult + + nonconsuming choice CredentialFactory_UpdateCredentials : CredentialFactory_UpdateCredentialsResult + -- ^ Update a set of credential records in the registry. + with + admin : Party -- ^ The admin of the credential records being updated. + holder : Party -- ^ The holder of the credential records being updated. + issuer : Party -- ^ The issuer of the credential records being updated. + oldCredentials : [ContractId Credential] + -- ^ The existing credential records to archive. + -- + -- MAY be empty if no prior record exists. + + newCredentialClaims : [Claims] + -- ^ The claims of the new credentials to record. + + newCreatedAt : Time + -- ^ The creation time to use for the new credential records. + -- + -- Implementations MAY use this as the basis for computing the expiry time of the record. + -- Callers MUST ensure that `newCreatedAt` is in the past and they likely want it to be close to the current time + -- to maximize the lifetime of the credential record. + -- + -- It is a required argument so that implementations can compute the expiry time of the record + -- without depending on `getTime`, as using `getTime` limits the delay between + -- preparing and submitting the transaction to a 1 minute window. + + extraArgs : ExtraArgs + -- ^ The extra arguments to pass to the implementation. + controller holder, issuer + do credentialFactory_updateCredentialsImpl this self arg + + nonconsuming choice CredentialFactory_PublicFetch : CredentialFactoryView + -- ^ Fetch the view of the factory contract. + with + expectedAdmin : Party + -- ^ The expected admin party issuing the factory. Implementations MUST validate that this matches + -- the admin of the factory. + -- + -- Callers SHOULD ensure they get `expectedAdmin` from a trusted source, e.g., a read against + -- their own participant. That way they can ensure that it is safe to exercise a choice + -- on a factory contract acquired from an untrusted source *provided* + -- all vetted Daml packages only contain interface implementations + -- that check the expected admin party. + actor : Party + -- ^ The party fetching the contract. + controller actor + do credentialFactory_publicFetchImpl this self arg + + + +-- | A view of a credential factory contract for managing credentials in a registry. +data CredentialFactoryView = CredentialFactoryView with + admin : Party -- ^ The party that administers this credential registry. + meta : Metadata -- ^ Metadata associated with this credential registry. Used for extensibility. + deriving (Eq, Show) + +data CredentialFactory_UpdateCredentialsResult = CredentialFactory_UpdateCredentialsResult with + newCredentials : [ContractId Credential] + -- ^ The newly created credential records for the provided claims. + -- Returned in the same order as the `newCredentialClaims` argument. + meta : Metadata + -- ^ Additional metadata specific to the update operation, used for extensibility. + deriving (Eq, Show) diff --git a/daml/splice-api-credential-registry-v1/openapi/README.md b/daml/splice-api-credential-registry-v1/openapi/README.md new file mode 100644 index 0000000000..d948434622 --- /dev/null +++ b/daml/splice-api-credential-registry-v1/openapi/README.md @@ -0,0 +1,41 @@ + +### Implementation note + +We expect Scan to maintain the following indices. +We expect Scan to use them in the following priority order: + +``` +-- Used when holder is specified. +create index idx_credentialrecord_holder ON CredentialRecord(holder, property, issuer, record_time); + +-- Used when a property prefix is specified. +create index idx_credentialrecord_issuer ON CredentialRecord(property, issuer, record_time); + +-- Used when issuer is specified. +create index idx_credentialrecord_issuer ON CredentialRecord(issuer, record_time); + +-- Used when no filters are specified. +create index idx_credentialrecord_recordtime ON CredentialRecord(record_time); +``` + +The queries are constructed such that pagination follows the index order. +They use a deterministic paging order based on the index columns, where `record_time` and +`contract_id` are used as tie breakers. +An ascending order for record_time is chosen to implement first write wins +semantics, which is preferred as it incentivizes archiving older records. + +The fallback order can be used to page through all records in the order in which +they were created; and it can also be used to tail the table to ingest all records as +they are created. + + +#### On deterministic responses when reading from multiple registry services + +The Scan proxy should implement a BFT read strategy where it queries +multiple scan endpoints. The following tricks might help to achieve +deterministic responses: + +- Determine a record_time cutoff based on discretizing record time + and choosing the end of a previous time slice to query up to. + + diff --git a/daml/splice-api-credential-registry-v1/openapi/credential-registry-v1.yaml b/daml/splice-api-credential-registry-v1/openapi/credential-registry-v1.yaml new file mode 100644 index 0000000000..cc4810377d --- /dev/null +++ b/daml/splice-api-credential-registry-v1/openapi/credential-registry-v1.yaml @@ -0,0 +1,384 @@ +# Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openapi: 3.0.0 +info: + title: Credential registry off-ledger API + description: | + Implemented by credential registries for the purpose of creating, updating, + and querying credential records. + version: 1.0.0 +paths: + + + /credential-registry/v1/info: + get: + operationId: "getCredentialRegistryInfo" + description: | + Get information about the credential registry. + Useful to discover the admin party and the supported versions of the credential registry standard. + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/GetCredentialRegistryInfoResponse" + "404": + $ref: "#/components/responses/404" + + /credential-registry/v1/credential-factory: + post: + operationId: "getCredentialFactory" + description: | + Get the credential factory contract and the choice context for updating credential records. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetCredentialFactoryRequest" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/CredentialFactoryWithChoiceContext" + "400": + $ref: "#/components/responses/400" + "404": + $ref: "#/components/responses/404" + + + /credential-registry/v1/credentials: + get: + operationId: "listCredentials" + description: | + List the credential records matching the provided filters. + + Note that registries MAY decide to limit the number of records returned. + parameters: + - in: query + name: holder + schema: + type: string + required: false + description: | + Only return credential records held by the specified party. + - in: query + name: issuer + schema: + type: array + items: + type: string + style: form + explode: true + required: false + description: | + Only return credential records issued by one of the specified issuers. + - in: query + name: keyPrefix + schema: + type: string + required: false + description: | + Only return credential records that contain a claim with a key that starts with the given prefix. + - in: query + name: limit + schema: + type: integer + format: int32 + required: false + description: | + The maximum number of credential records to return. + - in: query + name: pageToken + schema: + type: string + required: false + description: | + The page token to continue retrieving results from a previous list request. + Ensure that the other filter parameters are identical to the previous request. + - in: query + name: validAsOf + schema: + type: string + format: date-time + required: false + description: | + If provided, only return credential records that are valid at the given time. + + - in: query + name: includeDisclosedContracts + schema: + type: boolean + default: false + required: false + description: | + If set to true, the response will include the disclosed contracts of the credentials + for use with explicit contract disclosure upon command submission. + - in: query + name: excludeDebugFields + schema: + type: boolean + default: false + required: false + description: | + If set to true, then the disclosed contracts will not include debug fields. + + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/ListCredentialsResponse" + "400": + $ref: "#/components/responses/400" + "404": + $ref: "#/components/responses/404" + + + +components: + responses: + "400": + description: "bad request" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: "not found" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + schemas: + GetCredentialRegistryInfoResponse: + type: object + properties: + adminId: + description: "The Daml party of the credential registry administrator" + type: string + supportedVersions: + description: "The versions of the credential registry standard supported by this registry" + type: array + items: + type: integer + format: int32 + limits: + description: "The limits enforced by the credential registry" + type: object + schema: + $ref: "#/components/schemas/CredentialRegistryLimits" + required: + - adminId + - supportedVersions + - limits + + CredentialRegistryLimits: + type: object + description: "The limits of the credential registry" + properties: + maxPageSize: + description: "The maximum page size supported by the registry for listing credential records" + type: integer + format: int32 + maxNumIssuersFilter: + description: "The maximum number of issuers that can be provided in the issuer filter when listing credential records" + type: integer + format: int32 + maxNumClaims: + description: | + The maximum number of claims allowed in new credential records. + There is no limit on the number of claims when querying existing records. + type: integer + format: int32 + maxPropertyLength: + description: | + The maximum length of a property key allowed in new credential records. + There is no limit on the property key length when querying existing records. + type: integer + format: int32 + maxSubjectLength: + description: | + The maximum length of the subject field allowed in new credential records. + There is no limit on the subject field length when querying existing records. + type: integer + format: int32 + maxValueLength: + description: | + The maximum length of a claim value allowed in new credential records. + There is no limit on the claim value length when querying existing records. + type: integer + format: int32 + required: + - maxPageSize + - maxNumIssuersFilter + - maxNumClaims + - maxPropertyLength + - maxSubjectLength + - maxValueLength + + GetCredentialFactoryRequest: + type: object + properties: + choiceArguments: + type: object + description: | + The arguments that are intended to be passed to the choice provided by the factory. + To avoid repeating the Daml type definitions, they are specified as JSON objects. + However the concrete format is given by how the choice arguments are encoded using the Daml JSON API + (with the `extraArgs.context` and `extraArgs.meta` fields set to the empty object). + + The choice arguments are provided so that the registry can also make choice-argument + specific decisions, e.g., use different factories for different issuer or holder parties. + excludeDebugFields: + description: "If set to true, the response will not include fields prefixed with 'debug'. Useful to save bandwidth." + default: false + type: boolean + required: + [ + "choiceArguments", + ] + + CredentialFactoryWithChoiceContext: + description: | + The credential factory contract together with the choice context required to exercise the choice + provided by the factory. Typically used to implement the generic initiation of on-ledger workflows + via a Daml interface. + + Clients SHOULD avoid reusing the same `CredentialFactoryWithChoiceContext` for exercising multiple choices, + as the choice context MAY be specific to the choice being exercised. + type: object + properties: + factoryId: + description: "The contract ID of the contract implementing the factory interface." + type: string + choiceContext: + $ref: "#/components/schemas/ChoiceContext" + required: + [ + "factoryId", + "choiceContext", + "transferKind", + ] + + ChoiceContext: + description: | + The context required to exercise a choice on a contract via an interface. + Used to retrieve additional reference data that is passed in via disclosed contracts, + which are in turn referred to via their contract ID in the `choiceContextData`. + type: object + properties: + choiceContextData: + description: "The additional data to use when exercising the choice." + type: object + disclosedContracts: + description: | + The contracts that are required to be disclosed to the participant node for exercising + the choice. + type: array + items: + $ref: "#/components/schemas/DisclosedContract" + required: + [ + "choiceContextData", + "disclosedContracts", + ] + + DisclosedContract: + type: object + description: | + A contract that is disclosed for use with explicit contract disclosure + when preparing transactions on validator nodes that do not know this contract. + properties: + templateId: + type: string + description: "The template ID of the disclosed contract." + contractId: + type: string + description: "The contract ID of the disclosed contract." + createdEventBlob: + type: string + description: | + The binary-encoded created event of the disclosed contract. + This blob can be used to prepare transactions on validator + nodes that do not know this contract. This is done using + explicit contract disclosure, where the created event is + provided alongside the command that refers to the contract. + synchronizerId: + description: | + The synchronizer to which the contract is currently assigned. + If the contract is in the process of being reassigned, then a "409" response is returned. + type: string + debugPackageName: + description: | + The name of the Daml package that was used to create the contract. + Use this data only if you trust the provider, as it might not match the data in the + `createdEventBlob`. + type: string + debugPayload: + description: | + The contract arguments that were used to create the contract. + Use this data only if you trust the provider, as it might not match the data in the + `createdEventBlob`. + type: object + debugCreatedAt: + description: | + The ledger effective time at which the contract was created. + Use this data only if you trust the provider, as it might not match the data in the + `disclosedContract`. + type: string + format: date-time + required: + [ + "templateId", + "contractId", + "createdEventBlob", + "synchronizerId" + ] + + ListCredentialsResponse: + type: object + description: "The response for listing credential records." + properties: + credentials: + type: array + items: + $ref: "#/components/schemas/Credential" + description: | + The list of credential records matching the provided filters. + nextPageToken: + type: string + description: | + The token for the next page of results. Omitted if there are no more results. + + Credential: + type: object + description: | + A credential record stored in the registry. + properties: + credentialView: + type: object + description: | + The view computed for the credential with the schema of Splice.Api.Credential.RegistryV1.CredentialView. + disclosedContract: + $ref: "#/components/schemas/DisclosedContract" + description: | + The disclosed contract information for verifying the credential record using explicit contract disclosure. + required: + [ + "credentialView", + ] + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string diff --git a/daml/splice-api-credential-registry-v1/openapi/docker-compose.yml b/daml/splice-api-credential-registry-v1/openapi/docker-compose.yml new file mode 100644 index 0000000000..c26147c503 --- /dev/null +++ b/daml/splice-api-credential-registry-v1/openapi/docker-compose.yml @@ -0,0 +1,16 @@ +# Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Description: Docker compose file for running Swagger UI with the transfer-instruction OpenAPI specification. +# Usage: docker-compose up +version: '3.7' + +services: + swagger-ui: + image: swaggerapi/swagger-ui + ports: + - "8080:8080" + environment: + SWAGGER_JSON: /spec/credential-registry-v1.yaml + volumes: + - ./credential-registry-v1.yaml:/spec/credential-registry-v1.yaml