Please upload your wallet key file or create a new wallet.
@@ -278,258 +211,219 @@ function App() {
- Truvera Wallet React Example
+
+ Truvera Demo Web Wallet
{/* Action Buttons */}
-
-
-
-
-
+
{cloudWallet && (
-
+
)}
-
+
{/* DID Management */}
-
- {!defaultDID ? (
-
-
-
- ) : (
-
-
- Default DID:
- {defaultDID}
-
-
-
-
- )}
-
+
{/* Credentials List */}
-
-
Credentials ({formattedCredentials.length})
-
- {formattedCredentials.length === 0 ? (
-
- No credentials found. Import some credentials to get started.
-
- ) : (
-
- {formattedCredentials.map((document) => (
-
-
{document.id}
-
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document?.credentialSubject, null, 2)}
-
-
- ))}
-
- )}
-
+
{/* Import Credential Modal */}
-
setImportModalOpen(false)}>
-
- Import Credential
-
-
- setCredentialUrl(e.target.value)}
- placeholder="Enter credential offer URL"
- InputProps={{
- sx: {
- borderRadius: '8px',
- '&.Mui-focused': {
- boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)',
- },
- },
- }}
- />
-
-
-
-
-
-
-
+
{/* Verify Credential Modal */}
-
{
- setVerifyModalOpen(false);
- setVerifyStep(1);
- setProofRequestUrl("");
- setSelectedCredential(null);
- }}
+ verifyFlow.setVerifyStep(1)}
+ onClose={verifyFlow.resetVerifyFlow}
+ onSelectCredential={verifyFlow.setSelectedCredential}
+ modalStyle={modalStyle}
+ />
+ verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
>
-
- {verifyStep === 1 && (
- <>
- Verify Credential
-
-
- setProofRequestUrl(e.target.value)}
- placeholder="Enter proof request URL"
- InputProps={{
- sx: {
- borderRadius: '8px',
- '&.Mui-focused': {
- boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)',
- },
- },
- }}
- />
-
-
-
-
-
- >
- )}
- {verifyStep === 2 && (
- <>
- Select Credential to Present
-
- {formattedCredentials.map((document, idx) => (
-
setSelectedCredential(documents[idx])}
- >
-
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document.credentialSubject, null, 2)}
-
-
- ))}
-
-
-
-
-
- >
- )}
-
-
+
verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))}
+ >
+ {verifyFlow.verifyToast.message}
+
+
+
importFlow.setImportToast((prev) => ({ ...prev, open: false }))}
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
+ >
+ importFlow.setImportToast((prev) => ({ ...prev, open: false }))}
+ >
+ {importFlow.importToast.message}
+
+
+
walletManager.setWalletToast((prev) => ({ ...prev, open: false }))}
+ anchorOrigin={{ vertical: "top", horizontal: "center" }}
+ >
+ walletManager.setWalletToast((prev) => ({ ...prev, open: false }))}
+ >
+ {walletManager.walletToast.message}
+
+
+
credentialManagement.setCredentialToast((prev) => ({ ...prev, open: false }))}
+ anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
+ >
+ credentialManagement.setCredentialToast((prev) => ({ ...prev, open: false }))}
+ >
+ {credentialManagement.credentialToast.message}
+
+
);
}
diff --git a/examples/web-example/src/components/ActionButtons.js b/examples/web-example/src/components/ActionButtons.js
new file mode 100644
index 00000000..d99faca0
--- /dev/null
+++ b/examples/web-example/src/components/ActionButtons.js
@@ -0,0 +1,50 @@
+import React from "react";
+
+function ActionButtons({
+ onImportClick,
+ onVerifyClick,
+ onRefreshClick,
+ onSettingsClick,
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default ActionButtons;
diff --git a/examples/web-example/src/components/CredentialCard.js b/examples/web-example/src/components/CredentialCard.js
new file mode 100644
index 00000000..d5f315f3
--- /dev/null
+++ b/examples/web-example/src/components/CredentialCard.js
@@ -0,0 +1,242 @@
+import React, { useState } from "react";
+
+function humanizeKey(key) {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/^./, (s) => s.toUpperCase())
+ .trim();
+}
+
+function formatDate(value) {
+ if (!value) return null;
+ const clean = String(value).replace(/^"|"$/g, '').trim();
+ try {
+ const d = new Date(clean);
+ if (!isNaN(d)) return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
+ } catch (_) {}
+ return clean;
+}
+
+function formatAttributeValue(value) {
+ if (value === null || value === undefined) return '—';
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
+ if (Array.isArray(value)) return value.map(formatAttributeValue).join(', ');
+ if (typeof value === 'object') {
+ // Prefer a human-readable scalar property over raw JSON
+ const pick = value.name ?? value.value ?? value.id ?? value.label;
+ if (pick !== undefined && typeof pick !== 'object') return String(pick);
+ // Single-key object — just show its value
+ const entries = Object.entries(value);
+ if (entries.length === 1) return formatAttributeValue(entries[0][1]);
+ // Multi-key: show "Key: Value" pairs joined by space
+ return entries.map(([k, v]) => `${humanizeKey(k)}: ${formatAttributeValue(v)}`).join(' · ');
+ }
+ if (typeof value === 'string') {
+ const stripped = value.replace(/^"|"$/g, '');
+ if (/^\d{4}-\d{2}-\d{2}/.test(stripped)) {
+ try {
+ return new Date(stripped).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
+ } catch (_) {}
+ }
+ return stripped;
+ }
+ return String(value);
+}
+
+function isPrimitiveValue(value) {
+ return value === null || value === undefined || typeof value !== 'object';
+}
+
+function AttributeNode({ label, value, depth = 0 }) {
+ const normalizedLabel = label ? humanizeKey(label) : null;
+
+ if (Array.isArray(value)) {
+ if (value.length === 0 || value.every(isPrimitiveValue)) {
+ return (
+
+ {normalizedLabel}
+ {formatAttributeValue(value)}
+
+ );
+ }
+
+ return (
+
+ {normalizedLabel &&
{normalizedLabel}
}
+
+ {value.map((item, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (value && typeof value === 'object') {
+ const entries = Object.entries(value).filter(([key]) => key !== 'id');
+
+ if (entries.length === 0) {
+ return (
+
+ {normalizedLabel}
+ —
+
+ );
+ }
+
+ return (
+
+ {normalizedLabel &&
{normalizedLabel}
}
+
+ {entries.map(([key, nestedValue]) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {normalizedLabel}
+ {formatAttributeValue(value)}
+
+ );
+}
+
+function CredentialCard({ document, rawDocument, selectable, selected, onClick, onDelete, deleting }) {
+ const [expanded, setExpanded] = useState(false);
+ const [showJson, setShowJson] = useState(false);
+ const subject = document?.credentialSubject || {};
+ const subjectEntries = Object.entries(subject).filter(([key]) => key !== 'id');
+ const PREVIEW_COUNT = 4;
+ const visibleEntries = expanded ? subjectEntries : subjectEntries.slice(0, PREVIEW_COUNT);
+ const hasMore = subjectEntries.length > PREVIEW_COUNT;
+
+ const issuer = document?.issuer;
+ const issuerName = typeof issuer === 'string'
+ ? issuer
+ : issuer?.name || issuer?.id || null;
+ const issuerLogo = typeof issuer === 'object'
+ ? (issuer?.image?.id || issuer?.image || issuer?.logo?.id || issuer?.logo || null)
+ : null;
+ const issuerLogoUrl = typeof issuerLogo === 'string' ? issuerLogo : null;
+
+ const issuanceDate = formatDate(document?.issuanceDate);
+ const expirationDate = formatDate(document?.expirationDate);
+ const isExpired = document?.expirationDate && new Date(String(document.expirationDate).replace(/^"|"$/g, '').trim()) < new Date();
+
+ return (
+
+
+
{document.humanizedType || (document.type?.slice(-1)[0]) || 'Credential'}
+
+ {isExpired
+ ?
Expired
+ :
Valid}
+ {onDelete && (
+
+ )}
+
+
+
+ {showJson ? (
+
+
{JSON.stringify(rawDocument || document, null, 2)}
+
+ ) : (
+ subjectEntries.length > 0 && (
+
+ {visibleEntries.map(([key, value]) => (
+
+ ))}
+ {hasMore && (
+
+ )}
+
+ )
+ )}
+
+
+ {issuerName && (
+
+
Issued By
+
+ {issuerLogoUrl && (
+

+ )}
+
{issuerName}
+
+
+ )}
+
+ {issuanceDate && (
+
+ Issued
+ {issuanceDate}
+
+ )}
+ {expirationDate && (
+
+ Expires
+ {expirationDate}
+
+ )}
+
+ ID
+ {document.id}
+
+
+
+
+
+
+
+ );
+}
+
+export default CredentialCard;
diff --git a/examples/web-example/src/components/CredentialsSection.js b/examples/web-example/src/components/CredentialsSection.js
new file mode 100644
index 00000000..be9806a0
--- /dev/null
+++ b/examples/web-example/src/components/CredentialsSection.js
@@ -0,0 +1,35 @@
+import React from "react";
+import CredentialCard from "./CredentialCard";
+
+function CredentialsSection({
+ formattedCredentials,
+ documents,
+ deletingCredentialId,
+ onDeleteCredential,
+}) {
+ return (
+
+
Credentials ({formattedCredentials.length})
+
+ {formattedCredentials.length === 0 ? (
+
+ No credentials found. Import some credentials to get started.
+
+ ) : (
+
+ {formattedCredentials.map((document, idx) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default CredentialsSection;
diff --git a/examples/web-example/src/components/DidSection.js b/examples/web-example/src/components/DidSection.js
new file mode 100644
index 00000000..3e6166bf
--- /dev/null
+++ b/examples/web-example/src/components/DidSection.js
@@ -0,0 +1,105 @@
+import React from "react";
+import { FormControlLabel, Switch } from "@mui/material";
+
+function FetchMessagesIcon({ className, title }) {
+ return (
+
+ );
+}
+
+function DidSection({
+ defaultDID,
+ walletProfiles,
+ activeWalletId,
+ autoCheckMessages,
+ onProvisionNewWallet,
+ onSwitchWallet,
+ onFetchMessages,
+ onAutoCheckToggle,
+}) {
+ return (
+
+ {!defaultDID ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
{defaultDID}
+
+
+
+
+
+
+ onAutoCheckToggle(event.target.checked)}
+ />
+ )}
+ label="Auto-check every 30s"
+ />
+
+
+
+
+ )}
+
+ );
+}
+
+export default DidSection;
diff --git a/examples/web-example/src/components/ImportCredentialModal.js b/examples/web-example/src/components/ImportCredentialModal.js
new file mode 100644
index 00000000..336614fc
--- /dev/null
+++ b/examples/web-example/src/components/ImportCredentialModal.js
@@ -0,0 +1,72 @@
+import React from "react";
+import { Box, Modal, TextField, CircularProgress } from "@mui/material";
+
+function ImportCredentialModal({
+ open,
+ isImporting,
+ credentialUrl,
+ onCredentialUrlChange,
+ onImport,
+ onClose,
+ modalStyle,
+}) {
+ return (
+
{
+ if (isImporting) {
+ return;
+ }
+ onClose();
+ }}
+ >
+
+ {isImporting ? (
+
+
+
Importing credential offer and processing response...
+
+ ) : (
+ <>
+ Import Credential
+
+
+ onCredentialUrlChange(e.target.value)}
+ placeholder="Enter credential offer URL"
+ InputProps={{
+ sx: {
+ borderRadius: '8px',
+ '&.Mui-focused': {
+ boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)',
+ },
+ },
+ }}
+ />
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default ImportCredentialModal;
diff --git a/examples/web-example/src/components/VerifyCredentialModal.js b/examples/web-example/src/components/VerifyCredentialModal.js
new file mode 100644
index 00000000..1efc26f6
--- /dev/null
+++ b/examples/web-example/src/components/VerifyCredentialModal.js
@@ -0,0 +1,122 @@
+import React from "react";
+import { Box, Modal, TextField, CircularProgress } from "@mui/material";
+import CredentialCard from "./CredentialCard";
+
+function VerifyCredentialModal({
+ open,
+ isVerifying,
+ verifyStep,
+ proofRequestUrl,
+ loadingMatchingCredentials,
+ matchingCredentials,
+ selectedCredential,
+ onProofRequestUrlChange,
+ onLoadMatchingCredentials,
+ onVerifyCredential,
+ onBackStep,
+ onClose,
+ onSelectCredential,
+ modalStyle,
+}) {
+ return (
+
{
+ if (isVerifying) {
+ return;
+ }
+ onClose();
+ }}
+ >
+
+ {isVerifying ? (
+
+
+
Checking credential and submitting verification...
+
+ ) : (
+ <>
+ {verifyStep === 1 && (
+ <>
+ Verify Credential
+
+
+ onProofRequestUrlChange(e.target.value)}
+ placeholder="Enter proof request URL"
+ InputProps={{
+ sx: {
+ borderRadius: '8px',
+ '&.Mui-focused': {
+ boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)',
+ },
+ },
+ }}
+ />
+
+
+
+
+
+ >
+ )}
+ {verifyStep === 2 && (
+ <>
+ Select Credential to Present
+ {matchingCredentials.length === 0 ? (
+
+ No matching credentials found for this proof request.
+
+ ) : (
+
+ {matchingCredentials.map((item) => (
+ onSelectCredential(item.rawDocument)}
+ />
+ ))}
+
+ )}
+
+
+
+
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
+
+export default VerifyCredentialModal;
diff --git a/examples/web-example/src/hooks/useCloudWallet.js b/examples/web-example/src/hooks/useCloudWallet.js
index 231cdb1a..05ac0d0e 100644
--- a/examples/web-example/src/hooks/useCloudWallet.js
+++ b/examples/web-example/src/hooks/useCloudWallet.js
@@ -9,7 +9,7 @@ import { createMessageProvider } from '@docknetwork/wallet-sdk-core/lib/message-
const EDV_URL = 'https://edv.dock.io';
const EDV_AUTH_KEY = 'DOCKWALLET-TEST';
-function useCloudWallet(walletKeys) {
+function useCloudWallet(walletKeys, walletProfileId) {
const [loading, setLoading] = useState(false);
const [cloudWallet, setCloudWallet] = useState(null);
const [wallet, setWallet] = useState(null);
@@ -58,9 +58,18 @@ function useCloudWallet(walletKeys) {
}
setLoading(true);
+ setCloudWallet(null);
+ setWallet(null);
+ setCredentialProvider(null);
+ setDidProvider(null);
+ setDefaultDID(null);
+ setMessageProvider(null);
+ setDataStore(null);
+
try {
const _dataStore = await createDataStore({
- databasePath: 'dock-wallet',
+ // Keep each wallet profile in an isolated local datastore.
+ databasePath: `dock-wallet-${walletProfileId || 'default'}`,
defaultNetwork: 'testnet',
});
setDataStore(_dataStore);
@@ -107,7 +116,7 @@ function useCloudWallet(walletKeys) {
cloudWallet.unsubscribeEventListeners();
}
};
- }, [walletKeys]);
+ }, [walletKeys, walletProfileId]);
return {
loading,
diff --git a/examples/web-example/src/hooks/useCredentialManagement.js b/examples/web-example/src/hooks/useCredentialManagement.js
new file mode 100644
index 00000000..eb25a2fd
--- /dev/null
+++ b/examples/web-example/src/hooks/useCredentialManagement.js
@@ -0,0 +1,49 @@
+import { useState, useCallback } from 'react';
+import { deleteCredential } from '../services/credentialService';
+
+export function useCredentialManagement(
+ credentialProvider,
+ wallet,
+ cloudWallet,
+ documents,
+ refreshDocuments
+) {
+ const [deletingCredentialId, setDeletingCredentialId] = useState(null);
+ const [credentialToast, setCredentialToast] = useState({
+ open: false,
+ severity: 'success',
+ message: '',
+ });
+
+ const handleDeleteCredential = useCallback(async (credentialId) => {
+ const credential = documents.find((doc) => doc?.id === credentialId);
+ const credentialLabel = credential?.id || credentialId;
+ const confirmed = window.confirm(
+ `Delete this credential? This will remove it from local wallet storage and EDV.\n\n${credentialLabel}`
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setDeletingCredentialId(credentialId);
+ const result = await deleteCredential({ credentialProvider, wallet, cloudWallet, credentialId });
+ setDeletingCredentialId(null);
+
+ if (result.success) {
+ await refreshDocuments();
+ }
+
+ setCredentialToast({ open: true, severity: result.success ? 'success' : 'error', message: result.message });
+ }, [cloudWallet, credentialProvider, documents, refreshDocuments, wallet]);
+
+ return {
+ // State
+ deletingCredentialId,
+ credentialToast,
+ // Setters
+ setCredentialToast,
+ // Handlers
+ handleDeleteCredential,
+ };
+}
diff --git a/examples/web-example/src/hooks/useModalFlows.js b/examples/web-example/src/hooks/useModalFlows.js
new file mode 100644
index 00000000..29cdb04d
--- /dev/null
+++ b/examples/web-example/src/hooks/useModalFlows.js
@@ -0,0 +1,216 @@
+import { useState, useCallback } from "react";
+import axios from "axios";
+import { createVerificationController } from "@docknetwork/wallet-sdk-core/lib/verification-controller";
+
+/**
+ * useImportFlow - Manages import credential modal state and logic
+ */
+export function useImportFlow(credentialProvider, didProvider, refreshDocuments) {
+ const [importModalOpen, setImportModalOpen] = useState(false);
+ const [credentialUrl, setCredentialUrl] = useState("");
+ const [isImporting, setIsImporting] = useState(false);
+ const [importToast, setImportToast] = useState({
+ open: false,
+ severity: "success",
+ message: "",
+ });
+
+ const resetImportFlow = useCallback(() => {
+ setImportModalOpen(false);
+ setCredentialUrl("");
+ setIsImporting(false);
+ }, []);
+
+ const handleImportCredential = useCallback(async () => {
+ if (!credentialProvider) {
+ return;
+ }
+
+ if (!credentialUrl.startsWith("openid-credential-offer:")) {
+ alert("Invalid credential offer URL. Check https://docs.truvera.io/truvera-api/openid#credential-offers for more details.");
+ return;
+ }
+
+ try {
+ setIsImporting(true);
+
+ await credentialProvider.importCredentialFromURI({
+ uri: credentialUrl,
+ didProvider,
+ });
+
+ if (refreshDocuments) {
+ await refreshDocuments();
+ }
+
+ setImportToast({
+ open: true,
+ severity: "success",
+ message: "Credential imported successfully.",
+ });
+
+ resetImportFlow();
+ } catch (err) {
+ console.error("Error importing credential", err);
+ setImportToast({
+ open: true,
+ severity: "error",
+ message: `Import failed: ${err?.message || "Unable to import credential."}`,
+ });
+ setIsImporting(false);
+ }
+ }, [credentialProvider, didProvider, credentialUrl, refreshDocuments, resetImportFlow]);
+
+ return {
+ importModalOpen,
+ credentialUrl,
+ isImporting,
+ importToast,
+ // Setters
+ setImportModalOpen,
+ setCredentialUrl,
+ setImportToast,
+ // Handlers
+ resetImportFlow,
+ handleImportCredential,
+ };
+}
+
+/**
+ * useVerifyFlow - Manages verify credential modal state and logic
+ */
+export function useVerifyFlow(wallet, credentialProvider, didProvider) {
+ const [verifyModalOpen, setVerifyModalOpen] = useState(false);
+ const [proofRequestUrl, setProofRequestUrl] = useState("");
+ const [proofRequestTemplate, setProofRequestTemplate] = useState(null);
+ const [verifyStep, setVerifyStep] = useState(1);
+ const [selectedCredential, setSelectedCredential] = useState(null);
+ const [matchingCredentialIds, setMatchingCredentialIds] = useState([]);
+ const [loadingMatchingCredentials, setLoadingMatchingCredentials] = useState(false);
+ const [isVerifying, setIsVerifying] = useState(false);
+ const [verifyToast, setVerifyToast] = useState({
+ open: false,
+ severity: "success",
+ message: "",
+ });
+
+ const resetVerifyFlow = useCallback(() => {
+ setVerifyModalOpen(false);
+ setVerifyStep(1);
+ setProofRequestUrl("");
+ setProofRequestTemplate(null);
+ setMatchingCredentialIds([]);
+ setSelectedCredential(null);
+ setIsVerifying(false);
+ }, []);
+
+ const handleLoadMatchingCredentials = useCallback(async () => {
+ if (!wallet || !credentialProvider || !didProvider || !proofRequestUrl) {
+ return;
+ }
+
+ try {
+ setLoadingMatchingCredentials(true);
+ const proofRequest = (await axios.get(proofRequestUrl)).data;
+ const controller = createVerificationController({
+ wallet,
+ credentialProvider,
+ didProvider,
+ });
+
+ await controller.start({ template: proofRequest });
+ const filteredCredentials = controller.getFilteredCredentials() || [];
+ const filteredIds = filteredCredentials.map((credential) => credential.id);
+
+ setProofRequestTemplate(proofRequest);
+ setMatchingCredentialIds(filteredIds);
+ setSelectedCredential(null);
+ setVerifyStep(2);
+ } catch (err) {
+ console.error("Error loading matching credentials", err);
+ alert("Unable to load matching credentials for this proof request.");
+ } finally {
+ setLoadingMatchingCredentials(false);
+ }
+ }, [wallet, credentialProvider, didProvider, proofRequestUrl]);
+
+ const handleVerifyCredential = useCallback(async () => {
+ if (!wallet || !credentialProvider || !didProvider || !selectedCredential) {
+ return;
+ }
+
+ try {
+ setIsVerifying(true);
+ const proofRequest = proofRequestTemplate || (await axios.get(proofRequestUrl)).data;
+ const controller = createVerificationController({
+ wallet,
+ credentialProvider,
+ didProvider,
+ });
+
+ const credential = selectedCredential;
+
+ await controller.start({ template: proofRequest });
+
+ const attributesToReveal = ["credentialSubject.name"];
+
+ controller.selectedCredentials.set(credential.id, {
+ credential,
+ attributesToReveal,
+ });
+
+ const presentation = await controller.createPresentation();
+
+ console.log(presentation);
+
+ const { data: verificationResult } = await axios
+ .post(proofRequest.response_url, presentation)
+ .then((res) => res.data);
+
+ console.log("Verification sent", {
+ verificationResult,
+ });
+
+ setVerifyToast({
+ open: true,
+ severity: "success",
+ message: "Verification sent successfully.",
+ });
+ } catch (err) {
+ console.error("Error sending verification", err);
+
+ const errorMessage = err?.response?.data?.error || err?.message || "Unable to verify credential.";
+ setVerifyToast({
+ open: true,
+ severity: "error",
+ message: `Verification failed: ${errorMessage}`,
+ });
+ } finally {
+ setIsVerifying(false);
+ resetVerifyFlow();
+ }
+ }, [wallet, credentialProvider, didProvider, selectedCredential, proofRequestTemplate, proofRequestUrl, resetVerifyFlow]);
+
+ return {
+ // State
+ verifyModalOpen,
+ proofRequestUrl,
+ proofRequestTemplate,
+ verifyStep,
+ selectedCredential,
+ matchingCredentialIds,
+ loadingMatchingCredentials,
+ isVerifying,
+ verifyToast,
+ // Setters
+ setVerifyModalOpen,
+ setProofRequestUrl,
+ setVerifyStep,
+ setSelectedCredential,
+ setVerifyToast,
+ // Handlers
+ resetVerifyFlow,
+ handleLoadMatchingCredentials,
+ handleVerifyCredential,
+ };
+}
diff --git a/examples/web-example/src/hooks/useWalletManager.js b/examples/web-example/src/hooks/useWalletManager.js
new file mode 100644
index 00000000..506cf366
--- /dev/null
+++ b/examples/web-example/src/hooks/useWalletManager.js
@@ -0,0 +1,200 @@
+import { useState, useEffect } from 'react';
+import {
+ applyWalletStorageScope,
+ clearScopedWalletStorage,
+ clearWalletEdvForProfile,
+ createWalletProfile,
+ generateNewWalletKeys,
+ loadInitialWalletState,
+ normalizeWalletKeys,
+ persistWalletProfiles,
+ WALLET_PROFILES_KEY,
+ ACTIVE_WALLET_ID_KEY,
+} from '../services/walletService';
+
+export function useWalletManager() {
+ const [loading, setLoading] = useState(false);
+ const [walletProfiles, setWalletProfiles] = useState([]);
+ const [activeWalletId, setActiveWalletId] = useState(null);
+ const [walletKeys, setWalletKeys] = useState(null);
+ const [uploadError, setUploadError] = useState(null);
+ const [walletToast, setWalletToast] = useState({
+ open: false,
+ severity: 'success',
+ message: '',
+ });
+
+ useEffect(() => {
+ const { profiles, activeWalletId: resolvedId } = loadInitialWalletState();
+ if (profiles.length) {
+ setWalletProfiles(profiles);
+ setActiveWalletId(resolvedId);
+ setWalletKeys(profiles.find((p) => p.id === resolvedId)?.keys || null);
+ }
+ }, []);
+
+ const handleWalletKeyUpload = (event) => {
+ const file = event.target.files[0];
+ if (file) {
+ setLoading(true);
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const normalizedKeys = normalizeWalletKeys(JSON.parse(e.target.result));
+ const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1);
+ const updatedProfiles = [...walletProfiles, newProfile];
+
+ applyWalletStorageScope(newProfile.id);
+ setWalletProfiles(updatedProfiles);
+ setActiveWalletId(newProfile.id);
+ setWalletKeys(newProfile.keys);
+ persistWalletProfiles(updatedProfiles, newProfile.id);
+ setLoading(false);
+ setUploadError(null);
+ } catch (error) {
+ console.error('Error parsing wallet keys:', error);
+ setUploadError('Invalid wallet key file.');
+ setLoading(false);
+ }
+ };
+ reader.readAsText(file);
+ }
+ };
+
+ const handleCreateWallet = async () => {
+ setLoading(true);
+ try {
+ const normalizedKeys = await generateNewWalletKeys();
+ const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1);
+ const updatedProfiles = [...walletProfiles, newProfile];
+
+ console.log('generated new keys for the wallet');
+ applyWalletStorageScope(newProfile.id);
+ setWalletProfiles(updatedProfiles);
+ setActiveWalletId(newProfile.id);
+ setWalletKeys(newProfile.keys);
+ persistWalletProfiles(updatedProfiles, newProfile.id);
+ } catch (err) {
+ console.error('Error generating keys', err);
+ }
+ setLoading(false);
+ };
+
+ const handleSwitchWallet = (walletId) => {
+ const targetProfile = walletProfiles.find((profile) => profile.id === walletId);
+ if (!targetProfile) {
+ return;
+ }
+
+ applyWalletStorageScope(walletId);
+ setActiveWalletId(walletId);
+ setWalletKeys(targetProfile.keys);
+ persistWalletProfiles(walletProfiles, walletId);
+ };
+
+ const handleRenameWallet = (walletId) => {
+ const profile = walletProfiles.find((item) => item.id === walletId);
+ if (!profile) {
+ return;
+ }
+
+ const nextName = window.prompt('Set wallet nickname', profile.name)?.trim();
+ if (!nextName || nextName === profile.name) {
+ return;
+ }
+
+ const updatedProfiles = walletProfiles.map((item) =>
+ item.id === walletId ? { ...item, name: nextName } : item
+ );
+
+ setWalletProfiles(updatedProfiles);
+ persistWalletProfiles(updatedProfiles, activeWalletId);
+ setWalletToast({ open: true, severity: 'success', message: `Renamed wallet to "${nextName}".` });
+ };
+
+ const handleDeleteWallet = async (walletId) => {
+ const profile = walletProfiles.find((item) => item.id === walletId);
+ if (!profile) {
+ return;
+ }
+
+ const confirmed = window.confirm(
+ `Delete "${profile.name}"? This will remove its local data and clear its EDV documents.`
+ );
+ if (!confirmed) {
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await clearWalletEdvForProfile(profile, activeWalletId);
+ } catch (err) {
+ console.error('Error clearing EDV for wallet', err);
+ setWalletToast({
+ open: true,
+ severity: 'error',
+ message: `Failed to clear EDV for ${profile.name}. Wallet was not deleted.`,
+ });
+ setLoading(false);
+ return;
+ }
+
+ clearScopedWalletStorage(profile.id);
+ const updatedProfiles = walletProfiles.filter((item) => item.id !== walletId);
+
+ if (!updatedProfiles.length) {
+ setWalletProfiles([]);
+ setActiveWalletId(null);
+ setWalletKeys(null);
+ localStorage.removeItem(WALLET_PROFILES_KEY);
+ localStorage.removeItem(ACTIVE_WALLET_ID_KEY);
+ localStorage.removeItem('keys');
+ applyWalletStorageScope('default');
+ } else {
+ const nextActiveId = walletId === activeWalletId ? updatedProfiles[0].id : activeWalletId;
+ const nextActiveProfile = updatedProfiles.find((item) => item.id === nextActiveId) || updatedProfiles[0];
+
+ applyWalletStorageScope(nextActiveProfile.id);
+ setWalletProfiles(updatedProfiles);
+ setActiveWalletId(nextActiveProfile.id);
+ setWalletKeys(nextActiveProfile.keys);
+ persistWalletProfiles(updatedProfiles, nextActiveProfile.id);
+ }
+
+ setWalletToast({
+ open: true,
+ severity: 'success',
+ message: `Deleted wallet "${profile.name}" and cleared its EDV documents.`,
+ });
+ setLoading(false);
+ };
+
+ const handleClearWallet = () => {
+ const currentProfiles = walletProfiles;
+ const currentActiveWalletId = activeWalletId;
+ localStorage.clear();
+ if (currentProfiles.length) {
+ persistWalletProfiles(currentProfiles, currentActiveWalletId);
+ }
+ window.location.reload();
+ };
+
+ return {
+ // State
+ loading,
+ walletProfiles,
+ activeWalletId,
+ walletKeys,
+ uploadError,
+ walletToast,
+ // Setters
+ setWalletToast,
+ // Handlers
+ handleWalletKeyUpload,
+ handleCreateWallet,
+ handleSwitchWallet,
+ handleRenameWallet,
+ handleDeleteWallet,
+ handleClearWallet,
+ };
+}
diff --git a/examples/web-example/src/services/credentialService.js b/examples/web-example/src/services/credentialService.js
new file mode 100644
index 00000000..da36bde4
--- /dev/null
+++ b/examples/web-example/src/services/credentialService.js
@@ -0,0 +1,70 @@
+export async function waitForCloudWalletSync(cloudWallet) {
+ if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') {
+ throw new Error('Cloud wallet connection is required to safely delete credentials.');
+ }
+
+ await cloudWallet.waitForEdvIdle();
+}
+
+export async function hasCredentialArtifacts(wallet, credentialId) {
+ if (!wallet || !credentialId) {
+ return false;
+ }
+
+ const candidateIds = [credentialId, `${credentialId}#witness`, `${credentialId}#status`];
+ const docs = await Promise.all(candidateIds.map((id) => wallet.getDocumentById(id)));
+ return docs.some(Boolean);
+}
+
+export async function removeCredentialArtifacts(wallet, credentialId) {
+ if (!wallet || !credentialId) {
+ return;
+ }
+
+ const candidateIds = [credentialId, `${credentialId}#witness`, `${credentialId}#status`];
+ for (const id of candidateIds) {
+ const existingDoc = await wallet.getDocumentById(id);
+ if (existingDoc) {
+ await wallet.removeDocument(id);
+ }
+ }
+}
+
+/**
+ * Removes a credential from local wallet storage and EDV, with full sync verification.
+ * Returns { success: boolean, message: string }.
+ */
+export async function deleteCredential({ credentialProvider, wallet, cloudWallet, credentialId }) {
+ if (!credentialProvider || !wallet || !credentialId) {
+ return { success: false, message: 'Missing required dependencies for credential deletion.' };
+ }
+
+ if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') {
+ return { success: false, message: 'Cloud wallet is unavailable, so EDV deletion cannot be confirmed.' };
+ }
+
+ try {
+ await credentialProvider.removeCredential(credentialId);
+ await waitForCloudWalletSync(cloudWallet);
+
+ // Re-pull from EDV and re-check to confirm delete propagated to cloud.
+ await cloudWallet.pullDocuments();
+ await waitForCloudWalletSync(cloudWallet);
+
+ if (await hasCredentialArtifacts(wallet, credentialId)) {
+ await removeCredentialArtifacts(wallet, credentialId);
+ await waitForCloudWalletSync(cloudWallet);
+ await cloudWallet.pullDocuments();
+ await waitForCloudWalletSync(cloudWallet);
+ }
+
+ if (await hasCredentialArtifacts(wallet, credentialId)) {
+ throw new Error('Credential still exists after synchronization with EDV.');
+ }
+
+ return { success: true, message: 'Credential deleted from local wallet and EDV.' };
+ } catch (err) {
+ console.error('Error deleting credential', err);
+ return { success: false, message: `Delete failed: ${err?.message || 'Unable to delete credential.'}` };
+ }
+}
diff --git a/examples/web-example/src/services/walletService.js b/examples/web-example/src/services/walletService.js
new file mode 100644
index 00000000..b9adec2d
--- /dev/null
+++ b/examples/web-example/src/services/walletService.js
@@ -0,0 +1,190 @@
+import { generateCloudWalletMasterKey, initializeCloudWallet } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet';
+import { createDataStore } from '@docknetwork/wallet-sdk-data-store-web/lib/index';
+import { setLocalStorageImpl } from '@docknetwork/wallet-sdk-data-store-web/lib/localStorageJSON';
+
+export const WALLET_PROFILES_KEY = 'walletProfiles';
+export const ACTIVE_WALLET_ID_KEY = 'activeWalletId';
+const EDV_URL = 'https://edv.dock.io';
+const EDV_AUTH_KEY = 'DOCKWALLET-TEST';
+
+export function normalizeWalletKeys(rawKeys) {
+ if (!rawKeys) {
+ throw new Error('Missing wallet keys');
+ }
+
+ let masterKeyArray;
+ if (rawKeys.masterKey instanceof Uint8Array) {
+ masterKeyArray = Array.from(rawKeys.masterKey);
+ } else if (Array.isArray(rawKeys.masterKey)) {
+ masterKeyArray = rawKeys.masterKey;
+ } else if (rawKeys.masterKey && typeof rawKeys.masterKey === 'object') {
+ masterKeyArray = Object.values(rawKeys.masterKey);
+ } else {
+ throw new Error('Invalid master key format');
+ }
+
+ return {
+ masterKey: new Uint8Array(masterKeyArray),
+ mnemonic: rawKeys.mnemonic,
+ };
+}
+
+export function serializeWalletKeys(keys) {
+ return {
+ masterKey: Array.from(keys.masterKey || []),
+ mnemonic: keys.mnemonic,
+ };
+}
+
+export function createWalletProfile(keys, index = 1) {
+ return {
+ id: `wallet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name: `Wallet ${index}`,
+ createdAt: new Date().toISOString(),
+ keys,
+ };
+}
+
+export function createScopedLocalStorage(scope) {
+ const prefix = `walletScope:${scope}:`;
+
+ return {
+ getItem: (key) => localStorage.getItem(`${prefix}${key}`),
+ setItem: (key, value) => localStorage.setItem(`${prefix}${key}`, value),
+ removeItem: (key) => localStorage.removeItem(`${prefix}${key}`),
+ getData: () => {
+ const scopedData = {};
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(prefix)) {
+ scopedData[key.replace(prefix, '')] = localStorage.getItem(key);
+ }
+ }
+ return scopedData;
+ },
+ };
+}
+
+export function applyWalletStorageScope(walletId) {
+ setLocalStorageImpl(createScopedLocalStorage(walletId || 'default'));
+}
+
+export function clearScopedWalletStorage(walletId) {
+ const prefix = `walletScope:${walletId}:`;
+ const scopedKeys = [];
+
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(prefix)) {
+ scopedKeys.push(key);
+ }
+ }
+
+ scopedKeys.forEach((key) => localStorage.removeItem(key));
+}
+
+export function persistWalletProfiles(profiles, nextActiveWalletId) {
+ const serializedProfiles = profiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ createdAt: profile.createdAt,
+ keys: serializeWalletKeys(profile.keys),
+ }));
+
+ localStorage.setItem(WALLET_PROFILES_KEY, JSON.stringify(serializedProfiles));
+
+ const resolvedActiveWalletId = nextActiveWalletId || profiles[0]?.id;
+ if (resolvedActiveWalletId) {
+ localStorage.setItem(ACTIVE_WALLET_ID_KEY, resolvedActiveWalletId);
+ const activeProfile = profiles.find((profile) => profile.id === resolvedActiveWalletId);
+ if (activeProfile) {
+ localStorage.setItem('keys', JSON.stringify(serializeWalletKeys(activeProfile.keys)));
+ }
+ }
+}
+
+/**
+ * Reads wallet profiles from localStorage, handles legacy key migration,
+ * and applies the correct storage scope. Returns the resolved initial state.
+ */
+export function loadInitialWalletState() {
+ try {
+ const storedProfiles = localStorage.getItem(WALLET_PROFILES_KEY);
+ const storedActiveWalletId = localStorage.getItem(ACTIVE_WALLET_ID_KEY);
+
+ if (storedProfiles) {
+ const parsedProfiles = JSON.parse(storedProfiles);
+ const normalizedProfiles = parsedProfiles
+ .map((profile, index) => {
+ try {
+ return {
+ id: profile.id || `wallet-${index + 1}`,
+ name: profile.name || `Wallet ${index + 1}`,
+ createdAt: profile.createdAt || new Date().toISOString(),
+ keys: normalizeWalletKeys(profile.keys),
+ };
+ } catch (err) {
+ console.error('Invalid stored wallet profile', profile, err);
+ return null;
+ }
+ })
+ .filter(Boolean);
+
+ if (normalizedProfiles.length) {
+ const activeWalletId = normalizedProfiles.some((p) => p.id === storedActiveWalletId)
+ ? storedActiveWalletId
+ : normalizedProfiles[0].id;
+
+ applyWalletStorageScope(activeWalletId);
+ return { profiles: normalizedProfiles, activeWalletId };
+ }
+ }
+
+ // Legacy migration: single keys entry, no profiles list yet
+ const legacyKeys = localStorage.getItem('keys');
+ if (legacyKeys) {
+ const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys));
+ const migratedProfile = createWalletProfile(normalizedKeys, 1);
+
+ applyWalletStorageScope(migratedProfile.id);
+ persistWalletProfiles([migratedProfile], migratedProfile.id);
+
+ return { profiles: [migratedProfile], activeWalletId: migratedProfile.id };
+ }
+
+ applyWalletStorageScope('default');
+ return { profiles: [], activeWalletId: null };
+ } catch (err) {
+ console.error('Error loading wallet state:', err);
+ return { profiles: [], activeWalletId: null };
+ }
+}
+
+export async function generateNewWalletKeys() {
+ const generatedKeys = await generateCloudWalletMasterKey();
+ return normalizeWalletKeys(generatedKeys);
+}
+
+export async function clearWalletEdvForProfile(profile, currentActiveWalletId) {
+ const previousScope = currentActiveWalletId || 'default';
+
+ try {
+ applyWalletStorageScope(profile.id);
+ const dataStore = await createDataStore({
+ databasePath: `dock-wallet-${profile.id}`,
+ defaultNetwork: 'testnet',
+ });
+ const tempCloudWallet = await initializeCloudWallet({
+ dataStore,
+ edvUrl: EDV_URL,
+ masterKey: profile.keys.masterKey,
+ authKey: EDV_AUTH_KEY,
+ });
+ await tempCloudWallet.clearEdvDocuments();
+ if (typeof tempCloudWallet.unsubscribeEventListeners === 'function') {
+ tempCloudWallet.unsubscribeEventListeners();
+ }
+ } finally {
+ applyWalletStorageScope(previousScope);
+ }
+}