From a9459a8c4f99eddf0970b03d2c8236aa18315605 Mon Sep 17 00:00:00 2001 From: Hjort Date: Thu, 10 Aug 2023 10:17:01 +0200 Subject: [PATCH] Fixes to addWeb3IdCredentials --- examples/add-example-Web3Id/index.html | 4 +- packages/browser-wallet-api-helpers/README.md | 20 +-- .../src/wallet-api-types.ts | 22 ++-- packages/browser-wallet-api/src/wallet-api.ts | 12 +- .../browser-wallet-message-hub/src/message.ts | 2 +- packages/browser-wallet/package.json | 2 +- .../browser-wallet/src/background/index.ts | 117 ++++-------------- .../browser-wallet/src/background/web3Id.ts | 78 ++++++++++++ .../AddWeb3IdCredential.tsx | 44 +++---- .../src/shared/storage/types.ts | 11 +- .../utils/verifiable-credential-helpers.ts | 22 ++++ 11 files changed, 181 insertions(+), 153 deletions(-) create mode 100644 packages/browser-wallet/src/background/web3Id.ts diff --git a/examples/add-example-Web3Id/index.html b/examples/add-example-Web3Id/index.html index b1c511e1..0ff7c91e 100644 --- a/examples/add-example-Web3Id/index.html +++ b/examples/add-example-Web3Id/index.html @@ -37,7 +37,7 @@ 'ConcordiumVerifiableCredential', 'UniversityDegreeCredential', ], - issuer: 'did:ccd:testnet:sci:5463:0/issuer', + issuer: 'did:ccd:testnet:sci:' + issuerIndex.value + ':0/issuer', credentialSubject: { attributes: values, }, @@ -97,6 +97,8 @@

Account address:

value="https://raw.githubusercontent.com/Concordium/concordium-web3id/credential-metadata-example/examples/json-schemas/metadata/credential-metadata.json" />
+ issuer Index: +

Attribute values:

degreeType:
diff --git a/packages/browser-wallet-api-helpers/README.md b/packages/browser-wallet-api-helpers/README.md index ba9866f2..b3270f18 100644 --- a/packages/browser-wallet-api-helpers/README.md +++ b/packages/browser-wallet-api-helpers/README.md @@ -235,20 +235,20 @@ const provider = await detectConcordiumProvider(); await provider.requestIdProof('2za2yAXbFiaB151oYqTteZfqiBzibHXizwjNbpdU8hodq9SfEk', ['AA', 'BB'], '1399', '0'); ``` -### Add WebId Credentials +### Add Web3Id Credentials To add a Web3IdCredential, use the `addWeb3IdCredential` endpoint. -The credential itself and the url for the metadata must be provided. In addition, the function takes a callback function that takes the credentialHolderId as input, and which should return the randomness used to create the commitments on the values/properties in the credential, and the signature on the commitments and credentialHolderId. If the callback does return a valid signature, the credential is not added to the wallet. +The credential itself and the url for the metadata must be provided. In addition, the function takes a callback function that takes a DID for the credentialHolderId as input, and which should return the randomness used to create the commitments on the values/properties in the credential, and the signature on the commitments and credentialHolderId. If the callback does return a valid signature, the credential is not added to the wallet. -// TODO Add example. +Note that the id fields of the credential are omitted, and added by the wallet itself, as they require the credentialHolderId. -### Request Verifiable Presentation - -It is possible to request a veriable presentation on a number statements about accounts and web3IdCredentials. The function takes 2 arguments. The statements to be proved and a challenge to ensure that the proof was not generated for a different context. - -To build a statement, the Web3StatementBuilder, from the web-sdk, can be used. (// TODO link to it) - -If the wallet is locked, or you have not connected with the wallet (or previously been allowlisted) or if the user rejects proving the statement, the `Promise` will reject. +```typescript +provider.addWeb3IdCredential(credential, metadataUrl, async (id) => { + const randomness = createRandomness(attributes); // Choose some randomness for the attribute commitments. + const proof = createSignature(credential, id, randomness); // Create a signature to prove that the commitments are created by the issuer. + return { proof, randomness }; +}); +``` ## Events diff --git a/packages/browser-wallet-api-helpers/src/wallet-api-types.ts b/packages/browser-wallet-api-helpers/src/wallet-api-types.ts index e7635f65..59374799 100644 --- a/packages/browser-wallet-api-helpers/src/wallet-api-types.ts +++ b/packages/browser-wallet-api-helpers/src/wallet-api-types.ts @@ -18,18 +18,26 @@ export interface MetadataUrl { hash?: string; } +interface CredentialSchema { + id: string; + type: string; +} + +/** + * The expected form of a Web3IdCredential, with the id fields omitted. + */ export interface APIVerifiableCredential { $schema: string; type: string[]; issuer: string; issuanceDate: string; credentialSubject: Omit; - credentialSchema: { - id: string; - type: string; - }; + credentialSchema: CredentialSchema; } +/** + * Expected format for the proof that the Web3IdCredential's attribute commitments are valid + */ export interface CredentialProof { proofPurpose: 'assertionMethod'; proofValue: HexString; @@ -200,14 +208,14 @@ interface MainWalletApi { * Note that this will throw an error if the dApp is not allowlisted, locked, or if the user rejects adding the credential. * @param credential the web3IdCredential that should be added to the wallet * @param metadataUrl the url where the metadata, to display the credential, is located. - * @param createSignature a callback function, which takes the credentialId as input and must return the randomness used for the commitment of the values and signature on the commitments and credentialId. - * @returns the credentialId, containing the publicKey that will be associated with the credential. + * @param createSignature a callback function, which takes a DID identifier for the credentialHolderId as input and must return the randomness used for the commitment of the values and signature on the commitments and credentialId. + * @returns the DID identifier for the credentialHolderId, i.e. the publicKey that will be associated with the credential. */ addWeb3IdCredential( credential: APIVerifiableCredential, metadataUrl: MetadataUrl, createSignature: ( - credentialId: string + credentialHolderIdDID: string ) => Promise<{ randomness: Record; proof: CredentialProof }> ): Promise; } diff --git a/packages/browser-wallet-api/src/wallet-api.ts b/packages/browser-wallet-api/src/wallet-api.ts index 300b40a7..752d1bb3 100644 --- a/packages/browser-wallet-api/src/wallet-api.ts +++ b/packages/browser-wallet-api/src/wallet-api.ts @@ -251,7 +251,7 @@ class WalletApi extends EventEmitter implements IWalletApi { credential: APIVerifiableCredential, metadataUrl: MetadataUrl, createSignature: ( - credentialId: string + credentialHolderIdDID: string ) => Promise<{ randomness: Record; proof: CredentialProof }> ): Promise { const res = await this.messageHandler.sendMessage>( @@ -266,14 +266,14 @@ class WalletApi extends EventEmitter implements IWalletApi { throw new Error(res.message); } - const credentialId = res.result; + const credentialHolderIdDID = res.result; - const { proof, randomness } = await createSignature(credentialId); + const { proof, randomness } = await createSignature(credentialHolderIdDID); const saveSignatureResult = await this.messageHandler.sendMessage>( - MessageType.AddWeb3IdCredentialGiveSignature, + MessageType.AddWeb3IdCredentialFinish, { - credentialId, + credentialHolderIdDID, proof, randomness, } @@ -283,7 +283,7 @@ class WalletApi extends EventEmitter implements IWalletApi { throw new Error(saveSignatureResult.message); } - return credentialId; + return credentialHolderIdDID; } } diff --git a/packages/browser-wallet-message-hub/src/message.ts b/packages/browser-wallet-message-hub/src/message.ts index 5f37e832..4204a9e7 100644 --- a/packages/browser-wallet-message-hub/src/message.ts +++ b/packages/browser-wallet-message-hub/src/message.ts @@ -18,7 +18,7 @@ export enum MessageType { IdProof = 'M_IdProof', ConnectAccounts = 'M_ConnectAccounts', AddWeb3IdCredential = 'M_AddWeb3IdCredential', - AddWeb3IdCredentialGiveSignature = 'M_AddWeb3IdCredentialGiveSignature', + AddWeb3IdCredentialFinish = 'M_AddWeb3IdCredentialFinish', } /** diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index cbeae286..5867c6ef 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -1,6 +1,6 @@ { "name": "@concordium/browser-wallet", - "version": "1.1.0.0", + "version": "1.1.0", "description": "Browser extension wallet for the Concordium blockchain", "author": "Concordium Software", "license": "Apache-2.0", diff --git a/packages/browser-wallet/src/background/index.ts b/packages/browser-wallet/src/background/index.ts index ef00f1a1..9507700f 100644 --- a/packages/browser-wallet/src/background/index.ts +++ b/packages/browser-wallet/src/background/index.ts @@ -1,45 +1,35 @@ import { createMessageTypeFilter, - InternalMessageType, - MessageType, ExtensionMessageHandler, + InternalMessageType, MessageStatusWrapper, + MessageType, } from '@concordium/browser-wallet-message-hub'; +import { deserializeTypeValue, HttpProvider } from '@concordium/web-sdk'; import { - createConcordiumClient, - deserializeTypeValue, - HttpProvider, - verifyWeb3IdCredentialSignature, -} from '@concordium/web-sdk'; -import { - storedSelectedAccount, - storedCurrentNetwork, - sessionPasscode, + getGenesisHash, sessionOpenPrompt, + sessionPasscode, storedAcceptedTerms, - getGenesisHash, storedAllowlist, - storedVerifiableCredentials, - sessionVerifiableCredentials, - useIndexedStorage, + storedCurrentNetwork, + storedSelectedAccount, } from '@shared/storage/access'; -import JSONBig from 'json-bigint'; -import { ChromeStorageKey, NetworkConfiguration, VerifiableCredential } from '@shared/storage/types'; -import { buildURLwithSearchParameters } from '@shared/utils/url-helpers'; +import { mainnet, stagenet, testnet } from '@shared/constants/networkConfiguration'; +import { ChromeStorageKey, NetworkConfiguration } from '@shared/storage/types'; import { getTermsAndConditionsConfig } from '@shared/utils/network-helpers'; -import { Buffer } from 'buffer/'; -import { BackgroundSendTransactionPayload } from '@shared/utils/types'; import { parsePayload } from '@shared/utils/payload-helpers'; -import { GRPCTIMEOUT, mainnet, stagenet, testnet } from '@shared/constants/networkConfiguration'; -import { addToList, web3IdCredentialLock } from '@shared/storage/update'; -import { CredentialProof } from '@concordium/browser-wallet-api-helpers'; -import { - getCredentialRegistryContractAddress, - getCredentialRegistryIssuerKey, - getPublicKeyfromPublicKeyIdentifierDID, -} from '@shared/utils/verifiable-credential-helpers'; +import { BackgroundSendTransactionPayload } from '@shared/utils/types'; +import { buildURLwithSearchParameters } from '@shared/utils/url-helpers'; +import { Buffer } from 'buffer/'; +import JSONBig from 'json-bigint'; +import { startMonitoringPendingStatus } from './confirmation'; +import { sendCredentialHandler } from './credential-deployment'; +import { createIdProofHandler, runIfValidProof } from './id-proof'; +import { addIdpListeners, identityIssuanceHandler } from './identity-issuance'; import bgMessageHandler from './message-handler'; +import { setupRecoveryHandler, startRecovery } from './recovery'; import { forwardToPopup, HandleMessage, @@ -49,11 +39,7 @@ import { setPopupSize, testPopupOpen, } from './window-management'; -import { addIdpListeners, identityIssuanceHandler } from './identity-issuance'; -import { startMonitoringPendingStatus } from './confirmation'; -import { sendCredentialHandler } from './credential-deployment'; -import { startRecovery, setupRecoveryHandler } from './recovery'; -import { createIdProofHandler, runIfValidProof } from './id-proof'; +import { web3IdAddCredentialFinishHandler } from './web3Id'; const rpcCallNotAllowedMessage = 'RPC Call can only be performed by whitelisted sites'; const walletLockedMessage = 'The wallet is locked'; @@ -515,75 +501,14 @@ const getSelectedChainHandler: ExtensionMessageHandler = (_msg, sender, respond) bgMessageHandler.handleMessage(createMessageTypeFilter(MessageType.GetSelectedChain), getSelectedChainHandler); -const NO_CREDENTIALS_FIT = 'No temporary credentials fit the given id'; -const INVALID_CREDENTIAL_PROOF = 'Invalid credential proof given'; - -async function web3IdAddSignatureHandler(input: { - credentialId: string; - proof: CredentialProof; - randomness: Record; -}): Promise { - const { credentialId, proof, randomness } = input; - - const network = await storedCurrentNetwork.get(); - - if (!network) { - throw new Error('No network chosen'); - } - - const { genesisHash } = network; - const tempCredentials = await sessionVerifiableCredentials.get(genesisHash); - - if (!tempCredentials) { - throw new Error(NO_CREDENTIALS_FIT); - } - - const saved = tempCredentials.find((cred) => cred.credentialSubject.id === credentialId); - - if (!saved) { - throw new Error(NO_CREDENTIALS_FIT); - } - - const client = createConcordiumClient(network.grpcUrl, network.grpcPort, { timeout: GRPCTIMEOUT }); - const issuerContract = getCredentialRegistryContractAddress(saved.issuer); - - if ( - !proof?.proofValue || - !verifyWeb3IdCredentialSignature({ - globalContext: await client.getCryptographicParameters(), - signature: proof.proofValue, - randomness, - values: saved.credentialSubject.attributes, - issuerContract, - issuerPublicKey: await getCredentialRegistryIssuerKey(client, issuerContract), - holder: getPublicKeyfromPublicKeyIdentifierDID(saved.credentialSubject.id), - }) - ) { - throw new Error(INVALID_CREDENTIAL_PROOF); - } - - const credential: VerifiableCredential = { - ...saved, - signature: proof.proofValue, - randomness, - }; - - addToList( - web3IdCredentialLock, - credential, - useIndexedStorage(storedVerifiableCredentials, () => Promise.resolve(genesisHash)) - ); - // TODO remove temp in session -} - bgMessageHandler.handleMessage( - createMessageTypeFilter(MessageType.AddWeb3IdCredentialGiveSignature), + createMessageTypeFilter(MessageType.AddWeb3IdCredentialFinish), (input, sender, respond) => { if (!sender.url || !isAllowlisted(sender.url)) { respond({ success: false, message: 'not allowlisted' }); } - web3IdAddSignatureHandler(input.payload) + web3IdAddCredentialFinishHandler(input.payload) .then(() => respond({ success: true })) .catch((error) => respond({ success: false, message: error.message })); diff --git a/packages/browser-wallet/src/background/web3Id.ts b/packages/browser-wallet/src/background/web3Id.ts new file mode 100644 index 00000000..40a10424 --- /dev/null +++ b/packages/browser-wallet/src/background/web3Id.ts @@ -0,0 +1,78 @@ +import { createConcordiumClient, verifyWeb3IdCredentialSignature } from '@concordium/web-sdk'; +import { + sessionVerifiableCredentials, + storedCurrentNetwork, + storedVerifiableCredentials, + useIndexedStorage, +} from '@shared/storage/access'; + +import { CredentialProof } from '@concordium/browser-wallet-api-helpers'; +import { GRPCTIMEOUT } from '@shared/constants/networkConfiguration'; +import { VerifiableCredential } from '@shared/storage/types'; +import { addToList, web3IdCredentialLock } from '@shared/storage/update'; +import { + getCredentialRegistryContractAddress, + getCredentialRegistryIssuerKey, + getPublicKeyfromPublicKeyIdentifierDID, +} from '@shared/utils/verifiable-credential-helpers'; + +const NO_CREDENTIALS_FIT = 'No temporary credentials fit the given id'; +const INVALID_CREDENTIAL_PROOF = 'Invalid credential proof given'; + +export async function web3IdAddCredentialFinishHandler(input: { + credentialId: string; + proof: CredentialProof; + randomness: Record; +}): Promise { + const { credentialId, proof, randomness } = input; + + const network = await storedCurrentNetwork.get(); + + if (!network) { + throw new Error('No network chosen'); + } + + const { genesisHash } = network; + const tempCredentials = await sessionVerifiableCredentials.get(genesisHash); + + if (!tempCredentials) { + throw new Error(NO_CREDENTIALS_FIT); + } + + const saved = tempCredentials.find((cred) => cred.credentialSubject.id === credentialId); + + if (!saved) { + throw new Error(NO_CREDENTIALS_FIT); + } + + const client = createConcordiumClient(network.grpcUrl, network.grpcPort, { timeout: GRPCTIMEOUT }); + const issuerContract = getCredentialRegistryContractAddress(saved.issuer); + + if ( + !proof?.proofValue || + !verifyWeb3IdCredentialSignature({ + globalContext: await client.getCryptographicParameters(), + signature: proof.proofValue, + randomness, + values: saved.credentialSubject.attributes, + issuerContract, + issuerPublicKey: await getCredentialRegistryIssuerKey(client, issuerContract), + holder: getPublicKeyfromPublicKeyIdentifierDID(saved.credentialSubject.id), + }) + ) { + throw new Error(INVALID_CREDENTIAL_PROOF); + } + + const credential: VerifiableCredential = { + ...saved, + signature: proof.proofValue, + randomness, + }; + + addToList( + web3IdCredentialLock, + credential, + useIndexedStorage(storedVerifiableCredentials, () => Promise.resolve(genesisHash)) + ); + // TODO remove temp in session +} diff --git a/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx b/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx index d2fb87a0..e6320258 100644 --- a/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx +++ b/packages/browser-wallet/src/popup/pages/AddWeb3IdCredential/AddWeb3IdCredential.tsx @@ -12,18 +12,19 @@ import { storedVerifiableCredentialsAtom, storedVerifiableCredentialSchemasAtom, } from '@popup/store/verifiable-credential'; -import { NetworkConfiguration, VerifiableCredentialStatus, VerifiableCredentialSchema } from '@shared/storage/types'; +import { VerifiableCredentialStatus, VerifiableCredentialSchema } from '@shared/storage/types'; import { useAsyncMemo } from 'wallet-common-helpers'; import { useHdWallet } from '@popup/shared/utils/account-helpers'; -import { ContractAddress } from '@concordium/web-sdk'; import { displayUrl } from '@popup/shared/utils/string-helpers'; import { + createCredentialId, + createPublicKeyIdentifier, fetchCredentialMetadata, + fetchCredentialSchema, getCredentialRegistryContractAddress, } from '@shared/utils/verifiable-credential-helpers'; import { APIVerifiableCredential } from '@concordium/browser-wallet-api-helpers'; import { networkConfigurationAtom } from '@popup/store/settings'; -import { getNet } from '@shared/utils/network-helpers'; import { MetadataUrl } from '@concordium/browser-wallet-api-helpers/lib/wallet-api-types'; import { VerifiableCredentialCard } from '../VerifiableCredential/VerifiableCredentialCard'; @@ -42,16 +43,6 @@ interface Location { }; } -function createCredentialSubjectId(credentialHolderId: string, network: NetworkConfiguration) { - return `did:ccd:${getNet(network).toLowerCase()}:pkc:${credentialHolderId}`; -} - -function createCredentialId(credentialHolderId: string, issuer: ContractAddress, network: NetworkConfiguration) { - return `did:ccd:${getNet(network).toLowerCase()}:sci:${issuer.index}:${ - issuer.subindex - }/credentialEntry/${credentialHolderId}`; -} - export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { const { state } = useLocation() as Location; const { t } = useTranslation('addWeb3IdCredential'); @@ -74,15 +65,19 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { const controller = new AbortController(); const metadata = useAsyncMemo( - () => { + async () => { + if (verifiableCredentialMetadata.loading) { + return undefined; + } + if (metadataUrl.url in verifiableCredentialMetadata.value) { + // TODO check hash? + return verifiableCredentialMetadata.value[metadataUrl.url]; + } return fetchCredentialMetadata(metadataUrl, controller); }, undefined, - [metadataUrl] + [verifiableCredentialMetadata.loading] ); - useEffect(() => () => controller.abort(), [metadataUrl]); - - // TODO use Jakobs? const schema = useAsyncMemo( async () => { if (schemas.loading) { @@ -90,19 +85,19 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { } const schemaUrl = credential.credentialSchema.id; if (schemaUrl in schemas.value) { + // TODO check hash? return schemas.value[schemaUrl]; } - // TODO check checksum - const response = await fetch(schemaUrl); - return JSON.parse(await response.text()); + return fetchCredentialSchema(metadataUrl, controller); }, undefined, [schemas.loading] ); + useEffect(() => () => controller.abort(), [metadataUrl]); async function addCredential(credentialSchema: VerifiableCredentialSchema) { if (!wallet) { - throw new Error('unreachable'); + throw new Error('Wallet is unexpectedly missing'); } const schemaUrl = credential.credentialSchema.id; @@ -111,7 +106,8 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { updatedSchemas[schemaUrl] = credentialSchema; setSchemas(updatedSchemas); } - // Find the next unused index (// TODO verify on chain) + // Find the next unused index + // TODO verify index is unused on chain? const index = [...(web3IdCredentials || []), ...(storedWeb3IdCredentials || [])].reduce( (best, cred) => (cred.issuer === credential.issuer ? Math.max(cred.index + 1, best) : best), 0 @@ -120,7 +116,7 @@ export default function AddWeb3IdCredential({ onAllow, onReject }: Props) { const issuer = getCredentialRegistryContractAddress(credential.issuer); const credentialHolderId = wallet.getVerifiableCredentialPublicKey(issuer, index).toString('hex'); - const credentialSubjectId = createCredentialSubjectId(credentialHolderId, network); + const credentialSubjectId = createPublicKeyIdentifier(credentialHolderId, network); const credentialSubject = { ...credential.credentialSubject, id: credentialSubjectId }; const fullCredential = { diff --git a/packages/browser-wallet/src/shared/storage/types.ts b/packages/browser-wallet/src/shared/storage/types.ts index 2ab063df..54b22ee7 100644 --- a/packages/browser-wallet/src/shared/storage/types.ts +++ b/packages/browser-wallet/src/shared/storage/types.ts @@ -267,22 +267,19 @@ export enum VerifiableCredentialStatus { NotActivated, } -interface CredentialSchema { - id: string; - type: string; -} - export type CredentialSubject = { id: string; attributes: Record; }; export interface VerifiableCredential extends APIVerifiableCredential { + // With ID + credentialSubject: CredentialSubject; id: string; + // Secrets signature: string; randomness: Record; - credentialSchema: CredentialSchema; - credentialSubject: CredentialSubject; + /** index used to derive keys for credential */ index: number; } diff --git a/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts index ddd2fd40..6e482691 100644 --- a/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts @@ -1,6 +1,7 @@ import { ConcordiumGRPCClient, ContractAddress, sha256 } from '@concordium/web-sdk'; import { MetadataUrl, + NetworkConfiguration, VerifiableCredential, VerifiableCredentialSchema, VerifiableCredentialStatus, @@ -8,6 +9,7 @@ import { import { Buffer } from 'buffer/'; import jsonschema from 'jsonschema'; import { getContractName } from './contract-helpers'; +import { getNet } from './network-helpers'; /** * Extracts the credential holder id from a verifiable credential id (did). @@ -679,3 +681,23 @@ export async function getCredentialRegistryIssuerKey( return returnValue; } + +/** + * Create a publicKey DID identitifer for the given key. + */ +export function createPublicKeyIdentifier(publicKey: string, network: NetworkConfiguration): string { + return `did:ccd:${getNet(network).toLowerCase()}:pkc:${publicKey}`; +} + +/** + * Create a DID identitifer for the given web3Id credential. + */ +export function createCredentialId( + credentialHolderId: string, + issuer: ContractAddress, + network: NetworkConfiguration +): string { + return `did:ccd:${getNet(network).toLowerCase()}:sci:${issuer.index}:${ + issuer.subindex + }/credentialEntry/${credentialHolderId}`; +}