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}
+
+
+{/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)}
+
+{/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()}
+
+ {/snippet}
+
diff --git a/src/frontend/src/routes/(standalone)/sign/+page.svelte b/src/frontend/src/routes/(standalone)/sign/+page.svelte
new file mode 100644
index 000000000..45f054eb9
--- /dev/null
+++ b/src/frontend/src/routes/(standalone)/sign/+page.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#if $authSignedIn}
+
+ {#if nonNullish($missionControlIdDerived)}
+
+ {/if}
+
+ {:else}
+
{$i18n.signer.access_your_wallet}
+
+
+ {/if}
+
+
+
diff --git a/src/frontend/tests/lib/stores/signer.store.spec.ts b/src/frontend/tests/lib/stores/signer.store.spec.ts
new file mode 100644
index 000000000..98285ad81
--- /dev/null
+++ b/src/frontend/tests/lib/stores/signer.store.spec.ts
@@ -0,0 +1,102 @@
+import { LOCAL_REPLICA_HOST } from '$lib/constants/app.constants';
+import { initSignerContext } from '$lib/stores/signer.store';
+import { Ed25519KeyIdentity } from '@dfinity/identity';
+import { Signer } from '@dfinity/oisy-wallet-signer/signer';
+import { get } from 'svelte/store';
+
+describe('SignerContext', () => {
+ const identity = Ed25519KeyIdentity.generate();
+
+ it('initializes the signer with the owner identity and registers methods', () => {
+ const { init } = initSignerContext();
+
+ const spy = vi.spyOn(Signer, 'init');
+
+ init({ owner: identity });
+
+ expect(spy).toHaveBeenCalledWith({
+ owner: identity,
+ host: LOCAL_REPLICA_HOST
+ });
+ });
+
+ describe('accounts', () => {
+ it('payload should be undefined per default', () => {
+ const { accountsPrompt } = initSignerContext();
+
+ const payload = get(accountsPrompt.payload);
+ expect(payload).toBeUndefined();
+ });
+
+ // TODO: cover prompt registration and usage with E2E tests
+
+ it('payload should be null after reset', () => {
+ const { accountsPrompt, reset } = initSignerContext();
+
+ reset();
+
+ const payload = get(accountsPrompt.payload);
+ expect(payload).toBeNull();
+ });
+ });
+
+ describe('permissions', () => {
+ it('payload should be undefined per default', () => {
+ const { permissionsPrompt } = initSignerContext();
+
+ const payload = get(permissionsPrompt.payload);
+ expect(payload).toBeUndefined();
+ });
+
+ // TODO: cover prompt registration and usage with E2E tests
+
+ it('payload should be null after reset', () => {
+ const { permissionsPrompt, reset } = initSignerContext();
+
+ reset();
+
+ const payload = get(permissionsPrompt.payload);
+ expect(payload).toBeNull();
+ });
+ });
+
+ describe('consentMessage', () => {
+ it('payload should be undefined per default', () => {
+ const { consentMessagePrompt } = initSignerContext();
+
+ const payload = get(consentMessagePrompt.payload);
+ expect(payload).toBeUndefined();
+ });
+
+ // TODO: cover prompt registration and usage with E2E tests
+
+ it('payload should be null after reset', () => {
+ const { consentMessagePrompt, reset } = initSignerContext();
+
+ reset();
+
+ const payload = get(consentMessagePrompt.payload);
+ expect(payload).toBeNull();
+ });
+ });
+
+ describe('callCanister', () => {
+ it('payload should be undefined per default', () => {
+ const { callCanisterPrompt } = initSignerContext();
+
+ const payload = get(callCanisterPrompt.payload);
+ expect(payload).toBeUndefined();
+ });
+
+ // TODO: cover prompt registration and usage with E2E tests
+
+ it('payload should be null after reset', () => {
+ const { callCanisterPrompt, reset } = initSignerContext();
+
+ reset();
+
+ const payload = get(callCanisterPrompt.payload);
+ expect(payload).toBeNull();
+ });
+ });
+});