diff --git a/examples/add-example-Web3Id/README.md b/examples/add-example-Web3Id/README.md index 8aec899c..4924a0a8 100644 --- a/examples/add-example-Web3Id/README.md +++ b/examples/add-example-Web3Id/README.md @@ -10,7 +10,7 @@ The example project included in this repository, serves as a working example of ## Installing - Run `yarn` in package root. -- Build concordium helpers by running `yarn build:api-helpers`. +- Build concordium helpers by running `yarn build:api-helpers`. ## Running the example diff --git a/examples/add-example-Web3Id/index.html b/examples/add-example-Web3Id/index.html index 57f22252..e99b122d 100644 --- a/examples/add-example-Web3Id/index.html +++ b/examples/add-example-Web3Id/index.html @@ -42,26 +42,29 @@ b .revealAttribute('degreeType') .addMembership('degreeName', ['Bachelor of Science and Arts', 'Bachelor of Finance']) - ).getStatements(); + ) + .getStatements(); sendStatement(statement); }); document.getElementById('web3ProofIdOnly').addEventListener('click', () => { const statement = new concordiumSDK.Web3StatementBuilder() - .addForIdentityCredentials([0, 1, 2], (b) => - b.revealAttribute('firstName').addRange('dob', '08000101', '20000101') - ).getStatements(); + .addForIdentityCredentials([0, 1, 2], (b) => + b.revealAttribute('firstName').addRange('dob', '08000101', '20000101') + ) + .getStatements(); sendStatement(statement); }); document.getElementById('web3ProofMixed').addEventListener('click', () => { const statement = new concordiumSDK.Web3StatementBuilder() - .addForIdentityCredentials([0, 1, 2], (b) => - b.revealAttribute('firstName').addRange('dob', '08000101', '20000101') - ) - .addForVerifiableCredentials([{ index: 5463n, subindex: 0n }], (b) => - b - .revealAttribute('degreeType') - .addMembership('degreeName', ['Bachelor of Science and Arts', 'Bachelor of Finance']) - ).getStatements(); + .addForIdentityCredentials([0, 1, 2], (b) => + b.revealAttribute('firstName').addRange('dob', '08000101', '20000101') + ) + .addForVerifiableCredentials([{ index: 5463n, subindex: 0n }], (b) => + b + .revealAttribute('degreeType') + .addMembership('degreeName', ['Bachelor of Science and Arts', 'Bachelor of Finance']) + ) + .getStatements(); sendStatement(statement); }); // Add credential @@ -157,8 +160,8 @@

Attribute values:

Request Proofs:


- -
- + +
+ diff --git a/packages/browser-wallet/.storybook/main.js b/packages/browser-wallet/.storybook/main.js index 5ed1b0ac..c873a76d 100644 --- a/packages/browser-wallet/.storybook/main.js +++ b/packages/browser-wallet/.storybook/main.js @@ -38,4 +38,10 @@ module.exports = { return config; }, + babel: async (options) => { + return { + ...options, + plugins: options.plugins.filter((x) => !(typeof x === 'string' && x.includes('plugin-transform-classes'))), + }; + }, }; diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index 5867c6ef..c8df16b9 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -20,6 +20,7 @@ "@concordium/browser-wallet-api": "workspace:^", "@concordium/browser-wallet-api-helpers": "workspace:^", "@concordium/web-sdk": "^6.1.0-alpha.1", + "@noble/ed25519": "^1.7.0", "@protobuf-ts/runtime-rpc": "^2.8.2", "@scure/bip39": "^1.1.0", "axios": "^0.27.2", diff --git a/packages/browser-wallet/src/assets/svg/archive.svg b/packages/browser-wallet/src/assets/svg/archive.svg new file mode 100644 index 00000000..f9dbe4ce --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/archive.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/browser-wallet/src/assets/svg/back-icon.svg b/packages/browser-wallet/src/assets/svg/back-icon.svg new file mode 100644 index 00000000..c8fa3bd8 --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/back-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/browser-wallet/src/assets/svg/more.svg b/packages/browser-wallet/src/assets/svg/more.svg new file mode 100644 index 00000000..0abc2086 --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/more.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser-wallet/src/assets/svg/revoke.svg b/packages/browser-wallet/src/assets/svg/revoke.svg new file mode 100644 index 00000000..15142a8f --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/revoke.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss index 8353c456..f90ce37f 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss @@ -1,8 +1,16 @@ .verifiable-credential-list { + height: calc(100% - 56px); + background-color: $color-bg; overflow-y: auto; &__card { - margin: rem(16px); + margin-left: rem(16px); + margin-right: rem(16px); + margin-bottom: rem(16px); + + &:not(:first-child) { + margin-top: rem(16px); + } } } diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx new file mode 100644 index 00000000..cdc68847 --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialDetails.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { VerifiableCredential, VerifiableCredentialSchema, VerifiableCredentialStatus } from '@shared/storage/types'; +import Topbar, { ButtonTypes, MenuButton } from '@popup/shared/Topbar/Topbar'; +import { useTranslation } from 'react-i18next'; +import { AccountTransactionType } from '@concordium/web-sdk'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { grpcClientAtom } from '@popup/store/settings'; +import { absoluteRoutes } from '@popup/constants/routes'; +import { useHdWallet } from '@popup/shared/utils/account-helpers'; +import { + VerifiableCredentialMetadata, + buildRevokeTransaction, + buildRevokeTransactionParameters, + getCredentialHolderId, + getCredentialRegistryContractAddress, + getRevokeTransactionExecutionEnergyEstimate, +} from '@shared/utils/verifiable-credential-helpers'; +import { fetchContractName } from '@shared/utils/token-helpers'; +import { ClassName } from 'wallet-common-helpers'; +import { accountRoutes } from '../Account/routes'; +import { ConfirmGenericTransferState } from '../Account/ConfirmGenericTransfer'; +import RevokeIcon from '../../../assets/svg/revoke.svg'; +import { useCredentialEntry } from './VerifiableCredentialHooks'; +import { VerifiableCredentialCard } from './VerifiableCredentialCard'; + +interface CredentialDetailsProps extends ClassName { + credential: VerifiableCredential; + status: VerifiableCredentialStatus; + metadata: VerifiableCredentialMetadata; + schema: VerifiableCredentialSchema; + backButtonOnClick: () => void; +} + +export default function VerifiableCredentialDetails({ + credential, + status, + metadata, + schema, + backButtonOnClick, + className, +}: CredentialDetailsProps) { + const nav = useNavigate(); + const { pathname } = useLocation(); + const { t } = useTranslation('verifiableCredential'); + const client = useAtomValue(grpcClientAtom); + const hdWallet = useHdWallet(); + const credentialEntry = useCredentialEntry(credential); + + const goToConfirmPage = useCallback(async () => { + if (credentialEntry === undefined || hdWallet === undefined) { + return; + } + + const contractAddress = getCredentialRegistryContractAddress(credential.id); + const credentialId = getCredentialHolderId(credential.id); + const contractName = await fetchContractName(client, contractAddress.index, contractAddress.subindex); + if (contractName === undefined) { + throw new Error(`Unable to find contract name for address: ${contractAddress}`); + } + + const signingKey = hdWallet + .getVerifiableCredentialSigningKey(contractAddress, credential.index) + .toString('hex'); + + const parameters = await buildRevokeTransactionParameters( + contractAddress, + credentialId, + credentialEntry.revocationNonce, + signingKey + ); + const maxExecutionEnergy = await getRevokeTransactionExecutionEnergyEstimate(client, contractName, parameters); + const payload = await buildRevokeTransaction( + contractAddress, + contractName, + credentialId, + maxExecutionEnergy, + parameters + ); + + const confirmTransferState: ConfirmGenericTransferState = { + payload, + type: AccountTransactionType.Update, + }; + + nav(`${absoluteRoutes.home.account.path}/${accountRoutes.confirmTransfer}`, { + state: confirmTransferState, + }); + }, [client, credential, hdWallet, credentialEntry, nav, pathname]); + + const menuButton: MenuButton | undefined = useMemo(() => { + if ( + credentialEntry === undefined || + !credentialEntry.credentialInfo.holderRevocable || + status === VerifiableCredentialStatus.Revoked + ) { + return undefined; + } + + return { + type: ButtonTypes.More, + items: [ + { + title: t('menu.revoke'), + icon: , + onClick: goToConfirmPage, + }, + ], + }; + }, [credentialEntry, goToConfirmPage]); + + // Wait for the credential entry to be loaded from the chain, and for the HdWallet + // to be loaded to be ready to derive keys. + if (credentialEntry === undefined || hdWallet === undefined) { + return null; + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx index d5888279..01626333 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx @@ -18,7 +18,7 @@ import { AsyncWrapper } from '@popup/store/utils'; import { ConcordiumGRPCClient } from '@concordium/web-sdk'; /** - * Retrieve the on-chain credential status for a verifiable credential in a registry contract. + * Retrieve the on-chain credential status for a verifiable credential in a CIS-4 credential registry contract. * @param credential the verifiable credential to lookup the status for * @returns the status for the given credential */ diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx index fbe5932d..eef64ad6 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx @@ -4,8 +4,10 @@ import { storedVerifiableCredentialSchemasAtom, storedVerifiableCredentialsAtom, } from '@popup/store/verifiable-credential'; -import { useAtom, useAtomValue } from 'jotai'; -import { VerifiableCredential, VerifiableCredentialStatus, VerifiableCredentialSchema } from '@shared/storage/types'; +import { useAtomValue, useAtom } from 'jotai'; +import Topbar from '@popup/shared/Topbar/Topbar'; +import { useTranslation } from 'react-i18next'; +import { VerifiableCredential, VerifiableCredentialSchema, VerifiableCredentialStatus } from '@shared/storage/types'; import { VerifiableCredentialMetadata, getChangesToCredentialMetadata, @@ -18,6 +20,7 @@ import { useFetchingEffect, } from './VerifiableCredentialHooks'; import { VerifiableCredentialCard } from './VerifiableCredentialCard'; +import VerifiableCredentialDetails from './VerifiableCredentialDetails'; /** * Component to display while loading verifiable credentials from storage. @@ -30,12 +33,16 @@ function LoadingVerifiableCredentials() { * Component to display when there are no verifiable credentials in the wallet. */ function NoVerifiableCredentials() { + const { t } = useTranslation('verifiableCredential'); return ( -
-
-

You do not have any verifiable credentials in your wallet.

+ <> + +
+
+

You do not have any verifiable credentials in your wallet.

+
-
+ ); } @@ -84,6 +91,7 @@ function VerifiableCredentialCardWithStatusFromChain({ */ export default function VerifiableCredentialList() { const verifiableCredentials = useAtomValue(storedVerifiableCredentialsAtom); + const { t } = useTranslation('verifiableCredential'); const [selected, setSelected] = useState<{ credential: VerifiableCredential; status: VerifiableCredentialStatus; @@ -116,32 +124,36 @@ export default function VerifiableCredentialList() { if (selected) { return ( - setSelected(undefined)} /> ); } return ( -
- {verifiableCredentials.value.map((credential) => { - return ( - setSelected({ credential, status, schema, metadata })} - /> - ); - })} -
+ <> + +
+ {verifiableCredentials.value.map((credential) => { + return ( + setSelected({ credential, status, schema, metadata })} + /> + ); + })} +
+ ); } diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/da.ts b/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/da.ts index a581242f..536d7430 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/da.ts +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/da.ts @@ -1,6 +1,13 @@ -import en from './en'; +import type en from './en'; const t: typeof en = { + topbar: { + details: 'Kortdetaljer', + list: 'Web3 ID Kort', + }, + menu: { + revoke: 'Ophæv', + }, status: { Active: 'Aktiv', Revoked: 'Ophævet', diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/en.ts b/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/en.ts index c6e00d4a..a8dbf0f6 100644 --- a/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/en.ts +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/i18n/en.ts @@ -1,4 +1,11 @@ const t = { + topbar: { + details: 'Credential Details', + list: 'Web3 ID Credentials', + }, + menu: { + revoke: 'Revoke', + }, status: { Active: 'Active', Revoked: 'Revoked', diff --git a/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.scss b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.scss new file mode 100644 index 00000000..097c20b7 --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.scss @@ -0,0 +1,46 @@ +.popup-menu { + width: 200px; + box-shadow: 0 0 20px rgb(0 0 0 / 15%); + border-radius: 16px; + display: flex; + flex-direction: column; + background-color: $color-white; + + &__item { + &:hover { + background-color: lightgray; + } + + &:first-child { + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + + &:last-child { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } + + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + height: 44px; + + &:not(:last-child) { + border-bottom: 1px solid lightgray; + } + + &--disabled { + opacity: 0.2; + } + + &__icon { + height: 20px; + width: 20px; + display: flex; + align-items: center; + justify-content: center; + } + } +} diff --git a/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.stories.tsx b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.stories.tsx new file mode 100644 index 00000000..864aa61f --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.stories.tsx @@ -0,0 +1,35 @@ +/* eslint-disable react/function-component-definition */ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import RevokeIcon from '@assets/svg/revoke.svg'; +import ArchiveIcon from '@assets/svg/archive.svg'; +import PopupMenu from './PopupMenu'; + +export default { + title: 'Shared/PopupMenu', + component: PopupMenu, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + return ( + +
+ +
+
+ ); +}; + +export const WithSingleItem = Template.bind({}); +WithSingleItem.args = { + items: [{ title: 'Revoke', icon: }], +}; + +export const WithTwoItems = Template.bind({}); +WithTwoItems.args = { + items: [ + { title: 'Revoke', icon: }, + { title: 'Archive', icon: }, + ], +}; diff --git a/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.tsx b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.tsx new file mode 100644 index 00000000..8173b94f --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/PopupMenu/PopupMenu.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { DetectClickOutside } from 'wallet-common-helpers'; +import clsx from 'clsx'; +import Button from '../Button/Button'; + +export interface PopupMenuItem { + title: string; + icon: JSX.Element; + onClick?: () => void; +} + +interface PopupMenuProps { + items: PopupMenuItem[]; + onClickOutside: () => void; +} + +export default function PopupMenu({ items, onClickOutside }: PopupMenuProps) { + return ( + +
+ {items.map((item) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/browser-wallet/src/popup/shared/PopupMenu/index.ts b/packages/browser-wallet/src/popup/shared/PopupMenu/index.ts new file mode 100644 index 00000000..be13378e --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/PopupMenu/index.ts @@ -0,0 +1 @@ +export { default } from './PopupMenu'; diff --git a/packages/browser-wallet/src/popup/shared/Topbar/Topbar.scss b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.scss new file mode 100644 index 00000000..8ad3eefc --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.scss @@ -0,0 +1,45 @@ +.topbar { + position: relative; + height: 56px; + background-color: $color-bg; + color: $color-cta; + padding-left: 16px; + padding-right: 16px; + display: flex; + align-items: center; + justify-content: center; + + &__title { + text-align: center; + width: 100%; + } + + &__icon-container { + height: 24px; + width: 24px; + flex-shrink: 0; + + &__icon { + display: flex; + align-items: center; + height: 24px; + width: 24px; + stroke-width: 0; + + path { + fill: $color-cta; + } + } + } + + &__popup-menu { + display: none; + position: absolute; + right: 10px; + + &__show { + z-index: 1; + display: block; + } + } +} diff --git a/packages/browser-wallet/src/popup/shared/Topbar/Topbar.stories.tsx b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.stories.tsx new file mode 100644 index 00000000..34aba8de --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.stories.tsx @@ -0,0 +1,46 @@ +/* eslint-disable react/function-component-definition */ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import Topbar, { ButtonTypes } from './Topbar'; + +export default { + title: 'Shared/Topbar', + component: Topbar, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + return ( + +
+ +
+
+ ); +}; + +export const WithBackButton = Template.bind({}); +WithBackButton.args = { + title: 'Page Navigation Title', + onBackButtonClick: () => {}, +}; + +export const WithoutBackButton = Template.bind({}); +WithoutBackButton.args = { + title: 'Page Navigation Title', + onBackButtonClick: undefined, +}; + +export const WithMoreMenuButton = Template.bind({}); +WithMoreMenuButton.args = { + title: 'Page Navigation Title', + onBackButtonClick: undefined, + menuButton: { type: ButtonTypes.More, items: [{ title: 'Revoke', icon:
Test
}] }, +}; + +export const WithBackAndMoreMenuButton = Template.bind({}); +WithBackAndMoreMenuButton.args = { + title: 'Page Navigation Title', + onBackButtonClick: () => {}, + menuButton: { type: ButtonTypes.More, items: [{ title: 'Revoke', icon:
Test
}] }, +}; diff --git a/packages/browser-wallet/src/popup/shared/Topbar/Topbar.tsx b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.tsx new file mode 100644 index 00000000..551593e9 --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/Topbar/Topbar.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import BackIcon from '@assets/svg/back-icon.svg'; +import MoreIcon from '@assets/svg/more.svg'; +import clsx from 'clsx'; +import Button from '../Button'; +import PopupMenu, { PopupMenuItem } from '../PopupMenu/PopupMenu'; + +export enum ButtonTypes { + More, +} + +interface MoreMenuButton { + type: ButtonTypes.More; + items: PopupMenuItem[]; +} + +export type MenuButton = MoreMenuButton; + +interface TopbarProps { + title: string; + onBackButtonClick?: () => void; + menuButton?: MenuButton; +} + +export default function Topbar({ title, onBackButtonClick, menuButton }: TopbarProps) { + const [showPopupMenu, setShowPopupMenu] = useState(false); + + return ( +
+
+ {onBackButtonClick && ( + + )} +
+
{title}
+
+ {menuButton && ( + <> + +
+ setShowPopupMenu(false)} /> +
+ + )} +
+
+ ); +} diff --git a/packages/browser-wallet/src/popup/shared/Topbar/index.ts b/packages/browser-wallet/src/popup/shared/Topbar/index.ts new file mode 100644 index 00000000..91156873 --- /dev/null +++ b/packages/browser-wallet/src/popup/shared/Topbar/index.ts @@ -0,0 +1 @@ +export { default } from './Topbar'; diff --git a/packages/browser-wallet/src/popup/shell/i18n/locales/da.ts b/packages/browser-wallet/src/popup/shell/i18n/locales/da.ts index 13c1da54..b20015ec 100644 --- a/packages/browser-wallet/src/popup/shell/i18n/locales/da.ts +++ b/packages/browser-wallet/src/popup/shell/i18n/locales/da.ts @@ -20,9 +20,9 @@ import termsAndConditions from '@popup/pages/TermsAndConditions/i18n/da'; import idProofRequest from '@popup/pages/IdProofRequest/i18n/da'; import allowlist from '@popup/pages/Allowlist/i18n/da'; import connectAccountsRequest from '@popup/pages/ConnectAccountsRequest/i18n/da'; -import addWeb3IdCredential from '@popup/pages/AddWeb3IdCredential/i18n/da'; import web3IdProofRequest from '@popup/pages/Web3ProofRequest/i18n/da'; import verifiableCredential from '@popup/pages/VerifiableCredential/i18n/da'; +import addWeb3IdCredential from '@popup/pages/AddWeb3IdCredential/i18n/da'; import type en from './en'; diff --git a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts index 26ffeb87..a6161009 100644 --- a/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts +++ b/packages/browser-wallet/src/popup/shell/i18n/locales/en.ts @@ -20,9 +20,9 @@ import termsAndConditions from '@popup/pages/TermsAndConditions/i18n/en'; import idProofRequest from '@popup/pages/IdProofRequest/i18n/en'; import allowlist from '@popup/pages/Allowlist/i18n/en'; import connectAccountsRequest from '@popup/pages/ConnectAccountsRequest/i18n/en'; -import addWeb3IdCredential from '@popup/pages/AddWeb3IdCredential/i18n/en'; import web3IdProofRequest from '@popup/pages/Web3ProofRequest/i18n/en'; import verifiableCredential from '@popup/pages/VerifiableCredential/i18n/en'; +import addWeb3IdCredential from '@popup/pages/AddWeb3IdCredential/i18n/en'; const t = { shared, diff --git a/packages/browser-wallet/src/popup/styles/_components.scss b/packages/browser-wallet/src/popup/styles/_components.scss index 517b0c7b..d29f1c27 100644 --- a/packages/browser-wallet/src/popup/styles/_components.scss +++ b/packages/browser-wallet/src/popup/styles/_components.scss @@ -21,6 +21,8 @@ @import '../shared/TokenDetails/TokenDetails'; @import '../shared/ContractTokenLine/ContractTokenLine'; @import '../shared/DisabledAmountInput/DisabledAmountInput'; +@import '../shared/Topbar/Topbar'; +@import '../shared/PopupMenu/PopupMenu'; // Pages @import '../pages/Account'; diff --git a/packages/browser-wallet/src/popup/styles/config/_typography.scss b/packages/browser-wallet/src/popup/styles/config/_typography.scss index 132c6e82..ac33a567 100644 --- a/packages/browser-wallet/src/popup/styles/config/_typography.scss +++ b/packages/browser-wallet/src/popup/styles/config/_typography.scss @@ -1,6 +1,7 @@ @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@200;300;400;500&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@200;300;400;500&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@600&display=swap'); $font-family-roboto: 'Roboto', 'Inter', sans-serif; $font-family-mono: 'Roboto Mono', 'Inter', sans-serif; @@ -8,3 +9,17 @@ $font-weight-extra-light: 200; $font-weight-light: 300; $font-weight-regular: 400; $font-weight-bold: 500; + +.display6 { + font-family: 'Work Sans', sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 17px; +} + +.heading6 { + font-family: $font-family-roboto; + font-weight: 500; + font-size: 14px; + line-height: 18px; +} diff --git a/packages/browser-wallet/src/shared/utils/contract-helpers.ts b/packages/browser-wallet/src/shared/utils/contract-helpers.ts index 456c6b17..d8f68a78 100644 --- a/packages/browser-wallet/src/shared/utils/contract-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/contract-helpers.ts @@ -1,5 +1,15 @@ import { ContractAddress, InstanceInfo } from '@concordium/web-sdk'; +/** + * Applies a buffer of 20% to an estimated execution energy amount. The goal is to prevent transactions + * from running into insufficient energy errors. + * @param estimatedExecutionEnergy the estimated execution cost for an update transaction, should be retrieved by invoking the contract + * @returns returns the estimated execution energy with an additional buffer added to prevent running into insufficient energy errors + */ +export function applyExecutionNRGBuffer(estimatedExecutionEnergy: bigint) { + return (estimatedExecutionEnergy * 12n) / 10n; +} + /** * Get the name of a contract. * This works as the name in an instance info is prefixed with 'init_'. diff --git a/packages/browser-wallet/src/shared/utils/token-helpers.ts b/packages/browser-wallet/src/shared/utils/token-helpers.ts index 5fa3a707..ff3f63d1 100644 --- a/packages/browser-wallet/src/shared/utils/token-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/token-helpers.ts @@ -15,6 +15,7 @@ import { CIS2_SCHEMA_CONTRACT_NAME, CIS2_SCHEMA } from '@popup/constants/schema' import i18n from '@popup/shell/i18n'; import { SmartContractParameters } from '@concordium/browser-wallet-api-helpers'; import { determineUpdatePayloadSize } from './energy-helpers'; +import { applyExecutionNRGBuffer } from './contract-helpers'; export interface ContractDetails { contractName: string; @@ -347,8 +348,8 @@ async function getTokenTransferExecutionEnergyEstimate( if (!res || res.tag === 'failure') { throw new Error(res?.reason?.tag || 'no response'); } - // TODO: determine the "safety ratio" - return (res.usedEnergy * 12n) / 10n; + + return applyExecutionNRGBuffer(res.usedEnergy); } function getContractName(instanceInfo: InstanceInfo): string | undefined { 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 6c6dc27e..93cc5c08 100644 --- a/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts @@ -1,4 +1,5 @@ -import { ConcordiumGRPCClient, ContractAddress, sha256 } from '@concordium/web-sdk'; +import { CcdAmount, UpdateContractPayload, ConcordiumGRPCClient, ContractAddress, sha256 } from '@concordium/web-sdk'; +import * as ed from '@noble/ed25519'; import { MetadataUrl, NetworkConfiguration, @@ -8,7 +9,7 @@ import { } from '@shared/storage/types'; import { Buffer } from 'buffer/'; import jsonschema from 'jsonschema'; -import { getContractName } from './contract-helpers'; +import { applyExecutionNRGBuffer, getContractName } from './contract-helpers'; import { getNet } from './network-helpers'; /** @@ -51,6 +52,148 @@ export function getCredentialRegistryContractAddress(credentialId: string): Cont return { index, subindex }; } +export async function sign(digest: Buffer, privateKey: string) { + return Buffer.from(await ed.sign(digest, privateKey)).toString('hex'); +} + +// TODO This function is a copy from the node-sdk. Remove the duplicate here and export it in the SDK to use instead. +export function encodeWord64LE(value: bigint): Buffer { + if (value > 18446744073709551615n || value < 0n) { + throw new Error(`The input has to be a 64 bit unsigned integer but it was: ${value}`); + } + const arr = new ArrayBuffer(8); + const view = new DataView(arr); + view.setBigUint64(0, value, true); + return Buffer.from(new Uint8Array(arr)); +} + +export interface SigningData { + contractAddress: ContractAddress; + entryPoint: string; + nonce: bigint; + timestamp: bigint; +} + +export interface RevocationDataHolder { + /** The public key identifying the credential holder */ + credentialId: string; + /** Metadata of the signature */ + signingData: SigningData; +} + +export interface RevokeCredentialHolderParam { + /** Ed25519 signature on the revocation message as a hex string */ + signature: string; + /** Revocation data */ + data: RevocationDataHolder; +} + +/** + * Serializes the revocation data holder. This is the data that is signed, together with a prefix, to authorize + * the revocation of a held credential. + * @param data the revocation data context to serialize + * @returns the serialized revocation data holder + */ +function serializeRevocationDataHolder(data: RevocationDataHolder) { + const credentialId = Buffer.from(data.credentialId, 'hex'); + + const contractIndex = encodeWord64LE(data.signingData.contractAddress.index); + const contractSubindex = encodeWord64LE(data.signingData.contractAddress.subindex); + + const entrypointName = Buffer.from(data.signingData.entryPoint, 'utf-8'); + const entrypointLength = Buffer.alloc(2); + entrypointLength.writeUInt16LE(entrypointName.length, 0); + + const nonce = encodeWord64LE(data.signingData.nonce); + const timestamp = encodeWord64LE(data.signingData.timestamp); + + const optionalReason = Buffer.of(0); + + return Buffer.concat([ + credentialId, + contractIndex, + contractSubindex, + entrypointLength, + entrypointName, + nonce, + timestamp, + optionalReason, + ]); +} + +export function serializeRevokeCredentialHolderParam(parameter: RevokeCredentialHolderParam) { + const signature = Buffer.from(parameter.signature, 'hex'); + const data = serializeRevocationDataHolder(parameter.data); + return Buffer.concat([signature, data]); +} + +/** + * Builds the parameters used for a holder revocation transaction to revoke a given credential in a CIS-4 contract. + * @param address the address of the contract that hte credential to revoke is registered in + * @param credentialId the id of the credential to revoke (this is a derived public key) + * @param nonce the revocation nonce + * @param signingKey the signing key associated with the credential (the private key to the {@link credentialId}) + * @returns the unserialized parameters for a holder revocation transaction + */ +export async function buildRevokeTransactionParameters( + address: ContractAddress, + credentialId: string, + nonce: bigint, + signingKey: string +) { + const fiveMinutesInMilliseconds = 5 * 60000; + const signatureExpirationTimestamp = BigInt(Date.now() + fiveMinutesInMilliseconds); + const signingData: SigningData = { + contractAddress: address, + entryPoint: 'revokeCredentialHolder', + nonce, + timestamp: signatureExpirationTimestamp, + }; + + const data: RevocationDataHolder = { + credentialId, + signingData, + }; + + const REVOKE_SIGNATURE_MESSAGE = 'WEB3ID:REVOKE'; + const serializedData = serializeRevocationDataHolder(data); + const signature = await sign( + Buffer.concat([Buffer.from(REVOKE_SIGNATURE_MESSAGE, 'utf-8'), serializedData]), + signingKey + ); + + const parameter: RevokeCredentialHolderParam = { + signature, + data, + }; + + return parameter; +} + +/** + * Builds a holder revocation transaction to revoke a given verifiable credential in a CIS-4 contract. + * @param address the address of the contract that the credential to revoke is registered in + * @param contractName the name of the contract at {@link address} + * @param credentialId the id of the credential to revoke + * @param maxContractExecutionEnergy the maximum contract execution energy + * @returns an update contract transaction for revoking the credential with id {@link credentialId} + */ +export async function buildRevokeTransaction( + address: ContractAddress, + contractName: string, + credentialId: string, + maxContractExecutionEnergy: bigint, + parameters: RevokeCredentialHolderParam +): Promise { + return { + address, + amount: new CcdAmount(0n), + receiveName: `${contractName}.revokeCredentialHolder`, + maxContractExecutionEnergy, + message: serializeRevokeCredentialHolderParam(parameters), + }; +} + function deserializeCredentialStatus(serializedCredentialStatus: string): VerifiableCredentialStatus { const buff = Buffer.from(serializedCredentialStatus, 'hex'); switch (buff.readUInt8(0)) { @@ -67,6 +210,27 @@ function deserializeCredentialStatus(serializedCredentialStatus: string): Verifi } } +/** + * Estimates the cost of a holder revocation transaction. + * @param client the GRPC client for accessing a node + * @param contractName the name of the contract to invoke to get the estimate + * @param parameters the unserialized parameters for a holder revocation transaction + * @returns an estimate of the execution cost of the holder revocation transaction + */ +export async function getRevokeTransactionExecutionEnergyEstimate( + client: ConcordiumGRPCClient, + contractName: string, + parameters: RevokeCredentialHolderParam +) { + const invokeResult = await client.invokeContract({ + contract: parameters.data.signingData.contractAddress, + method: `${contractName}.${parameters.data.signingData.entryPoint}`, + parameter: serializeRevokeCredentialHolderParam(parameters), + }); + + return applyExecutionNRGBuffer(invokeResult.usedEnergy); +} + /** * Get the status of a verifiable credential in a CIS-4 contract. * @param client the GRPC client for accessing a node diff --git a/packages/browser-wallet/test/contract-helpers.test.ts b/packages/browser-wallet/test/contract-helpers.test.ts new file mode 100644 index 00000000..f41c42c7 --- /dev/null +++ b/packages/browser-wallet/test/contract-helpers.test.ts @@ -0,0 +1,11 @@ +import { applyExecutionNRGBuffer } from '../src/shared/utils/contract-helpers'; + +test('NRG buffer added is 20 percent', () => { + const estimatedExecutionEnergy = 500n; + expect(applyExecutionNRGBuffer(estimatedExecutionEnergy)).toEqual(600n); +}); + +test('NRG buffer returns 0 for 0 input', () => { + const estimatedExecutionEnergy = 0n; + expect(applyExecutionNRGBuffer(estimatedExecutionEnergy)).toEqual(0n); +}); diff --git a/packages/browser-wallet/test/verifiable-credential-helpers.test.ts b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts index cf927efa..5a8cc243 100644 --- a/packages/browser-wallet/test/verifiable-credential-helpers.test.ts +++ b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts @@ -1,15 +1,70 @@ import { - createCredentialId, - createPublicKeyIdentifier, + RevocationDataHolder, + RevokeCredentialHolderParam, + SigningData, getCredentialHolderId, getCredentialRegistryContractAddress, + serializeRevokeCredentialHolderParam, + createCredentialId, + createPublicKeyIdentifier, getPublicKeyfromPublicKeyIdentifierDID, + CredentialQueryResponse, + deserializeCredentialEntry, getCredentialIdFromSubjectDID, getContractAddressFromIssuerDID, getVerifiableCredentialPublicKeyfromSubjectDID, } from '../src/shared/utils/verifiable-credential-helpers'; import { mainnet, testnet } from '../src/shared/constants/networkConfiguration'; +test('serializing a revoke credential holder parameter', () => { + const signingData: SigningData = { + contractAddress: { index: 4718n, subindex: 0n }, + entryPoint: 'revokeCredentialHolder', + nonce: 0n, + timestamp: 1688542350309n, + }; + + const data: RevocationDataHolder = { + credentialId: '2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106', + signingData, + }; + + const revokeCredentialHolderParam: RevokeCredentialHolderParam = { + signature: + 'a70b2b7987a2835726bc6166da1e4d223b9f215962e20726a39ea95afaf9d10d13d0f093761f2f2a4c34d37f081ea501fed8ab74fb565b87822ff0aec6071309', + data, + }; + + expect(serializeRevokeCredentialHolderParam(revokeCredentialHolderParam).toString('hex')).toEqual( + 'a70b2b7987a2835726bc6166da1e4d223b9f215962e20726a39ea95afaf9d10d13d0f093761f2f2a4c34d37f081ea501fed8ab74fb565b87822ff0aec60713092eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d1066e12000000000000000000000000000016007265766f6b6543726564656e7469616c486f6c6465720000000000000000e58bf7248901000000' + ); +}); + +test('deserializing a credential entry', () => { + const serializedCredentialEntry = + '22ea01dfab98d77c686358528faade31b88d2866633a31a2d6dd4119cf7a58c401f9ad46f889010000004e0068747470733a2f2f676973742e67697468756275736572636f6e74656e742e636f6d2f6f72686f6a2f32326162376364316161373464633834663766663734643534303136346463342f7261772f001500687474703a2f2f636f6e636f726469756d2e636f6d000100000000000000'; + + const expected: CredentialQueryResponse = { + credentialInfo: { + credentialHolderId: '22ea01dfab98d77c686358528faade31b88d2866633a31a2d6dd4119cf7a58c4', + holderRevocable: true, + metadataUrl: { + url: 'https://gist.githubusercontent.com/orhoj/22ab7cd1aa74dc84f7ff74d540164dc4/raw/', + }, + validFrom: 1692087528953n, + validUntil: undefined, + }, + schemaRef: { + schema: { + url: 'http://concordium.com', + }, + }, + revocationNonce: 1n, + }; + + expect(deserializeCredentialEntry(serializedCredentialEntry)).toEqual(expected); +}); + test('credential holder id is extracted from verifiable credential id field', () => { const id = 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106'; diff --git a/yarn.lock b/yarn.lock index 14b85022..273a9331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1961,6 +1961,7 @@ __metadata: "@concordium/web-sdk": ^6.1.0-alpha.1 "@craftamap/esbuild-plugin-html": ^0.4.0 "@mdx-js/react": ^1.6.22 + "@noble/ed25519": ^1.7.0 "@protobuf-ts/runtime-rpc": ^2.8.2 "@scure/bip39": ^1.1.0 "@storybook/addon-actions": ^6.5.7 @@ -3010,6 +3011,13 @@ __metadata: languageName: node linkType: hard +"@noble/ed25519@npm:^1.7.0": + version: 1.7.3 + resolution: "@noble/ed25519@npm:1.7.3" + checksum: 45169927d51de513e47bbeebff3a603433c4ac7579e1b8c5034c380a0afedbe85e6959be3d69584a7a5ed6828d638f8f28879003b9bb2fb5f22d8aa2d88fd5fe + languageName: node + linkType: hard + "@noble/ed25519@npm:^1.7.1": version: 1.7.1 resolution: "@noble/ed25519@npm:1.7.1"