diff --git a/apps/zerodev-signer-demo/next-env.d.ts b/apps/zerodev-signer-demo/next-env.d.ts index 1b3be084..830fb594 100644 --- a/apps/zerodev-signer-demo/next-env.d.ts +++ b/apps/zerodev-signer-demo/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/zerodev-signer-demo/src/app/dashboard/page.tsx b/apps/zerodev-signer-demo/src/app/dashboard/page.tsx index 1fea4eed..6a042209 100644 --- a/apps/zerodev-signer-demo/src/app/dashboard/page.tsx +++ b/apps/zerodev-signer-demo/src/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthenticators } from "@zerodev/wallet-react"; +import { SignatureRequest } from "@zerodev/wallet-react-ui"; import { Check, Copy, @@ -107,6 +108,21 @@ export default function DashboardPage() { const [isLoggingOut, setIsLoggingOut] = useState(false); const [isBalanceRefreshing, setIsBalanceRefreshing] = useState(false); + // Toggle for whether SignatureRequest is mounted. When mounted, the kit + // gates signing calls on user confirmation; when not, calls go through + // silently (background mode). Default off; persisted in localStorage. + const [confirmationEnabled, setConfirmationEnabled] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("zd:signingConfirmation") === "true"; + }); + + useEffect(() => { + localStorage.setItem( + "zd:signingConfirmation", + String(confirmationEnabled), + ); + }, [confirmationEnabled]); + // Wagmi hooks const { address, status, chain, } = useAccount(); const publicClient = usePublicClient({ chainId: chain?.id }); @@ -241,6 +257,9 @@ export default function DashboardPage() { return ( <> setShowExportModal(false)} /> + {confirmationEnabled && ( + + )} @@ -256,7 +275,7 @@ export default function DashboardPage() { Created with {authMethodLabel} - + setShowExportModal(true)} @@ -266,6 +285,30 @@ export default function DashboardPage() { Export keys + setConfirmationEnabled((enabled) => !enabled)} + className="inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-[var(--border-warm)] bg-white px-3 text-xs font-semibold text-[#423a32] transition-colors hover:bg-[var(--surface-warm)]" + title="Gate transactions on user confirmation" + > + + Tx review + + + + { await connector.setup?.() }) - // Signing is pinned to background mode — the prompt-mode gate below is - // disabled. Restore this test when prompt-mode signing is released. - // it('wraps request by default (prompt is default mode)', async () => { - // const connector = createKitConnector() - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // - // let resolved = false - // const requestPromise = provider - // .request({ - // method: 'eth_sendTransaction', - // params: [{ to: '0x1' }], - // }) - // .then((result) => { - // resolved = true - // return result - // }) - // - // await new Promise((r) => setTimeout(r, 10)) - // expect(resolved).toBe(false) - // - // store.getState().pendingRequests[0]?.resolve() - // await requestPromise - // expect(resolved).toBe(true) - // }) - - it('does not wrap request (signing is pinned to background)', async () => { + it('wraps request by default (prompt is default mode)', async () => { const connector = createKitConnector() await connector.setup?.() + const store = connector.getKitStore() + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + + let resolved = false + const requestPromise = provider + .request({ + method: 'eth_sendTransaction', + params: [{ to: '0x1' }], + }) + .then((result) => { + resolved = true + return result + }) + + await new Promise((r) => setTimeout(r, 10)) + expect(resolved).toBe(false) + + store.getState().pendingRequests[0]?.resolve() + await requestPromise + expect(resolved).toBe(true) + }) + + it('does not wrap request when signing mode is background', async () => { + const connector = createKitConnector({ + config: { signing: { mode: 'background' } }, + }) + await connector.setup?.() + const provider = await connector.getProvider() const result = await provider.request({ method: 'eth_sendTransaction', @@ -105,122 +105,119 @@ describe('connector', () => { }) }) - // Signing is pinned to background mode — the prompt-mode confirmation gate is - // disabled and not part of this package's public surface. Restore this block - // when prompt-mode signing is released. - // describe('signing gate', () => { - // it('passes through when listener is not active', async () => { - // const connector = createKitConnector({ - // config: { signing: { mode: 'prompt' } }, - // }) - // await connector.setup?.() - // - // const provider = await connector.getProvider() - // const result = await provider.request({ - // method: 'eth_sendTransaction', - // params: [{ to: '0x1' }], - // }) - // expect(result).toBe('0xhash') - // }) - // - // it('passes through for non-signing methods even when listener is active', async () => { - // const connector = createKitConnector({ - // config: { signing: { mode: 'prompt' } }, - // }) - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // const result = await provider.request({ method: 'eth_chainId' }) - // expect(result).toBe('0xhash') - // }) - // - // it('gates request when listener is active and method matches', async () => { - // const connector = createKitConnector({ - // config: { signing: { mode: 'prompt' } }, - // }) - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // - // let resolved = false - // const requestPromise = provider - // .request({ - // method: 'eth_sendTransaction', - // params: [{ to: '0x1' }], - // }) - // .then((result) => { - // resolved = true - // return result - // }) - // - // // Request should be pending - // await new Promise((r) => setTimeout(r, 10)) - // expect(resolved).toBe(false) - // expect(store.getState().pendingRequests[0]).not.toBeNull() - // expect(store.getState().pendingRequests[0]?.method).toBe( - // 'eth_sendTransaction', - // ) - // - // // Confirm — resolve the gate - // store.getState().pendingRequests[0]?.resolve() - // const result = await requestPromise - // expect(resolved).toBe(true) - // expect(result).toBe('0xhash') - // }) - // - // it('rejects request when user rejects', async () => { - // const connector = createKitConnector({ - // config: { signing: { mode: 'prompt' } }, - // }) - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // - // const requestPromise = provider.request({ - // method: 'personal_sign', - // params: ['0xdata', '0xaddress'], - // }) - // - // await new Promise((r) => setTimeout(r, 10)) - // store.getState().pendingRequests[0]?.reject(new Error('User rejected')) - // - // await expect(requestPromise).rejects.toThrow('User rejected') - // }) - // - // it('respects custom methods list', async () => { - // const connector = createKitConnector({ - // config: { - // signing: { - // mode: 'prompt', - // methods: ['eth_sendTransaction'], - // }, - // }, - // }) - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // - // // personal_sign is NOT in the custom methods list — should pass through - // const result = await provider.request({ - // method: 'personal_sign', - // params: ['0xdata', '0xaddress'], - // }) - // expect(result).toBe('0xhash') - // expect(store.getState().pendingRequests).toEqual([]) - // }) - // }) + describe('signing gate', () => { + it('passes through when listener is not active', async () => { + const connector = createKitConnector({ + config: { signing: { mode: 'prompt' } }, + }) + await connector.setup?.() + + const provider = await connector.getProvider() + const result = await provider.request({ + method: 'eth_sendTransaction', + params: [{ to: '0x1' }], + }) + expect(result).toBe('0xhash') + }) + + it('passes through for non-signing methods even when listener is active', async () => { + const connector = createKitConnector({ + config: { signing: { mode: 'prompt' } }, + }) + await connector.setup?.() + + const store = connector.getKitStore() + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + const result = await provider.request({ method: 'eth_chainId' }) + expect(result).toBe('0xhash') + }) + + it('gates request when listener is active and method matches', async () => { + const connector = createKitConnector({ + config: { signing: { mode: 'prompt' } }, + }) + await connector.setup?.() + + const store = connector.getKitStore() + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + + let resolved = false + const requestPromise = provider + .request({ + method: 'eth_sendTransaction', + params: [{ to: '0x1' }], + }) + .then((result) => { + resolved = true + return result + }) + + // Request should be pending + await new Promise((r) => setTimeout(r, 10)) + expect(resolved).toBe(false) + expect(store.getState().pendingRequests[0]).not.toBeNull() + expect(store.getState().pendingRequests[0]?.method).toBe( + 'eth_sendTransaction', + ) + + // Confirm — resolve the gate + store.getState().pendingRequests[0]?.resolve() + const result = await requestPromise + expect(resolved).toBe(true) + expect(result).toBe('0xhash') + }) + + it('rejects request when user rejects', async () => { + const connector = createKitConnector({ + config: { signing: { mode: 'prompt' } }, + }) + await connector.setup?.() + + const store = connector.getKitStore() + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + + const requestPromise = provider.request({ + method: 'personal_sign', + params: ['0xdata', '0xaddress'], + }) + + await new Promise((r) => setTimeout(r, 10)) + store.getState().pendingRequests[0]?.reject(new Error('User rejected')) + + await expect(requestPromise).rejects.toThrow('User rejected') + }) + + it('respects custom methods list', async () => { + const connector = createKitConnector({ + config: { + signing: { + mode: 'prompt', + methods: ['eth_sendTransaction'], + }, + }, + }) + await connector.setup?.() + + const store = connector.getKitStore() + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + + // personal_sign is NOT in the custom methods list — should pass through + const result = await provider.request({ + method: 'personal_sign', + params: ['0xdata', '0xaddress'], + }) + expect(result).toBe('0xhash') + expect(store.getState().pendingRequests).toEqual([]) + }) + }) describe('getKitStore', () => { it('exposes the store', () => { @@ -257,13 +254,14 @@ describe('connector', () => { expect(store.getState().auth.step).toBeNull() }) - it('auth config works', () => { + it('auth config works with signing config', () => { const authConfig = { enabledMethods: ['passkey' as const], } const connector = createKitConnector({ config: { auth: authConfig, + signing: { mode: 'prompt' }, }, }) const store = connector.getKitStore() @@ -314,41 +312,39 @@ describe('connector', () => { ]) }) - // Depends on the prompt-mode signing gate, which is disabled while signing - // is pinned to background. Restore when prompt-mode signing is released. - // it('auth state persists across signing operations', async () => { - // const authConfig = { - // enabledMethods: ['email' as const], - // } - // const connector = createKitConnector({ - // config: { - // auth: authConfig, - // signing: { mode: 'prompt' }, - // }, - // }) - // await connector.setup?.() - // - // const store = connector.getKitStore() - // store.getState().auth.setEmail('test@example.com') - // store.getState().setUserConfirmationListenerActive(true) - // - // const provider = await connector.getProvider() - // - // const requestPromise = provider.request({ - // method: 'eth_sendTransaction', - // params: [{ to: '0x1' }], - // }) - // - // // Auth state should still be present during signing gate - // expect(store.getState().auth.email).toBe('test@example.com') - // - // // Complete request - // await new Promise((r) => setTimeout(r, 10)) - // store.getState().pendingRequests[0]?.resolve() - // await requestPromise - // - // // Auth state should still be present after signing - // expect(store.getState().auth.email).toBe('test@example.com') - // }) + it('auth state persists across signing operations', async () => { + const authConfig = { + enabledMethods: ['email' as const], + } + const connector = createKitConnector({ + config: { + auth: authConfig, + signing: { mode: 'prompt' }, + }, + }) + await connector.setup?.() + + const store = connector.getKitStore() + store.getState().auth.setEmail('test@example.com') + store.getState().setUserConfirmationListenerActive(true) + + const provider = await connector.getProvider() + + const requestPromise = provider.request({ + method: 'eth_sendTransaction', + params: [{ to: '0x1' }], + }) + + // Auth state should still be present during signing gate + expect(store.getState().auth.email).toBe('test@example.com') + + // Complete request + await new Promise((r) => setTimeout(r, 10)) + store.getState().pendingRequests[0]?.resolve() + await requestPromise + + // Auth state should still be present after signing + expect(store.getState().auth.email).toBe('test@example.com') + }) }) }) diff --git a/packages/wallet-react-ui/src/connector.ts b/packages/wallet-react-ui/src/connector.ts index 050dfc8b..25c6ccb8 100644 --- a/packages/wallet-react-ui/src/connector.ts +++ b/packages/wallet-react-ui/src/connector.ts @@ -20,7 +20,12 @@ const DEFAULT_SIGNING_PROMPT_METHODS: RequestMethod[] = [ 'eth_signTypedData_v4', ] +export type SigningConfig = + | { mode: 'background' } + | { mode: 'prompt'; methods?: RequestMethod[] } + export type ZeroDevKitConfig = { + signing?: SigningConfig auth?: AuthConfig /** * Optional brand logo rendered in the auth flow's top nav. When omitted, @@ -132,16 +137,11 @@ export function zeroDevWallet( // `window` for EIP-6963 discovery and would crash on the server. if (typeof window === 'undefined') return - // Signing is pinned to background mode: the prompt-mode confirmation UI - // is not part of this package's public surface, so requests always pass - // through without gating. The wrapping logic below is retained but - // unreachable. - // const signing = params.config?.signing - const signing = { mode: 'background' } as const - if (signing.mode === 'background') return + const signing = params.config?.signing + if (signing?.mode === 'background') return const methods = - // (signing?.mode === 'prompt' && signing.methods) || + (signing?.mode === 'prompt' && signing.methods) || DEFAULT_SIGNING_PROMPT_METHODS const provider = (await connector.getProvider()) as ZeroDevProvider diff --git a/packages/wallet-react-ui/src/index.ts b/packages/wallet-react-ui/src/index.ts index d3a963c7..c18f53a8 100644 --- a/packages/wallet-react-ui/src/index.ts +++ b/packages/wallet-react-ui/src/index.ts @@ -10,16 +10,16 @@ export type { AuthMethod, AuthStep } from './auth/types' // Connector export type { - // SigningConfig, + SigningConfig, ZeroDevKitConfig, ZeroDevKitConnectorParams, } from './connector.js' export { zeroDevWallet } from './connector.js' // Signing -// export type { SignatureRequestProps } from './signing' -// export { SignatureRequest } from './signing' -// export { usePendingRequest } from './signing/hooks/usePendingRequest.js' -// export { usePendingRequests } from './signing/hooks/usePendingRequests.js' -// -// export type { PendingRequest, Request, RequestMethod } from './types.js' +export type { SignatureRequestProps } from './signing' +export { SignatureRequest } from './signing' +export { usePendingRequest } from './signing/hooks/usePendingRequest.js' +export { usePendingRequests } from './signing/hooks/usePendingRequests.js' + +export type { PendingRequest, Request, RequestMethod } from './types.js' diff --git a/packages/wallet-react-ui/tsconfig.build.json b/packages/wallet-react-ui/tsconfig.build.json index 453a2155..7e78a169 100644 --- a/packages/wallet-react-ui/tsconfig.build.json +++ b/packages/wallet-react-ui/tsconfig.build.json @@ -12,7 +12,6 @@ "exclude": [ "node_modules", "dist", - "src/signing/**", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.ts",