(
+ verifiableCredentials,
+ schemas,
+ setSchemas,
+ getChangesToCredentialSchemas
+ );
+
+ if (!verifiableCredentials || !verifiableCredentials.length) {
+ return ;
+ }
+
+ if (selected) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {verifiableCredentials.map((credential) => {
+ return (
+ setSelected({ credential, status, schema, metadata })}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx
new file mode 100644
index 000000000..7155220f2
--- /dev/null
+++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx
@@ -0,0 +1,37 @@
+import { VerifiableCredentialStatus } from '@shared/storage/types';
+import React from 'react';
+import RevokedIcon from '@assets/svg/revoked.svg';
+import ActiveIcon from '@assets/svg/verified.svg';
+import ExpiredIcon from '@assets/svg/block.svg';
+import PendingIcon from '@assets/svg/pending.svg';
+
+/**
+ * Component for displaying the status of a verifiable credential.
+ */
+export default function StatusIcon({ status }: { status: VerifiableCredentialStatus }) {
+ let icon = null;
+ switch (status) {
+ case VerifiableCredentialStatus.Active:
+ icon = ;
+ break;
+ case VerifiableCredentialStatus.Revoked:
+ icon = ;
+ break;
+ case VerifiableCredentialStatus.Expired:
+ icon = ;
+ break;
+ case VerifiableCredentialStatus.NotActivated:
+ icon = ;
+ break;
+ default:
+ icon = null;
+ break;
+ }
+
+ return (
+
+ {VerifiableCredentialStatus[status]}
+ {icon}
+
+ );
+}
diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts b/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts
new file mode 100644
index 000000000..658eefc9c
--- /dev/null
+++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts
@@ -0,0 +1 @@
+export { default } from './VerifiableCredentialList';
diff --git a/packages/browser-wallet/src/popup/shell/Routes.tsx b/packages/browser-wallet/src/popup/shell/Routes.tsx
index 85c30f07b..a2d09fbc3 100644
--- a/packages/browser-wallet/src/popup/shell/Routes.tsx
+++ b/packages/browser-wallet/src/popup/shell/Routes.tsx
@@ -32,6 +32,7 @@ import RecoveryFinish from '@popup/pages/Recovery/RecoveryFinish';
import ChangePasscode from '@popup/pages/ChangePasscode/ChangePasscode';
import AddTokensPrompt from '@popup/pages/ExternalAddTokens/ExternalAddTokens';
import IdProofRequest from '@popup/pages/IdProofRequest';
+import VerifiableCredentialList from '@popup/pages/VerifiableCredential';
import ConnectAccountsRequest from '@popup/pages/ConnectAccountsRequest';
import AllowListRoutes from '@popup/pages/Allowlist';
@@ -195,6 +196,7 @@ export default function Routes() {
path={`${relativePath(relativeRoutes.home.path, absoluteRoutes.home.identities.add.path)}/*`}
/>
} path={relativeRoutes.home.identities.path} />
+ } path={relativeRoutes.home.verifiableCredentials.path} />
} />
} path={`${relativeRoutes.home.settings.allowlist.path}/*`} />
diff --git a/packages/browser-wallet/src/popup/store/utils.ts b/packages/browser-wallet/src/popup/store/utils.ts
index ebf80b1f8..66a89fa4a 100644
--- a/packages/browser-wallet/src/popup/store/utils.ts
+++ b/packages/browser-wallet/src/popup/store/utils.ts
@@ -26,12 +26,16 @@ import {
sessionCookie,
sessionOpenPrompt,
storedAcceptedTerms,
+ storedVerifiableCredentials,
+ storedVerifiableCredentialSchemas,
storedAllowlist,
+ storedVerifiableCredentialMetadata,
} from '@shared/storage/access';
import { ChromeStorageKey } from '@shared/storage/types';
import { atom, PrimitiveAtom, WritableAtom } from 'jotai';
-const accessorMap = {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const accessorMap: Record> = {
[ChromeStorageKey.Identities]: useIndexedStorage(storedIdentities, getGenesisHash),
[ChromeStorageKey.SelectedIdentity]: storedSelectedIdentity,
[ChromeStorageKey.ConnectedSites]: storedConnectedSites,
@@ -56,6 +60,9 @@ const accessorMap = {
[ChromeStorageKey.Cookie]: useIndexedStorage(sessionCookie, getGenesisHash),
[ChromeStorageKey.OpenPrompt]: sessionOpenPrompt,
[ChromeStorageKey.AcceptedTerms]: storedAcceptedTerms,
+ [ChromeStorageKey.VerifiableCredentials]: useIndexedStorage(storedVerifiableCredentials, getGenesisHash),
+ [ChromeStorageKey.VerifiableCredentialSchemas]: storedVerifiableCredentialSchemas,
+ [ChromeStorageKey.VerifiableCredentialMetadata]: storedVerifiableCredentialMetadata,
[ChromeStorageKey.Allowlist]: storedAllowlist,
};
diff --git a/packages/browser-wallet/src/popup/store/verifiable-credential.ts b/packages/browser-wallet/src/popup/store/verifiable-credential.ts
new file mode 100644
index 000000000..3fe0647e4
--- /dev/null
+++ b/packages/browser-wallet/src/popup/store/verifiable-credential.ts
@@ -0,0 +1,18 @@
+import { ChromeStorageKey, VerifiableCredential, VerifiableCredentialSchema } from '@shared/storage/types';
+import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers';
+import { atomWithChromeStorage } from './utils';
+
+export const storedVerifiableCredentialsAtom = atomWithChromeStorage(
+ ChromeStorageKey.VerifiableCredentials,
+ []
+);
+
+export const storedVerifiableCredentialSchemasAtom = atomWithChromeStorage>(
+ ChromeStorageKey.VerifiableCredentialSchemas,
+ {},
+ true
+);
+
+export const storedVerifiableCredentialMetadataAtom = atomWithChromeStorage<
+ Record
+>(ChromeStorageKey.VerifiableCredentialMetadata, {}, true);
diff --git a/packages/browser-wallet/src/popup/styles/_components.scss b/packages/browser-wallet/src/popup/styles/_components.scss
index caedb9ad8..d3845a5d2 100644
--- a/packages/browser-wallet/src/popup/styles/_components.scss
+++ b/packages/browser-wallet/src/popup/styles/_components.scss
@@ -30,6 +30,7 @@
@import '../pages/Settings/Settings';
@import '../pages/Identity/Identity';
@import '../pages/IdentityIssuance/IdentityIssuance';
+@import '../pages/VerifiableCredential/VerifiableCredential';
@import '../pages/About/About';
@import '../pages/Login/Login';
@import '../pages/AddAccount/AddAccount';
diff --git a/packages/browser-wallet/src/shared/storage/access.ts b/packages/browser-wallet/src/shared/storage/access.ts
index 33de0f933..206323fc8 100644
--- a/packages/browser-wallet/src/shared/storage/access.ts
+++ b/packages/browser-wallet/src/shared/storage/access.ts
@@ -1,3 +1,4 @@
+import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers';
import {
ChromeStorageKey,
EncryptedData,
@@ -11,6 +12,8 @@ import {
TokenMetadata,
TokenStorage,
AcceptedTermsState,
+ VerifiableCredential,
+ VerifiableCredentialSchema,
} from './types';
export type StorageAccessor = {
@@ -145,6 +148,18 @@ export const storedTokenMetadata = makeStorageAccessor('local', ChromeStorageKey.AcceptedTerms);
+export const storedVerifiableCredentials = makeIndexedStorageAccessor(
+ 'local',
+ ChromeStorageKey.VerifiableCredentials
+);
+export const storedVerifiableCredentialSchemas = makeStorageAccessor>(
+ 'local',
+ ChromeStorageKey.VerifiableCredentialSchemas
+);
+export const storedVerifiableCredentialMetadata = makeStorageAccessor>(
+ 'local',
+ ChromeStorageKey.VerifiableCredentialMetadata
+);
const indexedStoredAllowlist = makeIndexedStorageAccessor>(
'local',
ChromeStorageKey.Allowlist
diff --git a/packages/browser-wallet/src/shared/storage/types.ts b/packages/browser-wallet/src/shared/storage/types.ts
index 1e8f9a43c..1f8fb3caf 100644
--- a/packages/browser-wallet/src/shared/storage/types.ts
+++ b/packages/browser-wallet/src/shared/storage/types.ts
@@ -25,6 +25,9 @@ export enum ChromeStorageKey {
Cookie = 'cookie',
OpenPrompt = 'openPrompt',
AcceptedTerms = 'acceptedTerms',
+ VerifiableCredentials = 'verifiableCredentials',
+ VerifiableCredentialSchemas = 'verifiableCredentialSchemas',
+ VerifiableCredentialMetadata = 'verifiableCredentialMetadata',
Allowlist = 'allowlist',
}
@@ -254,3 +257,53 @@ export type AcceptedTermsState = {
version: string;
url?: string;
};
+
+export enum VerifiableCredentialStatus {
+ Active,
+ Revoked,
+ Expired,
+ NotActivated,
+}
+
+interface CredentialSchema {
+ id: string;
+ type: string;
+}
+
+export type CredentialSubject = { id: string } & Record;
+
+export interface VerifiableCredential {
+ '@context': string[];
+ id: string;
+ type: string[];
+ issuer: string;
+ issuanceDate: string;
+ credentialSubject: CredentialSubject;
+ credentialSchema: CredentialSchema;
+}
+
+interface CredentialSchemaProperty {
+ title: string;
+ type: 'string' | 'number' | string;
+ description: string;
+}
+
+interface CredentialSchemaSubject {
+ type: string;
+ required: string[];
+ properties: { id: CredentialSchemaProperty } & Record;
+}
+
+export interface SchemaProperties {
+ credentialSubject: CredentialSchemaSubject;
+}
+
+export interface VerifiableCredentialSchema {
+ $id: string;
+ $schema: string;
+ name: string;
+ description: string;
+ type: string;
+ properties: SchemaProperties;
+ required: string[];
+}
diff --git a/packages/browser-wallet/src/shared/utils/contract-helpers.ts b/packages/browser-wallet/src/shared/utils/contract-helpers.ts
new file mode 100644
index 000000000..031ef5432
--- /dev/null
+++ b/packages/browser-wallet/src/shared/utils/contract-helpers.ts
@@ -0,0 +1,11 @@
+import { InstanceInfo } from '@concordium/web-sdk';
+
+/**
+ * Get the name of a contract.
+ * This works as the name in an instance info is prefixed with 'init_'.
+ * @param instanceInfo the instance info to extract the contract name from
+ * @returns the contract's name to be used as a prefix when setting the receive name for a contract method
+ */
+export function getContractName(instanceInfo: InstanceInfo): string | undefined {
+ return instanceInfo.name.substring(5);
+}
diff --git a/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts
new file mode 100644
index 000000000..1954af286
--- /dev/null
+++ b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts
@@ -0,0 +1,637 @@
+import { ConcordiumGRPCClient, ContractAddress, sha256 } from '@concordium/web-sdk';
+import {
+ MetadataUrl,
+ VerifiableCredential,
+ VerifiableCredentialSchema,
+ VerifiableCredentialStatus,
+} from '@shared/storage/types';
+import { Buffer } from 'buffer/';
+import jsonschema from 'jsonschema';
+import { getContractName } from './contract-helpers';
+
+/**
+ * Extracts the credential holder id from a verifiable credential id (did).
+ * @param credentialId the did for a credential
+ * @returns the credential holder id
+ */
+export function getCredentialHolderId(credentialId: string): string {
+ const credentialIdParts = credentialId.split('/');
+ const credentialHolderId = credentialIdParts[credentialIdParts.length - 1];
+
+ if (credentialHolderId.length !== 64) {
+ throw new Error(`Invalid credential holder id found from: ${credentialId}`);
+ }
+
+ return credentialHolderId;
+}
+
+/**
+ * Extracts the credential registry contract addres from a verifiable credential id (did).
+ * @param credentialId the did for a credential
+ * @returns the contract address of the issuing contract of the provided credential id
+ */
+export function getCredentialRegistryContractAddress(credentialId: string): ContractAddress {
+ const credentialIdParts = credentialId.split(':');
+ const index = BigInt(credentialIdParts[4]);
+ const subindex = BigInt(credentialIdParts[5].split('/')[0]);
+ return { index, subindex };
+}
+
+function deserializeCredentialStatus(serializedCredentialStatus: string): VerifiableCredentialStatus {
+ const buff = Buffer.from(serializedCredentialStatus, 'hex');
+ switch (buff.readUInt8(0)) {
+ case 0:
+ return VerifiableCredentialStatus.Active;
+ case 1:
+ return VerifiableCredentialStatus.Revoked;
+ case 2:
+ return VerifiableCredentialStatus.Expired;
+ case 3:
+ return VerifiableCredentialStatus.NotActivated;
+ default:
+ throw new Error(`Received an invalid credential status: ${serializedCredentialStatus}`);
+ }
+}
+
+/**
+ * Get the status of a verifiable credential in a CIS-4 contract.
+ * @param client the GRPC client for accessing a node
+ * @param credentialId the id for a verifiable credential
+ * @throws an error if the invoke contract call fails, or if no return value is available
+ * @returns the status of the verifiable credential, the status will be undefined if the contract is not found
+ */
+export async function getVerifiableCredentialStatus(client: ConcordiumGRPCClient, credentialId: string) {
+ const contractAddress = getCredentialRegistryContractAddress(credentialId);
+ const instanceInfo = await client.getInstanceInfo(contractAddress);
+ if (instanceInfo === undefined) {
+ return undefined;
+ }
+
+ const result = await client.invokeContract({
+ contract: contractAddress,
+ method: `${getContractName(instanceInfo)}.credentialStatus`,
+ parameter: Buffer.from(getCredentialHolderId(credentialId), 'hex'),
+ });
+
+ if (result.tag !== 'success') {
+ throw new Error(result.reason.tag);
+ }
+
+ const { returnValue } = result;
+ if (returnValue === undefined) {
+ throw new Error(`Return value is missing from credentialStatus result in CIS-4 contract: ${contractAddress}`);
+ }
+
+ return deserializeCredentialStatus(returnValue);
+}
+
+export interface SchemaRef {
+ schema: MetadataUrl;
+}
+
+function deserializeUrlChecksumPair(buffer: Buffer, offset: number) {
+ let localOffset = offset;
+ const urlLength = buffer.readUInt16LE(localOffset);
+ localOffset += 2;
+
+ const url = buffer.toString('utf-8', localOffset, localOffset + urlLength);
+ localOffset += urlLength;
+
+ const containsChecksum = buffer.readUInt8(localOffset);
+ localOffset += 1;
+
+ let checksum: string | undefined;
+ if (containsChecksum) {
+ checksum = buffer.toString('hex', localOffset, localOffset + 32);
+ localOffset += 32;
+ }
+
+ return {
+ url,
+ checksum,
+ offset: localOffset,
+ };
+}
+
+export interface RegistryMetadata {
+ issuerMetadata: MetadataUrl;
+ credentialType: string;
+ credentialSchema: SchemaRef;
+}
+
+interface MetadataResponse {
+ issuerMetadata: MetadataUrl;
+ credentialType: string;
+ credentialSchema: SchemaRef;
+}
+
+function deserializeRegistryMetadata(serializedRegistryMetadata: string): MetadataResponse {
+ const buffer = Buffer.from(serializedRegistryMetadata, 'hex');
+ let offset = 0;
+
+ const issuerMetadata = deserializeUrlChecksumPair(buffer, offset);
+ offset = issuerMetadata.offset;
+
+ const typeLength = buffer.readInt8(offset);
+ offset += 1;
+ const credentialType = buffer.toString('utf-8', offset, offset + typeLength);
+ offset += typeLength;
+
+ const credentialSchema = deserializeUrlChecksumPair(buffer, offset);
+
+ return {
+ issuerMetadata: { url: issuerMetadata.url, hash: issuerMetadata.checksum },
+ credentialType,
+ credentialSchema: {
+ schema: { url: credentialSchema.url, hash: credentialSchema.checksum },
+ },
+ };
+}
+
+/**
+ * Get the registry metadata from a credential registry CIS-4 contract.
+ * @param client the GRPC client for accessing a node
+ * @param contractAddress the address of a CIS-4 contract
+ * @returns the registry metadata for the contract, or undefined if the contract instance was not found
+ */
+export async function getCredentialRegistryMetadata(client: ConcordiumGRPCClient, contractAddress: ContractAddress) {
+ const instanceInfo = await client.getInstanceInfo(contractAddress);
+ if (instanceInfo === undefined) {
+ return undefined;
+ }
+
+ const result = await client.invokeContract({
+ contract: contractAddress,
+ method: `${getContractName(instanceInfo)}.registryMetadata`,
+ });
+
+ if (result.tag !== 'success') {
+ throw new Error(result.reason.tag);
+ }
+
+ const { returnValue } = result;
+ if (returnValue === undefined) {
+ throw new Error(`Return value is missing from credentialStatus result in CIS-4 contract: ${contractAddress}`);
+ }
+
+ return deserializeRegistryMetadata(returnValue);
+}
+
+// The schemas have been generated using ts-json-schema-generator and their
+// corresponding type definitions.
+const verifiableCredentialSchemaSchema = {
+ $ref: '#/definitions/VerifiableCredentialSchema',
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ definitions: {
+ SchemaProperties: {
+ additionalProperties: false,
+ properties: {
+ credentialSubject: {
+ additionalProperties: false,
+ properties: {
+ properties: {
+ additionalProperties: {
+ type: 'object',
+ },
+ properties: {
+ id: {
+ additionalProperties: false,
+ properties: {
+ description: {
+ type: 'string',
+ },
+ title: {
+ type: 'string',
+ },
+ type: {
+ type: 'string',
+ },
+ },
+ required: ['title', 'type', 'description'],
+ type: 'object',
+ },
+ },
+ required: ['id'],
+ type: 'object',
+ },
+ required: {
+ items: {
+ type: 'string',
+ },
+ type: 'array',
+ },
+ type: {
+ type: 'string',
+ },
+ },
+ required: ['type', 'required', 'properties'],
+ type: 'object',
+ },
+ },
+ required: ['credentialSubject'],
+ type: 'object',
+ },
+ VerifiableCredentialSchema: {
+ additionalProperties: false,
+ properties: {
+ $id: {
+ type: 'string',
+ },
+ $schema: {
+ type: 'string',
+ },
+ description: {
+ type: 'string',
+ },
+ name: {
+ type: 'string',
+ },
+ properties: {
+ $ref: '#/definitions/SchemaProperties',
+ },
+ required: {
+ items: {
+ type: 'string',
+ },
+ type: 'array',
+ },
+ type: {
+ type: 'string',
+ },
+ },
+ required: ['$id', '$schema', 'name', 'description', 'type', 'properties', 'required'],
+ type: 'object',
+ },
+ },
+};
+
+const verifiableCredentialMetadataSchema = {
+ $ref: '#/definitions/VerifiableCredentialMetadata',
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ definitions: {
+ HexString: {
+ type: 'string',
+ },
+ MetadataUrl: {
+ additionalProperties: false,
+ properties: {
+ hash: {
+ $ref: '#/definitions/HexString',
+ },
+ url: {
+ type: 'string',
+ },
+ },
+ required: ['url'],
+ type: 'object',
+ },
+ VerifiableCredentialMetadata: {
+ additionalProperties: false,
+ properties: {
+ background_color: {
+ type: 'string',
+ },
+ image: {
+ $ref: '#/definitions/MetadataUrl',
+ },
+ localization: {
+ additionalProperties: {
+ $ref: '#/definitions/MetadataUrl',
+ },
+ type: 'object',
+ },
+ logo: {
+ $ref: '#/definitions/MetadataUrl',
+ },
+ title: {
+ type: 'string',
+ },
+ },
+ required: ['title', 'logo', 'background_color'],
+ type: 'object',
+ },
+ },
+};
+
+export interface VerifiableCredentialMetadata {
+ title: string;
+ logo: MetadataUrl;
+ background_color: string;
+ image?: MetadataUrl;
+ localization?: Record;
+}
+
+export interface CredentialInfo {
+ credentialHolderId: string;
+ holderRevocable: boolean;
+ validFrom: bigint;
+ validUntil?: bigint;
+ metadataUrl: MetadataUrl;
+}
+
+export interface CredentialQueryResponse {
+ credentialInfo: CredentialInfo;
+ schemaRef: SchemaRef;
+ revocationNonce: bigint;
+}
+
+/**
+ * Deserializes a CredentialEntry according to the CIS-4 specification.
+ * @param serializedCredentialEntry a serialized credential entry as a hex string
+ */
+export function deserializeCredentialEntry(serializedCredentialEntry: string): CredentialQueryResponse {
+ const buffer = Buffer.from(serializedCredentialEntry, 'hex');
+ let offset = 0;
+
+ const credentialHolderId = buffer.toString('hex', offset, offset + 32);
+ offset += 32;
+
+ const holderRevocable = Boolean(buffer.readUInt8(offset));
+ offset += 1;
+
+ const validFrom = buffer.readBigUInt64LE(offset) as bigint;
+ offset += 8;
+
+ const containsValidUntil = Boolean(buffer.readUInt8(offset));
+ offset += 1;
+
+ let validUntil: bigint | undefined;
+ if (containsValidUntil) {
+ validUntil = buffer.readBigUInt64LE(offset) as bigint;
+ offset += 8;
+ }
+
+ const metadata = deserializeUrlChecksumPair(buffer, offset);
+ offset = metadata.offset;
+
+ const schema = deserializeUrlChecksumPair(buffer, offset);
+ offset = schema.offset;
+
+ const revocationNonce = buffer.readBigInt64LE(offset) as bigint;
+ offset += 8;
+
+ return {
+ credentialInfo: {
+ credentialHolderId,
+ holderRevocable,
+ validFrom,
+ validUntil,
+ metadataUrl: { url: metadata.url, hash: metadata.checksum },
+ },
+ schemaRef: {
+ schema: { url: schema.url, hash: schema.checksum },
+ },
+ revocationNonce,
+ };
+}
+
+/**
+ * Get a Credential Entry from a CIS-4 contract.
+ * @param client the GRPC client for accessing a node
+ * @param contractAddress the address of a CIS-4 contract
+ * @param credentialHolderId the public key for the credential holder of the entry to retrieve
+ * @throws an error if the invoke contract call fails, or if no return value is available
+ * @returns the credential entry which contains data about the credential, undefined if the contract instance is not found
+ */
+export async function getVerifiableCredentialEntry(
+ client: ConcordiumGRPCClient,
+ contractAddress: ContractAddress,
+ credentialHolderId: string
+) {
+ const instanceInfo = await client.getInstanceInfo(contractAddress);
+ if (instanceInfo === undefined) {
+ return undefined;
+ }
+
+ const result = await client.invokeContract({
+ contract: contractAddress,
+ method: `${getContractName(instanceInfo)}.credentialEntry`,
+ parameter: Buffer.from(credentialHolderId, 'hex'),
+ });
+
+ if (result.tag !== 'success') {
+ throw new Error(result.reason.tag);
+ }
+
+ const { returnValue } = result;
+ if (returnValue === undefined) {
+ throw new Error(`Return value is missing from credentialEntry result in CIS-4 contract: ${contractAddress}`);
+ }
+
+ return deserializeCredentialEntry(returnValue);
+}
+
+/**
+ * Retrieves data from the from the specified URL. The result is validated according
+ * to the supplied JSON schema.
+ */
+async function fetchDataFromUrl(
+ { url, hash }: MetadataUrl,
+ abortController: AbortController,
+ jsonSchema: typeof verifiableCredentialMetadataSchema | typeof verifiableCredentialSchemaSchema
+): Promise {
+ const response = await fetch(url, {
+ headers: new Headers({ 'Access-Control-Allow-Origin': '*' }),
+ mode: 'cors',
+ signal: abortController.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch the schema at: ${url}`);
+ }
+
+ const body = Buffer.from(await response.arrayBuffer());
+ if (hash && sha256([body]).toString('hex') !== hash) {
+ throw new Error(`The content at URL ${url} did not match the provided checksum: ${hash}`);
+ }
+
+ let bodyAsObject;
+ let bodyAsString;
+ try {
+ bodyAsString = body.toString();
+ bodyAsObject = JSON.parse(bodyAsString);
+ } catch (e) {
+ throw new Error(`Failed to parse JSON: ${bodyAsString}`);
+ }
+
+ const validator = new jsonschema.Validator();
+ const validationResult = validator.validate(bodyAsObject, jsonSchema);
+ if (!validationResult.valid) {
+ throw new Error(
+ `The received JSON [${bodyAsString}] did not validate according to the schema: ${validationResult.errors}`
+ );
+ }
+
+ return bodyAsObject as T;
+}
+
+/**
+ * Retrieves a credential schema from the specified URL.
+ */
+export async function fetchCredentialSchema(
+ metadata: MetadataUrl,
+ abortController: AbortController
+): Promise {
+ return fetchDataFromUrl(metadata, abortController, verifiableCredentialSchemaSchema);
+}
+
+/**
+ * Retrieves credential metadata from the specified URL.
+ */
+export async function fetchCredentialMetadata(
+ metadata: MetadataUrl,
+ abortController: AbortController
+): Promise {
+ return fetchDataFromUrl(metadata, abortController, verifiableCredentialMetadataSchema);
+}
+
+/**
+ * Retrieves credential schemas for each of the provided credentials. The method ensures
+ * that duplicate schemas are not fetched multiple times, by only fetching once per
+ * contract.
+ * @param credentials the verifiable credentials to get schemas for
+ * @param client the GRPC client for accessing a node
+ * @param abortControllers controllers to enable aborting the fetching if needed
+ * @returns a list of verifiable credential schemas
+ */
+export async function getCredentialSchemas(
+ credentials: VerifiableCredential[],
+ abortControllers: AbortController[],
+ client: ConcordiumGRPCClient
+) {
+ const onChainSchemas: VerifiableCredentialSchema[] = [];
+
+ const allContractAddresses = credentials.map((vc) => getCredentialRegistryContractAddress(vc.id));
+ const issuerContractAddresses = new Set(allContractAddresses);
+
+ for (const contractAddress of issuerContractAddresses) {
+ let registryMetadata: MetadataResponse | undefined;
+ try {
+ registryMetadata = await getCredentialRegistryMetadata(client, contractAddress);
+ } catch (e) {
+ throw new Error(`Failed to get registry metadata for contract: ${contractAddress.index} with error: ${e}`);
+ }
+
+ if (registryMetadata) {
+ const controller = new AbortController();
+ abortControllers.push(controller);
+ try {
+ const credentialSchema = await fetchCredentialSchema(
+ registryMetadata.credentialSchema.schema,
+ controller
+ );
+ onChainSchemas.push(credentialSchema);
+ } catch (e) {
+ // Ignore errors that occur because we aborted, as that is expected to happen.
+ if (!controller.signal.aborted) {
+ // TODO This should be logged.
+ }
+ }
+ }
+ }
+
+ return onChainSchemas;
+}
+
+/**
+ * Retrieves credential metadata for each of the provided credentials. The method ensures
+ * that duplicate metadata (metadata hosted at the same URL) is not fetched multiple
+ * times.
+ * @param client the GRPC client for accessing a node
+ * @param credentials the verifiable credentials to get metadata for
+ * @param abortControllers controllers to enable aborting the fetching if needed
+ * @returns a list of pairs of verifiable credential metadata and the URL they were fetched from (their key)
+ */
+export async function getCredentialMetadata(
+ credentials: VerifiableCredential[],
+ client: ConcordiumGRPCClient,
+ abortControllers: AbortController[]
+) {
+ const metadataUrls: MetadataUrl[] = [];
+ for (const vc of credentials) {
+ const entry = await getVerifiableCredentialEntry(
+ client,
+ getCredentialRegistryContractAddress(vc.id),
+ getCredentialHolderId(vc.id)
+ );
+ if (entry) {
+ metadataUrls.push(entry.credentialInfo.metadataUrl);
+ }
+ }
+
+ // We filter any duplicate URLs. Note that there could be metadata pairs (url, hash) with the
+ // same URL but separate hashes that are thrown away here. This is done intentionally for now,
+ // as the assumption is that that would be a rare situation. This means that the first instance
+ // of the URL is the one used for gathering the credential metadata.
+ const uniqueMetadataUrls = [...new Map(metadataUrls.map((item) => [item.url, item])).values()];
+
+ const metadataList: { metadata: VerifiableCredentialMetadata; url: string }[] = [];
+ for (const metadataUrl of uniqueMetadataUrls) {
+ const controller = new AbortController();
+ abortControllers.push(controller);
+ try {
+ const metadata = await fetchCredentialMetadata(metadataUrl, controller);
+ metadataList.push({ metadata, url: metadataUrl.url });
+ } catch (e) {
+ // Ignore errors that occur because we aborted, as that is expected to happen.
+ if (!controller.signal.aborted) {
+ // TODO This should be logged.
+ }
+ }
+ }
+
+ return metadataList;
+}
+
+export async function getChangesToCredentialMetadata(
+ credentials: VerifiableCredential[],
+ client: ConcordiumGRPCClient,
+ abortControllers: AbortController[],
+ storedMetadata: Record
+) {
+ const upToDateCredentialMetadata = await getCredentialMetadata(credentials, client, abortControllers);
+ let updatedStoredMetadata = { ...storedMetadata };
+ let updateReceived = false;
+
+ for (const updatedMetadata of upToDateCredentialMetadata) {
+ if (storedMetadata.value === undefined) {
+ updatedStoredMetadata = {
+ [updatedMetadata.url]: updatedMetadata.metadata,
+ };
+ updateReceived = true;
+ } else {
+ updatedStoredMetadata[updatedMetadata.url] = updatedMetadata.metadata;
+ if (JSON.stringify(storedMetadata[updatedMetadata.url]) !== JSON.stringify(updatedMetadata.metadata)) {
+ updateReceived = true;
+ }
+ }
+ }
+
+ return { data: updatedStoredMetadata, updateReceived };
+}
+
+export async function getChangesToCredentialSchemas(
+ credentials: VerifiableCredential[],
+ client: ConcordiumGRPCClient,
+ abortControllers: AbortController[],
+ storedSchemas: Record
+) {
+ const upToDateSchemas = await getCredentialSchemas(credentials, abortControllers, client);
+ let updatedSchemasInStorage = { ...storedSchemas };
+ let updateReceived = false;
+
+ for (const updatedSchema of upToDateSchemas) {
+ if (storedSchemas === undefined) {
+ updatedSchemasInStorage = {
+ [updatedSchema.$id]: updatedSchema,
+ };
+ updateReceived = true;
+ } else {
+ updatedSchemasInStorage[updatedSchema.$id] = updatedSchema;
+ if (JSON.stringify(storedSchemas[updatedSchema.$id]) !== JSON.stringify(updatedSchema)) {
+ updateReceived = true;
+ }
+ }
+ }
+ return { data: updatedSchemasInStorage, updateReceived };
+}
diff --git a/packages/browser-wallet/test/verifiable-credential-helpers.test.ts b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts
new file mode 100644
index 000000000..fe8a57d3f
--- /dev/null
+++ b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts
@@ -0,0 +1,34 @@
+import {
+ getCredentialHolderId,
+ getCredentialRegistryContractAddress,
+} from '../src/shared/utils/verifiable-credential-helpers';
+
+test('credential holder id is extracted from verifiable credential id field', () => {
+ const id =
+ 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106';
+ expect(getCredentialHolderId(id)).toEqual('2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106');
+});
+
+test('an error is thrown if credential holder id has invalid length', () => {
+ const id =
+ 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106';
+ expect(() => getCredentialHolderId(id)).toThrow(Error);
+});
+
+test('credentialHolderId: an error is thrown if the credential id is invalid', () => {
+ const id =
+ 'did:ccd:mainnet:sci:4718:0credentialEntry2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106';
+ expect(() => getCredentialHolderId(id)).toThrow(Error);
+});
+
+test('registry contract address is extracted from verifiable credential id field', () => {
+ const id =
+ 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106';
+ expect(getCredentialRegistryContractAddress(id)).toEqual({ index: BigInt(4718), subindex: BigInt(0) });
+});
+
+test('credentialRegistryContractAddress: an error is thrown if the credential id is invalid', () => {
+ const id =
+ 'did:ccd:mainnet:sci:4718:0credentialEntry2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106';
+ expect(() => getCredentialRegistryContractAddress(id)).toThrow(Error);
+});
diff --git a/yarn.lock b/yarn.lock
index 07a811319..9bf6430f1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2001,6 +2001,7 @@ __metadata:
jest-chrome: ^0.8.0
jotai: ^1.6.5
json-bigint: ^1.0.0
+ jsonschema: ^1.4.1
leb128: ^0.0.5
lodash.debounce: ^4.0.8
lodash.groupby: ^4.6.0
@@ -15306,6 +15307,13 @@ __metadata:
languageName: node
linkType: hard
+"jsonschema@npm:^1.4.1":
+ version: 1.4.1
+ resolution: "jsonschema@npm:1.4.1"
+ checksum: 1ef02a6cd9bc32241ec86bbf1300bdbc3b5f2d8df6eb795517cf7d1cd9909e7beba1e54fdf73990fd66be98a182bda9add9607296b0cb00b1348212988e424b2
+ languageName: node
+ linkType: hard
+
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.2.1":
version: 3.3.1
resolution: "jsx-ast-utils@npm:3.3.1"