diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b63e687..2f66ef0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,6 +66,22 @@ jobs:
# (e.g. ESM/CJS interop regressions in new deps).
run: npm run build
+ - name: Install browser for recovery-alert layout checks
+ if: matrix.node == '22'
+ run: npx playwright install --with-deps chromium
+
+ - name: Check recovery-alert browser flow and small windows
+ if: matrix.node == '22'
+ run: npm run test:recovery-browser
+
+ - name: Preserve recovery-alert screenshots and failure traces
+ if: always() && matrix.node == '22'
+ uses: actions/upload-artifact@v4
+ with:
+ name: recovery-alert-browser
+ path: test-results/sentry/
+ if-no-files-found: ignore
+
deploy:
name: Deploy production
needs: [test]
diff --git a/README.md b/README.md
index 364db1e..e8fe243 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ https://sysnode.info/gov/* -> backend
https://sysnode.info/mnstats -> backend
https://sysnode.info/mncount -> backend
https://sysnode.info/govlist -> backend
+https://sysnode.info/api/recovery/* -> backend
```
The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep.
@@ -51,6 +52,25 @@ REACT_APP_API_BASE=https://your-backend.example npm start
The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches.
+## Recovery email alerts
+
+Open **Account → Recovery alerts**, connect a deployed smart account on a
+supported network, and sign the enrollment message in the wallet. Alerts go to
+the signed-in user's verified email; private keys never leave the wallet.
+The backend verifies the account's ERC-1271 signature and monitors independently
+of this page being open. The initial supported chain is zkTanenbaum (57057).
+
+Check the displayed monitoring status and on-chain execution time. Delivery is
+best effort; a delayed monitor is not evidence that an account is safe. Email
+links open the review page, never authorize cancellation or a transaction.
+Disabling an alert subscription requires the current Sysnode password and TOTP
+when configured; it does not disable the on-chain guardian policy.
+
+The public `/api/recovery/` prefix uses the existing `/api/` reverse proxy.
+Backends also accept `/recovery/` when that proxy strips `/api`, while local
+development can use `/api/recovery/` directly. Keep both on the same origin as
+the frontend and do not replace the credentialed cookie/CSRF boundary.
+
## Getting Started
Install dependencies:
diff --git a/package-lock.json b/package-lock.json
index 2dd8f3a..00883c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10178,9 +10178,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+ "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
diff --git a/package.json b/package.json
index 0152398..5dfd0e9 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"test": "react-scripts test",
"test:ci": "react-scripts test --watchAll=false --maxWorkers=50%",
"test:sentry-browser": "playwright test --config playwright.config.cjs",
+ "test:recovery-browser": "playwright test --config playwright.config.cjs tests/browser/recovery-alerts.spec.cjs",
"test:production-runtime": "node --test tests/productionRuntime.test.cjs",
"test:deployment": "node --test tests/deploymentRequest.test.cjs",
"eject": "react-scripts eject"
diff --git a/src/App.css b/src/App.css
index dfdbc21..38a66d3 100644
--- a/src/App.css
+++ b/src/App.css
@@ -2010,6 +2010,8 @@
.auth-kv__row dd {
margin: 0;
+ min-width: 0;
+ overflow-wrap: anywhere;
font-weight: 500;
color: var(--text);
}
diff --git a/src/components/RecoveryAlertsCard.css b/src/components/RecoveryAlertsCard.css
new file mode 100644
index 0000000..38b042e
--- /dev/null
+++ b/src/components/RecoveryAlertsCard.css
@@ -0,0 +1,180 @@
+.recovery-alerts {
+ scroll-margin-top: 100px;
+ min-width: 0;
+}
+.recovery-alerts h2 {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.recovery-alerts h2 svg {
+ width: 22px;
+ height: 22px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ flex: 0 0 auto;
+}
+.recovery-alerts h3 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 650;
+}
+.recovery-alerts .auth-input {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+}
+.recovery-alerts .auth-alert {
+ overflow-wrap: anywhere;
+}
+.recovery-email {
+ overflow-wrap: anywhere;
+}
+.recovery-note {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.6;
+}
+.recovery-subscriptions {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+.recovery-subscription,
+.recovery-remove {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ border: 1px solid var(--panel-outline);
+ border-radius: 16px;
+ padding: 18px;
+ min-width: 0;
+}
+.recovery-subscription__heading {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+.recovery-subscription__heading h3 {
+ overflow-wrap: anywhere;
+ flex: 1 1 130px;
+}
+.recovery-subscription .status-chip {
+ font-size: 0.72rem;
+}
+.recovery-address {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 0;
+}
+.recovery-address code {
+ overflow-wrap: anywhere;
+ word-break: break-all;
+ font-size: 0.8rem;
+ line-height: 1.7;
+ min-width: 0;
+}
+.recovery-copy {
+ flex: 0 0 auto;
+ min-height: 36px;
+ border: 1px solid var(--panel-outline);
+ color: var(--muted);
+ background: transparent;
+ border-radius: 7px;
+ padding: 4px 6px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.74rem;
+}
+.recovery-copy:hover,
+.recovery-copy:focus-visible {
+ background: var(--accent-soft);
+ color: var(--text);
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.recovery-operation {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ border-left: 3px solid var(--accent);
+ padding: 8px 0 8px 12px;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+.recovery-empty {
+ margin: 0;
+ padding: 16px;
+ border: 1px dashed var(--panel-outline);
+ border-radius: 14px;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+.recovery-enroll {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+.recovery-network {
+ margin: 0;
+ color: var(--text);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+.recovery-link-details summary {
+ cursor: pointer;
+ font-weight: 600;
+ line-height: 1.6;
+ padding: 10px 0;
+ color: var(--text);
+}
+.recovery-link-details summary:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+ border-radius: 5px;
+}
+.recovery-link-details[open] summary {
+ margin-bottom: 12px;
+}
+.recovery-disable,
+.recovery-refresh {
+ align-self: flex-start;
+ text-align: left;
+ line-height: 1.6;
+}
+.recovery-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+.recovery-actions .button {
+ flex: 1 1 140px;
+ min-width: 0;
+}
+.recovery-alerts .button {
+ white-space: normal;
+ min-width: 0;
+}
+.recovery-account-select {
+ font-size: 0.75rem;
+}
+@media (max-width: 400px) {
+ .recovery-subscription,
+ .recovery-remove {
+ padding: 14px;
+ }
+ .recovery-address {
+ flex-wrap: wrap;
+ }
+ .recovery-address code {
+ flex-basis: 100%;
+ }
+}
diff --git a/src/components/RecoveryAlertsCard.js b/src/components/RecoveryAlertsCard.js
new file mode 100644
index 0000000..f186959
--- /dev/null
+++ b/src/components/RecoveryAlertsCard.js
@@ -0,0 +1,805 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useAuth } from '../context/AuthContext';
+import { authService as defaultAuthService } from '../lib/authService';
+import {
+ discoverWallets,
+ enrollRecoveryAlerts,
+ readWalletSelection,
+ recoveryAlertsService,
+} from '../lib/recoveryAlerts';
+import PasswordInput from './PasswordInput';
+import './RecoveryAlertsCard.css';
+
+const ERROR_COPY = {
+ wallet_unavailable:
+ 'Open this page in a browser with Pali or another compatible smart-account wallet.',
+ wallet_account_missing:
+ 'Select your smart account in the wallet, then connect again.',
+ unsupported_chain:
+ 'This network is not monitored. Switch networks in your wallet, then connect again.',
+ monitor_unavailable:
+ 'Monitoring is temporarily unavailable. Your existing alerts have not been removed.',
+ recovery_not_installed:
+ 'This account needs guardian recovery installed before alerts can be enabled.',
+ invalid_signature:
+ 'The smart account could not verify this signature. Select its current authorized signer and try again.',
+ invalid_challenge:
+ 'The enrollment request could not be verified. Refresh and try again.',
+ challenge_expired:
+ 'The enrollment request expired. Try again to get a fresh signature request.',
+ wallet_changed:
+ 'The wallet account or network changed. Connect again before enabling alerts.',
+ invalid_credentials: 'Your current Sysnode password is incorrect.',
+ totp_required: 'Enter the current six-digit authenticator code.',
+ invalid_totp_code:
+ 'That authenticator code did not work. Enter the current code and try again.',
+ email_not_verified:
+ 'Verify your Sysnode email before enabling recovery alerts.',
+ unauthorized: 'Your session expired. Please sign in again.',
+ network_error:
+ 'We could not reach Sysnode. Check your connection and try again.',
+ not_found: 'This alert subscription no longer exists. Refresh the list.',
+ invalid_body: 'Check the supplied details and try again.',
+};
+
+function errorText(error) {
+ if (error && error.enrollmentMayHaveCompleted)
+ return 'Your wallet changed while enrollment was being saved. Refresh the list to check whether alerts were enabled for the original account.';
+ if (error && (error.code === 4001 || error.code === 'user_rejected'))
+ return 'The signature request was canceled. No alerts were enabled.';
+ return (
+ ERROR_COPY[error && error.code] ||
+ 'We could not complete that request. Please try again.'
+ );
+}
+
+function datetime(value, milliseconds = false) {
+ if (!Number.isFinite(Number(value)) || Number(value) <= 0)
+ return 'Not yet checked';
+ return new Date(Number(value) * (milliseconds ? 1 : 1000)).toLocaleString(
+ undefined,
+ { dateStyle: 'medium', timeStyle: 'short' }
+ );
+}
+
+function monitoringCopy(status) {
+ if (status === 'active')
+ return {
+ title: 'Monitoring active',
+ tone: 'is-positive',
+ description: 'The monitor is caught up with the chain.',
+ };
+ if (status === 'catching_up')
+ return {
+ title: 'Checking chain history',
+ tone: 'is-warning',
+ description:
+ 'Checking for recent and existing recoveries. Monitoring starts when this check is complete.',
+ };
+ return {
+ title: 'Monitoring delayed',
+ tone: 'is-warning',
+ description:
+ 'Chain data is not current. Alerts may be delayed; check the account in your wallet.',
+ };
+}
+
+function AccountAddress({ address }) {
+ const [copied, setCopied] = useState(false);
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopied(true);
+ } catch (_error) {
+ setCopied(false);
+ }
+ }
+ return (
+
+ {address}
+
+
+ );
+}
+
+export default function RecoveryAlertsCard({
+ service = recoveryAlertsService,
+ authService = defaultAuthService,
+ defaultOpen = false,
+ discover = discoverWallets,
+}) {
+ const { user, handleAuthLost } = useAuth();
+ const userId = user && user.id;
+ const [open, setOpen] = useState(
+ defaultOpen ||
+ (typeof window !== 'undefined' &&
+ window.location.hash === '#recovery-alerts')
+ );
+ const [wallets, setWallets] = useState([]);
+ const [walletId, setWalletId] = useState('');
+ const [selection, setSelection] = useState(null);
+ const [account, setAccount] = useState('');
+ const [label, setLabel] = useState('');
+ const [chains, setChains] = useState([]);
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [phase, setPhase] = useState('');
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [removing, setRemoving] = useState(null);
+ const [password, setPassword] = useState('');
+ const [code, setCode] = useState('');
+ const [needTotp, setNeedTotp] = useState(false);
+ const generation = useRef(0);
+ const fetchGeneration = useRef(0);
+ const userRef = useRef(userId);
+ userRef.current = userId;
+ const mounted = useRef(true);
+ const busy = useRef(false);
+ const wallet = wallets.find((item) => item.id === walletId);
+ const walletRef = useRef(wallet);
+ walletRef.current = wallet;
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ generation.current += 1;
+ };
+ }, []);
+ useEffect(
+ () =>
+ discover((choices) => {
+ setWallets(choices);
+ setWalletId(
+ (current) => current || (choices[0] && choices[0].id) || ''
+ );
+ }),
+ [discover]
+ );
+
+ const handleError = useCallback(
+ (err) => {
+ if (err && err.code === 'unauthorized') {
+ handleAuthLost();
+ return;
+ }
+ setError(errorText(err));
+ },
+ [handleAuthLost]
+ );
+
+ const load = useCallback(async () => {
+ const id = userRef.current;
+ if (id == null) return;
+ const request = ++fetchGeneration.current;
+ setLoading(true);
+ try {
+ const [supported, saved] = await Promise.all([
+ service.chains(),
+ service.subscriptions(),
+ ]);
+ if (
+ !mounted.current ||
+ userRef.current !== id ||
+ fetchGeneration.current !== request
+ )
+ return;
+ if (!Array.isArray(supported) || !Array.isArray(saved))
+ throw new Error('invalid_response');
+ setChains(supported);
+ setSubscriptions(saved);
+ setLoaded(true);
+ } catch (err) {
+ if (
+ mounted.current &&
+ userRef.current === id &&
+ fetchGeneration.current === request
+ ) {
+ setSubscriptions((current) => current.map((subscription) => ({
+ ...subscription,
+ monitoring: { ...subscription.monitoring, status: 'degraded' },
+ })));
+ handleError(err);
+ }
+ } finally {
+ if (
+ mounted.current &&
+ userRef.current === id &&
+ fetchGeneration.current === request
+ )
+ setLoading(false);
+ }
+ }, [service, handleError]);
+
+ useEffect(() => {
+ generation.current += 1;
+ fetchGeneration.current += 1;
+ setSelection(null);
+ setAccount('');
+ setSubscriptions([]);
+ setChains([]);
+ setLoaded(false);
+ setError(null);
+ setNotice(null);
+ setRemoving(null);
+ setPassword('');
+ setCode('');
+ setNeedTotp(false);
+ setLabel('');
+ setLoading(false);
+ if (open && userId != null) load();
+ }, [userId, open, load]);
+
+ useEffect(() => {
+ if (!open || !loaded) return undefined;
+ const refreshVisible = () => {
+ if (document.visibilityState !== 'hidden' && !busy.current) load();
+ };
+ const timer = setInterval(refreshVisible, 30000);
+ document.addEventListener('visibilitychange', refreshVisible);
+ return () => {
+ clearInterval(timer);
+ document.removeEventListener('visibilitychange', refreshVisible);
+ };
+ }, [open, loaded, load]);
+
+ useEffect(() => {
+ generation.current += 1;
+ setSelection(null);
+ setAccount('');
+ const provider = wallet && wallet.provider;
+ if (!provider) return undefined;
+ const changed = () => {
+ generation.current += 1;
+ setSelection(null);
+ setAccount('');
+ setNotice(null);
+ setError(ERROR_COPY.wallet_changed);
+ };
+ provider.on?.('accountsChanged', changed);
+ provider.on?.('chainChanged', changed);
+ provider.on?.('disconnect', changed);
+ return () => {
+ provider.removeListener?.('accountsChanged', changed);
+ provider.removeListener?.('chainChanged', changed);
+ provider.removeListener?.('disconnect', changed);
+ };
+ }, [wallet]);
+
+ async function connect() {
+ if (!wallet || busy.current) return;
+ busy.current = true;
+ setPhase('connecting');
+ setError(null);
+ setNotice(null);
+ const provider = wallet.provider;
+ const id = userId;
+ generation.current += 1;
+ try {
+ const selected = await readWalletSelection(provider, true);
+ if (
+ !mounted.current ||
+ walletRef.current?.provider !== provider ||
+ userRef.current !== id
+ )
+ return;
+ if (!chains.some((chain) => chain.chainId === selected.chainId))
+ throw Object.assign(new Error(), { code: 'unsupported_chain' });
+ setSelection(selected);
+ setAccount(selected.accounts[0]);
+ setError(null);
+ } catch (err) {
+ if (
+ mounted.current &&
+ walletRef.current?.provider === provider &&
+ userRef.current === id
+ )
+ handleError(err);
+ } finally {
+ busy.current = false;
+ if (mounted.current) setPhase('');
+ }
+ }
+
+ async function enroll(event) {
+ event.preventDefault();
+ if (busy.current || !selection || !account || !wallet) return;
+ busy.current = true;
+ fetchGeneration.current += 1;
+ setLoading(false);
+ setPhase('preparing');
+ setError(null);
+ setNotice(null);
+ const attempt = generation.current;
+ const id = userId;
+ try {
+ await enrollRecoveryAlerts({
+ provider: wallet.provider,
+ service,
+ chainId: selection.chainId,
+ account,
+ label: label.trim(),
+ isCurrent: () =>
+ mounted.current &&
+ userRef.current === id &&
+ generation.current === attempt,
+ onProgress: (next) => {
+ if (mounted.current && generation.current === attempt) setPhase(next);
+ },
+ });
+ if (!mounted.current || userRef.current !== id) return;
+ setNotice(
+ 'Account linked for recovery alerts. Monitoring starts after the initial chain checks.'
+ );
+ setLabel('');
+ await load();
+ } catch (err) {
+ if (mounted.current && userRef.current === id) handleError(err);
+ } finally {
+ busy.current = false;
+ if (mounted.current) setPhase('');
+ }
+ }
+
+ async function remove(event) {
+ event.preventDefault();
+ if (busy.current || !removing || !password || !user) return;
+ busy.current = true;
+ fetchGeneration.current += 1;
+ setLoading(false);
+ setPhase('removing');
+ setError(null);
+ setNotice(null);
+ const id = userId;
+ try {
+ const oldAuthHash = await authService.deriveStepUpAuthHash(
+ password,
+ user.email
+ );
+ if (!mounted.current || userRef.current !== id) return;
+ await service.remove(removing.id, {
+ oldAuthHash,
+ ...(code ? { code } : {}),
+ });
+ if (!mounted.current || userRef.current !== id) return;
+ // The acknowledged mutation is authoritative even if a following status
+ // refresh fails. Never leave a disabled watch labelled as active.
+ setSubscriptions((current) =>
+ current.filter((subscription) => subscription.id !== removing.id)
+ );
+ setRemoving(null);
+ setPassword('');
+ setCode('');
+ setNotice(
+ 'Alerts for this account were disabled. This does not cancel any on-chain recovery.'
+ );
+ await load();
+ } catch (err) {
+ if (mounted.current && userRef.current === id) {
+ if (err && err.code === 'totp_required') setNeedTotp(true);
+ handleError(err);
+ }
+ } finally {
+ busy.current = false;
+ if (mounted.current) setPhase('');
+ }
+ }
+
+ const chain =
+ selection && chains.find((item) => item.chainId === selection.chainId);
+ const isLinked =
+ selection &&
+ subscriptions.some(
+ (item) =>
+ item.chainId === selection.chainId &&
+ chain &&
+ item.recoveryModule?.toLowerCase() === chain.recoveryModule?.toLowerCase() &&
+ item.account.toLowerCase() === account.toLowerCase()
+ );
+ const phaseLabel = {
+ connecting: 'Connecting…',
+ preparing: 'Preparing request…',
+ signing: 'Confirm in wallet…',
+ saving: 'Saving alerts…',
+ removing: 'Disabling alerts…',
+ }[phase];
+
+ return (
+
+
+
+
+
+ Recovery alerts
+
+
+ Email warnings when guardian recovery starts on your smart account.
+
+
+
+
+ {open && (
+
+
+ Recovery-start, reminder and outcome emails go to{' '}
+ {user && user.email}.
+ Keys and recovery control stay in your wallet.
+
+
+ Alerts may be delayed and cannot cancel recovery. Keep access to
+ your wallet.
+
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+ {loading && !loaded &&
Loading recovery alerts…
}
+
+ {subscriptions.map((subscription) => {
+ const network = chains.find(
+ (item) => item.chainId === subscription.chainId
+ );
+ const monitor = monitoringCopy(
+ subscription.monitoring?.status === 'active' &&
+ (!subscription.monitoring.lastCheckedAt ||
+ Date.now() - subscription.monitoring.lastCheckedAt > 60000)
+ ? 'degraded'
+ : subscription.monitoring?.status
+ );
+ return (
+
+
+
{subscription.label || 'Smart account'}
+
+ {monitor.title}
+
+
+
+
+ {network ? network.name : `Chain ${subscription.chainId}`}
+ {network && network.testnet ? ' · Testnet' : ''}
+
+ {monitor.description}
+
+ Last chain check:{' '}
+ {datetime(
+ subscription.monitoring &&
+ subscription.monitoring.lastCheckedAt,
+ true
+ )}
+
+ {(subscription.pending || []).map((operation) => (
+
+
+ {{
+ scheduled: 'Recovery pending',
+ pending: 'Recovery pending',
+ ready: 'Recovery can be executed',
+ expired: 'Recovery expired',
+ executed: 'Recovery executed',
+ canceled: 'Recovery canceled or invalidated',
+ invalidated: 'Recovery no longer pending',
+ orphaned: 'Recovery observation no longer canonical',
+ unknown: 'Recovery status unavailable',
+ }[operation.status] || 'Recovery observed'}
+
+
+ {['pending', 'ready', 'scheduled'].includes(
+ operation.status
+ )
+ ? 'Can execute from'
+ : 'Original execution time'}{' '}
+ {datetime(operation.executableAt)}
+
+ {Number(operation.expiresAt) > 0 && (
+
+ {['pending', 'ready', 'scheduled'].includes(
+ operation.status
+ )
+ ? 'Expires'
+ : 'Original expiry'}{' '}
+ {datetime(operation.expiresAt)}
+
+ )}
+
+ Review this operation in your wallet. Passing the
+ deadline does not mean it has executed.
+
+
+ ))}
+
+
+ );
+ })}
+ {loaded && subscriptions.length === 0 && (
+
+ No smart accounts linked yet. Connect one below to begin.
+
+ )}
+
+ {removing ? (
+
+ ) : (
+
+
+ {subscriptions.length === 0
+ ? 'Link a smart account'
+ : 'Link another smart account'}
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/RecoveryAlertsCard.test.js b/src/components/RecoveryAlertsCard.test.js
new file mode 100644
index 0000000..46adeec
--- /dev/null
+++ b/src/components/RecoveryAlertsCard.test.js
@@ -0,0 +1,343 @@
+import React from 'react';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { act } from 'react';
+import userEvent from '@testing-library/user-event';
+import RecoveryAlertsCard from './RecoveryAlertsCard';
+import { AuthProvider } from '../context/AuthContext';
+
+const ACCOUNT = `0x${'12'.repeat(20)}`;
+const CHAIN_ID = 57057;
+const CHAIN = { chainId: CHAIN_ID, name: 'zkTanenbaum', testnet: true, recoveryModule: `0x${'45'.repeat(20)}` };
+const SIGNATURE = `0x${'ab'.repeat(24000)}`;
+const SUBSCRIPTION = {
+ id: 1,
+ chainId: CHAIN_ID,
+ recoveryModule: CHAIN.recoveryModule,
+ account: ACCOUNT,
+ label: 'Savings',
+ monitoring: { status: 'catching_up', lastCheckedAt: 1792600000000 },
+ pending: [],
+};
+
+const click = (element) =>
+ act(async () => {
+ await userEvent.click(element);
+ });
+const type = (element, value) =>
+ act(async () => {
+ await userEvent.type(element, value);
+ });
+
+function setup({
+ subscriptions = [],
+ open = true,
+ hasWallet = true,
+ totp = false,
+ service: overrides = {},
+} = {}) {
+ const handlers = {};
+ const provider = {
+ request: jest.fn(async ({ method }) =>
+ method === 'eth_chainId'
+ ? `0x${CHAIN_ID.toString(16)}`
+ : method === 'personal_sign'
+ ? SIGNATURE
+ : [ACCOUNT]
+ ),
+ on: jest.fn((name, fn) => {
+ handlers[name] = fn;
+ }),
+ removeListener: jest.fn(),
+ };
+ const discover = jest.fn((cb) => {
+ cb(hasWallet ? [{ id: 'pali', name: 'Pali', provider }] : []);
+ return () => {};
+ });
+ const service = {
+ chains: jest.fn().mockResolvedValue([CHAIN]),
+ subscriptions: jest.fn().mockResolvedValue(subscriptions),
+ challenge: jest.fn().mockResolvedValue({
+ id: 'nonce',
+ message: 'Enable recovery alerts',
+ account: ACCOUNT,
+ chainId: CHAIN_ID,
+ }),
+ subscribe: jest.fn().mockResolvedValue(SUBSCRIPTION),
+ remove: jest.fn().mockResolvedValue({ ok: true }),
+ ...overrides,
+ };
+ const authService = {
+ me: jest.fn().mockResolvedValue({
+ user: {
+ id: 1,
+ email: 'qa@example.test',
+ emailVerified: true,
+ totpEnabled: totp,
+ },
+ }),
+ deriveStepUpAuthHash: jest.fn().mockResolvedValue('auth-hash'),
+ };
+ const view = render(
+
+
+
+ );
+ return { ...view, service, authService, provider, handlers };
+}
+
+test('collapsed card adds no background API requests until it is opened', async () => {
+ const { service } = setup({ open: false });
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Expand recovery alerts' })
+ ).toBeInTheDocument()
+ );
+ expect(service.chains).not.toHaveBeenCalled();
+ await click(screen.getByRole('button', { name: 'Expand recovery alerts' }));
+ await screen.findByText(/No smart accounts linked yet/);
+ expect(service.subscriptions).toHaveBeenCalledTimes(1);
+});
+
+test('enrollment requests an off-chain signature, saves only proof and refreshes monitoring', async () => {
+ const { service, provider } = setup();
+ await screen.findByText(/No smart accounts linked yet/);
+ await click(screen.getByRole('button', { name: 'Connect wallet' }));
+ await screen.findByRole('button', { name: 'Enable recovery alerts' });
+ expect(
+ screen.getByText(/An off-chain signature links this account address/)
+ ).toBeInTheDocument();
+ await type(screen.getByLabelText(/Account label/), 'Savings');
+ service.subscriptions.mockResolvedValue([SUBSCRIPTION]);
+ await click(screen.getByRole('button', { name: 'Enable recovery alerts' }));
+ await screen.findByText('Checking chain history');
+ expect(service.subscribe).toHaveBeenCalledWith('nonce', SIGNATURE, 'Savings');
+ expect(
+ provider.request.mock.calls.some(
+ ([call]) => call.method === 'eth_sendTransaction'
+ )
+ ).toBe(false);
+ expect(
+ screen.getByText(/Account linked for recovery alerts/)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Checking for recent and existing recoveries/)
+ ).toBeInTheDocument();
+});
+
+test('normal accountsChanged on first wallet permission does not break connection', async () => {
+ const { provider, handlers } = setup();
+ await screen.findByText(/No smart accounts linked yet/);
+ provider.request.mockImplementation(async ({ method }) => {
+ if (method === 'eth_requestAccounts') handlers.accountsChanged([ACCOUNT]);
+ return method === 'eth_chainId' ? `0x${CHAIN_ID.toString(16)}` : [ACCOUNT];
+ });
+ await click(screen.getByRole('button', { name: 'Connect wallet' }));
+ expect(
+ await screen.findByRole('button', { name: 'Enable recovery alerts' })
+ ).toBeEnabled();
+});
+
+test('a rejected signature does not claim success or persist enrollment', async () => {
+ const { provider, service } = setup();
+ await screen.findByText(/No smart accounts linked yet/);
+ await click(screen.getByRole('button', { name: 'Connect wallet' }));
+ await screen.findByRole('button', { name: 'Enable recovery alerts' });
+ provider.request.mockImplementation(async ({ method }) => {
+ if (method === 'personal_sign')
+ throw Object.assign(new Error(), { code: 4001 });
+ return method === 'eth_chainId' ? `0x${CHAIN_ID.toString(16)}` : [ACCOUNT];
+ });
+ await click(screen.getByRole('button', { name: 'Enable recovery alerts' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /signature request was canceled/
+ );
+ expect(service.subscribe).not.toHaveBeenCalled();
+});
+
+test('shows degraded monitoring and accurate expiry without claiming recovery has executed', async () => {
+ setup({
+ subscriptions: [
+ {
+ ...SUBSCRIPTION,
+ monitoring: { status: 'degraded', lastCheckedAt: 1792600000000 },
+ pending: [
+ {
+ operationId: 'op',
+ executableAt: 1792700000,
+ expiresAt: 1792900000,
+ status: 'ready',
+ },
+ ],
+ },
+ ],
+ });
+ await screen.findByText('Monitoring delayed');
+ expect(screen.getByText(/Chain data is not current/)).toBeInTheDocument();
+ expect(screen.getByText('Recovery can be executed')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Passing the deadline does not mean it has executed/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Last chain check:/)).not.toHaveTextContent('58775');
+});
+
+test.each([
+ ['canceled', 'Recovery canceled or invalidated'],
+ ['invalidated', 'Recovery no longer pending'],
+ ['orphaned', 'Recovery observation no longer canonical'],
+])(
+ 'labels %s without inventing an operator action or a current execution deadline',
+ async (status, label) => {
+ setup({
+ subscriptions: [
+ {
+ ...SUBSCRIPTION,
+ pending: [
+ {
+ operationId: 'op',
+ executableAt: 1792700000,
+ expiresAt: 1792900000,
+ status,
+ },
+ ],
+ },
+ ],
+ });
+ expect(await screen.findByText(label)).toBeInTheDocument();
+ expect(screen.queryByText(/Can execute from/)).not.toBeInTheDocument();
+ expect(screen.getByText(/Original execution time/)).toBeInTheDocument();
+ }
+);
+
+test('disabling requires password and TOTP and identifies the exact account', async () => {
+ const { service, authService } = setup({
+ subscriptions: [SUBSCRIPTION],
+ totp: true,
+ });
+ await screen.findByText('Savings');
+ await click(screen.getByRole('button', { name: 'Disable these alerts…' }));
+ expect(screen.getByText('Disable alerts for Savings?')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Disable alerts' })).toBeDisabled();
+ await type(
+ screen.getByLabelText('Current Sysnode password'),
+ 'correct-password'
+ );
+ await type(screen.getByLabelText('Authenticator code'), '123456');
+ service.subscriptions.mockResolvedValue([]);
+ await click(screen.getByRole('button', { name: 'Disable alerts' }));
+ await screen.findByText(/This does not cancel any on-chain recovery/);
+ expect(authService.deriveStepUpAuthHash).toHaveBeenCalledWith(
+ 'correct-password',
+ 'qa@example.test'
+ );
+ expect(service.remove).toHaveBeenCalledWith(1, {
+ oldAuthHash: 'auth-hash',
+ code: '123456',
+ });
+ expect(
+ screen.queryByLabelText('Current Sysnode password')
+ ).not.toBeInTheDocument();
+});
+
+test('stale 2FA identity responds to server step-up requirement, without losing subscription', async () => {
+ const { service } = setup({
+ subscriptions: [SUBSCRIPTION],
+ service: { remove: jest.fn().mockRejectedValue({ code: 'totp_required' }) },
+ });
+ await screen.findByText('Savings');
+ await click(screen.getByRole('button', { name: 'Disable these alerts…' }));
+ fireEvent.change(screen.getByLabelText('Current Sysnode password'), {
+ target: { value: 'password' },
+ });
+ await click(screen.getByRole('button', { name: 'Disable alerts' }));
+ await screen.findByLabelText('Authenticator code');
+ expect(screen.getByText('Savings')).toBeInTheDocument();
+ expect(service.subscriptions).toHaveBeenCalledTimes(1);
+});
+
+test('a successful disable removes the active card even when refresh fails', async () => {
+ const { service } = setup({ subscriptions: [SUBSCRIPTION] });
+ await screen.findByText('Savings');
+ await click(screen.getByRole('button', { name: 'Disable these alerts…' }));
+ fireEvent.change(screen.getByLabelText('Current Sysnode password'), {
+ target: { value: 'password' },
+ });
+ service.subscriptions.mockRejectedValue({ code: 'network_error' });
+ await click(screen.getByRole('button', { name: 'Disable alerts' }));
+ await screen.findByText(/This does not cancel any on-chain recovery/);
+ expect(screen.queryByText('Savings')).not.toBeInTheDocument();
+ expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument();
+ expect(screen.getByText(/No smart accounts linked yet/)).toBeInTheDocument();
+});
+
+test('a failed status refresh preserves history but no longer claims active monitoring', async () => {
+ const { service } = setup({ subscriptions: [{
+ ...SUBSCRIPTION,
+ monitoring: { status: 'active', lastCheckedAt: Date.now() },
+ }] });
+ await screen.findByText('Monitoring active');
+ service.subscriptions.mockRejectedValue({ code: 'network_error' });
+ await click(screen.getByRole('button', { name: 'Refresh status' }));
+ await screen.findByText('Monitoring delayed');
+ expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument();
+ expect(screen.getByText('Savings')).toBeInTheDocument();
+});
+
+test('an old module subscription does not block enrolling the current module', async () => {
+ setup({ subscriptions: [{ ...SUBSCRIPTION, recoveryModule: `0x${'67'.repeat(20)}` }] });
+ await screen.findByText('Savings');
+ await click(screen.getByRole('button', { name: 'Connect wallet' }));
+ expect(await screen.findByRole('button', { name: 'Enable recovery alerts' })).toBeEnabled();
+});
+
+test('an old active snapshot is not displayed as current monitoring', async () => {
+ setup({ subscriptions: [{
+ ...SUBSCRIPTION,
+ monitoring: { status: 'active', lastCheckedAt: Date.now() - 120000 },
+ }] });
+ expect(await screen.findByText('Monitoring delayed')).toBeInTheDocument();
+ expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument();
+});
+
+test('a slow status refresh cannot restore a subscription after it was disabled', async () => {
+ const { service } = setup({ subscriptions: [SUBSCRIPTION] });
+ await screen.findByText('Savings');
+ let finishOldRefresh;
+ service.subscriptions.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishOldRefresh = resolve;
+ })
+ );
+ await click(screen.getByRole('button', { name: 'Refresh status' }));
+ await click(screen.getByRole('button', { name: 'Disable these alerts…' }));
+ fireEvent.change(screen.getByLabelText('Current Sysnode password'), {
+ target: { value: 'password' },
+ });
+ service.subscriptions.mockResolvedValue([]);
+ await click(screen.getByRole('button', { name: 'Disable alerts' }));
+ await screen.findByText(/No smart accounts linked yet/);
+ await act(async () => {
+ finishOldRefresh([SUBSCRIPTION]);
+ });
+ expect(screen.queryByText('Savings')).not.toBeInTheDocument();
+ expect(screen.getByText(/No smart accounts linked yet/)).toBeInTheDocument();
+});
+
+test('no wallet and load failure show retryable honest states', async () => {
+ setup({
+ hasWallet: false,
+ service: { chains: jest.fn().mockRejectedValue({ code: 'network_error' }) },
+ });
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /could not reach Sysnode/
+ );
+ expect(
+ screen.getByText(/Open this page in a browser with Pali/)
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Refresh status' })).toBeEnabled();
+});
diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js
index c7e6987..eeea917 100644
--- a/src/lib/apiClient.js
+++ b/src/lib/apiClient.js
@@ -192,8 +192,13 @@ export function createApiClient({
const isVerifyPassword = url.startsWith('/auth/verify-password');
const isVerifyPasswordMismatch =
isVerifyPassword && apiError.code === 'invalid_credentials';
+ const isRecoveryPasswordMismatch =
+ String(error.config && error.config.method).toUpperCase() ===
+ 'DELETE' &&
+ /^\/api\/recovery\/subscriptions\/[^/?]+$/.test(url) &&
+ apiError.code === 'invalid_credentials';
if (
- !isAuthEndpoint ||
+ (!isAuthEndpoint && !isRecoveryPasswordMismatch) ||
(isVerifyPassword && !isVerifyPasswordMismatch)
) {
try {
diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js
index d3c076f..deae5f4 100644
--- a/src/lib/apiClient.test.js
+++ b/src/lib/apiClient.test.js
@@ -148,6 +148,34 @@ describe('createApiClient — no global Content-Type (Codex round 3 P2)', () =>
});
describe('createApiClient — 401 handling', () => {
+ test('wrong recovery-alert step-up password does not invalidate the session', async () => {
+ const onAuthLost = jest.fn();
+ const client = createApiClient({ onAuthLost });
+ const adapter = new MockAdapter(client);
+ adapter
+ .onDelete('/api/recovery/subscriptions/1')
+ .reply(401, { error: 'invalid_credentials' });
+ await expect(
+ client.delete('/api/recovery/subscriptions/1')
+ ).rejects.toMatchObject({ code: 'invalid_credentials' });
+ expect(onAuthLost).not.toHaveBeenCalled();
+ adapter.restore();
+ });
+
+ test('recovery-alert session expiry still invalidates the session', async () => {
+ const onAuthLost = jest.fn();
+ const client = createApiClient({ onAuthLost });
+ const adapter = new MockAdapter(client);
+ adapter
+ .onDelete('/api/recovery/subscriptions/1')
+ .reply(401, { error: 'unauthorized' });
+ await expect(
+ client.delete('/api/recovery/subscriptions/1')
+ ).rejects.toMatchObject({ code: 'unauthorized' });
+ expect(onAuthLost).toHaveBeenCalledTimes(1);
+ adapter.restore();
+ });
+
test('invokes onAuthLost for non-auth 401s', async () => {
const onAuthLost = jest.fn();
const client = createApiClient({
diff --git a/src/lib/recoveryAlerts.js b/src/lib/recoveryAlerts.js
new file mode 100644
index 0000000..db2bf19
--- /dev/null
+++ b/src/lib/recoveryAlerts.js
@@ -0,0 +1,214 @@
+import { apiClient } from './apiClient';
+
+export function createRecoveryAlertsService(client = apiClient) {
+ return {
+ async chains() {
+ return (await client.get('/api/recovery/chains')).data.chains;
+ },
+ async subscriptions() {
+ return (await client.get('/api/recovery/subscriptions')).data.subscriptions;
+ },
+ async challenge(chainId, account) {
+ return (await client.post('/api/recovery/challenges', { chainId, account }))
+ .data;
+ },
+ async subscribe(challengeId, signature, label) {
+ const body = { challengeId, signature };
+ if (label) body.label = label;
+ return (await client.post('/api/recovery/subscriptions', body)).data
+ .subscription;
+ },
+ async remove(id, credentials) {
+ return (
+ await client.delete(
+ `/api/recovery/subscriptions/${encodeURIComponent(id)}`,
+ {
+ data: credentials,
+ }
+ )
+ ).data;
+ },
+ };
+}
+
+export const recoveryAlertsService = createRecoveryAlertsService();
+
+const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
+
+function walletError(code) {
+ return Object.assign(new Error(code), { code });
+}
+
+export function parseEvmChainId(value) {
+ if (typeof value !== 'string' || !/^0x[0-9a-f]+$/i.test(value)) return null;
+ const number = Number(value);
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
+}
+
+export async function readWalletSelection(provider, connect = false) {
+ if (!provider || typeof provider.request !== 'function')
+ throw walletError('wallet_unavailable');
+ const accounts = await provider.request({
+ method: connect ? 'eth_requestAccounts' : 'eth_accounts',
+ });
+ const chainId = parseEvmChainId(
+ await provider.request({ method: 'eth_chainId' })
+ );
+ if (
+ !Array.isArray(accounts) ||
+ !accounts.length ||
+ accounts.some(
+ (account) => typeof account !== 'string' || !ADDRESS.test(account)
+ )
+ ) {
+ throw walletError('wallet_account_missing');
+ }
+ if (!chainId) throw walletError('unsupported_chain');
+ return {
+ accounts: [...new Set(accounts.map((account) => account.toLowerCase()))],
+ chainId,
+ };
+}
+
+// Only sign the server-issued message, using the standard wallet interface.
+// The backend verifies the signature against the contract, not its EOA owner.
+export async function enrollRecoveryAlerts({
+ provider,
+ service = recoveryAlertsService,
+ chainId,
+ account,
+ label,
+ onProgress = () => {},
+ isCurrent = () => true,
+}) {
+ let changed = false;
+ let submitting = false;
+ const invalidate = () => {
+ changed = true;
+ };
+ provider.on?.('accountsChanged', invalidate);
+ provider.on?.('chainChanged', invalidate);
+ provider.on?.('disconnect', invalidate);
+ const assertCurrent = async () => {
+ if (changed || !isCurrent()) throw walletError('wallet_changed');
+ const selected = await readWalletSelection(provider);
+ if (
+ changed ||
+ !isCurrent() ||
+ selected.chainId !== chainId ||
+ !selected.accounts.includes(account.toLowerCase())
+ ) {
+ throw walletError('wallet_changed');
+ }
+ };
+ try {
+ await assertCurrent();
+ onProgress('preparing');
+ const challenge = await service.challenge(chainId, account);
+ if (
+ !challenge ||
+ typeof challenge.id !== 'string' ||
+ typeof challenge.message !== 'string' ||
+ !challenge.message ||
+ challenge.chainId !== chainId ||
+ String(challenge.account).toLowerCase() !== account.toLowerCase()
+ ) {
+ throw walletError('invalid_challenge');
+ }
+ await assertCurrent();
+ const encoded = `0x${Array.from(
+ new TextEncoder().encode(challenge.message),
+ (byte) => byte.toString(16).padStart(2, '0')
+ ).join('')}`;
+ onProgress('signing');
+ const signature = await provider.request({
+ method: 'personal_sign',
+ params: [encoded, account],
+ });
+ // Contract signatures can be much longer than a 65-byte ECDSA signature.
+ if (
+ typeof signature !== 'string' ||
+ !/^0x(?:[0-9a-fA-F]{2})+$/.test(signature)
+ )
+ throw walletError('invalid_signature');
+ await assertCurrent();
+ onProgress('saving');
+ submitting = true;
+ const subscription = await service.subscribe(
+ challenge.id,
+ signature,
+ label
+ );
+ if (changed || !isCurrent()) {
+ const error = walletError('wallet_changed');
+ error.enrollmentMayHaveCompleted = true;
+ throw error;
+ }
+ return subscription;
+ } catch (error) {
+ if ((changed || !isCurrent()) && error.code !== 'wallet_changed') {
+ const out = walletError('wallet_changed');
+ out.enrollmentMayHaveCompleted = submitting;
+ throw out;
+ }
+ throw error;
+ } finally {
+ provider.removeListener?.('accountsChanged', invalidate);
+ provider.removeListener?.('chainChanged', invalidate);
+ provider.removeListener?.('disconnect', invalidate);
+ }
+}
+
+// EIP-6963 metadata is display-only. Authorization never trusts a wallet name,
+// icon or brand flag; the selected provider still needs an on-chain proof.
+export function discoverWallets(
+ onChange,
+ target = typeof window === 'undefined' ? null : window
+) {
+ if (!target) return () => {};
+ const choices = [];
+ const add = (provider, name) => {
+ if (
+ !provider ||
+ typeof provider.request !== 'function' ||
+ choices.some((entry) => entry.provider === provider)
+ )
+ return;
+ choices.push({
+ provider,
+ name: String(name || 'Browser wallet').slice(0, 64),
+ id: `wallet-${choices.length}`,
+ });
+ onChange([...choices]);
+ };
+ const announced = (event) => {
+ const detail = event && event.detail;
+ if (detail && detail.info) add(detail.provider, detail.info.name);
+ };
+ target.addEventListener('eip6963:announceProvider', announced);
+ target.dispatchEvent(new Event('eip6963:requestProvider'));
+ const injected = target.ethereum;
+ if (injected && Array.isArray(injected.providers)) {
+ injected.providers.forEach((provider, i) =>
+ add(
+ provider,
+ provider.isPali
+ ? 'Pali'
+ : provider.isMetaMask
+ ? 'MetaMask'
+ : `Browser wallet ${i + 1}`
+ )
+ );
+ } else if (injected) {
+ add(
+ injected,
+ injected.isPali
+ ? 'Pali'
+ : injected.isMetaMask
+ ? 'MetaMask'
+ : 'Browser wallet'
+ );
+ }
+ return () =>
+ target.removeEventListener('eip6963:announceProvider', announced);
+}
diff --git a/src/lib/recoveryAlerts.test.js b/src/lib/recoveryAlerts.test.js
new file mode 100644
index 0000000..fa339d7
--- /dev/null
+++ b/src/lib/recoveryAlerts.test.js
@@ -0,0 +1,218 @@
+import {
+ createRecoveryAlertsService,
+ discoverWallets,
+ enrollRecoveryAlerts,
+ parseEvmChainId,
+ readWalletSelection,
+} from './recoveryAlerts';
+
+const ACCOUNT = `0x${'12'.repeat(20)}`;
+const OTHER = `0x${'34'.repeat(20)}`;
+const CHAIN = 57057;
+const SIGNATURE = `0x${'ab'.repeat(24000)}`;
+
+function fixture() {
+ const handlers = {};
+ const provider = {
+ request: jest.fn(async ({ method }) => {
+ if (method === 'eth_chainId') return `0x${CHAIN.toString(16)}`;
+ if (method === 'personal_sign') return SIGNATURE;
+ return [ACCOUNT];
+ }),
+ on: jest.fn((event, fn) => {
+ handlers[event] = fn;
+ }),
+ removeListener: jest.fn(),
+ };
+ const service = {
+ challenge: jest.fn().mockResolvedValue({
+ id: 'nonce',
+ message: 'Enable recovery alerts — user@example.test',
+ chainId: CHAIN,
+ account: ACCOUNT,
+ }),
+ subscribe: jest.fn().mockResolvedValue({ id: 1, account: ACCOUNT }),
+ };
+ return { provider, service, handlers };
+}
+
+test('uses authenticated API methods and never sends wallet keys or an RPC URL', async () => {
+ const client = {
+ get: jest
+ .fn()
+ .mockResolvedValue({ data: { chains: [], subscriptions: [] } }),
+ post: jest.fn().mockResolvedValue({ data: { subscription: { id: 1 } } }),
+ delete: jest.fn().mockResolvedValue({ data: { ok: true } }),
+ };
+ const service = createRecoveryAlertsService(client);
+ await service.chains();
+ await service.subscriptions();
+ await service.challenge(CHAIN, ACCOUNT);
+ await service.subscribe('nonce', SIGNATURE, 'Savings');
+ await service.remove(1, { oldAuthHash: 'hash', code: '123456' });
+ expect(client.get.mock.calls).toEqual([
+ ['/api/recovery/chains'],
+ ['/api/recovery/subscriptions'],
+ ]);
+ expect(client.post.mock.calls).toEqual([
+ ['/api/recovery/challenges', { chainId: CHAIN, account: ACCOUNT }],
+ [
+ '/api/recovery/subscriptions',
+ { challengeId: 'nonce', signature: SIGNATURE, label: 'Savings' },
+ ],
+ ]);
+ expect(client.delete).toHaveBeenCalledWith('/api/recovery/subscriptions/1', {
+ data: { oldAuthHash: 'hash', code: '123456' },
+ });
+});
+
+test('standard personal_sign preserves a long ERC1271 signature and UTF8 bytes', async () => {
+ const { provider, service } = fixture();
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ label: 'Savings',
+ })
+ ).resolves.toEqual({ id: 1, account: ACCOUNT });
+ const call = provider.request.mock.calls.find(
+ ([arg]) => arg.method === 'personal_sign'
+ )[0];
+ expect(Buffer.from(call.params[0].slice(2), 'hex').toString('utf8')).toBe(
+ 'Enable recovery alerts — user@example.test'
+ );
+ expect(call.params[1]).toBe(ACCOUNT);
+ expect(service.subscribe).toHaveBeenCalledWith('nonce', SIGNATURE, 'Savings');
+ expect(provider.removeListener).toHaveBeenCalledTimes(3);
+});
+
+test('a user rejection never saves an enrollment', async () => {
+ const { provider, service } = fixture();
+ provider.request.mockImplementation(async ({ method }) => {
+ if (method === 'personal_sign')
+ throw Object.assign(new Error(), { code: 4001 });
+ return method === 'eth_chainId' ? `0x${CHAIN.toString(16)}` : [ACCOUNT];
+ });
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ })
+ ).rejects.toMatchObject({ code: 4001 });
+ expect(service.subscribe).not.toHaveBeenCalled();
+});
+
+test('account changes while signing invalidate proof even if a signature arrives', async () => {
+ const { provider, service, handlers } = fixture();
+ provider.request.mockImplementation(async ({ method }) => {
+ if (method === 'personal_sign') {
+ handlers.accountsChanged([OTHER]);
+ return SIGNATURE;
+ }
+ return method === 'eth_chainId' ? `0x${CHAIN.toString(16)}` : [ACCOUNT];
+ });
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ })
+ ).rejects.toMatchObject({ code: 'wallet_changed' });
+ expect(service.subscribe).not.toHaveBeenCalled();
+});
+
+test('a network change while requesting the challenge prevents any signing', async () => {
+ const { provider, service, handlers } = fixture();
+ service.challenge.mockImplementation(async () => {
+ handlers.chainChanged('0x1');
+ return { id: 'nonce', message: 'Test', chainId: CHAIN, account: ACCOUNT };
+ });
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ })
+ ).rejects.toMatchObject({ code: 'wallet_changed' });
+ expect(
+ provider.request.mock.calls.some(([arg]) => arg.method === 'personal_sign')
+ ).toBe(false);
+});
+
+test('challenge identity must match the user selected chain and smart account', async () => {
+ const { provider, service } = fixture();
+ service.challenge.mockResolvedValue({
+ id: 'nonce',
+ message: 'wrong',
+ chainId: CHAIN,
+ account: OTHER,
+ });
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ })
+ ).rejects.toMatchObject({ code: 'invalid_challenge' });
+ expect(service.subscribe).not.toHaveBeenCalled();
+});
+
+test('context switching while the save is in flight does not falsely claim no subscription was made', async () => {
+ const { provider, service, handlers } = fixture();
+ service.subscribe.mockImplementation(async () => {
+ handlers.chainChanged('0x1');
+ return { id: 1 };
+ });
+ await expect(
+ enrollRecoveryAlerts({
+ provider,
+ service,
+ chainId: CHAIN,
+ account: ACCOUNT,
+ })
+ ).rejects.toMatchObject({
+ code: 'wallet_changed',
+ enrollmentMayHaveCompleted: true,
+ });
+});
+
+test('reads only safe chain IDs and well formed accounts', async () => {
+ expect(parseEvmChainId('0xde e1')).toBeNull();
+ expect(parseEvmChainId('57057')).toBeNull();
+ expect(parseEvmChainId('0xffffffffffffffffffff')).toBeNull();
+ const { provider } = fixture();
+ provider.request.mockResolvedValue(['not an address']);
+ await expect(readWalletSelection(provider)).rejects.toMatchObject({
+ code: 'wallet_account_missing',
+ });
+});
+
+test('EIP6963 discovery deduplicates providers and never renders supplied SVG or HTML', () => {
+ const provider = { request: jest.fn() };
+ const target = new EventTarget();
+ const changed = jest.fn();
+ target.ethereum = provider;
+ const stop = discoverWallets(changed, target);
+ const event = new Event('eip6963:announceProvider');
+ event.detail = {
+ info: {
+ name: '