diff --git a/package-lock.json b/package-lock.json index d24e72a9d..3bb471b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@dfinity/identity": "^2.3.0", "@dfinity/ledger-icp": "^2.6.8", "@dfinity/ledger-icrc": "^2.7.3", - "@dfinity/oisy-wallet-signer": "^0.1.6", + "@dfinity/oisy-wallet-signer": "^0.1.7", "@dfinity/principal": "^2.3.0", "@dfinity/utils": "^2.10.0", "@dfinity/zod-schemas": "^0.0.2", @@ -716,9 +716,9 @@ } }, "node_modules/@dfinity/oisy-wallet-signer": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@dfinity/oisy-wallet-signer/-/oisy-wallet-signer-0.1.6.tgz", - "integrity": "sha512-t/aX2bHompkjbVnoUOeVlpYX6lpuhWqSikH5rOPnp2NAzjM555KHtAzgGK0HBIJ1KAJ3kXZ4okf0LbezKHHDIw==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@dfinity/oisy-wallet-signer/-/oisy-wallet-signer-0.1.7.tgz", + "integrity": "sha512-GrDEAVkf2x55Y/uEJipvonqQqg0kie5H7h+DGMr6uOUirzuB001mWuPd91OMhaUEtiVjiJC9DwI9B3B6FN9hfQ==", "license": "Apache-2.0", "engines": { "node": ">=22" diff --git a/package.json b/package.json index 5cd1ad3bd..6d89f1a52 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@dfinity/identity": "^2.3.0", "@dfinity/ledger-icp": "^2.6.8", "@dfinity/ledger-icrc": "^2.7.3", - "@dfinity/oisy-wallet-signer": "^0.1.6", + "@dfinity/oisy-wallet-signer": "^0.1.7", "@dfinity/principal": "^2.3.0", "@dfinity/utils": "^2.10.0", "@dfinity/zod-schemas": "^0.0.2", diff --git a/src/frontend/src/lib/components/icons/IconShield.svelte b/src/frontend/src/lib/components/icons/IconShield.svelte new file mode 100644 index 000000000..95a5782b3 --- /dev/null +++ b/src/frontend/src/lib/components/icons/IconShield.svelte @@ -0,0 +1,26 @@ + + + + diff --git a/src/frontend/src/lib/components/signer/Signer.svelte b/src/frontend/src/lib/components/signer/Signer.svelte new file mode 100644 index 000000000..6f381b7f4 --- /dev/null +++ b/src/frontend/src/lib/components/signer/Signer.svelte @@ -0,0 +1,38 @@ + + + + {#if $idle} +
+ +
+ {:else} + + + + + + {/if} +
diff --git a/src/frontend/src/lib/components/signer/SignerAccounts.svelte b/src/frontend/src/lib/components/signer/SignerAccounts.svelte new file mode 100644 index 000000000..b9c6a9ab4 --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerAccounts.svelte @@ -0,0 +1,44 @@ + + +{@render children()} diff --git a/src/frontend/src/lib/components/signer/SignerCallCanister.svelte b/src/frontend/src/lib/components/signer/SignerCallCanister.svelte new file mode 100644 index 000000000..c65d02009 --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerCallCanister.svelte @@ -0,0 +1,37 @@ + + +{#if $payload?.status === 'executing'} + Executing call... +{:else if $payload?.status === 'result'} +

Success ✅

+{:else if $payload?.status === 'error'} +

Error 🥲

+{/if} diff --git a/src/frontend/src/lib/components/signer/SignerConsentMessage.svelte b/src/frontend/src/lib/components/signer/SignerConsentMessage.svelte new file mode 100644 index 000000000..049f89d7a --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerConsentMessage.svelte @@ -0,0 +1,146 @@ + + +{#if loading} + Loading consent message... +{:else if nonNullish(text)} + {@const { title, content } = text} + +
+

{title}

+ + + + + + + +
+ + +
+ +{/if} diff --git a/src/frontend/src/lib/components/signer/SignerConsentMessageWarning.svelte b/src/frontend/src/lib/components/signer/SignerConsentMessageWarning.svelte new file mode 100644 index 000000000..363b06963 --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerConsentMessageWarning.svelte @@ -0,0 +1,20 @@ + + +{#if displayWarning} + $i18n.signer.consent_message.warning.token_without_consent_message +{/if} diff --git a/src/frontend/src/lib/components/signer/SignerIdle.svelte b/src/frontend/src/lib/components/signer/SignerIdle.svelte new file mode 100644 index 000000000..10eba142f --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerIdle.svelte @@ -0,0 +1,6 @@ + + +{$i18n.signer.idle_waiting} diff --git a/src/frontend/src/lib/components/signer/SignerOrigin.svelte b/src/frontend/src/lib/components/signer/SignerOrigin.svelte new file mode 100644 index 000000000..bac8eab4c --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerOrigin.svelte @@ -0,0 +1,44 @@ + + +{#if nonNullish(origin)} +

+ {$i18n.signer.origin_request_from} + {#if nonNullish(host)}{host}{:else}{$i18n.signer.origin_invalid_origin}{/if} +

+{/if} diff --git a/src/frontend/src/lib/components/signer/SignerPermissions.svelte b/src/frontend/src/lib/components/signer/SignerPermissions.svelte new file mode 100644 index 000000000..8c9dda61f --- /dev/null +++ b/src/frontend/src/lib/components/signer/SignerPermissions.svelte @@ -0,0 +1,132 @@ + + +{#if nonNullish($payload)} +
+ + +
+

{$i18n.signer.permissions_requested_permissions}

+ +
    + {#each scopes as { scope: { method } } (method)} + {@const { icon: Icon, label } = listItems[method]} + +
  • + + +
  • + {/each} +
+
+ +
+ + +
+ +{/if} + + diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index bb9da474e..8de20ac77 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -794,5 +794,18 @@ "resources_description": "View a collection of sample code, applications, and microservices build with Juno", "changelog": "Releases", "changelog_description": "See the last updates and improvements." + }, + "signer": { + "title": "Juno Wallet", + "access_your_wallet": "Access your wallet to securely connect a dApp and start using your assets.", + "permissions_no_confirm_callback": "No callback to confirm the permissions, which is unexpected. Close the wallet and try again.", + "permissions_icrc27_accounts": "View your wallet address", + "permissions_icrc49_call_canister": "Request approval for transactions", + "permissions_requested_permissions": "Requested permissions", + "permissions_your_wallet_address": "Your wallet address", + "origin_request_from": "Request from:", + "origin_invalid_origin": "Invalid origin️!!", + "origin_link_to_dapp": "Link to the dApp requesting permissions", + "idle_waiting": "Waiting for the dApp interaction..." } } diff --git a/src/frontend/src/lib/stores/signer.store.ts b/src/frontend/src/lib/stores/signer.store.ts new file mode 100644 index 000000000..92f0d24a5 --- /dev/null +++ b/src/frontend/src/lib/stores/signer.store.ts @@ -0,0 +1,225 @@ +import { DEV, LOCAL_REPLICA_HOST } from '$lib/constants/app.constants'; +import type { Option } from '$lib/types/utils'; +import type { Identity } from '@dfinity/agent'; +import { + ICRC21_CALL_CONSENT_MESSAGE, + ICRC25_REQUEST_PERMISSIONS, + ICRC27_ACCOUNTS, + ICRC49_CALL_CANISTER, + type AccountsPromptPayload, + type CallCanisterPromptPayload, + type ConsentMessagePromptPayload, + type PermissionsPromptPayload +} from '@dfinity/oisy-wallet-signer'; +import { Signer } from '@dfinity/oisy-wallet-signer/signer'; +import { isNullish } from '@dfinity/utils'; +import { derived, writable, type Readable } from 'svelte/store'; + +/** + * Interface for managing the OISY Wallet Signer context in any route or component. + */ +export interface SignerContext { + /** + * Initializes the signer with the authenticated user - the owner of the wallet. + * @param {Object} params - Initialization parameters. + * @param {Identity} params.owner - The identity of the signer owner. + */ + init: (params: { owner: Identity }) => void; + + /** + * Resets the signer context and disconnects the signer. + */ + reset: () => void; + + /** + * A derived store that indicates if all prompts of the signer that either require user interactions or are calls to the IC are idle. + * @type {Readable} + */ + idle: Readable; + + /** + * Handles the accounts prompt requests. + */ + accountsPrompt: { + /** + * A derived store containing the accounts prompt payload. + * @type {Readable} + */ + payload: Readable; + + /** + * Resets the accounts prompt payload to null once processed. + */ + reset: () => void; + }; + + /** + * Handles the permissions prompt requests. + */ + permissionsPrompt: { + /** + * A derived store containing the permissions prompt payload. + * @type {Readable} + */ + payload: Readable; + + /** + * Resets the permissions prompt payload to null once processed. + */ + reset: () => void; + }; + + /** + * Handles the consent message prompt requests. + */ + consentMessagePrompt: { + /** + * A derived store containing the consent message prompt payload. + * @type {Readable} + */ + payload: Readable; + + /** + * Resets the consent message prompt payload to null once processed. + */ + reset: () => void; + }; + + /** + * Handles the call canister prompt state. + */ + callCanisterPrompt: { + /** + * A derived store containing the call canister prompt payload. + * @type {Readable} + */ + payload: Readable; + + /** + * Resets the call canister prompt payload to null once processed. + */ + reset: () => void; + }; +} + +/** + * Initializes the SignerContext, creating the signer and registering various stores to handles its prompts. + * + * @returns {SignerContext} The initialized signer context, providing functions and stores to interact with the signer. + */ +export const initSignerContext = (): SignerContext => { + let signer: Option; + + const accountsPromptPayloadStore = writable(undefined); + + const permissionsPromptPayloadStore = writable( + undefined + ); + + const consentMessagePromptPayloadStore = writable( + undefined + ); + + const callCanisterPromptPayloadStore = writable( + undefined + ); + + const accountsPromptPayload = derived( + [accountsPromptPayloadStore], + ([$accountsPromptPayloadStore]) => $accountsPromptPayloadStore + ); + + const permissionsPromptPayload = derived( + [permissionsPromptPayloadStore], + ([$permissionsPromptPayloadStore]) => $permissionsPromptPayloadStore + ); + + const consentMessagePromptPayload = derived( + [consentMessagePromptPayloadStore], + ([$consentMessagePromptPayloadStore]) => $consentMessagePromptPayloadStore + ); + + const callCanisterPromptPayload = derived( + [callCanisterPromptPayloadStore], + ([$callCanisterPromptPayloadStore]) => $callCanisterPromptPayloadStore + ); + + // We omit the accountsPrompt for the idle status because this prompt is handled without user interactions. + const idle = derived( + [permissionsPromptPayload, consentMessagePromptPayload, callCanisterPromptPayload], + ([$permissionsPromptPayload, $consentMessagePromptPayload, $callCanisterPromptPayloadStore]) => + isNullish($permissionsPromptPayload) && + isNullish($consentMessagePromptPayload) && + isNullish($callCanisterPromptPayloadStore) + ); + + const init = ({ owner }: { owner: Identity }) => { + signer = Signer.init({ + owner, + ...(DEV && { host: LOCAL_REPLICA_HOST }) + }); + + signer.register({ + method: ICRC25_REQUEST_PERMISSIONS, + prompt: (payload: PermissionsPromptPayload) => permissionsPromptPayloadStore.set(payload) + }); + + signer.register({ + method: ICRC27_ACCOUNTS, + prompt: (payload: AccountsPromptPayload) => accountsPromptPayloadStore.set(payload) + }); + + signer.register({ + method: ICRC21_CALL_CONSENT_MESSAGE, + prompt: (payload: ConsentMessagePromptPayload) => { + consentMessagePromptPayloadStore.set(payload); + } + }); + + signer.register({ + method: ICRC49_CALL_CANISTER, + prompt: (payload: CallCanisterPromptPayload) => { + callCanisterPromptPayloadStore.set(payload); + } + }); + }; + + const resetAccountsPromptPayload = () => accountsPromptPayloadStore.set(null); + const resetPermissionsPromptPayload = () => permissionsPromptPayloadStore.set(null); + const resetConsentMessagePromptPayload = () => consentMessagePromptPayloadStore.set(null); + const resetCallCanisterPromptPayload = () => callCanisterPromptPayloadStore.set(null); + + const reset = () => { + resetAccountsPromptPayload(); + resetPermissionsPromptPayload(); + resetConsentMessagePromptPayload(); + resetCallCanisterPromptPayload(); + + signer?.disconnect(); + signer = null; + }; + + return { + init, + reset, + idle, + accountsPrompt: { + payload: accountsPromptPayload, + reset: resetAccountsPromptPayload + }, + permissionsPrompt: { + payload: permissionsPromptPayload, + reset: resetPermissionsPromptPayload + }, + consentMessagePrompt: { + payload: consentMessagePromptPayload, + reset: resetConsentMessagePromptPayload + }, + callCanisterPrompt: { + payload: callCanisterPromptPayload, + reset: resetCallCanisterPromptPayload + } + }; +}; + +export const SIGNER_CONTEXT_KEY = Symbol('signer'); diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index 0f6b86838..3907f9aea 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -824,6 +824,20 @@ interface I18nResources { changelog_description: string; } +interface I18nSigner { + title: string; + access_your_wallet: string; + permissions_no_confirm_callback: string; + permissions_icrc27_accounts: string; + permissions_icrc49_call_canister: string; + permissions_requested_permissions: string; + permissions_your_wallet_address: string; + origin_request_from: string; + origin_invalid_origin: string; + origin_link_to_dapp: string; + idle_waiting: string; +} + interface I18n { lang: Languages; core: I18nCore; @@ -852,4 +866,5 @@ interface I18n { preferences: I18nPreferences; examples: I18nExamples; resources: I18nResources; + signer: I18nSigner; } diff --git a/src/frontend/src/routes/(standalone)/sign/+layout.svelte b/src/frontend/src/routes/(standalone)/sign/+layout.svelte new file mode 100644 index 000000000..7e091668e --- /dev/null +++ b/src/frontend/src/routes/(standalone)/sign/+layout.svelte @@ -0,0 +1,38 @@ + + + + {#snippet navbar()} + + {/snippet} + + {@render children()} + + {#snippet footer()} +