diff --git a/apps/docs/docs/pages/account/index.mdx b/apps/docs/docs/pages/account/index.mdx index 1e3e5ece0..ade5a9ae1 100644 --- a/apps/docs/docs/pages/account/index.mdx +++ b/apps/docs/docs/pages/account/index.mdx @@ -122,8 +122,8 @@ Configuration for creating or loading an account. React Native options (`nativeG interface AccountConfig { /** Chain ID for the account */ chainId: number; - /** API key for JAW services (required) */ - apiKey: string; + /** API key for JAW services, if the caller has one */ + apiKey?: string; /** Custom paymaster URL for gas sponsorship */ paymasterUrl?: string; /** Custom paymaster context for gas sponsorship */ diff --git a/apps/docs/docs/pages/configuration/apiKey.mdx b/apps/docs/docs/pages/configuration/apiKey.mdx index 9eacab9d8..c04c607ea 100644 --- a/apps/docs/docs/pages/configuration/apiKey.mdx +++ b/apps/docs/docs/pages/configuration/apiKey.mdx @@ -3,7 +3,11 @@ Your JAW API key for authentication with JAW services. **Type:** `string` -**Required:** Yes +**Required:** In [app-specific mode](/configuration/mode/app-specific). Optional in cross-platform mode, which is the default. + +In cross-platform mode the SDK sends whatever it has and the backend decides whether to answer. With a key, the request is attributed to the project that key belongs to, and the allowed domains below are what the key is checked against. Without a key, the caller is identified by the origin the request comes from, and only origins registered with JustaName are served, so ask the team to register yours before dropping the option. + +App-specific mode always needs a key, because it hands one to the UI handler your application implements. `JAW.create()` rejects an app-specific configuration without one. ## Usage @@ -27,6 +31,14 @@ const jaw = JAW.create({ }); ``` +### Without a key + +```typescript +const connector = jaw({ appName: 'My DApp' }); +``` + +Requests then carry no `api-key` parameter, and the origin your application runs on is what names it. An origin nobody registered is refused. + ## How to Get an API Key 1. Visit the [JAW Dashboard](https://dashboard.jaw.id/) diff --git a/apps/docs/docs/pages/configuration/index.mdx b/apps/docs/docs/pages/configuration/index.mdx index 4e3b54bbc..af744d8c8 100644 --- a/apps/docs/docs/pages/configuration/index.mdx +++ b/apps/docs/docs/pages/configuration/index.mdx @@ -117,7 +117,7 @@ await jaw.disconnect(); ## Minimal Configuration -The only required option is `apiKey`: +Every option is optional in cross-platform mode, the default. A request without an [apiKey](/configuration/apiKey) is identified by the origin it comes from, which JustaName has to have registered beforehand. ```typescript // Wagmi @@ -127,6 +127,8 @@ const connector = jaw({ apiKey: 'your-api-key' }); const jaw = JAW.create({ apiKey: 'your-api-key' }); ``` +[App-specific mode](/configuration/mode/app-specific) is the exception: it requires `apiKey` and a `uiHandler`. + ## Related - [Wagmi Integration](/wagmi) - Using with Wagmi diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index 507bd4de2..62a72922c 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -11,6 +11,7 @@ import type { TransactionRequestData } from '../components/TransactionModal'; import { useAuth, usePasskeys } from '../hooks'; import { SignInScreen, type AuthenticatedAccount } from '../components/OnboardingSection'; import { PasskeyManager, type PasskeyAccount } from '@jaw.id/core'; +import { setDappOrigin } from '@jaw.id/core/internal'; import { SiweModal } from '../components/SiweModal'; import { ensureIntNumber, type SignInWithEthereumCapabilityRequest } from '@jaw.id/core'; import { ConnectModal } from '../components/ConnectModal'; @@ -317,6 +318,11 @@ function KeysJawIdAppContent({ if (message.data.apiKey) { setApiKey(message.data.apiKey); } + // The keyless counterpart of that bootstrap. Without a key the origin is the only + // thing that names the dApp, and the account screen below reads addresses over the + // proxy before any handshake runs. The message that triggered this handler is what + // locked the origin, so it is already available here. + setDappOrigin(communicator.getOrigin() || undefined); // Apply the dApp's theme tokens so the embedded dialog matches its // look & feel (accent color, border radius, light/dark), translated @@ -560,9 +566,14 @@ function KeysJawIdAppContent({ } // Get origin and set it as current context - const origin = communicator.getOrigin() || ''; + const origin = communicator.getOrigin() ?? ''; setCurrentOrigin(origin); cryptoHandler.setOrigin(origin); + // Our own Origin is the same whichever dApp opened us, so the backend + // cannot tell which one a call belongs to unless we say. An unknown origin + // goes through as unset rather than as '': it clears whatever the last + // request left behind, so a call is never credited to the wrong dApp. + setDappOrigin(origin || undefined); const peerPublicKey = request.sender; const method = request.content.handshake.method; @@ -720,10 +731,14 @@ function KeysJawIdAppContent({ try { // Load session for this origin - const origin = communicator.getOrigin() || ''; + const origin = communicator.getOrigin() ?? ''; // Update React state with current origin (needed for useAuth hook) setCurrentOrigin(origin); + // Set again rather than relying on the handshake having run in this + // document: the origin the backend is told comes from the request being + // served, not from an earlier one. + setDappOrigin(origin || undefined); // Reply to the SDK with a reconnect-required sentinel (tied to this // request id, carries no secret) so it re-establishes a session against diff --git a/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx b/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx index 73b9b9114..7823e6d20 100644 --- a/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx +++ b/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx @@ -16,6 +16,7 @@ import { type Address, } from '@jaw.id/core'; import { useAuth } from '../../hooks'; +import { apiKeyFromChain } from '../../lib/api-key'; export interface AddFundsModalProps { /** The dapp's raw params, validated here before anything renders. */ @@ -67,17 +68,7 @@ export const AddFundsModal = ({ } }, [params]); - const prodApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - return new URL(chain.rpcUrl).searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const prodApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); const mainnetRpcUrl = prodApiKey ? `${JAW_RPC_URL}?chainId=1&api-key=${prodApiKey}` : `${JAW_RPC_URL}?chainId=1`; diff --git a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx index 312e6543f..ea7bc47c0 100644 --- a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx +++ b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from 'react'; import type { chain } from '../../lib/sdk-types'; import { getChainNameFromId } from '../../lib/chain-handlers'; import { standardErrorCodes, JAW_RPC_URL } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; export interface ConnectModalProps { origin: string; @@ -33,36 +34,18 @@ export const ConnectModal = ({ const [isProcessing, setIsProcessing] = useState(false); // Extract API key from rpcUrl if not provided as prop - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Get chain name and icon const chainName = useMemo(() => (chain ? getChainNameFromId(chain.id) : undefined), [chain]); const chainIcon = useChainIconURI(chain?.id || 1, effectiveApiKey, 24); - // Extract API key from chain.rpcUrl for mainnet RPC URL - const mainnetRpcUrl = useMemo(() => { - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - const apiKey = url.searchParams.get('api-key'); - return apiKey ? `${JAW_RPC_URL}?chainId=1&api-key=${apiKey}` : `${JAW_RPC_URL}?chainId=1`; - } catch { - return `${JAW_RPC_URL}?chainId=1`; - } - } - return `${JAW_RPC_URL}?chainId=1`; - }, [chain?.rpcUrl]); + // Mainnet, for ENS, under whatever key this request carries: the same one the + // chain icon above resolves with, so the two cannot disagree about who is asking. + const mainnetRpcUrl = useMemo( + () => (effectiveApiKey ? `${JAW_RPC_URL}?chainId=1&api-key=${effectiveApiKey}` : `${JAW_RPC_URL}?chainId=1`), + [effectiveApiKey] + ); const handleConnect = async () => { try { diff --git a/apps/keys-jaw-id/src/components/Eip712Modal/index.tsx b/apps/keys-jaw-id/src/components/Eip712Modal/index.tsx index aa29ac94d..d867341aa 100644 --- a/apps/keys-jaw-id/src/components/Eip712Modal/index.tsx +++ b/apps/keys-jaw-id/src/components/Eip712Modal/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from 'react'; import type { chain } from '../../lib/sdk-types'; import { getChainNameFromId } from '../../lib/chain-handlers'; import { standardErrorCodes, JAW_RPC_URL } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; export interface Eip712ModalProps { origin: string; @@ -56,18 +57,7 @@ export const Eip712Modal = ({ const [signatureStatus, setSignatureStatus] = useState(''); // Extract API key for other uses (chain icon, mainnet RPC) - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Compute mainnet RPC URL for JustaName SDK (ENS resolution) const mainnetRpcUrl = useMemo(() => { diff --git a/apps/keys-jaw-id/src/components/PermissionModal/index.tsx b/apps/keys-jaw-id/src/components/PermissionModal/index.tsx index 712b1af9c..416b2703a 100644 --- a/apps/keys-jaw-id/src/components/PermissionModal/index.tsx +++ b/apps/keys-jaw-id/src/components/PermissionModal/index.tsx @@ -32,6 +32,7 @@ import { handleGetCapabilitiesRequest, type FeeTokenCapability, } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; // Known function selectors mapping // Permission request data @@ -149,22 +150,7 @@ export const PermissionModal = ({ const [feeTokens, setFeeTokens] = useState([]); const [feeTokensLoading, setFeeTokensLoading] = useState(true); - // Extract API key from rpcUrl if not provided as prop - const extractedApiKey = useMemo(() => { - if (apiKey) return apiKey; - - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch (error) { - console.error('Failed to parse rpcUrl:', error); - return ''; - } - } - - return ''; - }, [apiKey, chain?.rpcUrl]); + const extractedApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Note: Account initialization is handled by useSessionAccount hook @@ -493,7 +479,10 @@ export const PermissionModal = ({ let isMounted = true; const fetchFeeTokensData = async () => { - if (!viemChain || !extractedApiKey) { + // The chain, and nothing else: keyless the capabilities come back on the + // origin, and gating on the key here left this dialog without its fee row + // while the transaction dialog of the same session showed one. + if (!viemChain) { setFeeTokensLoading(false); return; } @@ -504,7 +493,7 @@ export const PermissionModal = ({ // Fetch capabilities from JAW RPC const capabilities = await handleGetCapabilitiesRequest( { method: 'wallet_getCapabilities', params: [] }, - extractedApiKey || '', + extractedApiKey, true // showTestnets ); diff --git a/apps/keys-jaw-id/src/components/SignatureModal/index.tsx b/apps/keys-jaw-id/src/components/SignatureModal/index.tsx index 1ce20df42..9eff8ad87 100644 --- a/apps/keys-jaw-id/src/components/SignatureModal/index.tsx +++ b/apps/keys-jaw-id/src/components/SignatureModal/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from 'react'; import type { chain } from '../../lib/sdk-types'; import { getChainNameFromId } from '../../lib/chain-handlers'; import { standardErrorCodes, JAW_RPC_URL } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; export interface SignatureModalProps { origin: string; @@ -48,18 +49,7 @@ export const SignatureModal = ({ const [signatureStatus, setSignatureStatus] = useState(''); // Extract API key for other uses (chain icon, mainnet RPC) - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Compute mainnet RPC URL for JustaName SDK (ENS resolution) const mainnetRpcUrl = useMemo(() => { diff --git a/apps/keys-jaw-id/src/components/SiweModal/index.tsx b/apps/keys-jaw-id/src/components/SiweModal/index.tsx index eed3e9807..e1b3a36bd 100644 --- a/apps/keys-jaw-id/src/components/SiweModal/index.tsx +++ b/apps/keys-jaw-id/src/components/SiweModal/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from 'react'; import type { chain } from '../../lib/sdk-types'; import { getChainNameFromId } from '../../lib/chain-handlers'; import { standardErrorCodes, JAW_RPC_URL } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; export interface SiweModalProps { origin: string; @@ -50,18 +51,7 @@ export const SiweModal = ({ const [siweStatus, setSiweStatus] = useState(''); // Extract API key for other uses (chain icon, mainnet RPC) - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Compute mainnet RPC URL for JustaName SDK (ENS resolution) const mainnetRpcUrl = useMemo(() => { diff --git a/apps/keys-jaw-id/src/components/TransactionModal/index.tsx b/apps/keys-jaw-id/src/components/TransactionModal/index.tsx index ca3847f04..53c5d6d49 100644 --- a/apps/keys-jaw-id/src/components/TransactionModal/index.tsx +++ b/apps/keys-jaw-id/src/components/TransactionModal/index.tsx @@ -25,6 +25,7 @@ import { JAW_RPC_URL, type FeeTokenCapability, } from '@jaw.id/core'; +import { apiKeyFromChain } from '../../lib/api-key'; // Transaction execution result export interface TransactionResult { @@ -103,18 +104,7 @@ export const TransactionModal = ({ const [feeTokensLoading, setFeeTokensLoading] = useState(false); // Extract API key from rpcUrl if not provided as prop - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Determine if sponsored based on transactionRequest or prop const isSponsored = useMemo(() => { @@ -302,7 +292,7 @@ export const TransactionModal = ({ // Fetch capabilities from JAW RPC const capabilities = await handleGetCapabilitiesRequest( { method: 'wallet_getCapabilities', params: [] }, - effectiveApiKey || '', + effectiveApiKey, true // showTestnets ); diff --git a/apps/keys-jaw-id/src/hooks/useLogin/index.test.tsx b/apps/keys-jaw-id/src/hooks/useLogin/index.test.tsx new file mode 100644 index 000000000..8de2c0377 --- /dev/null +++ b/apps/keys-jaw-id/src/hooks/useLogin/index.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +// The second visit of a keyless user. The first goes through `useCreatePasskey` +// and never reaches here, which is why this refused where creating had worked, +// and the message it refused with named an env var at an end user. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createElement, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const get = vi.fn(); + +vi.mock('@jaw.id/core', () => ({ Account: { get: (...args: unknown[]) => get(...args) } })); +vi.mock('../useAuth', () => ({ useAuth: () => ({ refetch: vi.fn() }) })); + +const { useLogin } = await import('./index'); + +const CHAIN = { id: 84532, paymaster: undefined } as never; + +function Probe({ apiKey, onDone }: { apiKey?: string; onDone: (err: unknown) => void }) { + const login = useLogin(); + useEffect(() => { + login + .mutateAsync({ chainId: CHAIN, credentialId: 'cred-1', apiKey }) + .then(() => onDone(null)) + .catch(onDone); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +let root: Root | null = null; + +async function login(apiKey?: string): Promise { + let outcome: unknown = 'never settled'; + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + root = createRoot(document.createElement('div')); + await act(async () => { + root!.render( + createElement(QueryClientProvider, { client }, createElement(Probe, { apiKey, onDone: (err) => (outcome = err) })) + ); + }); + await act(() => Promise.resolve()); + return outcome; +} + +beforeEach(() => { + get.mockReset(); + get.mockResolvedValue({ + getMetadata: () => ({ username: 'someone', creationDate: '2026-01-01' }), + getAddress: async () => '0xabc', + }); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; +}); + +describe('useLogin', () => { + it('signs a returning user in with no api key', async () => { + const outcome = await login(undefined); + + expect(outcome).toBeNull(); + expect(get).toHaveBeenCalledTimes(1); + expect((get.mock.calls[0][0] as { apiKey?: string }).apiKey).toBeUndefined(); + }); + + it('still carries the key when there is one', async () => { + await login('k1'); + + expect((get.mock.calls[0][0] as { apiKey?: string }).apiKey).toBe('k1'); + }); +}); diff --git a/apps/keys-jaw-id/src/hooks/useLogin/index.ts b/apps/keys-jaw-id/src/hooks/useLogin/index.ts index 9f398e33c..41622e242 100644 --- a/apps/keys-jaw-id/src/hooks/useLogin/index.ts +++ b/apps/keys-jaw-id/src/hooks/useLogin/index.ts @@ -15,20 +15,12 @@ export const useLogin = () => { return useMutation({ mutationFn: async ({ chainId, credentialId, apiKey }: LoginParams) => { try { - // Use apiKey from params, fallback to env var - const effectiveApiKey = apiKey; - - if (!effectiveApiKey) { - throw new Error( - 'API key is required. Provide it via apiKey parameter or NEXT_PUBLIC_API_KEY environment variable.' - ); - } - - // Use Account.get which handles WebAuthn auth and smart account creation + // The key is optional: with none, the proxy answers on the origin keys + // forwards on the caller's behalf. const account = await Account.get( { chainId: chainId.id, - apiKey: effectiveApiKey, + apiKey, paymasterUrl: chainId.paymaster?.url, }, credentialId diff --git a/apps/keys-jaw-id/src/hooks/usePasskeys/index.test.tsx b/apps/keys-jaw-id/src/hooks/usePasskeys/index.test.tsx new file mode 100644 index 000000000..f09427f21 --- /dev/null +++ b/apps/keys-jaw-id/src/hooks/usePasskeys/index.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +// `restoreAccount` is what `useSessionAccount` calls once its guard lets go, so +// a key requirement here would refuse the same keyless session one step later. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createElement, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const restore = vi.fn(); + +vi.mock('@jaw.id/core', () => ({ + Account: { restore: (...args: unknown[]) => restore(...args) }, + PasskeyAccount: class {}, +})); +vi.mock('../../lib/passkey-service', () => ({ + PasskeyService: class { + fetchAccounts = () => []; + }, +})); + +const { usePasskeys } = await import('./index'); + +const CHAIN = { id: 84532, rpcUrl: 'https://rpc.example', paymaster: undefined } as never; + +function Probe({ apiKey, onDone }: { apiKey?: string; onDone: (err: unknown) => void }) { + const { restoreAccount } = usePasskeys(apiKey ? { apiKey } : undefined); + useEffect(() => { + restoreAccount(CHAIN, 'cred-1', '0xpub') + .then(() => onDone(null)) + .catch(onDone); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +let root: Root | null = null; + +async function restoreWith(apiKey?: string): Promise { + let outcome: unknown = 'never settled'; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + root = createRoot(document.createElement('div')); + await act(async () => { + root!.render( + createElement(QueryClientProvider, { client }, createElement(Probe, { apiKey, onDone: (e) => (outcome = e) })) + ); + }); + await act(() => Promise.resolve()); + return outcome; +} + +beforeEach(() => { + restore.mockReset(); + restore.mockResolvedValue({ address: '0xabc' }); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; +}); + +describe('usePasskeys restoreAccount', () => { + it('restores with no api key anywhere', async () => { + const outcome = await restoreWith(undefined); + + expect(outcome).toBeNull(); + expect((restore.mock.calls[0][0] as { apiKey?: string }).apiKey).toBeUndefined(); + }); + + it('carries the key the hook was given', async () => { + await restoreWith('k1'); + + expect((restore.mock.calls[0][0] as { apiKey?: string }).apiKey).toBe('k1'); + }); +}); diff --git a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts index 0f9c2e4ce..701817fd2 100644 --- a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts +++ b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts @@ -31,34 +31,6 @@ export const usePasskeys = (options?: UsePasskeysOptions) => { gcTime: 0, }); - /** - * Get account with WebAuthn authentication (triggers passkey prompt) - * Use this for initial login/authentication - */ - const getAccount = useCallback( - async (chain: chain, credentialId: string, overrideApiKey?: string) => { - const effectiveApiKey = overrideApiKey || apiKey; - if (!effectiveApiKey) { - throw new Error( - 'API key is required. Provide it via apiKey parameter or NEXT_PUBLIC_API_KEY environment variable.' - ); - } - if (!credentialId) { - throw new Error('credentialId is required to get an account'); - } - const account = await Account.get( - { - chainId: chain.id, - apiKey: effectiveApiKey, - paymasterUrl: chain.paymaster?.url, - }, - credentialId - ); - return account; - }, - [apiKey] - ); - /** * Restore account WITHOUT triggering WebAuthn (no passkey prompt) * Use this when user has already authenticated and you just need the Account instance @@ -66,12 +38,7 @@ export const usePasskeys = (options?: UsePasskeysOptions) => { */ const restoreAccount = useCallback( async (chain: chain, credentialId: string, publicKey: `0x${string}`, overrideApiKey?: string) => { - const effectiveApiKey = overrideApiKey || apiKey; - if (!effectiveApiKey) { - throw new Error( - 'API key is required. Provide it via apiKey parameter or NEXT_PUBLIC_API_KEY environment variable.' - ); - } + const effectiveApiKey = overrideApiKey || apiKey || undefined; if (!credentialId || !publicKey) { throw new Error('credentialId and publicKey are required to restore an account'); } @@ -89,21 +56,10 @@ export const usePasskeys = (options?: UsePasskeysOptions) => { [apiKey] ); - // Legacy method - returns underlying smart account for backwards compatibility - const getSmartAccount = useCallback( - async (chain: chain, credentialId: string, overrideApiKey?: string) => { - const account = await getAccount(chain, credentialId, overrideApiKey); - return account.getSmartAccount(); - }, - [getAccount] - ); - return { accounts: query.data || [], accountsLoading: query.isLoading, refetchAccounts: query.refetch, - getAccount, restoreAccount, - getSmartAccount, }; }; diff --git a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx new file mode 100644 index 000000000..1580aa7b8 --- /dev/null +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +// Keyless, nothing anywhere carries an api key: not the handshake, not the rpc +// url keys parses it out of. Treating that as missing data left `account` null +// for the whole session, and every dialog answered "Account not initialized" on +// Confirm, on a guard that never clears. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const restoreAccount = vi.fn(); + +vi.mock('../useAuth', () => ({ + useAuth: () => ({ + credentialId: 'cred-1', + publicKey: '0xpub', + walletAddress: '0xabc', + isAuthenticated: true, + }), +})); +vi.mock('../usePasskeys', () => ({ usePasskeys: () => ({ restoreAccount }) })); + +const { useSessionAccount } = await import('./index'); + +const CHAIN = { id: 84532, rpcUrl: 'https://api.justaname.id/proxy/v1/rpc/handle', paymaster: undefined }; + +function Probe({ chain, apiKey }: { chain: typeof CHAIN; apiKey?: string }) { + const { account } = useSessionAccount({ origin: 'https://dapp.example', chain, apiKey }); + return createElement('span', null, account ? 'ready' : 'none'); +} + +let root: Root | null = null; +let container: HTMLDivElement; + +async function mount(chain: typeof CHAIN, apiKey?: string) { + container = document.createElement('div'); + root = createRoot(container); + await act(async () => { + root!.render(createElement(Probe, { chain, apiKey })); + }); + await act(() => Promise.resolve()); +} + +async function rerender(chain: typeof CHAIN, apiKey?: string) { + await act(async () => { + root!.render(createElement(Probe, { chain, apiKey })); + }); + await act(() => Promise.resolve()); +} + +beforeEach(() => { + restoreAccount.mockReset(); + restoreAccount.mockResolvedValue({ address: '0xabc' }); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; +}); + +describe('useSessionAccount', () => { + it('restores the account when no api key exists anywhere', async () => { + await mount(CHAIN); + + expect(restoreAccount).toHaveBeenCalledTimes(1); + expect(restoreAccount.mock.calls[0][3]).toBeUndefined(); + expect(container.textContent).toBe('ready'); + }); + + it('still passes the key when the rpc url carries one', async () => { + await mount({ ...CHAIN, rpcUrl: `${CHAIN.rpcUrl}?api-key=k1` }); + + expect(restoreAccount.mock.calls[0][3]).toBe('k1'); + }); + + // keys learns the key from the handshake and again from each request, so it + // can arrive after a keyless restore has already started. + it('restores again when the key arrives mid-flight', async () => { + let release: (value: unknown) => void = () => undefined; + restoreAccount.mockReturnValueOnce(new Promise((resolve) => (release = resolve))); + await mount(CHAIN); + + await rerender(CHAIN, 'k1'); + expect(restoreAccount).toHaveBeenCalledTimes(1); + + await act(async () => { + release({ address: '0xabc' }); + }); + await act(() => Promise.resolve()); + + expect(restoreAccount).toHaveBeenCalledTimes(2); + expect(restoreAccount.mock.calls[1][3]).toBe('k1'); + }); + + it('prefers the key it was handed over the one in the url', async () => { + await mount({ ...CHAIN, rpcUrl: `${CHAIN.rpcUrl}?api-key=from-url` }, 'from-caller'); + + expect(restoreAccount.mock.calls[0][3]).toBe('from-caller'); + }); +}); diff --git a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts index 48379a255..49f3ededb 100644 --- a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts @@ -9,6 +9,7 @@ import { useEffect, useRef, useState, useMemo } from 'react'; import { Account } from '@jaw.id/core'; import { useAuth } from '../useAuth'; import { usePasskeys } from '../usePasskeys'; +import { apiKeyFromChain } from '../../lib/api-key'; import type { chain } from '../../lib/sdk-types'; export interface UseSessionAccountOptions { @@ -64,36 +65,33 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe // Prevent double initialization const isInitializingRef = useRef(false); const lastInitKeyRef = useRef(''); + // A run skipped because another was in flight, and the counter that brings it + // back: the deps that asked for it will not change again on their own. + const supersededRef = useRef(false); + const [restarts, setRestarts] = useState(0); - // Extract API key from chain.rpcUrl if not provided - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; - } catch { - return ''; - } - } - return ''; - }, [apiKey, chain?.rpcUrl]); + const effectiveApiKey = useMemo(() => apiKeyFromChain(apiKey, chain?.rpcUrl), [apiKey, chain?.rpcUrl]); // Create a key to track what we're initializing for const initKey = useMemo(() => { - if (!chain || !credentialId || !publicKey || !effectiveApiKey) return ''; - return `${chain.id}-${credentialId}-${effectiveApiKey}`; + if (!chain || !credentialId || !publicKey) return ''; + return `${chain.id}-${credentialId}-${effectiveApiKey ?? ''}`; }, [chain, credentialId, publicKey, effectiveApiKey]); useEffect(() => { - // Skip if missing required data - if (!chain || !credentialId || !publicKey || !effectiveApiKey) { + // Skip if missing required data. The key is not part of it. + if (!chain || !credentialId || !publicKey) { setIsLoading(false); return; } - // Skip if already initializing or already initialized with same params - if (isInitializingRef.current || lastInitKeyRef.current === initKey) { + if (lastInitKeyRef.current === initKey) return; + + // Already restoring something else. The key can arrive after a keyless + // restore has started, so this run is remembered and made again once that + // one is done, rather than dropped. + if (isInitializingRef.current) { + supersededRef.current = true; return; } @@ -118,11 +116,15 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe } finally { setIsLoading(false); isInitializingRef.current = false; + if (supersededRef.current) { + supersededRef.current = false; + setRestarts((n) => n + 1); + } } }; initAccount(); - }, [chain, credentialId, publicKey, effectiveApiKey, restoreAccount, initKey]); + }, [chain, credentialId, publicKey, effectiveApiKey, restoreAccount, initKey, restarts]); // Reset when origin changes (different session) useEffect(() => { diff --git a/apps/keys-jaw-id/src/lib/api-key.test.ts b/apps/keys-jaw-id/src/lib/api-key.test.ts new file mode 100644 index 000000000..9467d5284 --- /dev/null +++ b/apps/keys-jaw-id/src/lib/api-key.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { apiKeyFromChain } from './api-key'; + +const RPC = 'https://api.justaname.id/proxy/v1/rpc?chainId=8453'; + +describe('apiKeyFromChain', () => { + it('prefers the key it was handed', () => { + expect(apiKeyFromChain('mine', `${RPC}&api-key=from-url`)).toBe('mine'); + }); + + it('reads the one the dApp put in the rpc url', () => { + expect(apiKeyFromChain(undefined, `${RPC}&api-key=from-url`)).toBe('from-url'); + }); + + // The case the six copies spelled as '': keyless there is no key anywhere, and + // everything downstream takes it optional. + it.each([ + ['no key in the url', RPC], + ['an empty key in the url', `${RPC}&api-key=`], + ['no url at all', undefined], + ['a url that does not parse', 'not a url'], + ])('answers undefined with %s', (_label, rpcUrl) => { + expect(apiKeyFromChain(undefined, rpcUrl)).toBeUndefined(); + }); + + it('treats an empty key handed in as no key', () => { + expect(apiKeyFromChain('', RPC)).toBeUndefined(); + }); +}); diff --git a/apps/keys-jaw-id/src/lib/api-key.ts b/apps/keys-jaw-id/src/lib/api-key.ts new file mode 100644 index 000000000..f8a087574 --- /dev/null +++ b/apps/keys-jaw-id/src/lib/api-key.ts @@ -0,0 +1,21 @@ +/** + * The api key a request carries: the one handed in, or the one keys can read off + * the rpc url the dApp sent, and undefined when it carries none. + * + * Undefined rather than '', which is what six copies of this block spelled it as. + * A keyless session has no key anywhere, and everything downstream takes it + * optional, so an empty string is a key that is present and empty in the only + * place it still reads as one. + */ +export function apiKeyFromChain(apiKey: string | undefined, rpcUrl: string | undefined): string | undefined { + if (apiKey) return apiKey; + if (!rpcUrl) return undefined; + try { + // `||`, not `??`: `api-key=` with nothing after it is a url that carries no + // key, and the proxy reads it as a malformed one rather than as absent. + return new URL(rpcUrl).searchParams.get('api-key') || undefined; + } catch { + // A url keys could not parse says nothing about a key. + return undefined; + } +} diff --git a/apps/keys-jaw-id/src/lib/session-manager.test.ts b/apps/keys-jaw-id/src/lib/session-manager.test.ts index 2c810f8dd..1410dd0ba 100644 --- a/apps/keys-jaw-id/src/lib/session-manager.test.ts +++ b/apps/keys-jaw-id/src/lib/session-manager.test.ts @@ -179,9 +179,9 @@ describe('two SessionManagers over one storage', () => { // `storageArea`, and on Node 25 the shared setup puts an in-memory stub in // that slot (see vitest.setup.localstorage.ts). Attaching the area to the // instance sends the same event the listener reads, whichever one is live. - function dispatchStorageEvent(key: string | null) { + function dispatchStorageEvent(key: string | null, storageArea: unknown = window.localStorage) { const event = new StorageEvent('storage', { key }); - Object.defineProperty(event, 'storageArea', { value: window.localStorage }); + Object.defineProperty(event, 'storageArea', { value: storageArea }); window.dispatchEvent(event); } @@ -271,4 +271,26 @@ describe('two SessionManagers over one storage', () => { expect(await reader.isAuthenticated(ORIGIN)).toBe(true); }); + + it('ignores an event from another storage area', async () => { + // sessionStorage raises the same event type under our own key. + const reader = secondDocument(); + await reader.createSession({ origin: ORIGIN, peerPublicKey: '04aabbccdd', account: AUTH }); + await new SessionManager().deleteSession(ORIGIN); + + dispatchStorageEvent('jaw:sessions:apps', {}); + + expect(await reader.isAuthenticated(ORIGIN)).toBe(true); + }); + + it('drops its cache when the whole storage is cleared', async () => { + // A `clear()` carries a null key and takes our key with it. + const reader = secondDocument(); + await reader.createSession({ origin: ORIGIN, peerPublicKey: '04aabbccdd', account: AUTH }); + await new SessionManager().deleteSession(ORIGIN); + + dispatchStorageEvent(null); + + expect(await reader.isAuthenticated(ORIGIN)).toBe(false); + }); }); diff --git a/apps/keys-jaw-id/vitest.config.mts b/apps/keys-jaw-id/vitest.config.mts index 0fa90a0b5..dd3be024f 100644 --- a/apps/keys-jaw-id/vitest.config.mts +++ b/apps/keys-jaw-id/vitest.config.mts @@ -10,6 +10,9 @@ export default defineConfig({ // Resolve the SDK to its TS source so tests don't require a built // `dist` (the Nx-inferred `test` target has no `^build` dependency, so // core is unbuilt in CI). Mirrors packages/wagmi. + // Aliases match by prefix in order, so the subpath goes first or it + // resolves to `src/index.ts/internal`. + '@jaw.id/core/internal': resolve(__dirname, '../../packages/core/src/internal.ts'), '@jaw.id/core': resolve(__dirname, '../../packages/core/src/index.ts'), '@jaw.id/ui': resolve(__dirname, '../../packages/ui/src/index.ts'), }, diff --git a/packages/core/cjs-error-internal.cjs b/packages/core/cjs-error-internal.cjs new file mode 100644 index 000000000..1f997f8ed --- /dev/null +++ b/packages/core/cjs-error-internal.cjs @@ -0,0 +1,3 @@ +throw new Error( + '@jaw.id/core/internal is ESM-only. The CJS build is one bundle per entry point, so a CJS copy of this one would carry its own store and the values set through it would never reach a request.' +); diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 358746922..e172de4f2 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -69,7 +69,7 @@ export class Account { // @public export interface AccountConfig { - apiKey: string; + apiKey?: string; chainId: number; nativeCreateFn?: NativePasskeyCreateFn; nativeGetFn?: NativePasskeyGetFn; @@ -173,7 +173,7 @@ export function buildGrantPermissionCall(account: Address_2, spender: Address_2, }; // @public -export function buildHandleJawRpcUrl(baseUrl: string, apiKey: string): string; +export function buildHandleJawRpcUrl(baseUrl: string, apiKey?: string): string; // @public export function buildRevokePermissionCall(relayPermission: StorePermissionApiResponse): { @@ -347,7 +347,7 @@ export function createJAWProvider(options: CreateProviderOptions): JAWProvider; // @public (undocumented) export type CreateJAWSDKOptions = Partial & { - apiKey: string; + apiKey?: string; preference?: Partial; paymasters?: Record; ens?: string; @@ -572,7 +572,7 @@ export function getErrorCode(error: unknown): number | undefined; export function getMessageFromCode(code: number | undefined, fallbackMessage?: string): string; // @public -export function getPermissionFromRelay(permissionHash: Hex, apiKey: string): Promise; +export function getPermissionFromRelay(permissionHash: Hex, apiKey?: string): Promise; // @public export function getSupportedChains(showTestnets?: boolean): readonly Chain_2[]; @@ -583,16 +583,16 @@ export interface GrantPermissionsOptions { } // @public -export function handleGetAssetsRequest(request: RequestArguments, apiKey: string, showTestnets?: boolean): Promise; +export function handleGetAssetsRequest(request: RequestArguments, apiKey: string | undefined, showTestnets?: boolean): Promise; // @public -export function handleGetCallsHistoryRequest(request: RequestArguments, apiKey: string, connectedAddress?: Address_2): Promise; +export function handleGetCallsHistoryRequest(request: RequestArguments, apiKey: string | undefined, connectedAddress?: Address_2): Promise; // @public -export function handleGetCapabilitiesRequest(request: RequestArguments, apiKey: string, showTestnets?: boolean): Promise; +export function handleGetCapabilitiesRequest(request: RequestArguments, apiKey: string | undefined, showTestnets?: boolean): Promise; // @public -export function handleGetPermissionsRequest(request: RequestArguments, apiKey: string, connectedAddress?: Address_2): Promise; +export function handleGetPermissionsRequest(request: RequestArguments, apiKey: string | undefined, connectedAddress?: Address_2): Promise; // Warning: (ae-forgotten-export) The symbol "HexString" needs to be exported by the entry point index.d.ts // @@ -813,7 +813,7 @@ export function logAccountIssuance(params: LogAccountIssuanceParams): void; // @public export interface LogAccountIssuanceParams { address: Address_2; - apiKey: string; + apiKey?: string; type: IssuanceType; } @@ -823,7 +823,7 @@ export function logSignature(params: LogSignatureParams): void; // @public export interface LogSignatureParams { address: Address_2; - apiKey: string; + apiKey?: string; } // @public @@ -965,6 +965,9 @@ export type PaymasterServiceCapability = { optional?: boolean; }; +// @public +export function peekCapabilities(request: RequestArguments, apiKey: string | undefined, showTestnets?: boolean): CapabilitiesResult | undefined; + // @public export type Permission = { account: Address_2; diff --git a/packages/core/package.json b/packages/core/package.json index a004a75c3..9d454858c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,12 @@ "import": "./dist/index.js", "require": "./dist/cjs/index.cjs", "default": "./dist/index.js" + }, + "./internal": { + "@jaw-mono/source": "./src/internal.ts", + "types": "./dist/internal.d.ts", + "import": "./dist/internal.js", + "require": "./cjs-error-internal.cjs" } }, "repository": { @@ -27,6 +33,7 @@ }, "files": [ "dist", + "cjs-error-internal.cjs", "LICENSE", "NOTICE", "!**/*.tsbuildinfo" diff --git a/packages/core/src/account/Account.chainStore.test.ts b/packages/core/src/account/Account.chainStore.test.ts new file mode 100644 index 000000000..8e21ef6fe --- /dev/null +++ b/packages/core/src/account/Account.chainStore.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { sepolia } from 'viem/chains'; + +import { Account } from './Account.js'; +import { store, ChainClients, getClient, type Chain } from '../store/index.js'; +import { JAW_RPC_URL } from '../constants.js'; +import { createMemoryStorage } from '../storage-manager/index.js'; + +// `buildChainConfig` is private and writes the shared chain store as a side effect, +// which is the behaviour under test here. +const buildChainConfig = ( + chainId: number, + apiKey?: string, + paymasterUrl?: string, + paymasterContext?: Record +): Chain => + ( + Account as unknown as { + buildChainConfig: ( + chainId: number, + apiKey?: string, + paymasterUrl?: string, + paymasterContext?: Record + ) => Chain; + } + ).buildChainConfig(chainId, apiKey, paymasterUrl, paymasterContext); + +const keyless = `${JAW_RPC_URL}?chainId=${sepolia.id}`; +const keyed = `${keyless}&api-key=k1`; + +describe('buildChainConfig and the stored chain entry', () => { + beforeEach(() => { + store.chains.set([]); + ChainClients.setState({}, true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('adds a chain that is not stored yet', () => { + buildChainConfig(sepolia.id, 'k1'); + + expect(store.chains.get()).toEqual([{ id: sepolia.id, rpcUrl: keyed }]); + }); + + // `chains` is persisted, and on the keys origin that store is shared by every dApp + // the user opens. First-write-wins let a keyless entry outlive its session and serve + // the next keyed one, which reaches the proxy with no key and is refused. + it('lets a keyed session replace the entry a keyless one left behind', () => { + buildChainConfig(sepolia.id); + expect(store.chains.get()?.[0].rpcUrl).toBe(keyless); + + buildChainConfig(sepolia.id, 'k1'); + + expect(store.chains.get()).toEqual([{ id: sepolia.id, rpcUrl: keyed }]); + }); + + it('replaces in place, so the order of the list is kept', () => { + store.chains.set([ + { id: 1, rpcUrl: `${JAW_RPC_URL}?chainId=1` }, + { id: sepolia.id, rpcUrl: keyless }, + { id: 8453, rpcUrl: `${JAW_RPC_URL}?chainId=8453` }, + ]); + + buildChainConfig(sepolia.id, 'k1'); + + expect(store.chains.get()?.map((c) => c.id)).toEqual([1, sepolia.id, 8453]); + expect(store.chains.get()?.[1].rpcUrl).toBe(keyed); + }); + + it('drops the cached clients so the next read uses the new url', () => { + buildChainConfig(sepolia.id); + expect(getClient(sepolia.id)?.transport.url).toBe(keyless); + + buildChainConfig(sepolia.id, 'k1'); + + expect(getClient(sepolia.id)?.transport.url).toBe(keyed); + }); + + // Rebuilding on every call would cost the multicall batching, which only folds calls + // issued on the same client instance. + it('keeps the cached clients when the entry is unchanged', () => { + buildChainConfig(sepolia.id, 'k1'); + const client = getClient(sepolia.id); + + buildChainConfig(sepolia.id, 'k1'); + + expect(getClient(sepolia.id)).toBe(client); + }); + + it('replaces when only the paymaster changed', () => { + buildChainConfig(sepolia.id, 'k1', 'https://paymaster.test/a'); + const client = getClient(sepolia.id); + + buildChainConfig(sepolia.id, 'k1', 'https://paymaster.test/b'); + + expect(store.chains.get()?.[0].paymaster?.url).toBe('https://paymaster.test/b'); + expect(getClient(sepolia.id)).not.toBe(client); + }); + + // Last write wins, so a caller that leaves the context out strips the one the + // entry already carried. + it('keeps the paymaster context when backfilling addresses', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + const storage = createMemoryStorage(); + storage.setItem('accounts', [ + { + creationDate: '2026-09-22', + credentialId: 'cred-1', + isImported: false, + username: 'alice', + publicKey: '0x00', + }, + ]); + const paymaster = { url: 'https://paymaster.test/a', context: { sponsorshipPolicyId: 'sp_1' } }; + buildChainConfig(sepolia.id, 'k1', paymaster.url, paymaster.context); + + await Account.backfillStoredAccountAddresses({ + chainId: sepolia.id, + apiKey: 'k1', + paymasterUrl: paymaster.url, + paymasterContext: paymaster.context, + storage, + }); + + expect(store.chains.get()?.[0].paymaster).toEqual(paymaster); + }); +}); diff --git a/packages/core/src/account/Account.ts b/packages/core/src/account/Account.ts index 3eb2243e1..ff1cb8695 100644 --- a/packages/core/src/account/Account.ts +++ b/packages/core/src/account/Account.ts @@ -1,5 +1,5 @@ import type { Address, Hash, Hex, TypedDataDefinition, TypedData, LocalAccount } from 'viem'; -import { isHex, encodeFunctionData, erc20Abi, createPublicClient, http, numberToHex } from 'viem'; +import { isHex, encodeFunctionData, erc20Abi, createPublicClient, numberToHex } from 'viem'; import { toWebAuthnAccount, type SmartAccount } from 'viem/account-abstraction'; import { createSmartAccount, @@ -23,6 +23,7 @@ import { type CallStatusResponse, } from '../rpc/wallet_sendCalls.js'; import type { JustanAccountImplementation } from './toJustanAccount.js'; +import { jawHttp } from '../utils/jawHttp.js'; import { PasskeyManager, type PasskeyAccount, @@ -47,7 +48,7 @@ import { type SpendPermissionDetail, } from '../rpc/permissions.js'; import { JAW_RPC_URL, JAW_PAYMASTER_URL, ERC20_PAYMASTER_ADDRESS } from '../constants.js'; -import { type Chain, chains as chainStore } from '../store/index.js'; +import { type Chain, chains as chainStore, dropChainClients } from '../store/index.js'; import { logAccountIssuance } from '../analytics/index.js'; /** @@ -56,8 +57,8 @@ import { logAccountIssuance } from '../analytics/index.js'; export interface AccountConfig { /** Chain ID for the account */ chainId: number; - /** API key for JAW services (required) */ - apiKey: string; + /** API key for JAW services, if the caller has one */ + apiKey?: string; /** Custom paymaster URL for gas sponsorship */ paymasterUrl?: string; /** Custom paymaster context for gas sponsorship */ @@ -148,7 +149,7 @@ export class Account { private readonly _smartAccount: SmartAccount; private readonly _chain: Chain; private readonly _passkeyAccount: PasskeyAccount | null; - private readonly _apiKey: string; + private readonly _apiKey?: string; private readonly _localAccount: LocalAccount | null; /** @@ -157,7 +158,7 @@ export class Account { private constructor( smartAccount: SmartAccount, chain: Chain, - apiKey: string, + apiKey: string | undefined, passkeyAccount?: PasskeyAccount, localAccount?: LocalAccount ) { @@ -183,13 +184,13 @@ export class Account { * the next call. */ static async backfillStoredAccountAddresses(config: AccountConfig): Promise { - const { chainId, apiKey, paymasterUrl } = config; + const { chainId, apiKey, paymasterUrl, paymasterContext } = config; const passkeyManager = new PasskeyManager(config.storage, undefined, apiKey); const accounts = passkeyManager.fetchAccounts(); const missing = accounts.filter((account) => !account.address); if (missing.length === 0) return accounts; - const chain = Account.buildChainConfig(chainId, apiKey, paymasterUrl); + const chain = Account.buildChainConfig(chainId, apiKey, paymasterUrl, paymasterContext); const bundlerClient = getBundlerClient(chain); const derived = await Promise.all( @@ -1192,7 +1193,7 @@ export class Account { ): Promise<{ to: Address; value: bigint; data: Hex } | null> { const publicClient = createPublicClient({ chain: { id: this._chain.id } as Parameters[0]['chain'], - transport: http(this._chain.rpcUrl), + transport: jawHttp(this._chain.rpcUrl), }); const read = { @@ -1432,8 +1433,22 @@ export class Account { }; const existingChains = chainStore.get() ?? []; - if (!existingChains.some((c) => c.id === chain.id)) { + const stored = existingChains.find((c) => c.id === chain.id); + + // Last write wins. `chains` is persisted, and on keys.jaw.id that one origin is + // shared by every dApp the user opens, so first-write-wins let an entry outlive + // the session that wrote it and serve every later one. With the api key optional + // that entry can carry a url with no key in it, which turns the old + // mis-attribution into an outright refusal on the next keyed session. + if (!stored) { chainStore.set([...existingChains, chain]); + } else if (JSON.stringify(stored) !== JSON.stringify(chain)) { + chainStore.set(existingChains.map((c) => (c.id === chain.id ? chain : c))); + // Every field of the entry is baked into the clients, url and paymaster and + // native currency alike, so any difference means the cached pair is wrong. + // Compared whole rather than field by field so a new field cannot be + // forgotten here; two entries that differ only in key order cost one rebuild. + dropChainClients(chain.id); } return chain; @@ -1640,7 +1655,7 @@ export class Account { // Check current allowance const publicClient = createPublicClient({ chain: { id: this._chain.id } as Parameters[0]['chain'], - transport: http(this._chain.rpcUrl), + transport: jawHttp(this._chain.rpcUrl), }); const currentAllowance = await publicClient.readContract({ diff --git a/packages/core/src/account/erc20Paymaster.test.ts b/packages/core/src/account/erc20Paymaster.test.ts index 74af23b18..23c2ac674 100644 --- a/packages/core/src/account/erc20Paymaster.test.ts +++ b/packages/core/src/account/erc20Paymaster.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setDappOrigin } from '../dappOrigin.js'; +import { JAW_PAYMASTER_URL } from '../constants.js'; import { buildErc20PaymasterContext, calculateDisplayTokenCost, @@ -6,6 +8,7 @@ import { calculateTokenEstimatesFromGas, computeEffectiveGasPrice, computeMeasuredDisplayGas, + fetchTokenQuotes, type TokenInfo, type TokenQuote, type UserOpGasFields, @@ -209,3 +212,58 @@ describe('calculateTokenEstimatesFromGas', () => { expect(est.tokenCostFormatted).toBe('23900000.00'); }); }); + +// The quotes are the first call `estimateErc20PaymasterCosts` makes, and the only +// one to our proxy that went out without saying which dApp it acts for. Keyless +// that leaves nothing to attribute it to, and the proxy turns it down. +describe('fetchTokenQuotes and the calling dApp', () => { + const QUOTES = { + jsonrpc: '2.0', + id: 1, + result: { quotes: [{ token: '0xabc', postOpGas: '1', exchangeRate: '2', paymaster: '0xdef' }] }, + }; + + function headersSent(): Record { + return (vi.mocked(globalThis.fetch).mock.calls[0][1] as { headers: Record }).headers; + } + + afterEach(() => { + setDappOrigin(undefined); + vi.unstubAllGlobals(); + }); + + it('names the dApp on a quote from our proxy', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(QUOTES))) + ); + setDappOrigin('https://dapp.example'); + + await fetchTokenQuotes(`${JAW_PAYMASTER_URL}?chainId=8453`, 8453, ['0xabc']); + + expect(headersSent()['x-dapp-origin']).toBe('https://dapp.example'); + }); + + it('sends no dApp header to a paymaster the dApp pointed us at', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(QUOTES))) + ); + setDappOrigin('https://dapp.example'); + + await fetchTokenQuotes('https://paymaster.dapp.example', 8453, ['0xabc']); + + expect(headersSent()['x-dapp-origin']).toBeUndefined(); + }); + + it('sends no dApp header from a dApp page, where the browser sets the Origin', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(QUOTES))) + ); + + await fetchTokenQuotes(`${JAW_PAYMASTER_URL}?chainId=8453`, 8453, ['0xabc']); + + expect(headersSent()['x-dapp-origin']).toBeUndefined(); + }); +}); diff --git a/packages/core/src/account/erc20Paymaster.ts b/packages/core/src/account/erc20Paymaster.ts index 6b968f47e..c986bdf90 100644 --- a/packages/core/src/account/erc20Paymaster.ts +++ b/packages/core/src/account/erc20Paymaster.ts @@ -1,14 +1,15 @@ -import { Address, Hex, createPublicClient, encodeFunctionData, erc20Abi, formatUnits, getAddress, http } from 'viem'; +import { Address, Hex, createPublicClient, encodeFunctionData, erc20Abi, formatUnits, getAddress } from 'viem'; import { SmartAccount, entryPoint08Address } from 'viem/account-abstraction'; import { getBundlerClient } from './smartAccount.js'; -import { Chain, getClient } from '../store/index.js'; -import { ERC20_PAYMASTER_ADDRESS, PERMISSIONS_MANAGER_ADDRESS } from '../constants.js'; +import { Chain, getClient, store } from '../store/index.js'; +import { ERC20_PAYMASTER_ADDRESS, JAW_PROXY_URL, PERMISSIONS_MANAGER_ADDRESS } from '../constants.js'; import { getPermissionFromRelay, relayPermissionToPermission, encodeExecuteBatchWithPermission, } from '../rpc/permissions.js'; import { simulateUserOpGasUsage, type MeasuredUserOpGas } from './userOpGasSimulation.js'; +import { jawHttp } from '../utils/jawHttp.js'; /** * Token quote from Pimlico's ERC-20 paymaster @@ -164,9 +165,18 @@ export async function fetchTokenQuotes( params: [{ tokens }, entryPoint08Address, `0x${chainId.toString(16)}`], }; + // Calls made from the keys origin all carry the same `Origin`, so the caller + // they act on behalf of travels alongside instead of in it. Only to our own + // proxy: a paymaster url an app-specific dApp points elsewhere belongs to + // somebody else, and which dApp the user is on is not theirs to be told. + const dappOrigin = paymasterUrl.startsWith(JAW_PROXY_URL) ? store.config.get().dappOrigin : undefined; + const response = await fetch(paymasterUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(dappOrigin ? { 'x-dapp-origin': dappOrigin } : {}), + }, body: JSON.stringify(requestBody), }); @@ -253,10 +263,6 @@ export async function estimateErc20PaymasterCosts( // directly — they must be routed through the permissions manager. let preparedCalls: Array<{ to: Address; value: bigint; data: Hex }>; if (options?.permissionId) { - if (!options.apiKey) { - throw new Error('apiKey is required when estimating with permissionId'); - } - const relayPermission = await getPermissionFromRelay(options.permissionId, options.apiKey); const permission = relayPermissionToPermission(relayPermission); @@ -291,7 +297,7 @@ export async function estimateErc20PaymasterCosts( // independent and the block is only consumed after the userOp resolves. Reuse the // cached per-chain client when the chain is registered in the store. Best-effort: // without a base fee the display falls back to the ceiling price. - const publicClient = getClient(chain.id) ?? createPublicClient({ transport: http(chain.rpcUrl) }); + const publicClient = getClient(chain.id) ?? createPublicClient({ transport: jawHttp(chain.rpcUrl) }); const blockPromise = publicClient.getBlock({ blockTag: 'latest' }).catch(() => null); const userOp = await bundlerClient.prepareUserOperation({ @@ -320,7 +326,7 @@ export async function estimateErc20PaymasterCosts( // really consume — the padded limits stay as the fallback (and the ceiling). // No retries + short timeout so a node without eth_simulateV1 can't stall the // fee estimate (viem would otherwise retry up to ~40s on every refetch). - const simClient = createPublicClient({ transport: http(chain.rpcUrl, { retryCount: 0, timeout: 2_500 }) }); + const simClient = createPublicClient({ transport: jawHttp(chain.rpcUrl, { retryCount: 0, timeout: 2_500 }) }); const measuredPromise = simulateUserOpGasUsage(simClient, userOp, smartAccount.entryPoint.address); // 6. Price the displayed estimate at the effective gas price instead of the diff --git a/packages/core/src/account/smartAccount.dappOrigin.test.ts b/packages/core/src/account/smartAccount.dappOrigin.test.ts new file mode 100644 index 000000000..28602af4c --- /dev/null +++ b/packages/core/src/account/smartAccount.dappOrigin.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getBundlerClient } from './smartAccount.js'; +import { setDappOrigin } from '../dappOrigin.js'; +import type { Chain } from '../store/index.js'; + +// The send path used to build its clients on a raw `http()`, so a keyless dApp +// reached the proxy from the keys origin with nothing identifying it: the +// capabilities read on the way in succeeded and the userOp on Confirm was refused. +describe('naming the calling dApp on the userOp path', () => { + afterEach(() => { + setDappOrigin(undefined); + vi.unstubAllGlobals(); + }); + + const CHAIN = { id: 1, rpcUrl: 'https://rpc.example' } as Chain; + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + return () => new Headers(fetchMock.mock.calls[0][1].headers); + } + + it('names the dApp on the bundler transport', async () => { + const headers = stubFetch(); + setDappOrigin('https://dapp.example'); + + await getBundlerClient(CHAIN).request({ method: 'eth_chainId' } as never); + + expect(headers().get('x-dapp-origin')).toBe('https://dapp.example'); + }); + + it('sends no dApp header when this instance was told nothing', async () => { + const headers = stubFetch(); + + await getBundlerClient(CHAIN).request({ method: 'eth_chainId' } as never); + + expect(headers().has('x-dapp-origin')).toBe(false); + }); +}); diff --git a/packages/core/src/account/smartAccount.test.ts b/packages/core/src/account/smartAccount.test.ts index 494139f42..01ca7ffde 100644 --- a/packages/core/src/account/smartAccount.test.ts +++ b/packages/core/src/account/smartAccount.test.ts @@ -41,6 +41,7 @@ vi.mock('./toJustanAccount.js', () => ({ vi.mock('../constants.js', () => ({ PERMISSIONS_MANAGER_ADDRESS: '0xf1b40E3D5701C04d86F7828f0EB367B9C90901D8', FACTORY_ADDRESS: '0x0000000000000000000000000000000000factory', + JAW_PROXY_URL: 'https://proxy.jaw.example', })); vi.mock('../errors/errors.js', async () => { @@ -87,11 +88,13 @@ import { createBundlerClient, createPaymasterClient } from 'viem/account-abstrac import { toJustanAccount } from './toJustanAccount.js'; import { createPaymasterFunctions } from './paymaster.js'; import { getPermissionFromRelay, encodeExecuteBatchWithPermission } from '../rpc/permissions.js'; +import { notifyReceiptReceived } from '../analytics/index.js'; import { createSmartAccountForAddress, findOwnerIndex, getBundlerClient, sendCallsWithPermission, + sendTransaction, } from './smartAccount.js'; const MOCK_TARGET_ADDRESS = '0x1234567890123456789012345678901234567890' as Address; @@ -384,6 +387,38 @@ describe('sendCallsWithPermission — the sized call is the sent one', () => { }); }); +// A keyless dApp is attributed by the forwarded origin, so its transactions are +// reported like anybody else's. This path is the popup's: keys calls it on Confirm. +describe('sendTransaction — receipt reporting', () => { + const CHAIN = { id: 1, rpcUrl: 'https://rpc.example' } as never; + const CALLS = [{ to: MOCK_TARGET_ADDRESS }]; + const TX_HASH = '0xbbb2'.padEnd(66, '0'); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createBundlerClient).mockReturnValue({ + sendUserOperation: vi.fn().mockResolvedValue('0xuserophash'), + waitForUserOperationReceipt: vi.fn().mockResolvedValue({ + receipt: { status: '0x1', transactionHash: TX_HASH }, + }), + } as never); + }); + + // Keys builds the account with `preference?.apiKey || ''`, so a keyless dApp + // arrives as the empty string rather than as undefined. + it.each(['real-key', undefined, ''])('reports the receipt with apiKey %o', async (apiKey) => { + await sendTransaction({} as never, CALLS, CHAIN, undefined, undefined, apiKey); + + expect(notifyReceiptReceived).toHaveBeenCalledTimes(1); + expect(vi.mocked(notifyReceiptReceived).mock.calls[0][0]).toMatchObject({ + userOpHash: '0xuserophash', + transactionHash: TX_HASH, + success: true, + apiKey, + }); + }); +}); + describe('findOwnerIndex', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/account/smartAccount.ts b/packages/core/src/account/smartAccount.ts index d2fc4f270..74f6c2816 100644 --- a/packages/core/src/account/smartAccount.ts +++ b/packages/core/src/account/smartAccount.ts @@ -61,7 +61,8 @@ import { unichain, monad, } from 'viem/chains'; -import { PERMISSIONS_MANAGER_ADDRESS, FACTORY_ADDRESS } from '../constants.js'; +import { PERMISSIONS_MANAGER_ADDRESS, FACTORY_ADDRESS, JAW_PROXY_URL } from '../constants.js'; +import { jawHttp } from '../utils/jawHttp.js'; import { standardErrors } from '../errors/errors.js'; import { getPermissionFromRelay, @@ -196,7 +197,7 @@ export const getBundlerClient = ( // unlisted chain resolves to `undefined` here. const publicClient = createPublicClient({ chain: viemChain, - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); // Priority: overrides (from capabilities) > chain config (from SDK config). @@ -212,19 +213,23 @@ export const getBundlerClient = ( if (!effectivePaymasterUrl) { return createBundlerClient({ client: publicClient, - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); } const paymasterClient = createPaymasterClient({ - transport: http(effectivePaymasterUrl), + // A paymaster can be another company's server, and which dApp the user is + // on is not theirs to learn. Ours is the only one told. + transport: effectivePaymasterUrl.startsWith(JAW_PROXY_URL) + ? jawHttp(effectivePaymasterUrl) + : http(effectivePaymasterUrl), }); // Use shared paymaster functions that handle gas price fetching and v0.8 gas limits return createBundlerClient({ client: publicClient, paymaster: createPaymasterFunctions(publicClient, paymasterClient, chain.id, effectivePaymasterContext), - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); }; @@ -247,7 +252,7 @@ async function prepareEip7702Calls( // gates the next), so there is never more than one eth_call in flight to fold. const publicClient = createPublicClient({ chain: SUPPORTED_CHAINS.find((c) => c.id === chain.id), - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); const implementationAddress = await readContract(publicClient, { @@ -375,27 +380,25 @@ export async function sendTransaction( hash: userOpHash, }); - // Fire-and-forget notification to proxy - if (apiKey) { - // Extract the actual receipt - same logic as wallet_sendCalls.ts - const actualReceipt = (receipt as any).receipt || receipt; - const receiptStatus = actualReceipt.status; - - // Determine if transaction succeeded: - // - status === '0x1' or 1 means success - // - If status is undefined but transactionHash exists, assume success (included on-chain) - const isSuccess = - receiptStatus === '0x1' || - receiptStatus === 1 || - (receiptStatus === undefined && actualReceipt.transactionHash !== undefined); - - notifyReceiptReceived({ - userOpHash, - transactionHash: actualReceipt.transactionHash, - success: isSuccess, - apiKey, - }); - } + // Extract the actual receipt - same logic as wallet_sendCalls.ts + const actualReceipt = (receipt as any).receipt || receipt; + const receiptStatus = actualReceipt.status; + + // Determine if transaction succeeded: + // - status === '0x1' or 1 means success + // - If status is undefined but transactionHash exists, assume success (included on-chain) + const isSuccess = + receiptStatus === '0x1' || + receiptStatus === 1 || + (receiptStatus === undefined && actualReceipt.transactionHash !== undefined); + + // Fire-and-forget notification to proxy, keyless callers included. + notifyReceiptReceived({ + userOpHash, + transactionHash: actualReceipt.transactionHash, + success: isSuccess, + apiKey, + }); return receipt.receipt.transactionHash; } @@ -493,7 +496,7 @@ export async function sendCallsWithPermission( }>, chain: Chain, permissionId: Hex, - apiKey: string, + apiKey: string | undefined, paymasterUrlOverride?: string, paymasterContextOverride?: Record, localAccount?: LocalAccount, @@ -588,7 +591,7 @@ export async function estimateUserOpGasWithPermission( }>, chain: Chain, permissionId: Hex, - apiKey: string + apiKey?: string ): Promise { // Built the same way the send builds it, so what is estimated stays the shape // that goes out. diff --git a/packages/core/src/analytics/index.test.ts b/packages/core/src/analytics/index.test.ts new file mode 100644 index 000000000..daf03c163 --- /dev/null +++ b/packages/core/src/analytics/index.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const restCall = vi.fn(); +vi.mock('../api/index.js', () => ({ restCall: (...args: unknown[]) => restCall(...args) })); + +import { logAccountIssuance, logSignature } from './index.js'; + +const ADDRESS = '0x6ca44a56b06869530c953dFa7868973be1456769'; + +// Both are fire and forget, so nothing downstream rejects what they send: a value +// that is not an address reached production and sat in the table as a row nothing +// could join. +describe('analytics against a value that is not an address', () => { + afterEach(() => { + restCall.mockReset(); + }); + + it.each([ + ['junk', 'not-an-address'], + ['a truncated address', '0xabc'], + ['an empty string', ''], + ['nothing at all', undefined], + ])('sends no signature for %s', (_label, address) => { + restCall.mockResolvedValue({}); + + logSignature({ address: address as never }); + + expect(restCall).not.toHaveBeenCalled(); + }); + + it('sends no issuance for junk either', () => { + restCall.mockResolvedValue({}); + + logAccountIssuance({ address: 'not-an-address' as never, type: 'created' as never }); + + expect(restCall).not.toHaveBeenCalled(); + }); + + // Shape, not checksum: plenty of our own paths carry a lowercase address. + it.each([ + ['checksummed', ADDRESS], + ['lowercase', ADDRESS.toLowerCase()], + ])('still sends a %s address', (_label, address) => { + restCall.mockResolvedValue({}); + + logSignature({ address: address as never }); + + expect(restCall).toHaveBeenCalledTimes(1); + expect(restCall.mock.calls[0][2]).toEqual({ address }); + }); +}); diff --git a/packages/core/src/analytics/index.ts b/packages/core/src/analytics/index.ts index 9ea679fd4..ebd9bb949 100644 --- a/packages/core/src/analytics/index.ts +++ b/packages/core/src/analytics/index.ts @@ -1,5 +1,7 @@ import type { Address } from 'viem'; import { restCall } from '../api/index.js'; +// By path: `paramUtils` is not part of the package's public surface. +import { isHexAddress } from '../rpc/paramUtils.js'; import type { IssuanceType } from '../api/routes/index.js'; export type { IssuanceType } from '../api/routes/index.js'; @@ -13,8 +15,8 @@ export interface LogAccountIssuanceParams { address: Address; /** The type of account creation */ type: IssuanceType; - /** API key for authentication */ - apiKey: string; + /** API key for authentication, if the caller has one */ + apiKey?: string; } /** @@ -28,6 +30,10 @@ export interface LogAccountIssuanceParams { export function logAccountIssuance(params: LogAccountIssuanceParams): void { try { const { address, type, apiKey } = params; + // Both calls here are fire and forget, so nothing downstream ever rejects + // what they send and a value that is not an address lands in the table as + // a row nothing can join. Shape, not checksum: a lowercase one is an address. + if (!isHexAddress(address)) return; restCall( 'LOG_ACCOUNT_ISSUANCE', @@ -37,7 +43,7 @@ export function logAccountIssuance(params: LogAccountIssuanceParams): void { type, timestamp: Date.now(), }, - { 'x-api-key': apiKey } + apiKey ? { 'x-api-key': apiKey } : {} ).catch(() => { // Silently swallow async errors }); @@ -52,8 +58,8 @@ export function logAccountIssuance(params: LogAccountIssuanceParams): void { export interface LogSignatureParams { /** The address that produced the signature */ address: Address; - /** API key for authentication */ - apiKey: string; + /** API key for authentication, if the caller has one */ + apiKey?: string; } /** @@ -67,8 +73,9 @@ export interface LogSignatureParams { export function logSignature(params: LogSignatureParams): void { try { const { address, apiKey } = params; + if (!isHexAddress(address)) return; - restCall('LOG_SIGNATURE', 'POST', { address }, { 'x-api-key': apiKey }).catch(() => { + restCall('LOG_SIGNATURE', 'POST', { address }, apiKey ? { 'x-api-key': apiKey } : {}).catch(() => { // Silently swallow async errors }); } catch { diff --git a/packages/core/src/analytics/receiptNotification.test.ts b/packages/core/src/analytics/receiptNotification.test.ts new file mode 100644 index 000000000..e4b0dd16d --- /dev/null +++ b/packages/core/src/analytics/receiptNotification.test.ts @@ -0,0 +1,54 @@ +// A receipt is reported to the proxy whether or not the caller has a key: a keyless +// dApp is attributed by the forwarded origin, so gating the call on the key would +// leave its transactions unrecorded. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../api/index.js', () => ({ + restCall: vi.fn().mockResolvedValue(undefined), +})); + +import { restCall } from '../api/index.js'; +import { notifyReceiptReceived } from './receiptNotification.js'; + +const restCallMock = vi.mocked(restCall); + +const receipt = { + userOpHash: '0xaaa1'.padEnd(66, '0') as `0x${string}`, + transactionHash: '0xbbb2'.padEnd(66, '0') as `0x${string}`, + success: true, +}; + +/** The last restCall, by parameter name: the function takes eight positionals. */ +function lastCall() { + const [route, method, body, headers, pathParams, dev, serverUrl, queryParams] = + restCallMock.mock.calls.at(-1) ?? []; + return { route, method, body, headers, pathParams, dev, serverUrl, queryParams }; +} + +describe('notifyReceiptReceived', () => { + beforeEach(() => { + restCallMock.mockClear(); + }); + + it('sends the api-key as a query param when the caller has one', () => { + notifyReceiptReceived({ ...receipt, apiKey: 'real-key' }); + + expect(restCallMock).toHaveBeenCalledTimes(1); + expect(lastCall().queryParams).toEqual({ 'api-key': 'real-key' }); + }); + + // Keys builds the account with `preference?.apiKey || ''`, so a keyless dApp + // arrives here as the empty string rather than as undefined. + it.each([undefined, ''])('still notifies with no key (%o), and omits the param', (apiKey) => { + notifyReceiptReceived({ ...receipt, apiKey }); + + expect(restCallMock).toHaveBeenCalledTimes(1); + expect(lastCall().queryParams).toBeUndefined(); + }); + + it('reports a revert as status 500', () => { + notifyReceiptReceived({ ...receipt, success: false }); + + expect(lastCall().body).toMatchObject({ status: 500 }); + }); +}); diff --git a/packages/core/src/analytics/receiptNotification.ts b/packages/core/src/analytics/receiptNotification.ts index 208ea0abc..e6aade240 100644 --- a/packages/core/src/analytics/receiptNotification.ts +++ b/packages/core/src/analytics/receiptNotification.ts @@ -12,8 +12,8 @@ export interface NotifyReceiptParams { transactionHash: Hash; /** Whether the transaction was successful (true) or reverted (false) */ success: boolean; - /** API key for authentication */ - apiKey: string; + /** API key for authentication, if the caller has one */ + apiKey?: string; } /** @@ -42,7 +42,7 @@ export function notifyReceiptReceived(params: NotifyReceiptParams): void { { id: userOpHash }, undefined, JAW_PROXY_URL, - { 'api-key': apiKey } + apiKey ? { 'api-key': apiKey } : undefined ).catch(() => { // Silently swallow async errors }); diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts new file mode 100644 index 000000000..cf04a7781 --- /dev/null +++ b/packages/core/src/api/rest.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { restCall } from './rest.js'; +import { notifyReceiptReceived } from '../analytics/receiptNotification.js'; +import { setDappOrigin } from '../dappOrigin.js'; +import { JAW_BASE_URL, JAW_PROXY_URL } from '../constants.js'; + +const request = vi.fn(); + +vi.mock('./axiosController.js', async () => { + const actual = await vi.importActual('./axiosController.js'); + return { + ...actual, + backendInstance: () => ({ request }), + }; +}); + +// The relay calls are made from the keys origin too, since grantPermissions writes +// through here. Without the header a keyless dApp's permission is refused on the +// relay write, after the transaction has already been signed and sent. +describe('restCall and the calling dApp', () => { + afterEach(() => { + setDappOrigin(undefined); + request.mockReset(); + }); + + function headersSent() { + return request.mock.calls[0][0].headers as Record; + } + + async function callThrough(headers?: Record) { + request.mockResolvedValue({ data: { result: { data: {} } } }); + await restCall('GET_PERMISSION', 'GET', {}, headers, { hash: '0xabc' }, undefined, JAW_PROXY_URL); + } + + it('names the dApp alongside the key it was given', async () => { + setDappOrigin('https://dapp.example'); + + await callThrough({ 'x-api-key': 'k1' }); + + expect(headersSent()).toEqual({ 'x-api-key': 'k1', 'x-dapp-origin': 'https://dapp.example' }); + }); + + it('names the dApp when there is no key at all', async () => { + setDappOrigin('https://dapp.example'); + + await callThrough(); + + expect(headersSent()).toEqual({ 'x-dapp-origin': 'https://dapp.example' }); + }); + + it('sends no dApp header from a dApp page, where the browser sets the Origin', async () => { + await callThrough({ 'x-api-key': 'k1' }); + + expect(headersSent()).toEqual({ 'x-api-key': 'k1' }); + }); + + // Analytics is the wallet API, not the proxy, and a keyless caller has no key + // for it to bill the signature to. Its service lists `x-dapp-origin` in + // Access-Control-Allow-Headers, so the preflight passes. + it('names the dApp on the wallet API too', async () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + await restCall('LOG_SIGNATURE', 'POST', { address: '0xabc' }); + + expect(headersSent()).toEqual({ 'x-dapp-origin': 'https://dapp.example' }); + }); + + // The patch that reports a landed transaction is the one call a keyless dApp + // cannot be identified on any other way: it leaves after the popup is gone. + describe('the receipt patch', () => { + const receipt = { + userOpHash: `0xaaa1${'0'.repeat(60)}` as `0x${string}`, + transactionHash: `0xbbb2${'0'.repeat(60)}` as `0x${string}`, + success: true, + }; + + function paramsSent() { + return request.mock.calls[0][0].params; + } + + it('names the dApp and sends no empty key', () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + notifyReceiptReceived(receipt); + + expect(headersSent()).toEqual({ 'x-dapp-origin': 'https://dapp.example' }); + expect(paramsSent()).toBeUndefined(); + }); + + it('sends the key as a query param when the caller has one', () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + notifyReceiptReceived({ ...receipt, apiKey: 'k1' }); + + expect(paramsSent()).toEqual({ 'api-key': 'k1' }); + }); + }); + + // A server the dApp runs itself, which app-specific mode allows. Which dApp the + // user is on is ours to know and not theirs to be told. + it('sends no dApp header to a server the dApp pointed us at', async () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + await restCall( + 'LOOKUP_PASSKEYS', + 'GET', + { credentialIds: ['abc'] }, + undefined, + undefined, + undefined, + 'https://passkeys.dapp.example' + ); + + expect(headersSent()).toEqual({}); + }); + + // `https://api.justaname.id.evil.com` starts with our base url and is a host of + // theirs, so the match is on the origin. + it('sends no dApp header to a host that only looks like ours', async () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + await restCall( + 'LOOKUP_PASSKEYS', + 'GET', + { credentialIds: ['abc'] }, + undefined, + undefined, + undefined, + `${JAW_BASE_URL}.evil.com/wallet/v2/passkeys` + ); + + expect(headersSent()).toEqual({}); + }); +}); diff --git a/packages/core/src/api/rest.ts b/packages/core/src/api/rest.ts index 820bd3ff7..287952403 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -1,7 +1,24 @@ -import { backendInstance, controlledAxiosPromise } from './axiosController.js'; +import { backendInstance, controlledAxiosPromise, getBaseUrl } from './axiosController.js'; import { Routes, ROUTES } from './routes/index.js'; +import { store } from '../store/index.js'; import qs from 'qs'; +const OUR_ORIGINS = [getBaseUrl(), getBaseUrl(true)].map((url) => new URL(url).origin); + +/** + * Whether a url points at a backend of ours: the wallet API or staging. + * + * Compared by origin, not by prefix: `https://api.justaname.id.evil.com` starts with + * ours and is somebody else's host. + */ +function isOurHost(serverUrl: string): boolean { + try { + return OUR_ORIGINS.includes(new URL(serverUrl).origin); + } catch { + return false; + } +} + /** * Makes a REST call to the Backend API. * @typeparam T - The type of the route. @@ -48,6 +65,13 @@ export const restCall = < // POST/DELETE: request goes to data const params = method === 'GET' ? request : method === 'PATCH' && queryParams ? queryParams : undefined; + // Any backend of ours is told, whichever host it runs on: the proxy, the wallet + // API, staging. Analytics lives on the wallet API, and a keyless caller has no + // key there for its workspace to be credited with. The one url that may belong + // to somebody else is a `serverUrl` an app-specific dApp points at its own server. + const serverIsOurs = !serverUrl || isOurHost(serverUrl); + const dappOrigin = serverIsOurs ? store.config.get().dappOrigin : undefined; + return controlledAxiosPromise( backendInstance(dev, serverUrl).request({ url, @@ -57,7 +81,9 @@ export const restCall = < return qs.stringify(params, { arrayFormat: 'repeat' }); }, data: method === 'POST' || method === 'PATCH' ? request : undefined, - headers: headers || {}, + // Calls made from the keys origin all carry the same `Origin`, so the + // caller they act on behalf of travels alongside instead of in it. + headers: { ...(headers ?? {}), ...(dappOrigin ? { 'x-dapp-origin': dappOrigin } : {}) }, }) ); }; diff --git a/packages/core/src/api/routes/callsHistory.ts b/packages/core/src/api/routes/callsHistory.ts index f3702c0b6..be9d8cdaf 100644 --- a/packages/core/src/api/routes/callsHistory.ts +++ b/packages/core/src/api/routes/callsHistory.ts @@ -60,13 +60,13 @@ export interface CallsHistoryRoutes { response: void; headers?: Record; pathParams: { id: string }; - queryParams: { 'api-key': string }; + queryParams: { 'api-key'?: string }; }; GET_CALLS_HISTORY: { request: GetCallsHistoryRequest; response: CallsHistoryItem[]; headers?: Record; pathParams?: never; - queryParams: { 'api-key': string }; + queryParams: { 'api-key'?: string }; }; } diff --git a/packages/core/src/api/routes/permissions.ts b/packages/core/src/api/routes/permissions.ts index 75d7463d3..47589c539 100644 --- a/packages/core/src/api/routes/permissions.ts +++ b/packages/core/src/api/routes/permissions.ts @@ -13,22 +13,26 @@ export const PERMISSIONS_ROUTE = '/permissions'; * Route definitions for permissions operations */ export interface PermissionsRoutes { + // The key is optional on all three: a dApp registered by origin has none, and + // reads and writes alike are reached both from its own page and from the keys + // origin. What identifies the caller when it is missing is `x-dapp-origin`, + // which restCall attaches. STORE_PERMISSION: { request: StorePermissionApiRequest; response: StorePermissionApiResponse; - headers: { 'x-api-key': string }; + headers: { 'x-api-key'?: string }; pathParams?: never; }; GET_PERMISSION: { request: Record; response: StorePermissionApiResponse; - headers: { 'x-api-key': string }; + headers: { 'x-api-key'?: string }; pathParams: { hash: string }; }; DELETE_PERMISSION: { request: Record; response: RevokePermissionApiResponse; - headers: { 'x-api-key': string }; + headers: { 'x-api-key'?: string }; pathParams: { hash: string }; }; } diff --git a/packages/core/src/dappOrigin.ts b/packages/core/src/dappOrigin.ts new file mode 100644 index 000000000..112bdfcba --- /dev/null +++ b/packages/core/src/dappOrigin.ts @@ -0,0 +1,16 @@ +import { store } from './store/index.js'; + +/** + * Tells this core instance which dApp it is acting for. + * + * Calls made from the keys origin all carry the same `Origin`, so the caller + * they act on behalf of is not visible to the backend. Only keys sets this, and + * only from the origin the browser handed it. + * + * A dApp's own page must never call it: the browser already puts the right + * `Origin` on its requests, and a value set here would be a claim the browser + * did not make. + */ +export function setDappOrigin(origin: string | undefined): void { + store.config.set({ dappOrigin: origin }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bbf26b56e..5bb570ef9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,7 @@ export { handleGetCallsHistoryRequest, handleGetPermissionsRequest, handleGetCapabilitiesRequest, + peekCapabilities, clearCapabilitiesCache, type CapabilitiesResult, type ChainMetadataCapability, diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts new file mode 100644 index 000000000..93704845f --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,16 @@ +/** + * Entry point for keys.jaw.id, not for dApps. + * + * What it exports is safe only when the caller is keys itself, so it is kept out of + * the package's public entry point rather than documented as off limits there. + * + * ESM only, on purpose. The CJS build is a bundle per entry point, so a second one + * would carry its own copy of the store: the origin set through it would land in a + * different instance from the one the request path reads, and the header would go + * missing with nothing failing. A `require` of this path fails loudly instead. + */ +export { setDappOrigin } from './dappOrigin.js'; + +// Also used by @jaw.id/ui, which runs on dApp pages in app-specific mode. There it +// adds nothing: only keys sets the origin it reads. +export { jawHttp } from './utils/jawHttp.js'; diff --git a/packages/core/src/provider/JAWProvider.test.ts b/packages/core/src/provider/JAWProvider.test.ts index 0d3daa2c3..db75b19f4 100644 --- a/packages/core/src/provider/JAWProvider.test.ts +++ b/packages/core/src/provider/JAWProvider.test.ts @@ -76,6 +76,9 @@ vi.mock('../store/index.js', async (importOriginal) => { account: { get: vi.fn(() => ({ chain: { id: 1 } })), }, + config: { + get: vi.fn(() => ({ apiKey: 'test-api-key' })), + }, callStatuses: { get: vi.fn(), set: vi.fn(), @@ -1252,6 +1255,28 @@ describe('JAWProvider', () => { expect(disconnectSpy).toHaveBeenCalled(); }); + // The same code, from the proxy turning the caller down rather than from a + // session that died. It arrives on read-only calls, so an unregistered + // dApp asking for capabilities would be logged out of a working wallet. + it('stays connected when the backend refused the caller', async () => { + // The real transport, since the marker is what this is about and the + // mock above cannot carry it. + const transport = await vi.importActual('../utils/provider.js'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => 'no' })); + const refusal = await transport + .fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example') + .catch((e) => e); + vi.unstubAllGlobals(); + (mockSigner.request as Mock).mockRejectedValue(refusal); + const disconnectSpy = vi.spyOn(provider, 'disconnect'); + + await expect(provider.request({ method: 'eth_accounts' })).rejects.toMatchObject({ + code: standardErrorCodes.provider.unauthorized, + }); + + expect(disconnectSpy).not.toHaveBeenCalled(); + }); + it('should not disconnect on non-unauthorized errors', async () => { // Arrange const request: RequestArguments = { diff --git a/packages/core/src/provider/JAWProvider.ts b/packages/core/src/provider/JAWProvider.ts index 36c244e3b..8517471ce 100644 --- a/packages/core/src/provider/JAWProvider.ts +++ b/packages/core/src/provider/JAWProvider.ts @@ -1,5 +1,7 @@ import { Communicator } from '../communicator/index.js'; import { standardErrorCodes, serializeError, standardErrors } from '../errors/index.js'; +// By path: the marker is ours to read and not part of the package's surface. +import { isBackendRefusal } from '../utils/provider.js'; import { SignerType } from '../messages/index.js'; @@ -37,7 +39,7 @@ export class JAWProvider extends ProviderEventEmitter implements ProviderInterfa private readonly metadata: AppMetadata; private readonly preference: JawProviderPreference; private readonly communicator: Communicator; - private readonly apiKey: string; + private readonly apiKey?: string; private readonly paymasters?: Record; private theme?: JawTheme; @@ -320,14 +322,20 @@ export class JAWProvider extends ProviderEventEmitter implements ProviderInterfa return result as T; } catch (error) { const { code } = error as { code?: number }; - // 4100 means two different things here. From a live signer it means + // 4100 means three different things here. From a live signer it means // the session died, and tearing it down locally is right. From the // no-session branch above it just means "connect first", and there // is nothing to tear down: disconnecting would emit accountsChanged // and disconnect on a provider that was never connected, log the // passkey session out, and drop the iframe. A dapp that probes with // personal_sign before connecting would pay for it. - if (code === standardErrorCodes.provider.unauthorized && this.signer) { + // + // The third is the backend turning the caller down, over an origin it + // does not serve or a key it will not take. That says nothing about + // the session, and it arrives from read-only calls: an unregistered + // dApp asking for capabilities would be logged out of a wallet that + // is working. + if (code === standardErrorCodes.provider.unauthorized && this.signer && !isBackendRefusal(error)) { await this.disconnect(); } return Promise.reject(serializeError(error)); diff --git a/packages/core/src/provider/interface.ts b/packages/core/src/provider/interface.ts index 521f792ed..a609cbc3d 100644 --- a/packages/core/src/provider/interface.ts +++ b/packages/core/src/provider/interface.ts @@ -100,7 +100,7 @@ export type PaymasterConfig = { export interface ConstructorOptions { metadata: AppMetadata; preference: JawProviderPreference; - apiKey: string; + apiKey?: string; /** Mapping of chain IDs to paymaster configuration */ paymasters?: Record; /** Theme configuration for UI appearance */ diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 14c57f22a..c80cd3acc 100644 --- a/packages/core/src/rpc/capabilities.test.ts +++ b/packages/core/src/rpc/capabilities.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { handleGetCapabilitiesRequest, clearCapabilitiesCache } from './capabilities.js'; +import { handleGetCapabilitiesRequest, clearCapabilitiesCache, peekCapabilities } from './capabilities.js'; const CAPS = { '0x2105': { feeToken: { supported: true } } }; @@ -61,6 +61,17 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(results).toEqual([CAPS, CAPS, CAPS]); }); + // Keys reads the key out of the rpc url and gets '' for a dApp that has none, + // while the SDK passes undefined. Both are the same caller. + it('treats an empty key and no key as one caller', async () => { + const fetchSpy = stubFetch(); + + await handleGetCapabilitiesRequest(request, undefined, true); + await handleGetCapabilitiesRequest(request, '', true); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + it('keys the cache separately per api key', async () => { const fetchSpy = stubFetch(); @@ -80,7 +91,22 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it('does not cache failures, so the next caller retries', async () => { + // An origin the backend will not serve answers the same way every time, and the + // icon hook asks per chain: without this a chain picker re-fires one refused + // request per chain on every mount. + it('holds a refusal for its window instead of asking again', async () => { + const fetchSpy = vi.fn(async () => new Response('no', { status: 403 })); + vi.stubGlobal('fetch', fetchSpy); + + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: 4100 }); + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: 4100 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + // The other half of the rule: a blip is not an answer, and nothing here retries + // on its own, so holding one would leave a dialog without its fee row for the + // whole window over a failure that was already gone. + it('lets the next caller retry after a transient failure', async () => { let calls = 0; const fetchSpy = vi.fn(async () => { calls++; @@ -97,6 +123,52 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); + // The refusal the proxy sends today is not a JSON-RPC envelope, so it arrives + // as a 4100. One wrapped in an envelope keeps that envelope's code and is the + // same answer: asking again cannot change it. + it('holds a refusal that arrives inside a JSON-RPC envelope', async () => { + const fetchSpy = vi.fn( + async () => + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { code: -32001, message: 'origin not registered' }, + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + vi.stubGlobal('fetch', fetchSpy); + + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: -32001 }); + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: -32001 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries once the refusal goes stale', async () => { + let calls = 0; + const fetchSpy = vi.fn(async () => { + calls++; + if (calls === 1) return new Response('no', { status: 403 }); + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: CAPS }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: 4100 }); + // The refusal window is 30s; jump past it. + const realNow = Date.now; + vi.spyOn(Date, 'now').mockImplementation(() => realNow() + 31_000); + + await expect(handleGetCapabilitiesRequest(request, 'key', true)).resolves.toEqual(CAPS); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + it('refetches once the entry goes stale', async () => { const fetchSpy = stubFetch(); @@ -139,3 +211,40 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(a).toEqual(b); }); }); + +// The synchronous read the chain icon seeds itself from. It has to answer for the +// same entry the async path fills and age with it, or the two disagree on screen. +describe('peekCapabilities', () => { + it('says nothing before anything was asked', () => { + expect(peekCapabilities(request, 'key', true)).toBeUndefined(); + }); + + it('answers with what the async call cached, for the same key', async () => { + stubFetch(); + await handleGetCapabilitiesRequest(request, 'key', true); + + expect(peekCapabilities(request, 'key', true)).toEqual(CAPS); + // A different effective request is a different entry, not a near miss. + expect(peekCapabilities(request, 'key', false)).toBeUndefined(); + expect(peekCapabilities(request, 'other-key', true)).toBeUndefined(); + }); + + it('stops answering once the entry goes stale', async () => { + stubFetch(); + await handleGetCapabilitiesRequest(request, 'key', true); + const realNow = Date.now; + vi.spyOn(Date, 'now').mockImplementation(() => realNow() + 61_000); + + expect(peekCapabilities(request, 'key', true)).toBeUndefined(); + }); + + it('hands back a copy, so a reader cannot corrupt what the next one gets', async () => { + stubFetch(); + await handleGetCapabilitiesRequest(request, 'key', true); + + const peeked = peekCapabilities(request, 'key', true) as Record; + peeked['0x2105'].evil = true; + + expect(peekCapabilities(request, 'key', true)).toEqual(CAPS); + }); +}); diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 588547bb9..1ee6cb5f7 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -2,7 +2,10 @@ import { type Address } from 'viem'; import type { RequestArguments } from '../provider/index.js'; import { JAW_RPC_URL } from '../constants.js'; import { buildHandleJawRpcUrl, fetchRPCRequest, hexStringFromNumber } from '../utils/index.js'; +// By path, not through the barrel: this one is ours to read and not the dApp's. +import { isBackendRefusal } from '../utils/provider.js'; import { MAINNET_CHAINS } from '../account/smartAccount.js'; +import { store } from '../store/index.js'; /** * Chain metadata capability returned by wallet_getCapabilities @@ -25,43 +28,39 @@ export type CapabilitiesResult = Record<`0x${string}`, Record>; */ const CAPABILITIES_TTL_MS = 60_000; +/** + * How long a refusal keeps the next caller from repeating it. + * + * Only a refusal, never a transient failure. What this is for is the answer that + * will not change by asking again: the proxy turning down a caller it cannot + * attribute, which the icon hook otherwise re-asks once per chain on every mount. + * A blip is the opposite, and holding one would leave a dialog without its fee row + * for the window with nothing on the way to clear it, since no caller here retries. + */ +const CAPABILITIES_REFUSAL_TTL_MS = 30_000; + const capabilitiesCache = new Map(); +const capabilitiesRefusals = new Map(); /** Requests in flight, so concurrent callers share one fetch instead of racing duplicates. */ const capabilitiesInflight = new Map>(); /** Drop every cached capabilities response. Exposed for tests and for callers that need a forced refresh. */ export function clearCapabilitiesCache(): void { capabilitiesCache.clear(); + capabilitiesRefusals.clear(); capabilitiesInflight.clear(); } /** - * Handle wallet_getCapabilities request (EIP-5792) - * - * Returns the wallet's capabilities for all supported chains or filtered by chain IDs. - * Fetches capabilities from the proxy service. - * - * If no chain filter is provided in params: - * - If showTestnets is true: fetches capabilities for all chains - * - If showTestnets is false: fetches capabilities only for mainnet chains - * - * Responses are memoized per (api key, effective params) for `CAPABILITIES_TTL_MS`, - * and concurrent callers for the same key share a single request — the dialogs ask for - * this on mount from several places at once, and it gates the fee-token chain. - * Failures are never cached, and every caller gets its own copy of the response. - * - * @param request - The wallet_getCapabilities request - * @param apiKey - API key for authentication - * @param showTestnets - Whether to include testnet chains (default: false) - * @returns Capabilities for all or filtered chains + * The request as it goes on the wire, with the chain filter `showTestnets` implies, + * and the entry it is cached under. One function so a reader of the cache and the + * caller that fills it cannot derive the key two different ways. */ -export async function handleGetCapabilitiesRequest( +function resolveRequest( request: RequestArguments, - apiKey: string, - showTestnets = false -): Promise { - const rpcUrl = buildHandleJawRpcUrl(JAW_RPC_URL, apiKey); - + apiKey: string | undefined, + showTestnets: boolean +): { requestArgs: RequestArguments; cacheKey: string } { // EIP-5792 format: params[0] is account address, params[1] is optional array of chain IDs to filter by const params = request.params as [Address?, `0x${string}`[]?] | undefined; const filterChainIds = params?.[1]; @@ -83,7 +82,65 @@ export async function handleGetCapabilitiesRequest( // Key on the *effective* params, after the chain filter above is injected — two // callers that differ only in `showTestnets` resolve to different requests. - const cacheKey = `${apiKey}|${JSON.stringify(requestArgs.params ?? [])}`; + // The dApp is part of the key: with no api-key the proxy answers on the origin + // we name instead, so two of them would otherwise share the keyless entry. + // A caller with no key reaches this as '' from keys and as undefined from the + // SDK, and both mean the same request, so they share one entry. + const cacheKey = `${apiKey ?? ''}|${store.config.get().dappOrigin ?? ''}|${JSON.stringify(requestArgs.params ?? [])}`; + + return { requestArgs, cacheKey }; +} + +/** + * The cached answer for this request, or undefined when there is none to give + * without asking for it. + * + * For callers that have to decide what to paint before they can await: the chain + * icon resolves on a microtask otherwise, so a warm cache still costs a frame of + * placeholder on every mount, on eleven call sites including the confirm screen. + * Same entry and same freshness as the async path, so the two cannot disagree. + */ +export function peekCapabilities( + request: RequestArguments, + apiKey: string | undefined, + showTestnets = false +): CapabilitiesResult | undefined { + const { cacheKey } = resolveRequest(request, apiKey, showTestnets); + const cached = capabilitiesCache.get(cacheKey); + if (!cached || Date.now() - cached.at >= CAPABILITIES_TTL_MS) return undefined; + return structuredClone(cached.value); +} + +/** + * Handle wallet_getCapabilities request (EIP-5792) + * + * Returns the wallet's capabilities for all supported chains or filtered by chain IDs. + * Fetches capabilities from the proxy service. + * + * If no chain filter is provided in params: + * - If showTestnets is true: fetches capabilities for all chains + * - If showTestnets is false: fetches capabilities only for mainnet chains + * + * Responses are memoized per (api key, effective params) for `CAPABILITIES_TTL_MS`, + * and concurrent callers for the same key share a single request — the dialogs ask for + * this on mount from several places at once, and it gates the fee-token chain. + * A refusal is held for `CAPABILITIES_REFUSAL_TTL_MS` and rethrown, so an origin the + * backend will not serve is asked about once per window instead of once per mount. + * Anything else is retried by the next caller. Every caller gets its own copy of the + * response. + * + * @param request - The wallet_getCapabilities request + * @param apiKey - API key for authentication, if the caller has one + * @param showTestnets - Whether to include testnet chains (default: false) + * @returns Capabilities for all or filtered chains + */ +export async function handleGetCapabilitiesRequest( + request: RequestArguments, + apiKey: string | undefined, + showTestnets = false +): Promise { + const rpcUrl = buildHandleJawRpcUrl(JAW_RPC_URL, apiKey); + const { requestArgs, cacheKey } = resolveRequest(request, apiKey, showTestnets); // Every exit hands back a copy, never the cache entry itself. `JAWProvider` forwards // this result straight to the dApp, and the internal UI call sites all key on the same @@ -94,15 +151,23 @@ export async function handleGetCapabilitiesRequest( return structuredClone(cached.value); } + const refused = capabilitiesRefusals.get(cacheKey); + if (refused && Date.now() - refused.at < CAPABILITIES_REFUSAL_TTL_MS) throw refused.error; + const inflight = capabilitiesInflight.get(cacheKey); if (inflight) return structuredClone(await inflight); const pending = (async () => { - const result = (await fetchRPCRequest(requestArgs, rpcUrl)) as CapabilitiesResult; - // Only a fulfilled response is cached; a rejection propagates to every sharer - // and leaves the next caller free to retry. - capabilitiesCache.set(cacheKey, { at: Date.now(), value: result }); - return result; + try { + const result = (await fetchRPCRequest(requestArgs, rpcUrl)) as CapabilitiesResult; + capabilitiesCache.set(cacheKey, { at: Date.now(), value: result }); + return result; + } catch (error) { + // The rejection propagates to every sharer either way. Only a refusal is + // kept, and only it is handed to the callers that follow inside the window. + if (isBackendRefusal(error)) capabilitiesRefusals.set(cacheKey, { at: Date.now(), error }); + throw error; + } })(); capabilitiesInflight.set(cacheKey, pending); diff --git a/packages/core/src/rpc/index.ts b/packages/core/src/rpc/index.ts index bb4d537b0..1728f0c3c 100644 --- a/packages/core/src/rpc/index.ts +++ b/packages/core/src/rpc/index.ts @@ -49,6 +49,7 @@ export { clearCapabilitiesCache, type CapabilitiesResult, type ChainMetadataCapability, + peekCapabilities, } from './capabilities.js'; export { diff --git a/packages/core/src/rpc/paramUtils.ts b/packages/core/src/rpc/paramUtils.ts index 56e070805..a3655ebd1 100644 --- a/packages/core/src/rpc/paramUtils.ts +++ b/packages/core/src/rpc/paramUtils.ts @@ -40,16 +40,24 @@ export function requireParamsObject(params: unknown, method: string): Record, prependCalls?: { to: Address; value?: bigint; data?: Hex } | Array<{ to: Address; value?: bigint; data?: Hex }> @@ -431,7 +431,7 @@ export async function revokePermission( smartAccount: SmartAccount, permissionId: Hex, chain: Chain, - apiKey: string, + apiKey: string | undefined, paymasterUrlOverride?: string, paymasterContextOverride?: Record, erc20ApprovalCall?: { to: Address; value?: bigint; data: Hex } @@ -456,14 +456,17 @@ export async function revokePermission( /** * Get permission from the relay using typed REST API call with path params */ -export async function getPermissionFromRelay(permissionHash: Hex, apiKey: string): Promise { +export async function getPermissionFromRelay( + permissionHash: Hex, + apiKey?: string +): Promise { const permissionsBaseUrl = JAW_PROXY_URL; return await restCall( 'GET_PERMISSION', 'GET', {}, - { 'x-api-key': apiKey }, + apiKey ? { 'x-api-key': apiKey } : {}, { hash: permissionHash }, undefined, permissionsBaseUrl @@ -506,13 +509,13 @@ export function relayPermissionToPermission(relayPermission: StorePermissionApiR * 2. Calls the relay API to fetch permissions for that address * * @param request - The wallet_getPermissions request - * @param apiKey - API key for relay authentication + * @param apiKey - API key for relay authentication, if the caller has one * @param connectedAddress - Optional connected account address to inject if no address in params * @returns Permissions for the specified address */ export async function handleGetPermissionsRequest( request: RequestArguments, - apiKey: string, + apiKey: string | undefined, connectedAddress?: Address ): Promise { const params = request.params as Array<{ address?: Address; chainId?: string }> | undefined; @@ -612,7 +615,7 @@ async function storePermissionInRelay( permissionHash: Hex, permission: Permission, chainId: string, - apiKey: string + apiKey?: string ): Promise { const requestData: StorePermissionApiRequest = { permissionId: permissionHash, @@ -641,7 +644,7 @@ async function storePermissionInRelay( 'STORE_PERMISSION', 'POST', requestData, - { 'x-api-key': apiKey }, + apiKey ? { 'x-api-key': apiKey } : {}, undefined, undefined, permissionsBaseUrl @@ -651,14 +654,14 @@ async function storePermissionInRelay( /** * Delete permission from the relay using typed REST API call with path params */ -async function deletePermissionFromRelay(permissionHash: Hex, apiKey: string): Promise { +async function deletePermissionFromRelay(permissionHash: Hex, apiKey?: string): Promise { const permissionsBaseUrl = JAW_PROXY_URL; return await restCall( 'DELETE_PERMISSION', 'DELETE', {}, - { 'x-api-key': apiKey }, + apiKey ? { 'x-api-key': apiKey } : {}, { hash: permissionHash }, undefined, permissionsBaseUrl diff --git a/packages/core/src/rpc/wallet_getAssets.ts b/packages/core/src/rpc/wallet_getAssets.ts index c2784b1b5..d259aa70d 100644 --- a/packages/core/src/rpc/wallet_getAssets.ts +++ b/packages/core/src/rpc/wallet_getAssets.ts @@ -81,13 +81,13 @@ export type WalletGetAssetsResponse = { * Automatically injects chainFilter based on showTestnets preference if not provided. * * @param request - The request arguments containing params - * @param apiKey - API key for authentication + * @param apiKey - API key for authentication, if the caller has one * @param showTestnets - Whether to include testnet chains (default: false) * @returns The assets response from the RPC server */ export async function handleGetAssetsRequest( request: RequestArguments, - apiKey: string, + apiKey: string | undefined, showTestnets = false ): Promise { const rpcUrl = buildHandleJawRpcUrl(JAW_RPC_URL, apiKey); diff --git a/packages/core/src/rpc/wallet_getCallsHistory.ts b/packages/core/src/rpc/wallet_getCallsHistory.ts index a784337ef..425993940 100644 --- a/packages/core/src/rpc/wallet_getCallsHistory.ts +++ b/packages/core/src/rpc/wallet_getCallsHistory.ts @@ -33,13 +33,13 @@ export type WalletGetCallsHistoryResponse = CallsHistoryItem[]; * Fetches the call history for a given address from the RPC server. * * @param request - The RPC request arguments - * @param apiKey - The API key for authentication + * @param apiKey - The API key for authentication, if the caller has one * @param connectedAddress - Optional connected account address to inject if no address in params * @returns Array of call history items */ export async function handleGetCallsHistoryRequest( request: RequestArguments, - apiKey: string, + apiKey: string | undefined, connectedAddress?: Address ): Promise { const params = request.params as Array<{ address?: Address }> | undefined; diff --git a/packages/core/src/rpc/wallet_sendCalls.receipt.test.ts b/packages/core/src/rpc/wallet_sendCalls.receipt.test.ts new file mode 100644 index 000000000..a6d43bdc1 --- /dev/null +++ b/packages/core/src/rpc/wallet_sendCalls.receipt.test.ts @@ -0,0 +1,90 @@ +// The receipt is reported to the proxy for every caller, keyed or not: a keyless +// dApp is attributed by the forwarded origin. And one waiter per hash, because +// `wallet_getCallsStatus` starts another on every poll while the op is pending. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const waitForUserOperationReceipt = vi.fn(); + +vi.mock('../store/chain-clients/utils.js', () => ({ + getBundlerClient: vi.fn(() => ({ waitForUserOperationReceipt })), +})); + +vi.mock('../analytics/index.js', () => ({ + notifyReceiptReceived: vi.fn(), +})); + +import { notifyReceiptReceived } from '../analytics/index.js'; +import { waitForReceiptInBackground } from './wallet_sendCalls.js'; + +const notifyMock = vi.mocked(notifyReceiptReceived); + +const USER_OP_HASH = '0xaaa1'.padEnd(66, '0'); +const TX_HASH = '0xbbb2'.padEnd(66, '0'); + +function succeedingReceipt() { + return { receipt: { status: '0x1', transactionHash: TX_HASH } }; +} + +describe('waitForReceiptInBackground', () => { + beforeEach(() => { + vi.clearAllMocks(); + waitForUserOperationReceipt.mockResolvedValue(succeedingReceipt()); + }); + + // Keys builds the account with `preference?.apiKey || ''`, so a keyless dApp + // arrives as the empty string rather than as undefined. + it.each(['real-key', undefined, ''])('reports the receipt with apiKey %o', async (apiKey) => { + await waitForReceiptInBackground(USER_OP_HASH, 1, apiKey); + + expect(notifyMock).toHaveBeenCalledTimes(1); + expect(notifyMock.mock.calls[0][0]).toMatchObject({ + userOpHash: USER_OP_HASH, + transactionHash: TX_HASH, + success: true, + apiKey, + }); + }); + + it('reports a reverted receipt as unsuccessful', async () => { + waitForUserOperationReceipt.mockResolvedValue({ receipt: { status: '0x0', transactionHash: TX_HASH } }); + + await waitForReceiptInBackground(USER_OP_HASH, 1); + + expect(notifyMock.mock.calls[0][0]).toMatchObject({ success: false }); + }); + + it('runs one waiter per hash while the first is still polling', async () => { + let settle: (receipt: unknown) => void = () => undefined; + waitForUserOperationReceipt.mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }) + ); + + const first = waitForReceiptInBackground(USER_OP_HASH, 1); + const second = waitForReceiptInBackground(USER_OP_HASH, 1); + + expect(waitForUserOperationReceipt).toHaveBeenCalledTimes(1); + + settle(succeedingReceipt()); + await Promise.all([first, second]); + + expect(notifyMock).toHaveBeenCalledTimes(1); + }); + + it('polls again once the previous waiter is done', async () => { + await waitForReceiptInBackground(USER_OP_HASH, 1); + await waitForReceiptInBackground(USER_OP_HASH, 1); + + expect(waitForUserOperationReceipt).toHaveBeenCalledTimes(2); + }); + + it('keeps waiters for different hashes apart', async () => { + const other = '0xccc3'.padEnd(66, '0'); + + await Promise.all([waitForReceiptInBackground(USER_OP_HASH, 1), waitForReceiptInBackground(other, 1)]); + + expect(waitForUserOperationReceipt).toHaveBeenCalledTimes(2); + expect(notifyMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/rpc/wallet_sendCalls.ts b/packages/core/src/rpc/wallet_sendCalls.ts index 0dacb2967..2de5ea735 100644 --- a/packages/core/src/rpc/wallet_sendCalls.ts +++ b/packages/core/src/rpc/wallet_sendCalls.ts @@ -202,14 +202,34 @@ export function getCallStatusEIP5792(batchId: string): CallStatusResponse | unde }; } +/** The waiters running right now, by userOpHash. */ +const receiptWaiters = new Map>(); + /** * Starts a background task to wait for user operation receipt * This function does NOT await - it runs in the background + * + * One waiter per hash: `wallet_getCallsStatus` re-triggers this on every poll + * while the status is pending, so a dApp polling a twelve-second transaction + * once a second would otherwise run a dozen waiters that each report the same + * receipt. A waiter that times out leaves the map, so the next poll retries. + * * @param userOpHash - The user operation hash to wait for * @param chainId - The chain ID where the operation was submitted * @param apiKey - Optional API key for notifying the proxy when receipt is received */ -export async function waitForReceiptInBackground(userOpHash: string, chainId: number, apiKey?: string): Promise { +export function waitForReceiptInBackground(userOpHash: string, chainId: number, apiKey?: string): Promise { + const running = receiptWaiters.get(userOpHash); + if (running) return running; + + const waiter = pollForReceipt(userOpHash, chainId, apiKey).finally(() => { + receiptWaiters.delete(userOpHash); + }); + receiptWaiters.set(userOpHash, waiter); + return waiter; +} + +async function pollForReceipt(userOpHash: string, chainId: number, apiKey?: string): Promise { try { // Get bundler client for the chain const bundlerClient = getBundlerClient(chainId); @@ -237,15 +257,14 @@ export async function waitForReceiptInBackground(userOpHash: string, chainId: nu receiptStatus === 1 || (receiptStatus === undefined && actualReceipt.transactionHash !== undefined); - // Fire-and-forget notification to proxy - if (apiKey) { - notifyReceiptReceived({ - userOpHash: userOpHash as `0x${string}`, - transactionHash: actualReceipt.transactionHash, - success: isSuccess, - apiKey, - }); - } + // Fire-and-forget notification to proxy. A keyless caller is attributed by + // the forwarded origin, so the receipt is reported either way. + notifyReceiptReceived({ + userOpHash: userOpHash as `0x${string}`, + transactionHash: actualReceipt.transactionHash, + success: isSuccess, + apiKey, + }); if (isSuccess) { // Transaction succeeded - mark as completed diff --git a/packages/core/src/sdk/createJAWSDK.test.ts b/packages/core/src/sdk/createJAWSDK.test.ts new file mode 100644 index 000000000..82ff616bf --- /dev/null +++ b/packages/core/src/sdk/createJAWSDK.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { create } from './createJAWSDK.js'; +import { Mode } from '../provider/interface.js'; +import { sdkstore, store } from '../store/index.js'; +import { SDK_VERSION } from '../sdk-info.js'; +import { JAW_RPC_URL } from '../constants.js'; + +describe('create() chain registration', () => { + // create() writes into a module-level singleton, so without this the + // account chain a later test asserts on could have been set by an earlier one. + beforeEach(() => { + sdkstore.setState( + { chains: [], keys: {}, account: {}, config: { version: SDK_VERSION }, callStatuses: {} }, + true + ); + }); + + it('registers chains when an api-key is given', () => { + create({ apiKey: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', appName: 'Test' }); + + const chains = store.chains.get(); + expect(chains.length).toBeGreaterThan(0); + expect(chains[0].rpcUrl).toContain('api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6'); + }); + + // The chain list used to be built only when a key was present, so a config + // without one produced an SDK with no chains and no error saying why. The + // backend decides whether a request is served; the SDK does not get to decide + // it by leaving the store empty. + it('registers the same chains when there is no api-key', () => { + create({ appName: 'Test' }); + + const chains = store.chains.get(); + expect(chains.length).toBeGreaterThan(0); + for (const chain of chains) { + expect(chain.rpcUrl).toBe(`${JAW_RPC_URL}?chainId=${chain.id}`); + } + }); + + // App-specific mode hands the key to the dApp's own UIHandler, which has nowhere + // to get one. It used to be caught in createSigner, so a misconfigured app got + // an SDK that built fine and failed on its first request instead. + it('refuses app-specific mode with no api-key, at config time', () => { + expect(() => create({ appName: 'Test', preference: { mode: Mode.AppSpecific } })).toThrow(/API key/i); + }); + + it('honours defaultChainId without an api-key', () => { + create({ appName: 'Test', defaultChainId: 8453 }); + + expect(store.chains.get().some((c) => c.id === 8453)).toBe(true); + expect(store.account.get().chain?.id).toBe(8453); + }); +}); diff --git a/packages/core/src/sdk/createJAWSDK.ts b/packages/core/src/sdk/createJAWSDK.ts index df5fa6bc7..6bb256da2 100644 --- a/packages/core/src/sdk/createJAWSDK.ts +++ b/packages/core/src/sdk/createJAWSDK.ts @@ -13,7 +13,11 @@ import { announceProvider as announceProviderFn, type AnnounceProviderCleanup } import type { JawTheme } from '../ui/theme.js'; export type CreateJAWSDKOptions = Partial & { - apiKey: string; + /** + * Identifies the calling dApp to the JAW backend. Optional, because whether + * a request is served is the backend's decision rather than the SDK's. + */ + apiKey?: string; preference?: Partial; /** Mapping of chain IDs to paymaster configuration */ paymasters?: Record; @@ -76,6 +80,13 @@ export function create(params: CreateJAWSDKOptions) { throw new Error('Custom Server Url not available with Cross Platform Mode.'); } + // App-specific mode hands the key to the dApp's own UIHandler, which has + // nowhere to get one. Refused here rather than on the first request, so a + // misconfigured app fails while it is still being wired up. + if (options.preference.mode == Mode.AppSpecific && !params.apiKey) { + throw new Error('API key is required for App Specific Mode.'); + } + // Store the config const storedOptions = { metadata: options.metadata, @@ -89,22 +100,20 @@ export function create(params: CreateJAWSDKOptions) { store.chains.clear(); ChainClients.setState({}); - if (params.apiKey) { - const initialChains = createInitialChains(params.apiKey, params.paymasters, options.preference.showTestnets); - store.chains.set(initialChains); - createClients(initialChains); - - // Update stored account chain if defaultChainId is provided and differs from stored chain - if (params.defaultChainId !== undefined) { - const currentAccount = store.account.get(); - const storedChainId = currentAccount.chain?.id; - - // Only update if the stored chain differs from the requested default - if (storedChainId !== params.defaultChainId) { - const targetChain = initialChains.find((c) => c.id === params.defaultChainId); - if (targetChain) { - store.account.set({ chain: targetChain }); - } + const initialChains = createInitialChains(params.apiKey, params.paymasters, options.preference.showTestnets); + store.chains.set(initialChains); + createClients(initialChains); + + // Update stored account chain if defaultChainId is provided and differs from stored chain + if (params.defaultChainId !== undefined) { + const currentAccount = store.account.get(); + const storedChainId = currentAccount.chain?.id; + + // Only update if the stored chain differs from the requested default + if (storedChainId !== params.defaultChainId) { + const targetChain = initialChains.find((c) => c.id === params.defaultChainId); + if (targetChain) { + store.account.set({ chain: targetChain }); } } } diff --git a/packages/core/src/signer/JAWSigner.apiKey.test.ts b/packages/core/src/signer/JAWSigner.apiKey.test.ts new file mode 100644 index 000000000..434d5321d --- /dev/null +++ b/packages/core/src/signer/JAWSigner.apiKey.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Address } from 'viem'; + +import { JAWSigner } from './JAWSigner.js'; +import { sdkstore } from '../store/index.js'; +import { SDK_VERSION } from '../sdk-info.js'; +import { handleGetAssetsRequest } from '../rpc/wallet_getAssets.js'; +import { handleGetCallsHistoryRequest } from '../rpc/wallet_getCallsHistory.js'; +import { handleGetPermissionsRequest, handleGetCapabilitiesRequest } from '../rpc/index.js'; +import type { RequestArguments } from '../provider/index.js'; + +vi.mock('../rpc/wallet_getAssets.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleGetAssetsRequest: vi.fn().mockResolvedValue([]) }; +}); +vi.mock('../rpc/wallet_getCallsHistory.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleGetCallsHistoryRequest: vi.fn().mockResolvedValue([]) }; +}); +vi.mock('../rpc/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + handleGetPermissionsRequest: vi.fn().mockResolvedValue([]), + handleGetCapabilitiesRequest: vi.fn().mockResolvedValue({}), + }; +}); + +/** Minimal concrete signer; only the authenticated read path is under test. */ +class TestSigner extends JAWSigner { + async handshake(): Promise { + /* not under test */ + } + protected async handleWalletConnect(): Promise { + return null; + } + protected async handleWalletConnectUnauthenticated(): Promise { + return null; + } + protected async handleSigningRequest(): Promise { + return null; + } +} + +const ACCOUNT = '0x1111111111111111111111111111111111111111' as Address; + +function seedConnected(apiKey?: string) { + sdkstore.setState( + { + chains: [], + keys: {}, + account: { accounts: [ACCOUNT], connectedAt: Date.now() }, + config: { version: SDK_VERSION, apiKey }, + callStatuses: {}, + }, + true + ); + return new TestSigner({ metadata: { name: 'test', defaultChainId: 1 } as never, callback: null }); +} + +// Third argument per method: the history and permission reads inject the +// connected account, the other two pass the testnet preference. +const reads = [ + { method: 'wallet_getCallsHistory', handler: vi.mocked(handleGetCallsHistoryRequest), third: ACCOUNT }, + { method: 'wallet_getAssets', handler: vi.mocked(handleGetAssetsRequest), third: false }, + { method: 'wallet_getPermissions', handler: vi.mocked(handleGetPermissionsRequest), third: ACCOUNT }, + { method: 'wallet_getCapabilities', handler: vi.mocked(handleGetCapabilitiesRequest), third: false }, +] as const; + +describe('JAWSigner proxy reads when no api-key is configured', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // These used to throw "No API key configured" before reaching the network. + // Whether to serve a request is the backend's decision, so the absence has to + // travel there for it to have a say. wallet_getCapabilities is the one that + // matters most: it declares EIP-5792 atomic support, which is what an + // interface gates one-click swaps on. + for (const { method, handler, third } of reads) { + const request = { method } as RequestArguments; + + it(`${method} forwards the missing key instead of refusing`, async () => { + const signer = seedConnected(undefined); + + await expect(signer.request(request)).resolves.toBeDefined(); + expect(handler).toHaveBeenCalledWith(request, undefined, third); + }); + + it(`${method} still forwards a key when there is one`, async () => { + const signer = seedConnected('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6'); + + await signer.request(request); + expect(handler).toHaveBeenCalledWith(request, 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', third); + }); + } +}); diff --git a/packages/core/src/signer/JAWSigner.test.ts b/packages/core/src/signer/JAWSigner.test.ts index 6f55f92c2..7284771a6 100644 --- a/packages/core/src/signer/JAWSigner.test.ts +++ b/packages/core/src/signer/JAWSigner.test.ts @@ -226,8 +226,8 @@ describe('JAWSigner signature analytics reporting', () => { expect(logSignature).not.toHaveBeenCalled(); }); - it('skips reporting silently when no API key is configured', async () => { - // Given an authenticated session without an API key + it('reports without a key, which the backend resolves from the dApp origin', async () => { + // Given an authenticated session with no API key seedAuthenticatedSession(undefined); const signer = makeSigningSigner(); @@ -237,9 +237,12 @@ describe('JAWSigner signature analytics reporting', () => { params: ['0xdeadbeef', SIGNER_ADDRESS], }); - // Then signing still succeeds and nothing is reported + // Then the signature is still reported, with no key to send expect(result).toBe('0xsignature'); - expect(logSignature).not.toHaveBeenCalled(); + expect(logSignature).toHaveBeenCalledExactlyOnceWith({ + address: SIGNER_ADDRESS, + apiKey: undefined, + }); }); it('still returns the signature when reporting itself throws synchronously', async () => { @@ -335,7 +338,7 @@ describe('JAWSigner SIWE signature reporting (wallet_connect)', () => { expect(logSignature).not.toHaveBeenCalled(); }); - it('skips reporting silently when no API key is configured', async () => { + it('reports without a key, which the backend resolves from the dApp origin', async () => { // Given no API key and a connect with a successful SIWE capability seedConfig(undefined); const signer = makeConnectSigner(); @@ -345,9 +348,9 @@ describe('JAWSigner SIWE signature reporting (wallet_connect)', () => { accounts: [{ address: ACCOUNT, capabilities: { signInWithEthereum: SIWE_SUCCESS } }], }); - // Then the connect still succeeds and nothing is reported + // Then the connect succeeds and the SIWE signature is still reported expect(result).toBeDefined(); - expect(logSignature).not.toHaveBeenCalled(); + expect(logSignature).toHaveBeenCalledExactlyOnceWith({ address: ACCOUNT, apiKey: undefined }); }); }); diff --git a/packages/core/src/signer/JAWSigner.ts b/packages/core/src/signer/JAWSigner.ts index ea70c3568..b5babbdc4 100644 --- a/packages/core/src/signer/JAWSigner.ts +++ b/packages/core/src/signer/JAWSigner.ts @@ -245,12 +245,13 @@ export abstract class JAWSigner implements Signer { // `.at(0)` (unlike `[0]`) is typed Address | undefined: the accounts // array is empty when signing is reached unauthenticated. An // unauthenticated wallet_sign without an address param therefore - // goes unreported on purpose — without an address there is no + // goes unreported on purpose. Without an address there is no // meaningful per-wallet metric to record. const address = this.extractSignerAddress(request) ?? this.accounts.at(0); - const apiKey = store.getState().config.apiKey; - if (!address || !apiKey) return; - logSignature({ address, apiKey }); + if (!address) return; + // The key travels when there is one. A keyless caller is attributed from the + // dApp origin instead, so dropping the report here would lose the metric. + logSignature({ address, apiKey: store.getState().config.apiKey }); } /** @@ -264,7 +265,7 @@ export abstract class JAWSigner implements Signer { protected reportSiweSignatures(response: WalletConnectResponse | null | undefined): void { try { const apiKey = store.getState().config.apiKey; - if (!apiKey || !response?.accounts) return; + if (!response?.accounts) return; for (const account of response.accounts) { const siwe = account.capabilities?.signInWithEthereum; if (siwe && 'signature' in siwe && account.address) { @@ -409,47 +410,26 @@ export abstract class JAWSigner implements Signer { case 'wallet_getCallsStatus': return await handleGetCallsStatusRequest(request); - case 'wallet_getCallsHistory': { - const config = store.config.get(); - const apiKey = config.apiKey; - - if (!apiKey) { - throw standardErrors.rpc.internal('No API key configured'); - } - - return await handleGetCallsHistoryRequest(request, apiKey, this.accounts[0]); - } + // These four reach the JAW proxy, which decides whether to serve + // them. The key is forwarded as it is rather than demanded here. + case 'wallet_getCallsHistory': + return await handleGetCallsHistoryRequest(request, store.config.get().apiKey, this.accounts[0]); case 'wallet_getAssets': { const config = store.config.get(); - const apiKey = config.apiKey; - const showTestnets = config.preference?.showTestnets ?? false; - - if (!apiKey) { - throw standardErrors.rpc.internal('No API key configured'); - } - - return await handleGetAssetsRequest(request, apiKey, showTestnets); + return await handleGetAssetsRequest(request, config.apiKey, config.preference?.showTestnets ?? false); } - case 'wallet_getPermissions': { - const config = store.config.get(); - const apiKey = config.apiKey; - - if (!apiKey) { - throw standardErrors.rpc.internal('No API key configured'); - } - - return await handleGetPermissionsRequest(request, apiKey, this.accounts[0]); - } + case 'wallet_getPermissions': + return await handleGetPermissionsRequest(request, store.config.get().apiKey, this.accounts[0]); case 'wallet_getCapabilities': { - const apiKey = store.getState().config.apiKey; - if (!apiKey) { - throw standardErrors.rpc.internal('No API key configured'); - } - const showTestnets = store.getState().config.preference?.showTestnets ?? false; - return await handleGetCapabilitiesRequest(request, apiKey, showTestnets); + const config = store.config.get(); + return await handleGetCapabilitiesRequest( + request, + config.apiKey, + config.preference?.showTestnets ?? false + ); } case 'wallet_switchEthereumChain': diff --git a/packages/core/src/signer/cross-platform/CrossPlatformSigner.ts b/packages/core/src/signer/cross-platform/CrossPlatformSigner.ts index c9e4246bd..7d5abf580 100644 --- a/packages/core/src/signer/cross-platform/CrossPlatformSigner.ts +++ b/packages/core/src/signer/cross-platform/CrossPlatformSigner.ts @@ -137,12 +137,8 @@ export class CrossPlatformSigner extends JAWSigner { if (processedRequest.method === 'wallet_revokePermissions') { const params = processedRequest.params as [{ id: `0x${string}` }]; const permissionId = params[0].id; - const apiKey = store.config.get().apiKey; - if (!apiKey) { - throw standardErrors.rpc.internal('No API key configured'); - } try { - const relayPermission = await getPermissionFromRelay(permissionId, apiKey); + const relayPermission = await getPermissionFromRelay(permissionId, store.config.get().apiKey); resolvedChain = this.resolveChain(relayPermission.chainId); } catch { throw standardErrors.rpc.invalidParams( diff --git a/packages/core/src/signer/utils.test.ts b/packages/core/src/signer/utils.test.ts new file mode 100644 index 000000000..a001d2765 --- /dev/null +++ b/packages/core/src/signer/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createSigner } from './utils.js'; +import type { UIHandler } from '../ui/interface.js'; +import type { AppMetadata } from '../provider/index.js'; + +const metadata = { appName: 'Test', appLogoUrl: null, defaultChainId: 1 } as AppMetadata; +const uiHandler = { init: vi.fn() } as unknown as UIHandler; + +describe('createSigner api-key requirement', () => { + // App-specific mode hands the key straight to the dApp's own UIHandler, which + // has nowhere to get one. An empty string is treated as absent here the same + // way the URL builders do. + it.each([undefined, ''])('refuses to build an appSpecific signer with %p', (apiKey) => { + expect(() => + createSigner({ signerType: 'appSpecific', metadata, uiHandler, callback: vi.fn(), apiKey }) + ).toThrow('API key is required for appSpecific signer'); + }); + + // This refusal comes back out of provider.request, where a dApp classifies by + // error.code. A bare Error carries none, so it read as an unknown failure. + it('refuses with a numeric code, like every other refusal on this path', () => { + expect(() => createSigner({ signerType: 'appSpecific', metadata, uiHandler, callback: vi.fn() })).toThrow( + expect.objectContaining({ code: expect.any(Number) }) + ); + }); + + it('builds a crossPlatform signer with no key', () => { + const communicator = { onMessage: vi.fn(), postMessage: vi.fn() } as never; + + expect(() => + createSigner({ signerType: 'crossPlatform', metadata, communicator, callback: vi.fn() }) + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/signer/utils.ts b/packages/core/src/signer/utils.ts index f563435ce..a69181f14 100644 --- a/packages/core/src/signer/utils.ts +++ b/packages/core/src/signer/utils.ts @@ -1,4 +1,5 @@ import { SignerType } from '../messages/index.js'; +import { standardErrors } from '../errors/index.js'; import { AppMetadata, ProviderEventCallback, PaymasterConfig } from '../provider/index.js'; import { Communicator } from '../communicator/index.js'; import { Signer } from './interface.js'; @@ -16,7 +17,7 @@ export function createSigner(params: { communicator?: Communicator; uiHandler?: UIHandler; callback: ProviderEventCallback; - apiKey: string; + apiKey?: string; paymasters?: Record; ens?: string; theme?: JawTheme; @@ -26,7 +27,7 @@ export function createSigner(params: { switch (signerType) { case 'crossPlatform': { if (!communicator) { - throw new Error('Communicator is required for crossPlatform signer'); + throw standardErrors.rpc.internal('Communicator is required for crossPlatform signer'); } return new CrossPlatformSigner({ metadata, @@ -37,7 +38,12 @@ export function createSigner(params: { case 'appSpecific': { if (!uiHandler) { - throw new Error('UIHandler is required for appSpecific signer'); + throw standardErrors.rpc.internal('UIHandler is required for appSpecific signer'); + } + // Already refused at config time in create(); kept as the last word on + // the type, which allows the key to be absent for the other mode. + if (!apiKey) { + throw standardErrors.rpc.internal('API key is required for appSpecific signer'); } return new AppSpecificSigner({ metadata, diff --git a/packages/core/src/store/chain-clients/utils.test.ts b/packages/core/src/store/chain-clients/utils.test.ts index fa651bcf0..b6eb76581 100644 --- a/packages/core/src/store/chain-clients/utils.test.ts +++ b/packages/core/src/store/chain-clients/utils.test.ts @@ -1,8 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { sepolia, optimismSepolia, arbitrumSepolia } from 'viem/chains'; import { ChainClients } from './store.js'; -import { createClients, getClient, getBundlerClient } from './utils.js'; +import { createClients, createInitialChains, dropChainClients, getClient, getBundlerClient } from './utils.js'; +import { store } from '../store.js'; +import { JAW_RPC_URL } from '../../constants.js'; +import { setDappOrigin } from '../../dappOrigin.js'; describe('chain-clients/utils', () => { beforeEach(() => { @@ -297,3 +300,137 @@ describe('chain-clients/utils', () => { expect(Object.keys(state).length).toBe(1); }); }); + +describe('createInitialChains api-key in the rpc url', () => { + it('appends the api-key when the caller has one', () => { + const chains = createInitialChains('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6'); + + expect(chains.length).toBeGreaterThan(0); + for (const chain of chains) { + expect(chain.rpcUrl).toBe(`${JAW_RPC_URL}?chainId=${chain.id}&api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6`); + } + }); + + // Dropped rather than sent empty, which would read as a malformed key + // instead of as no key at all. + it('omits the parameter entirely when there is no key', () => { + const chains = createInitialChains(); + + expect(chains.length).toBeGreaterThan(0); + for (const chain of chains) { + expect(chain.rpcUrl).toBe(`${JAW_RPC_URL}?chainId=${chain.id}`); + expect(chain.rpcUrl).not.toContain('api-key'); + } + }); +}); + +describe('naming the calling dApp on the wire', () => { + afterEach(() => { + setDappOrigin(undefined); + vi.unstubAllGlobals(); + ChainClients.setState({}, true); + }); + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + return () => new Headers(fetchMock.mock.calls[0][1].headers); + } + + async function callThrough(rpcUrl: string) { + ChainClients.setState({}, true); + createClients([{ id: 1, rpcUrl }]); + await getClient(1)?.getChainId(); + } + + // A dApp's own page never names one, and the browser is already putting the + // right Origin on the request. + it('sends no dApp header when this instance was told nothing', async () => { + const headers = stubFetch(); + + await callThrough(`${JAW_RPC_URL}?chainId=1`); + + expect(headers().has('x-dapp-origin')).toBe(false); + }); + + it('names the dApp on our own proxy', async () => { + const headers = stubFetch(); + setDappOrigin('https://dapp.example'); + + await callThrough(`${JAW_RPC_URL}?chainId=1`); + + expect(headers().get('x-dapp-origin')).toBe('https://dapp.example'); + }); + + // The rpc url is ours whatever host it points at, so the header cannot hang off + // matching the production one: a staging or local backend would silently stop + // being told which dApp is calling. The one url that may belong to somebody + // else is the paymaster's, and that is where the check lives. + it('names the dApp on a backend that is not the production one', async () => { + const headers = stubFetch(); + setDappOrigin('https://dapp.example'); + + await callThrough('http://localhost:3013/proxy/v1/rpc?chainId=1'); + + expect(headers().get('x-dapp-origin')).toBe('https://dapp.example'); + }); +}); + +// The two lazy getters return a cached client before they ever read the store, so a +// replaced chain entry is invisible for the lifetime of the document without this. +describe('dropChainClients', () => { + beforeEach(() => { + ChainClients.setState({}, true); + store.chains.set([]); + }); + + it('makes the next getter rebuild from the entry the store holds now', () => { + const keyless = `${JAW_RPC_URL}?chainId=${sepolia.id}`; + const keyed = `${keyless}&api-key=k1`; + + store.chains.set([{ id: sepolia.id, rpcUrl: keyless }]); + const first = getClient(sepolia.id); + expect(first?.transport.url).toBe(keyless); + + store.chains.set([{ id: sepolia.id, rpcUrl: keyed }]); + // Without the drop the cached client is handed back and the new url never applies. + expect(getClient(sepolia.id)?.transport.url).toBe(keyless); + + dropChainClients(sepolia.id); + expect(getClient(sepolia.id)?.transport.url).toBe(keyed); + }); + + it('drops the bundler client alongside the public one', () => { + store.chains.set([{ id: sepolia.id, rpcUrl: `${JAW_RPC_URL}?chainId=${sepolia.id}` }]); + getClient(sepolia.id); + getBundlerClient(sepolia.id); + expect(ChainClients.getState()[sepolia.id]).toBeDefined(); + + dropChainClients(sepolia.id); + + expect(ChainClients.getState()[sepolia.id]).toBeUndefined(); + }); + + it('leaves the other chains alone', () => { + store.chains.set([ + { id: sepolia.id, rpcUrl: `${JAW_RPC_URL}?chainId=${sepolia.id}` }, + { id: optimismSepolia.id, rpcUrl: `${JAW_RPC_URL}?chainId=${optimismSepolia.id}` }, + ]); + getClient(sepolia.id); + const other = getClient(optimismSepolia.id); + + dropChainClients(sepolia.id); + + expect(getClient(optimismSepolia.id)).toBe(other); + }); + + it('is a no-op for a chain with nothing cached', () => { + expect(() => dropChainClients(sepolia.id)).not.toThrow(); + expect(ChainClients.getState()).toEqual({}); + }); +}); diff --git a/packages/core/src/store/chain-clients/utils.ts b/packages/core/src/store/chain-clients/utils.ts index 4ee5a4d40..a4a00e22a 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -3,10 +3,11 @@ import { BundlerClient, createBundlerClient, createPaymasterClient } from 'viem/ import { ChainClients } from './store.js'; import { RPCResponseNativeCurrency } from '../../messages/rpcMessage.js'; -import { JAW_RPC_URL } from '../../constants.js'; +import { JAW_PROXY_URL, JAW_RPC_URL } from '../../constants.js'; import { getSupportedChains, SUPPORTED_CHAINS } from '../../account/smartAccount.js'; import { createPaymasterFunctions } from '../../account/paymaster.js'; import { store } from '../store.js'; +import { jawHttp } from '../../utils/jawHttp.js'; /** * Paymaster configuration for a chain @@ -69,7 +70,7 @@ function createClientForChain(chain: SDKChain): { client: PublicClient; bundlerC const client = createPublicClient({ chain: viemchain, - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), // Fold eth_calls issued in the same tick into a single Multicall3 // aggregate3 — callers that fan out over N tokens (balances, decimals, // symbols) pay one round-trip instead of N. aggregate3 sets @@ -93,21 +94,25 @@ function createClientForChain(chain: SDKChain): { client: PublicClient; bundlerC const bundlerClient = createBundlerClient({ chain: viemchain, client, - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); return { client, bundlerClient }; } // Create paymaster client and wrap with custom functions that handle gas price fetching and v0.8 gas limits const paymasterClient = createPaymasterClient({ - transport: http(chain.paymaster.url), + // A paymaster can be another company's server, and which dApp the user is + // on is not theirs to learn. Ours is the only one told. + transport: chain.paymaster.url.startsWith(JAW_PROXY_URL) + ? jawHttp(chain.paymaster.url) + : http(chain.paymaster.url), }); const bundlerClient = createBundlerClient({ chain: viemchain, client, paymaster: createPaymasterFunctions(client, paymasterClient, chain.id, chain.paymaster.context), - transport: http(chain.rpcUrl), + transport: jawHttp(chain.rpcUrl), }); return { client, bundlerClient }; @@ -130,6 +135,21 @@ export function createClients(chains: SDKChain[]) { }); } +/** + * Forgets the cached clients for a chain, so the next `getClient` or + * `getBundlerClient` rebuilds them from whatever the store holds now. + * + * Both getters return the cached client before they ever read the store, so + * replacing a chain's entry is invisible for the lifetime of the document + * without this: the transport built from the old entry keeps being handed out. + */ +export function dropChainClients(chainId: number): void { + const { [chainId]: dropped, ...rest } = ChainClients.getState(); + if (!dropped) return; + // `replace` is required: a merging setState cannot take a key away. + ChainClients.setState(rest, true); +} + /** * Gets or creates a PublicClient for a chain. * If the client doesn't exist, it will be created lazily from the chain config in the store. @@ -198,9 +218,10 @@ export function getBundlerClient(chainId: number): BundlerClient | undefined { /** * Creates initial chains with RPC URLs for all supported chains. - * RPC URLs are constructed as: {JAW_RPC_URL}?chainId={chainId}&api-key={apiKey} + * RPC URLs are constructed as: {JAW_RPC_URL}?chainId={chainId}&api-key={apiKey}, + * dropping the parameter when there is no key rather than sending it empty. * - * @param apiKey - API key for authentication + * @param apiKey - API key for authentication, if the caller has one * @param paymasters - Optional mapping of chain IDs to paymaster configuration * @param showTestnets - Whether to include testnet chains (default: false) * @returns Array of SDKChain objects with constructed RPC URLs for supported chains @@ -220,14 +241,14 @@ export function getBundlerClient(chainId: number): BundlerClient | undefined { * ``` */ export function createInitialChains( - apiKey: string, + apiKey?: string, paymasters?: Record, showTestnets = false ): SDKChain[] { const chains = getSupportedChains(showTestnets); return chains.map((chain) => ({ id: chain.id, - rpcUrl: `${JAW_RPC_URL}?chainId=${chain.id}&api-key=${apiKey}`, + rpcUrl: apiKey ? `${JAW_RPC_URL}?chainId=${chain.id}&api-key=${apiKey}` : `${JAW_RPC_URL}?chainId=${chain.id}`, ...(paymasters?.[chain.id] ? { paymaster: paymasters[chain.id] } : {}), })); } diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index daa9fe757..1db51f250 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -2,4 +2,4 @@ export * from './chain-clients/index.js'; export * from './correlation-ids/index.js'; export * from './store.js'; export * from './types.js'; -export { createInitialChains } from './chain-clients/utils.js'; +export { createInitialChains, dropChainClients } from './chain-clients/utils.js'; diff --git a/packages/core/src/store/store.test.ts b/packages/core/src/store/store.test.ts index 1386b5894..9b260cb2c 100644 --- a/packages/core/src/store/store.test.ts +++ b/packages/core/src/store/store.test.ts @@ -511,6 +511,21 @@ describe('store', () => { }); }); + // keys.jaw.id is one origin shared by every dApp in popup mode, and the config + // slice is persisted wholesale. A dappOrigin surviving there would greet the + // next dApp's document holding the previous one's origin. + describe('what the config slice persists', () => { + it('leaves the calling dApp out of storage', () => { + store.config.set({ apiKey: 'k1', dappOrigin: 'https://dapp.example' }); + + const persisted = sdkstore.persist.getOptions().partialize?.(sdkstore.getState()); + + expect(persisted?.config.apiKey).toBe('k1'); + expect(persisted?.config).not.toHaveProperty('dappOrigin'); + expect(store.config.get().dappOrigin).toBe('https://dapp.example'); + }); + }); + describe('state isolation', () => { it('should not affect other slices when updating one', () => { chains.set([{ id: 1 }]); diff --git a/packages/core/src/store/store.ts b/packages/core/src/store/store.ts index c96064f72..15e90bc93 100644 --- a/packages/core/src/store/store.ts +++ b/packages/core/src/store/store.ts @@ -123,11 +123,17 @@ export const sdkstore = createStore( {} as Record ); + // dappOrigin is per-flow, not per-install: keys.jaw.id is one origin + // shared by every dApp in popup mode, so a persisted value would greet + // the next dApp holding the previous one's origin. + const config = { ...state.config }; + delete config.dappOrigin; + return { chains: state.chains, keys: state.keys, account: state.account, - config: state.config, + config, callStatuses: serializedCallStatuses, } as StoreState; }, diff --git a/packages/core/src/store/types.ts b/packages/core/src/store/types.ts index fa6b5b164..5faa5078e 100644 --- a/packages/core/src/store/types.ts +++ b/packages/core/src/store/types.ts @@ -63,6 +63,12 @@ export type Config = { version: string; deviceId?: string; apiKey?: string; + /** + * The dApp this core instance is acting for. Set only by keys, whose own + * `Origin` is the same whichever dApp opened it. Never set in a dApp's own + * page, where the browser already puts the right `Origin` on the request. + */ + dappOrigin?: string; /** Mapping of chain IDs to paymaster configuration */ paymasters?: Record; }; diff --git a/packages/core/src/utils/jawHttp.ts b/packages/core/src/utils/jawHttp.ts new file mode 100644 index 000000000..d23339e18 --- /dev/null +++ b/packages/core/src/utils/jawHttp.ts @@ -0,0 +1,28 @@ +import { http, HttpTransportConfig } from 'viem'; + +import { store } from '../store/index.js'; + +/** + * An http transport that names the dApp this instance acts for, when it was told + * one. Read per request rather than baked into the transport, because the clients + * are built before the dApp is known. + * + * Only for hosts of ours. A third-party paymaster is a different company's server + * and has no business learning which dApp the user is on, so callers that may be + * pointing at one check the url before reaching for this. + * + * `onFetchRequest` is this transport's whole point, so it is not a caller's to set. + */ +export function jawHttp(url?: string, config?: Omit) { + return http(url, { + ...config, + onFetchRequest: (_request, init) => { + const dappOrigin = store.config.get().dappOrigin; + if (!dappOrigin) return undefined; + + // viem uses whatever comes back here in place of `init` rather than + // merging it, so it goes back whole. + return { ...init, headers: { ...init.headers, 'x-dapp-origin': dappOrigin } }; + }, + }); +} diff --git a/packages/core/src/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts new file mode 100644 index 000000000..da2fc48ca --- /dev/null +++ b/packages/core/src/utils/provider.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +import { buildHandleJawRpcUrl, fetchRPCRequest, isBackendRefusal } from './provider.js'; +import { setDappOrigin } from '../dappOrigin.js'; + +describe('buildHandleJawRpcUrl', () => { + it('appends the api-key when the caller has one', () => { + expect(buildHandleJawRpcUrl('https://rpc.example', 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6')).toBe( + 'https://rpc.example/handle?api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6' + ); + }); + + // Sending `api-key=` empty would reach the proxy as a malformed key and be + // rejected before anything else is considered, so the parameter goes away. + it('omits the parameter entirely when there is no key', () => { + expect(buildHandleJawRpcUrl('https://rpc.example')).toBe('https://rpc.example/handle'); + expect(buildHandleJawRpcUrl('https://rpc.example', '')).toBe('https://rpc.example/handle'); + }); +}); + +describe('fetchRPCRequest', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubResponse(status: number, body: string) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => JSON.parse(body), + text: async () => body, + }) + ); + } + + it('returns the JSON-RPC result on success', async () => { + stubResponse(200, JSON.stringify({ result: { atomic: { status: 'supported' } } })); + + await expect(fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example')).resolves.toEqual({ + atomic: { status: 'supported' }, + }); + }); + + it('throws the JSON-RPC error when the proxy answers with one', async () => { + stubResponse(200, JSON.stringify({ error: { code: -32000, message: 'nope' } })); + + await expect(fetchRPCRequest({ method: 'wallet_getAssets' }, 'https://rpc.example')).rejects.toMatchObject({ + message: 'nope', + }); + }); + + // The refusal a guard sends is not a JSON-RPC envelope. Destructuring it used + // to yield `{ result: undefined, error: undefined }` and resolve to undefined, + // which wallet_getCapabilities then memoized for a minute. + it('throws on a rejection instead of resolving to undefined', async () => { + stubResponse(401, JSON.stringify({ statusCode: 401, message: 'Api key not found' })); + + await expect(fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example')).rejects.toThrow( + /401/ + ); + }); + + it('reports the status for a non-auth failure too', async () => { + stubResponse(502, 'Bad Gateway'); + + await expect(fetchRPCRequest({ method: 'wallet_getAssets' }, 'https://rpc.example')).rejects.toThrow(/502/); + }); + + // An error status can still carry a JSON-RPC envelope, and that envelope is the + // only place a revert reason reaches the dApp: a 200-char slice of the body + // would drop both the code and the ABI-encoded data. + it('throws the JSON-RPC error even when the status says failure', async () => { + stubResponse(400, JSON.stringify({ error: { code: 3, message: 'execution reverted', data: '0x08c379a0' } })); + + await expect(fetchRPCRequest({ method: 'eth_call' }, 'https://rpc.example')).rejects.toMatchObject({ + code: 3, + message: 'execution reverted', + data: '0x08c379a0', + }); + }); + + // A 200 carrying an HTML error page from something in front of the proxy used to + // resolve to undefined, which wallet_getCapabilities then memoized for a minute. + it('fails on a 2xx whose body is not a JSON-RPC response', async () => { + stubResponse(200, 'upstream timeout'); + + await expect(fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example')).rejects.toThrow( + /not a JSON-RPC response/ + ); + }); + + // `{}` and `[]` are objects too, so a guard on the type alone lets them through + // and hands the caller the same undefined an HTML body used to. + it.each([ + ['an empty object', '{}'], + ['an array', '[]'], + ])('fails on a 2xx whose body is %s', async (_label, body) => { + stubResponse(200, body); + + await expect(fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example')).rejects.toThrow( + /not a JSON-RPC response/ + ); + }); + + // A method that answers with null answered: the envelope carries `result`. + it('returns a null result', async () => { + stubResponse(200, JSON.stringify({ jsonrpc: '2.0', id: 1, result: null })); + + await expect( + fetchRPCRequest({ method: 'eth_getTransactionReceipt' }, 'https://rpc.example') + ).resolves.toBeNull(); + }); + + it('still fails when the rejection body cannot be read', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => { + throw new Error('stream already consumed'); + }, + }) + ); + + await expect(fetchRPCRequest({ method: 'wallet_getPermissions' }, 'https://rpc.example')).rejects.toThrow( + /403/ + ); + }); +}); + +describe('fetchRPCRequest and the calling dApp', () => { + afterEach(() => { + setDappOrigin(undefined); + vi.unstubAllGlobals(); + }); + + function captureHeaders() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0x1' }), + }); + vi.stubGlobal('fetch', fetchMock); + return () => fetchMock.mock.calls[0][1].headers as Record; + } + + // A dApp's own page never sets one: the browser already puts the right + // Origin on the request, so a value here would be a claim it did not make. + it('sends no dApp header when none was set', async () => { + const headers = captureHeaders(); + + await fetchRPCRequest({ method: 'eth_chainId' }, 'https://rpc.example'); + + expect(headers()).not.toHaveProperty('x-dapp-origin'); + }); + + it('sends the dApp it was told it is acting for', async () => { + const headers = captureHeaders(); + setDappOrigin('https://dapp.example'); + + await fetchRPCRequest({ method: 'eth_chainId' }, 'https://rpc.example'); + + expect(headers()['x-dapp-origin']).toBe('https://dapp.example'); + }); + + it('stops sending it once it is cleared', async () => { + setDappOrigin('https://dapp.example'); + setDappOrigin(undefined); + const headers = captureHeaders(); + + await fetchRPCRequest({ method: 'eth_chainId' }, 'https://rpc.example'); + + expect(headers()).not.toHaveProperty('x-dapp-origin'); + }); +}); + +// The marker the capabilities cache reads. It stays out of the error itself: +// `JAWProvider` serialises what it catches straight to the dApp. +describe('a refusal is marked without being announced', () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubResponse(status: number, body: string) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => JSON.parse(body), + text: async () => body, + }) + ); + } + + it.each([401, 403])('marks a %s, whatever the body was', async (status) => { + stubResponse(status, 'no'); + + const error = await fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example').catch( + (e) => e + ); + + expect(isBackendRefusal(error)).toBe(true); + expect(Object.keys(error as object)).not.toContain('refused'); + }); + + it('marks a refusal that came wrapped in an envelope', async () => { + stubResponse(403, JSON.stringify({ error: { code: -32001, message: 'origin not registered' } })); + + const error = await fetchRPCRequest({ method: 'wallet_getCapabilities' }, 'https://rpc.example').catch( + (e) => e + ); + + expect(error).toMatchObject({ code: -32001 }); + expect(isBackendRefusal(error)).toBe(true); + }); + + it('leaves a server error unmarked, since asking again can answer it', async () => { + stubResponse(502, 'Bad Gateway'); + + const error = await fetchRPCRequest({ method: 'wallet_getAssets' }, 'https://rpc.example').catch((e) => e); + + expect(isBackendRefusal(error)).toBe(false); + }); +}); diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 197b319f9..c14a22472 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -1,14 +1,37 @@ import { standardErrors } from '../errors/index.js'; import { RequestArguments } from '../provider/index.js'; +import { store } from '../store/index.js'; /** - * Constructs the JAW RPC URL with the provided API key as a query parameter + * Constructs the JAW RPC URL, appending the API key as a query parameter when + * there is one. The parameter is dropped rather than sent empty, since + * `api-key=` with nothing after it reaches the proxy as a malformed key. * @param baseUrl The base RPC URL - * @param apiKey The API key to append to the URL - * @returns The constructed URL with the API key query parameter + * @param apiKey The API key to append to the URL, if the caller has one + * @returns The constructed URL */ -export function buildHandleJawRpcUrl(baseUrl: string, apiKey: string): string { - return `${baseUrl}/handle?api-key=${apiKey}`; +export function buildHandleJawRpcUrl(baseUrl: string, apiKey?: string): string { + return apiKey ? `${baseUrl}/handle?api-key=${apiKey}` : `${baseUrl}/handle`; +} + +/** + * The errors this module threw because the backend turned the caller down. + * + * Held beside the error rather than on it: `JAWProvider` serialises what it + * catches straight to the dApp, and a marker for our own caching would become a + * field of the public error nobody documented. + */ +const refusals = new WeakSet(); + +/** Whether this error is the backend refusing the caller, which asking again cannot change. */ +export function isBackendRefusal(error: unknown): boolean { + return typeof error === 'object' && error !== null && refusals.has(error); +} + +/** Marks and returns the error, so a thrower can stay a one-liner. */ +function refusal(error: T): T { + if (typeof error === 'object' && error !== null) refusals.add(error); + return error; } export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) { @@ -17,17 +40,68 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) jsonrpc: '2.0', id: crypto.randomUUID(), }; + // Calls made from the keys origin all carry the same `Origin`, so the caller + // they act on behalf of travels alongside instead of in it. + const dappOrigin = store.config.get().dappOrigin; + const res = await fetch(rpcUrl, { method: 'POST', body: JSON.stringify(requestBody), mode: 'cors', headers: { 'Content-Type': 'application/json', + ...(dappOrigin ? { 'x-dapp-origin': dappOrigin } : {}), }, }); - const { result, error } = await res.json(); - if (error) throw error; - return result; + + // Read the body before looking at the status: an error status can still carry + // a JSON-RPC envelope, and that envelope is the only place a revert reason + // reaches the dApp. viem's own http transport does the same. + const body = await res.text().catch(() => ''); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the result is whatever the method returns, as before + let envelope: { result?: any; error?: { code?: unknown; message?: unknown } } | undefined; + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object') envelope = parsed; + } catch { + // Not JSON at all. Either the status below explains it, or the envelope check does. + } + + // Whether the backend turned this caller down, which is the one failure that + // answering again cannot change. Taken from the status, not from the error's + // code: a refusal that arrives inside a JSON-RPC envelope keeps that + // envelope's own code, so a caller matching on the code alone would read the + // same refusal as a blip depending on what the backend wrapped it in. + const refused = res.status === 401 || res.status === 403; + + // A well-formed JSON-RPC error is the answer whatever the status says. + const rpcError = envelope?.error; + if (rpcError && typeof rpcError.code === 'number' && typeof rpcError.message === 'string') { + throw refused ? refusal(rpcError) : rpcError; + } + + // A refusal from the proxy is not a JSON-RPC envelope, so destructuring it + // hands back two undefineds and the call resolves to `undefined` instead of + // failing. Callers that memoize their result then cache that silence, which + // is how a rejected wallet_getCapabilities reads as "no capabilities". + if (!res.ok) { + const message = `JAW RPC request failed with ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`; + throw refused ? refusal(standardErrors.provider.unauthorized(message)) : standardErrors.rpc.internal(message); + } + + // On a 2xx, anything sitting in `error` is still a failure, however malformed. + if (rpcError) throw rpcError; + + // A 2xx whose body is not an envelope is the same silence as a refusal: returning + // undefined here is what wallet_getCapabilities would memoize for a minute. `{}` + // and `[]` parse as objects and carry no `result`, so the key is what decides. + if (!envelope || !('result' in envelope)) { + throw standardErrors.rpc.internal( + `JAW RPC request returned a body that is not a JSON-RPC response${body ? `: ${body.slice(0, 200)}` : ''}` + ); + } + + return envelope.result; } /** * Validates the arguments for an invalid request and returns an error if any validation fails. diff --git a/packages/ui/src/components/Eip712Dialog/index.test.ts b/packages/ui/src/components/Eip712Dialog/index.test.ts index bb3e26bcc..f56022cc0 100644 --- a/packages/ui/src/components/Eip712Dialog/index.test.ts +++ b/packages/ui/src/components/Eip712Dialog/index.test.ts @@ -17,6 +17,8 @@ vi.mock('@jaw.id/core', () => ({ ANY_TARGET: '0x3232323232323232323232323232323232323232', ANY_FN_SEL: '0x32323232', EMPTY_CALLDATA_FN_SEL: '0xe0e0e0e0', + // Same reason: the chain icon hook reads the capabilities cache on render. + peekCapabilities: () => undefined, })); vi.mock('../../hooks/useReverseIdentity', () => ({ useReverseIdentity: () => ({ name: undefined, avatar: undefined }), diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts new file mode 100644 index 000000000..a233b44b3 --- /dev/null +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts @@ -0,0 +1,48 @@ +// The address backfill used to refuse to run without an api key, which left a +// keyless dApp's account chips with no address. It derives for every caller now. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@jaw.id/core', () => ({ + Account: { + backfillStoredAccountAddresses: vi.fn(), + getStoredAccounts: vi.fn(() => []), + }, +})); + +import { Account } from '@jaw.id/core'; +import { backfillLocalAccountAddresses } from './accountHelpers'; + +const backfillMock = vi.mocked(Account.backfillStoredAccountAddresses); + +const ADDRESS = '0x1111111111111111111111111111111111111111'; + +describe('backfillLocalAccountAddresses', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('derives with no api key', async () => { + backfillMock.mockResolvedValue([{ credentialId: 'cred-1', address: ADDRESS }] as never); + + const byCredentialId = await backfillLocalAccountAddresses({ chainId: 1 }); + + expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: undefined }); + expect(byCredentialId).toEqual({ 'cred-1': ADDRESS }); + }); + + it('defaults to mainnet when the dialog has no chain yet', async () => { + backfillMock.mockResolvedValue([] as never); + + await backfillLocalAccountAddresses({ apiKey: 'test-key' }); + + expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: 'test-key' }); + }); + + // A record whose derivation failed comes back without an address, and the + // dialog renders it without a chip rather than with somebody else's. + it('leaves out the records that resolved nothing', async () => { + backfillMock.mockResolvedValue([{ credentialId: 'cred-1', address: ADDRESS }, { credentialId: 'cred-2' }] as never); + + expect(await backfillLocalAccountAddresses({ chainId: 1 })).toEqual({ 'cred-1': ADDRESS }); + }); +}); diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts index 4f177438b..fa7c21fd1 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts @@ -26,9 +26,6 @@ export async function backfillLocalAccountAddresses(params: { chainId?: number; apiKey?: string; }): Promise> { - // Derivation is an authenticated RPC call; without a key every record would - // just fail-and-retry, so don't bother. - if (!params.apiKey) return {}; const accounts = await Account.backfillStoredAccountAddresses({ chainId: params.chainId ?? 1, apiKey: params.apiKey, diff --git a/packages/ui/src/components/OnboardingDialog/index.tsx b/packages/ui/src/components/OnboardingDialog/index.tsx index 1d3bb8e27..495a7f848 100644 --- a/packages/ui/src/components/OnboardingDialog/index.tsx +++ b/packages/ui/src/components/OnboardingDialog/index.tsx @@ -395,7 +395,7 @@ export function OnboardingDialog({ const [backfillInFlight, setBackfillInFlight] = useState(false); const hasAddressGaps = accounts.some((a) => !a.address && a.credentialId); useEffect(() => { - if (!hasAddressGaps || !apiKey) return; + if (!hasAddressGaps) return; let cancelled = false; setBackfillInFlight(true); backfillLocalAccountAddresses({ chainId, apiKey }) diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index f4fba9d98..e8d2d293c 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,7 +1,9 @@ export * from './useIsMobile'; export * from './useDialogMobileFullScreen'; export * from './useChainIconURI'; -export * from './useChainIcons'; +// Named, not `export *`: the cache clear beside it is for this package's own +// tests, and a consumer that called it would empty the icons of the whole app. +export { useChainIcons, type ChainIconMap } from './useChainIcons'; export * from './useReverseIdentity'; export * from './useFeeTokenPrice'; export * from './useGasEstimation'; diff --git a/packages/ui/src/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx new file mode 100644 index 000000000..9ea486c71 --- /dev/null +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +// The chain icon comes from wallet_getCapabilities, which the proxy now serves to a +// dApp registered by origin. Refusing to fetch without a key left keyless dApps with +// the '?' placeholder on the confirm screen. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@jaw.id/core', () => ({ + handleGetCapabilitiesRequest: vi.fn(), + peekCapabilities: vi.fn(), +})); + +import { handleGetCapabilitiesRequest, peekCapabilities } from '@jaw.id/core'; +import { useChainIconURI } from './useChainIconURI'; + +const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); +const peekMock = vi.mocked(peekCapabilities); + +const ICON = 'https://icons.example/base.png'; +const OTHER_ICON = 'https://icons.example/optimism.png'; + +function Probe({ chainId, apiKey }: { chainId: number; apiKey?: string }) { + return useChainIconURI(chainId, apiKey, 24); +} + +let root: Root | null = null; +let container: HTMLDivElement; + +async function mount(chainId: number, apiKey?: string) { + container = document.createElement('div'); + root = createRoot(container); + await act(async () => { + root!.render(createElement(Probe, { chainId, apiKey })); + }); + await act(() => Promise.resolve()); +} + +beforeEach(() => { + // Cold by default: what the cache can answer is its own test below. + peekMock.mockReturnValue(undefined); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + vi.clearAllMocks(); +}); + +describe('useChainIconURI', () => { + it('renders the icon a keyed caller gets back', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, 'test-key'); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + // Keys extracts the key from the rpc url, so a keyless dApp arrives as ''. + it.each([undefined, ''])('fetches and renders with no key (%o)', async (apiKey) => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, apiKey); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(capabilitiesMock.mock.calls[0][1]).toBe(apiKey); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + // The response is cached a layer below, in handleGetCapabilitiesRequest, which + // is also where concurrent callers are merged into one request. + it('asks on every mount', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, 'test-key'); + if (root) act(() => root.unmount()); + await mount(1, 'test-key'); + + expect(capabilitiesMock).toHaveBeenCalledTimes(2); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + it('tries again after a failed lookup', async () => { + capabilitiesMock.mockRejectedValueOnce(new Error('offline')); + await mount(1, 'test-key'); + expect(container.querySelector('img')).toBeNull(); + + if (root) act(() => root.unmount()); + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, 'test-key'); + + expect(capabilitiesMock).toHaveBeenCalledTimes(2); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + it('falls back for a chain the backend has no icon for', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': {} } as never); + await mount(1, 'test-key'); + + expect(container.querySelector('img')).toBeNull(); + }); + + // The dialog stays mounted when the user switches chain, and the icon it is + // showing is the old one until the new lookup lands. + it('drops the previous chain icon while the next one loads', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, 'test-key'); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + + let resolveSecond: (value: unknown) => void = () => undefined; + capabilitiesMock.mockReturnValue(new Promise((resolve) => (resolveSecond = resolve)) as never); + await act(async () => { + root!.render(createElement(Probe, { chainId: 10, apiKey: 'test-key' })); + }); + + expect(container.querySelector('img')).toBeNull(); + + await act(async () => { + resolveSecond({ '0xa': { chainMetadata: { icon: OTHER_ICON } } }); + }); + expect(container.querySelector('img')?.getAttribute('src')).toBe(OTHER_ICON); + }); + + // `chainId ?? 0` is what a dialog passes when the request names no chain, and + // the icon left on screen would read as that request's chain. + it('clears the icon when the chain goes away', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, 'test-key'); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + + await act(async () => { + root!.render(createElement(Probe, { chainId: 0, apiKey: 'test-key' })); + }); + + expect(container.querySelector('img')).toBeNull(); + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + }); + + // The measured cost of awaiting a warm cache: one committed frame with the + // placeholder on every mount, on eleven call sites including the confirm screen. + it('paints the icon on the first frame when the cache already has it', async () => { + peekMock.mockReturnValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + + container = document.createElement('div'); + root = createRoot(container); + // No flush: this is the first painted frame, before any promise resolves. + act(() => { + root!.render(createElement(Probe, { chainId: 1, apiKey: 'test-key' })); + }); + + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + expect(capabilitiesMock).not.toHaveBeenCalled(); + }); + + it('does not fetch without a chain', async () => { + await mount(0, 'test-key'); + + expect(capabilitiesMock).not.toHaveBeenCalled(); + expect(container.querySelector('img')).toBeNull(); + }); +}); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 83a2a1d51..f65e85981 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -1,37 +1,52 @@ import { JSX, useState, useEffect, useMemo } from 'react'; -import { handleGetCapabilitiesRequest, type ChainMetadataCapability } from '@jaw.id/core'; +import { handleGetCapabilitiesRequest, peekCapabilities, type ChainMetadataCapability } from '@jaw.id/core'; -// Simple in-memory cache for chain icons to avoid redundant API calls -const chainIconCache = new Map(); +/** The icon the cache can answer with, or undefined when it cannot answer at all. */ +function cachedIcon(chainId: number, apiKey?: string): { icon: string | null } | undefined { + if (!chainId) return undefined; + const chainIdHex = `0x${chainId.toString(16)}` as `0x${string}`; + const capabilities = peekCapabilities( + { method: 'wallet_getCapabilities', params: [undefined, [chainIdHex]] }, + apiKey, + true + ); + if (!capabilities) return undefined; + const metadata = capabilities[chainIdHex]?.chainMetadata as ChainMetadataCapability | undefined; + return { icon: metadata?.icon ?? null }; +} /** * Hook to fetch chain icon from wallet_getCapabilities chainMetadata * Returns a JSX element (img or fallback) similar to useChainIcon * + * The response is cached by `handleGetCapabilitiesRequest`, which also shares one + * request between callers that mount together. A mount that the cache can already + * answer reads it synchronously and asks nothing: awaiting a warm entry still + * paints the placeholder for a frame, on every dialog that shows a chain. + * * @param chainId - The chain ID to get the icon for - * @param apiKey - The API key for authentication + * @param apiKey - The API key for authentication, if the caller has one * @param size - The size of the icon in pixels (default: 24) * @returns JSX.Element - The chain icon or a fallback element */ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number): JSX.Element => { const iconSize = size ?? 24; - const cacheKey = `${chainId}-${apiKey}`; - const [iconURI, setIconURI] = useState(() => { - // Check cache first - return chainIconCache.get(cacheKey) ?? null; - }); - const [isLoading, setIsLoading] = useState(!chainIconCache.has(cacheKey)); + // Read once: every read clones the cached response, and the two states below + // and StrictMode would otherwise ask for the same answer four times a mount. + const [seeded] = useState(() => cachedIcon(chainId, apiKey)); + const [iconURI, setIconURI] = useState(seeded?.icon ?? null); + const [isLoading, setIsLoading] = useState(!seeded); useEffect(() => { - if (!apiKey || !chainId) { - setIsLoading(false); - return; - } + // Whatever the cache says about this chain, which is nothing at all when it + // has not been asked yet. Either way the icon of the chain we were rendering + // before comes off: a mounted dialog can switch chain, and keeping it up + // would put the wrong one on the screen for the length of the lookup. + const cached = cachedIcon(chainId, apiKey); + setIconURI(cached?.icon ?? null); - // If already cached, don't refetch - if (chainIconCache.has(cacheKey)) { - setIconURI(chainIconCache.get(cacheKey) ?? null); + if (cached || !chainId) { setIsLoading(false); return; } @@ -54,18 +69,12 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) if (isMounted) { const chainCapabilities = capabilities[chainIdHex]; const chainMetadata = chainCapabilities?.chainMetadata as ChainMetadataCapability | undefined; - const icon = chainMetadata?.icon ?? null; - - // Cache the result - chainIconCache.set(cacheKey, icon); - setIconURI(icon); + setIconURI(chainMetadata?.icon ?? null); setIsLoading(false); } } catch (error) { console.warn(`Failed to fetch capabilities for chain ${chainId}:`, error); if (isMounted) { - // Cache null to prevent repeated failed requests - chainIconCache.set(cacheKey, null); setIconURI(null); setIsLoading(false); } @@ -77,7 +86,7 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) return () => { isMounted = false; }; - }, [chainId, apiKey, cacheKey]); + }, [chainId, apiKey]); // Memoize the JSX to prevent unnecessary re-renders const icon = useMemo(() => { diff --git a/packages/ui/src/hooks/useChainIcons.test.tsx b/packages/ui/src/hooks/useChainIcons.test.tsx new file mode 100644 index 000000000..75906ad96 --- /dev/null +++ b/packages/ui/src/hooks/useChainIcons.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +// The whole-stack sibling of useChainIconURI. Its `!apiKey` gate was structural, +// since the cache was keyed on the key as well, so keyless it asked for nothing +// and every chain in the stack rendered its fallback glyph. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@jaw.id/core', () => ({ handleGetCapabilitiesRequest: vi.fn() })); + +import { handleGetCapabilitiesRequest } from '@jaw.id/core'; +import { clearChainIconsCache, useChainIcons } from './useChainIcons'; + +const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); +const CAPS = { '0x1': { chainMetadata: { icon: 'ICON1' } }, '0xa': { chainMetadata: { icon: 'ICON10' } } }; + +function Probe({ apiKey }: { apiKey?: string }) { + const icons = useChainIcons(apiKey); + return createElement('span', null, JSON.stringify(icons)); +} + +let root: Root | null = null; +let container: HTMLDivElement; + +async function mount(apiKey?: string) { + container = document.createElement('div'); + root = createRoot(container); + await act(async () => { + root!.render(createElement(Probe, { apiKey })); + }); + await act(() => Promise.resolve()); +} + +beforeEach(() => { + clearChainIconsCache(); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + vi.clearAllMocks(); +}); + +describe('useChainIcons', () => { + // Keys hands this `''` and the SDK hands it undefined, and both are the same + // caller: the proxy answers them on the origin it was told. + it.each([undefined, ''])('asks and renders with no key (%o)', async (apiKey) => { + capabilitiesMock.mockResolvedValue(CAPS as never); + + await mount(apiKey); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(capabilitiesMock.mock.calls[0][1]).toBe(apiKey); + expect(container.textContent).toBe(JSON.stringify({ 1: 'ICON1', 10: 'ICON10' })); + }); + + it('serves the cached map whichever way the missing key is spelled', async () => { + capabilitiesMock.mockResolvedValue(CAPS as never); + await mount(''); + if (root) act(() => root.unmount()); + capabilitiesMock.mockClear(); + + await mount(undefined); + + expect(capabilitiesMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain('ICON1'); + }); +}); diff --git a/packages/ui/src/hooks/useChainIcons.ts b/packages/ui/src/hooks/useChainIcons.ts index 94a8094d7..acdab6fd6 100644 --- a/packages/ui/src/hooks/useChainIcons.ts +++ b/packages/ui/src/hooks/useChainIcons.ts @@ -5,11 +5,19 @@ import { handleGetCapabilitiesRequest, type ChainMetadataCapability } from '@jaw export type ChainIconMap = Readonly>; // Keyed on the api key alone: the request below is the same for every caller, -// so one entry serves them all. Module-level, like useChainIconURI's cache, so -// this is a first-open cost per session rather than per mount. +// so one entry serves them all. A caller with no key reaches this as '' from +// keys and as undefined from the SDK, and both mean the same request, so they +// share the entry. Module-level, so this is a first-open cost per session +// rather than per mount. const iconsCache = new Map(); const inflight = new Map>(); +/** Drops the cached maps. For tests, which would otherwise share them. */ +export function clearChainIconsCache(): void { + iconsCache.clear(); + inflight.clear(); +} + /** * Every chain's icon in one request. * @@ -27,12 +35,11 @@ const inflight = new Map>(); * put a whole-catalogue payload behind every signing dialog. */ export function useChainIcons(apiKey?: string): ChainIconMap { - const [icons, setIcons] = useState(() => (apiKey ? (iconsCache.get(apiKey) ?? {}) : {})); + const cacheKey = apiKey ?? ''; + const [icons, setIcons] = useState(() => iconsCache.get(cacheKey) ?? {}); useEffect(() => { - if (!apiKey) return; - - const cached = iconsCache.get(apiKey); + const cached = iconsCache.get(cacheKey); if (cached) { setIcons(cached); return; @@ -42,7 +49,7 @@ export function useChainIcons(apiKey?: string): ChainIconMap { // Shared so two stacks mounting together (dialog + popup) still make one // request, which the per-chain hook could not do. - let request = inflight.get(apiKey); + let request = inflight.get(cacheKey); if (!request) { request = handleGetCapabilitiesRequest( { method: 'wallet_getCapabilities', params: [] }, @@ -55,13 +62,13 @@ export function useChainIcons(apiKey?: string): ChainIconMap { const metadata = (chainCapabilities as { chainMetadata?: ChainMetadataCapability }).chainMetadata; if (metadata?.icon) map[Number(chainIdHex)] = metadata.icon; } - iconsCache.set(apiKey, map); + iconsCache.set(cacheKey, map); return map as ChainIconMap; }) .finally(() => { - inflight.delete(apiKey); + inflight.delete(cacheKey); }); - inflight.set(apiKey, request); + inflight.set(cacheKey, request); } request @@ -76,7 +83,7 @@ export function useChainIcons(apiKey?: string): ChainIconMap { return () => { active = false; }; - }, [apiKey]); + }, [apiKey, cacheKey]); return icons; } diff --git a/packages/ui/src/hooks/usePermissionExecution.test.ts b/packages/ui/src/hooks/usePermissionExecution.test.ts index 51b6320c7..0367a5be1 100644 --- a/packages/ui/src/hooks/usePermissionExecution.test.ts +++ b/packages/ui/src/hooks/usePermissionExecution.test.ts @@ -109,10 +109,13 @@ describe('usePermissionExecution lookup outcomes', () => { expect(hook.problem).toBe('lookup-failed'); }); - it('a missing apiKey is a failed lookup — the permission itself may be fine', async () => { + // A dApp registered by origin has no key, and the relay resolves it from the + // origin instead. Refusing here left the screen unable to say whose funds move. + it('looks the permission up with no api key', async () => { + relayMock.mockResolvedValue(relayPermission as never); await mount(undefined); - expect(relayMock).not.toHaveBeenCalled(); - expect(hook.problem).toBe('lookup-failed'); + expect(relayMock).toHaveBeenCalledWith(PERMISSION_ID, undefined); + expect(hook.problem).toBeNull(); }); }); diff --git a/packages/ui/src/hooks/usePermissionExecution.ts b/packages/ui/src/hooks/usePermissionExecution.ts index 870154438..293177790 100644 --- a/packages/ui/src/hooks/usePermissionExecution.ts +++ b/packages/ui/src/hooks/usePermissionExecution.ts @@ -49,13 +49,6 @@ export function usePermissionExecution({ setPermission(null); setUnresolved(null); if (!permissionId) return; - // No key means no way to reach the relay. Terminal, not pending — otherwise the screen - // renders as an ordinary transaction and never says whose funds are moving. A failed lookup, - // not a revocation: the permission itself may be perfectly valid. - if (!apiKey) { - setUnresolved('lookup-failed'); - return; - } let cancelled = false; getPermissionFromRelay(permissionId, apiKey) diff --git a/packages/ui/src/hooks/usePermissionRevocation.test.ts b/packages/ui/src/hooks/usePermissionRevocation.test.ts index 9bd18e8fa..3b85b00ed 100644 --- a/packages/ui/src/hooks/usePermissionRevocation.test.ts +++ b/packages/ui/src/hooks/usePermissionRevocation.test.ts @@ -95,10 +95,12 @@ describe('usePermissionRevocation', () => { expect(relayMock).not.toHaveBeenCalled(); }); - it('reports lookup-failed with no api key, without calling the relay', async () => { + // A dApp registered by origin has no key, and the relay resolves it from the + // origin instead. Refusing here built the revocation from no record at all. + it('looks the permission up with no api key', async () => { await mount({ apiKey: undefined }); - expect(hook.problem).toBe('lookup-failed'); - expect(relayMock).not.toHaveBeenCalled(); + expect(hook.problem).toBeNull(); + expect(relayMock).toHaveBeenCalledWith(expect.any(String), undefined); }); it('reports not-found on a relay 404 — the permission is gone', async () => { diff --git a/packages/ui/src/hooks/usePermissionRevocation.ts b/packages/ui/src/hooks/usePermissionRevocation.ts index 1fc585072..ccd99d83f 100644 --- a/packages/ui/src/hooks/usePermissionRevocation.ts +++ b/packages/ui/src/hooks/usePermissionRevocation.ts @@ -64,17 +64,12 @@ export function usePermissionRevocation({ setPermission(null); setUnresolved(null); if (!enabled) return; - // Both are terminal, not pending. A revocation is *about* a permission, so an absent id is a - // malformed request rather than an absence — and without a key the relay is unreachable, so the - // record this revocation is built from can never arrive. + // Terminal, not pending: a revocation is *about* a permission, so an absent id is a + // malformed request rather than an absence. if (!permissionId) { setUnresolved('missing-id'); return; } - if (!apiKey) { - setUnresolved('lookup-failed'); - return; - } let cancelled = false; getPermissionFromRelay(permissionId, apiKey) diff --git a/packages/ui/src/utils/publicClient.test.ts b/packages/ui/src/utils/publicClient.test.ts index 152358370..7d855c21d 100644 --- a/packages/ui/src/utils/publicClient.test.ts +++ b/packages/ui/src/utils/publicClient.test.ts @@ -8,7 +8,10 @@ import { type Hex, } from 'viem'; +import { setDappOrigin } from '@jaw.id/core/internal'; + import { getJawPublicClient, getPublicClient, jawRpcUrl } from './publicClient'; +import { getChainLabel } from './resolveChainLabel'; import { fetchTokenBalance } from './tokenBalance'; import { createTokenResolver } from './clearSigning'; @@ -95,14 +98,14 @@ const json = (id: number, result: Hex) => * `allowFailure` isolation claim rests on. */ function stubRpc(options: { revertFor?: string[]; unavailable?: boolean } = {}) { - const bodies: { method: string; to?: string; data?: Hex }[] = []; + const bodies: { method: string; to?: string; data?: Hex; dappOrigin: string | null }[] = []; const reverts = new Set((options.revertFor ?? []).map((a) => a.toLowerCase())); vi.stubGlobal( 'fetch', vi.fn(async (_url: string, init: RequestInit) => { if (options.unavailable) { - bodies.push({ method: 'unavailable' }); + bodies.push({ method: 'unavailable', dappOrigin: null }); return new Response('service unavailable', { status: 503 }); } const body = JSON.parse(String(init.body)) as { @@ -110,7 +113,12 @@ function stubRpc(options: { revertFor?: string[]; unavailable?: boolean } = {}) method: string; params: [{ to?: string; data?: Hex }]; }; - bodies.push({ method: body.method, to: body.params?.[0]?.to, data: body.params?.[0]?.data }); + bodies.push({ + method: body.method, + to: body.params?.[0]?.to, + data: body.params?.[0]?.data, + dappOrigin: new Headers(init.headers).get('x-dapp-origin'), + }); const { to, data } = body.params[0]; @@ -147,6 +155,7 @@ function stubRpc(options: { revertFor?: string[]; unavailable?: boolean } = {}) afterEach(() => { vi.unstubAllGlobals(); + setDappOrigin(undefined); }); describe('getPublicClient', () => { @@ -184,6 +193,62 @@ describe('getPublicClient', () => { }); }); +// keys.jaw.id mounts these dialogs, so a keyless read leaves with keys' Origin and +// the backend needs the header to know which dApp it serves. +describe('x-dapp-origin', () => { + const DAPP = 'https://dapp.example'; + const THIRD_PARTY_RPC = 'https://rpc.third-party.test'; + + it('names the dApp on a JAW RPC url', async () => { + const bodies = stubRpc(); + setDappOrigin(DAPP); + + await fetchTokenBalance(TOKENS[0], HOLDER, jawRpcUrl(CHAIN_WITHOUT_MULTICALL), CHAIN_WITHOUT_MULTICALL); + + expect(bodies[0].dappOrigin).toBe(DAPP); + }); + + it('sends nothing when no dApp was set', async () => { + const bodies = stubRpc(); + + await fetchTokenBalance(TOKENS[0], HOLDER, jawRpcUrl(CHAIN_WITHOUT_MULTICALL), CHAIN_WITHOUT_MULTICALL); + + expect(bodies[0].dappOrigin).toBeNull(); + }); + + it('never tells a third-party RPC which dApp the user is on', async () => { + const bodies = stubRpc(); + setDappOrigin(DAPP); + + await fetchTokenBalance(TOKENS[0], HOLDER, THIRD_PARTY_RPC, CHAIN_WITHOUT_MULTICALL); + + expect(bodies[0].dappOrigin).toBeNull(); + }); + + // What lets clientCache stay keyed on (chainId, rpcUrl): the dApp arrives after + // the client exists. + it('picks up a dApp set after the client was cached', async () => { + const bodies = stubRpc(); + const rpcUrl = jawRpcUrl(CHAIN_WITH_MULTICALL, 'key-late-origin'); + const client = getPublicClient(CHAIN_WITH_MULTICALL, rpcUrl); + + setDappOrigin(DAPP); + await fetchTokenBalance(TOKENS[0], HOLDER, rpcUrl, CHAIN_WITH_MULTICALL); + + expect(getPublicClient(CHAIN_WITH_MULTICALL, rpcUrl)).toBe(client); + expect(bodies[0].dappOrigin).toBe(DAPP); + }); + + it('names the dApp on the chain label lookup', async () => { + const bodies = stubRpc(); + setDappOrigin(DAPP); + + await getChainLabel(42161, jawRpcUrl(1)); + + expect(bodies[0].dappOrigin).toBe(DAPP); + }); +}); + // The EIP-3668 revert a resolver uses to name the urls a client should fetch // instead of answering on chain. const offchainLookupAbi = [ diff --git a/packages/ui/src/utils/publicClient.ts b/packages/ui/src/utils/publicClient.ts index 6494524e8..614f8771d 100644 --- a/packages/ui/src/utils/publicClient.ts +++ b/packages/ui/src/utils/publicClient.ts @@ -1,5 +1,14 @@ import { createPublicClient, http, type Chain } from 'viem'; import { JAW_RPC_URL, SUPPORTED_CHAINS } from '@jaw.id/core'; +import { jawHttp } from '@jaw.id/core/internal'; + +/** + * Transport for an RPC url. Ours get `jawHttp`, which names the dApp keys is acting + * for; a third-party node must never learn that, so it gets plain `http`. + */ +export function rpcTransport(rpcUrl: string) { + return rpcUrl.startsWith(JAW_RPC_URL) ? jawHttp(rpcUrl) : http(rpcUrl); +} /** JAW RPC proxy URL for a chain, with the dApp's API key when one is available. */ export function jawRpcUrl(chainId: number, apiKey?: string): string { @@ -18,7 +27,7 @@ function createClient(chainId: number, rpcUrl: string) { // the counterparty picks the host. A cert error on it taints the page and blocks // the passkey ceremony in strict browsers. A server can follow those urls; a // signing page must not. - return createPublicClient({ chain, transport: http(rpcUrl), batch: { multicall: true }, ccipRead: false }); + return createPublicClient({ chain, transport: rpcTransport(rpcUrl), batch: { multicall: true }, ccipRead: false }); } const clientCache = new Map>(); diff --git a/packages/ui/src/utils/resolveChainLabel.ts b/packages/ui/src/utils/resolveChainLabel.ts index 612b6783c..d5457a516 100644 --- a/packages/ui/src/utils/resolveChainLabel.ts +++ b/packages/ui/src/utils/resolveChainLabel.ts @@ -1,6 +1,8 @@ -import { createPublicClient, http } from 'viem'; +import { createPublicClient } from 'viem'; import { mainnet } from 'viem/chains'; +import { rpcTransport } from './publicClient'; + const CHAIN_RESOLVER_ADDRESS = '0x2a9B5787207863cf2d63d20172ed1F7bB2c9487A' as const; const CHAIN_LABEL_ABI = [ @@ -107,7 +109,7 @@ async function queryChainLabel(chainId: number, rpcUrl: string): Promise ({ }, rollupOptions: { // External packages that should not be bundled into your library. - external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], + // `@jaw.id/core` is one of them: it holds module state, the sdk store among + // it, and bundling a copy in here gives an app that also imports core two of + // them. What one sets, such as the dApp an origin-served call acts for, the + // other never sees. Rollup matches these strings exactly, so the internal + // subpath is listed on its own. + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@jaw.id/core', + '@jaw.id/core/internal', + ], }, }, })); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 00c57d3b8..7fe97ad7d 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ // Resolve the SDK to its TS source so tests don't require a built `dist` // (the Nx-inferred `test` target has no `^build` dependency, so core is // unbuilt in CI). Mirrors packages/wagmi and apps/keys-jaw-id. + // Aliases match by prefix in order, so the subpath goes first or it + // resolves to `src/index.ts/internal`. + '@jaw.id/core/internal': resolve(__dirname, '../../packages/core/src/internal.ts'), '@jaw.id/core': resolve(__dirname, '../../packages/core/src/index.ts'), }, },