From d1c1004b9caf36ff7f37d53e11ab5a0591f491f7 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 8 Sep 2026 14:18:15 -0300 Subject: [PATCH 01/58] feat(core): let the backend decide whether a request is served --- packages/core/etc/core.api.md | 14 +-- packages/core/src/api/routes/permissions.ts | 4 +- packages/core/src/provider/JAWProvider.ts | 2 +- packages/core/src/provider/interface.ts | 2 +- packages/core/src/rpc/capabilities.ts | 4 +- packages/core/src/rpc/permissions.ts | 11 ++- packages/core/src/rpc/wallet_getAssets.ts | 4 +- .../core/src/rpc/wallet_getCallsHistory.ts | 4 +- packages/core/src/sdk/createJAWSDK.test.ts | 46 +++++++++ packages/core/src/sdk/createJAWSDK.ts | 32 +++--- .../core/src/signer/JAWSigner.apiKey.test.ts | 97 +++++++++++++++++++ packages/core/src/signer/JAWSigner.ts | 47 +++------ .../cross-platform/CrossPlatformSigner.ts | 6 +- packages/core/src/signer/utils.test.ts | 27 ++++++ packages/core/src/signer/utils.ts | 8 +- .../src/store/chain-clients/utils.test.ts | 26 ++++- .../core/src/store/chain-clients/utils.ts | 9 +- packages/core/src/utils/provider.test.ts | 86 ++++++++++++++++ packages/core/src/utils/provider.ts | 25 ++++- 19 files changed, 369 insertions(+), 85 deletions(-) create mode 100644 packages/core/src/sdk/createJAWSDK.test.ts create mode 100644 packages/core/src/signer/JAWSigner.apiKey.test.ts create mode 100644 packages/core/src/signer/utils.test.ts create mode 100644 packages/core/src/utils/provider.test.ts diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 6075fa9a1..643a4d1ba 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -161,7 +161,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): { @@ -335,7 +335,7 @@ export function createJAWProvider(options: CreateProviderOptions): JAWProvider; // @public (undocumented) export type CreateJAWSDKOptions = Partial & { - apiKey: string; + apiKey?: string; preference?: Partial; paymasters?: Record; ens?: string; @@ -560,7 +560,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[]; @@ -571,16 +571,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 // diff --git a/packages/core/src/api/routes/permissions.ts b/packages/core/src/api/routes/permissions.ts index 75d7463d3..7744aec3f 100644 --- a/packages/core/src/api/routes/permissions.ts +++ b/packages/core/src/api/routes/permissions.ts @@ -22,7 +22,9 @@ export interface PermissionsRoutes { GET_PERMISSION: { request: Record; response: StorePermissionApiResponse; - headers: { 'x-api-key': string }; + // Optional here and required on its siblings: this is the only relay read + // a dApp makes for itself. The writes are made where a key is always present. + headers: { 'x-api-key'?: string }; pathParams: { hash: string }; }; DELETE_PERMISSION: { diff --git a/packages/core/src/provider/JAWProvider.ts b/packages/core/src/provider/JAWProvider.ts index b19fa8a08..05624c18a 100644 --- a/packages/core/src/provider/JAWProvider.ts +++ b/packages/core/src/provider/JAWProvider.ts @@ -37,7 +37,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; 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.ts b/packages/core/src/rpc/capabilities.ts index 588547bb9..45d858b9e 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -51,13 +51,13 @@ export function clearCapabilitiesCache(): void { * 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 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, + apiKey: string | undefined, showTestnets = false ): Promise { const rpcUrl = buildHandleJawRpcUrl(JAW_RPC_URL, apiKey); diff --git a/packages/core/src/rpc/permissions.ts b/packages/core/src/rpc/permissions.ts index 7f8938bdd..6e3411050 100644 --- a/packages/core/src/rpc/permissions.ts +++ b/packages/core/src/rpc/permissions.ts @@ -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; 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/sdk/createJAWSDK.test.ts b/packages/core/src/sdk/createJAWSDK.test.ts new file mode 100644 index 000000000..2e0728f42 --- /dev/null +++ b/packages/core/src/sdk/createJAWSDK.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { create } from './createJAWSDK.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}`); + } + }); + + 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..ee98c7b84 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; @@ -89,22 +93,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); + 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; + // 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 }); - } + // 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.ts b/packages/core/src/signer/JAWSigner.ts index 05bb321b1..5bc71bfff 100644 --- a/packages/core/src/signer/JAWSigner.ts +++ b/packages/core/src/signer/JAWSigner.ts @@ -328,47 +328,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..1c2d7081b --- /dev/null +++ b/packages/core/src/signer/utils.test.ts @@ -0,0 +1,27 @@ +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'); + }); + + 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..7662ef221 100644 --- a/packages/core/src/signer/utils.ts +++ b/packages/core/src/signer/utils.ts @@ -16,7 +16,7 @@ export function createSigner(params: { communicator?: Communicator; uiHandler?: UIHandler; callback: ProviderEventCallback; - apiKey: string; + apiKey?: string; paymasters?: Record; ens?: string; theme?: JawTheme; @@ -39,6 +39,12 @@ export function createSigner(params: { if (!uiHandler) { throw new Error('UIHandler is required for appSpecific signer'); } + // App-specific mode hands the key to the dApp's own UIHandler, which has + // nowhere to get one, so it stays required even though the type allows it + // to be absent. + if (!apiKey) { + throw new Error('API key is required for appSpecific signer'); + } return new AppSpecificSigner({ metadata, callback, diff --git a/packages/core/src/store/chain-clients/utils.test.ts b/packages/core/src/store/chain-clients/utils.test.ts index fa651bcf0..a9991d1cd 100644 --- a/packages/core/src/store/chain-clients/utils.test.ts +++ b/packages/core/src/store/chain-clients/utils.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { sepolia, optimismSepolia, arbitrumSepolia } from 'viem/chains'; import { ChainClients } from './store.js'; -import { createClients, getClient, getBundlerClient } from './utils.js'; +import { createClients, createInitialChains, getClient, getBundlerClient } from './utils.js'; +import { JAW_RPC_URL } from '../../constants.js'; describe('chain-clients/utils', () => { beforeEach(() => { @@ -297,3 +298,26 @@ 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'); + } + }); +}); diff --git a/packages/core/src/store/chain-clients/utils.ts b/packages/core/src/store/chain-clients/utils.ts index 4ee5a4d40..668ea34ab 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -198,9 +198,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 +221,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/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts new file mode 100644 index 000000000..ccfeef623 --- /dev/null +++ b/packages/core/src/utils/provider.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +import { buildHandleJawRpcUrl, fetchRPCRequest } from './provider.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/); + }); + + 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/ + ); + }); +}); diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 197b319f9..3f4a39d04 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -2,13 +2,15 @@ import { standardErrors } from '../errors/index.js'; import { RequestArguments } from '../provider/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`; } export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) { @@ -25,6 +27,19 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) 'Content-Type': 'application/json', }, }); + + // 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 detail = await res.text().catch(() => ''); + const message = `JAW RPC request failed with ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`; + throw res.status === 401 || res.status === 403 + ? standardErrors.provider.unauthorized(message) + : standardErrors.rpc.internal(message); + } + const { result, error } = await res.json(); if (error) throw error; return result; From 3136e627853020906a79a12f9b8aa6ed19507a51 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 8 Sep 2026 14:33:10 -0300 Subject: [PATCH 02/58] feat(core): let keys name the dApp a call acts for --- apps/keys-jaw-id/src/app/page.tsx | 5 ++- packages/core/etc/core.api.md | 3 ++ packages/core/src/dappOrigin.ts | 16 ++++++++ packages/core/src/index.ts | 1 + packages/core/src/store/types.ts | 6 +++ packages/core/src/utils/provider.test.ts | 47 ++++++++++++++++++++++++ packages/core/src/utils/provider.ts | 6 +++ 7 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/dappOrigin.ts diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index cc93fcd1f..01d41f1e8 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -10,7 +10,7 @@ import { extractTransactionData } from '../lib/tx-handler'; 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 { PasskeyManager, setDappOrigin, type PasskeyAccount } from '@jaw.id/core'; import { SiweModal } from '../components/SiweModal'; import { ensureIntNumber, type SignInWithEthereumCapabilityRequest } from '@jaw.id/core'; import { ConnectModal } from '../components/ConnectModal'; @@ -563,6 +563,9 @@ function KeysJawIdAppContent({ 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. + setDappOrigin(origin); const peerPublicKey = request.sender; const method = request.content.handshake.method; diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 643a4d1ba..d174fe55b 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -1196,6 +1196,9 @@ export interface ServerErrorOptions extends EthereumErrorOptions { code: number; } +// @public +export function setDappOrigin(origin: string | undefined): void; + // @public export interface SignatureUIRequest extends BaseUIRequest { // (undocumented) 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 1193abed2..af72dd4ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,7 @@ export * from './utils/index.js'; /** Store exports **/ export { type Chain, type FeeToken, type FeeTokenCapability } from './store/index.js'; +export { setDappOrigin } from './dappOrigin.js'; /** Analytics exports **/ export { 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/provider.test.ts b/packages/core/src/utils/provider.test.ts index ccfeef623..17f2777cf 100644 --- a/packages/core/src/utils/provider.test.ts +++ b/packages/core/src/utils/provider.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { buildHandleJawRpcUrl, fetchRPCRequest } from './provider.js'; +import { setDappOrigin } from '../dappOrigin.js'; describe('buildHandleJawRpcUrl', () => { it('appends the api-key when the caller has one', () => { @@ -84,3 +85,49 @@ describe('fetchRPCRequest', () => { ); }); }); + +describe('fetchRPCRequest and the calling dApp', () => { + afterEach(() => { + setDappOrigin(undefined); + vi.unstubAllGlobals(); + }); + + function captureHeaders() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ 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'); + }); +}); diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 3f4a39d04..941c6ecb5 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -1,5 +1,6 @@ import { standardErrors } from '../errors/index.js'; import { RequestArguments } from '../provider/index.js'; +import { store } from '../store/index.js'; /** * Constructs the JAW RPC URL, appending the API key as a query parameter when @@ -19,12 +20,17 @@ 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 } : {}), }, }); From 527abb42a39ae97d570dc7c1c20737f3b0aed839 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 8 Sep 2026 14:38:07 -0300 Subject: [PATCH 03/58] feat(core): carry the calling dApp on the bundler transport too --- .../src/store/chain-clients/utils.test.ts | 59 ++++++++++++++++++- .../core/src/store/chain-clients/utils.ts | 35 +++++++++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/core/src/store/chain-clients/utils.test.ts b/packages/core/src/store/chain-clients/utils.test.ts index a9991d1cd..f9db679f0 100644 --- a/packages/core/src/store/chain-clients/utils.test.ts +++ b/packages/core/src/store/chain-clients/utils.test.ts @@ -1,9 +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, createInitialChains, getClient, getBundlerClient } from './utils.js'; import { JAW_RPC_URL } from '../../constants.js'; +import { setDappOrigin } from '../../dappOrigin.js'; +import { getClient } from './utils.js'; describe('chain-clients/utils', () => { beforeEach(() => { @@ -321,3 +323,58 @@ describe('createInitialChains api-key in the rpc url', () => { } }); }); + +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'); + }); + + // A third-party paymaster is another company's server. Which dApp the user is + // on is not theirs to learn. + it('says nothing to a host that is not ours', async () => { + const headers = stubFetch(); + setDappOrigin('https://dapp.example'); + + await callThrough('https://api.pimlico.io/v2/1/rpc'); + + expect(headers().has('x-dapp-origin')).toBe(false); + }); +}); diff --git a/packages/core/src/store/chain-clients/utils.ts b/packages/core/src/store/chain-clients/utils.ts index 668ea34ab..4e24a8695 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -3,11 +3,36 @@ 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'; +/** + * 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 our own proxy. A third-party paymaster is a different company's server + * and has no business learning which dApp the user is on. + */ +function jawHttp(url: string) { + if (!url.startsWith(JAW_PROXY_URL)) { + return http(url); + } + + return http(url, { + 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 } }; + }, + }); +} + /** * Paymaster configuration for a chain */ @@ -69,7 +94,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 +118,21 @@ 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), + transport: jawHttp(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 }; From cc539715f802b532eeaee5a8e0438966b46aaf14 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 8 Sep 2026 16:12:04 -0300 Subject: [PATCH 04/58] fix(core): stop the dApp header hanging off the production host --- packages/core/src/store/chain-clients/utils.test.ts | 13 +++++++------ packages/core/src/store/chain-clients/utils.ts | 13 +++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/core/src/store/chain-clients/utils.test.ts b/packages/core/src/store/chain-clients/utils.test.ts index f9db679f0..5d853ff0e 100644 --- a/packages/core/src/store/chain-clients/utils.test.ts +++ b/packages/core/src/store/chain-clients/utils.test.ts @@ -5,7 +5,6 @@ import { ChainClients } from './store.js'; import { createClients, createInitialChains, getClient, getBundlerClient } from './utils.js'; import { JAW_RPC_URL } from '../../constants.js'; import { setDappOrigin } from '../../dappOrigin.js'; -import { getClient } from './utils.js'; describe('chain-clients/utils', () => { beforeEach(() => { @@ -367,14 +366,16 @@ describe('naming the calling dApp on the wire', () => { expect(headers().get('x-dapp-origin')).toBe('https://dapp.example'); }); - // A third-party paymaster is another company's server. Which dApp the user is - // on is not theirs to learn. - it('says nothing to a host that is not ours', async () => { + // 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('https://api.pimlico.io/v2/1/rpc'); + await callThrough('http://localhost:3013/proxy/v1/rpc?chainId=1'); - expect(headers().has('x-dapp-origin')).toBe(false); + expect(headers().get('x-dapp-origin')).toBe('https://dapp.example'); }); }); diff --git a/packages/core/src/store/chain-clients/utils.ts b/packages/core/src/store/chain-clients/utils.ts index 4e24a8695..896ee717d 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -12,15 +12,8 @@ import { store } from '../store.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 our own proxy. A third-party paymaster is a different company's server - * and has no business learning which dApp the user is on. */ function jawHttp(url: string) { - if (!url.startsWith(JAW_PROXY_URL)) { - return http(url); - } - return http(url, { onFetchRequest: (_request, init) => { const dappOrigin = store.config.get().dappOrigin; @@ -125,7 +118,11 @@ function createClientForChain(chain: SDKChain): { client: PublicClient; bundlerC // Create paymaster client and wrap with custom functions that handle gas price fetching and v0.8 gas limits const paymasterClient = createPaymasterClient({ - transport: jawHttp(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({ From 87f0c6522d1192fb8e7d041873d857409a47f3ef Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:01:58 -0300 Subject: [PATCH 05/58] fix(core): name the calling dApp on the userOp path too --- packages/core/src/account/Account.ts | 7 +-- packages/core/src/account/erc20Paymaster.ts | 7 +-- .../account/smartAccount.dappOrigin.test.ts | 45 +++++++++++++++++++ .../core/src/account/smartAccount.test.ts | 1 + packages/core/src/account/smartAccount.ts | 17 ++++--- .../core/src/store/chain-clients/utils.ts | 19 +------- packages/core/src/utils/jawHttp.ts | 26 +++++++++++ 7 files changed, 92 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/account/smartAccount.dappOrigin.test.ts create mode 100644 packages/core/src/utils/jawHttp.ts diff --git a/packages/core/src/account/Account.ts b/packages/core/src/account/Account.ts index 3eb2243e1..d4d13fc8c 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, @@ -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 = { @@ -1640,7 +1641,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.ts b/packages/core/src/account/erc20Paymaster.ts index 6b968f47e..cfb24f638 100644 --- a/packages/core/src/account/erc20Paymaster.ts +++ b/packages/core/src/account/erc20Paymaster.ts @@ -1,4 +1,4 @@ -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'; @@ -9,6 +9,7 @@ import { 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 @@ -291,7 +292,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 +321,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..113c5407d 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 () => { diff --git a/packages/core/src/account/smartAccount.ts b/packages/core/src/account/smartAccount.ts index 5cf8bcf12..3825cc9aa 100644 --- a/packages/core/src/account/smartAccount.ts +++ b/packages/core/src/account/smartAccount.ts @@ -56,7 +56,8 @@ import { robinhood, soneium, } 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, @@ -175,7 +176,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). @@ -191,19 +192,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), }); }; @@ -226,7 +231,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, { diff --git a/packages/core/src/store/chain-clients/utils.ts b/packages/core/src/store/chain-clients/utils.ts index 896ee717d..bcef46aab 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -7,24 +7,7 @@ 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'; - -/** - * 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. - */ -function jawHttp(url: string) { - return http(url, { - 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 } }; - }, - }); -} +import { jawHttp } from '../../utils/jawHttp.js'; /** * Paymaster configuration for a chain diff --git a/packages/core/src/utils/jawHttp.ts b/packages/core/src/utils/jawHttp.ts new file mode 100644 index 000000000..1b736fc92 --- /dev/null +++ b/packages/core/src/utils/jawHttp.ts @@ -0,0 +1,26 @@ +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. + */ +export function jawHttp(url?: string, config?: HttpTransportConfig) { + 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 } }; + }, + }); +} From 300016b230018299f0064480048bf821775014ea Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:01:58 -0300 Subject: [PATCH 06/58] fix(core): name the calling dApp on relay calls too --- packages/core/src/api/rest.test.ts | 55 +++++++++++++++++++ packages/core/src/api/rest.ts | 8 ++- packages/core/src/api/routes/permissions.ts | 10 ++-- packages/core/src/rpc/permissions.ts | 8 +-- .../src/hooks/usePermissionExecution.test.ts | 9 ++- .../ui/src/hooks/usePermissionExecution.ts | 7 --- .../src/hooks/usePermissionRevocation.test.ts | 8 ++- .../ui/src/hooks/usePermissionRevocation.ts | 9 +-- 8 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/api/rest.test.ts diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts new file mode 100644 index 000000000..05b7fc482 --- /dev/null +++ b/packages/core/src/api/rest.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { restCall } from './rest.js'; +import { setDappOrigin } from '../dappOrigin.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 — grantPermissions writes +// through here — so 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' }); + } + + 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' }); + }); +}); diff --git a/packages/core/src/api/rest.ts b/packages/core/src/api/rest.ts index 820bd3ff7..c4673c8c1 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -1,5 +1,6 @@ import { backendInstance, controlledAxiosPromise } from './axiosController.js'; import { Routes, ROUTES } from './routes/index.js'; +import { store } from '../store/index.js'; import qs from 'qs'; /** @@ -48,6 +49,8 @@ export const restCall = < // POST/DELETE: request goes to data const params = method === 'GET' ? request : method === 'PATCH' && queryParams ? queryParams : undefined; + const dappOrigin = store.config.get().dappOrigin; + return controlledAxiosPromise( backendInstance(dev, serverUrl).request({ url, @@ -57,7 +60,10 @@ 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. + // Every route here is ours; a dApp's own page sets no origin. + headers: { ...(headers ?? {}), ...(dappOrigin ? { 'x-dapp-origin': dappOrigin } : {}) }, }) ); }; diff --git a/packages/core/src/api/routes/permissions.ts b/packages/core/src/api/routes/permissions.ts index 7744aec3f..47589c539 100644 --- a/packages/core/src/api/routes/permissions.ts +++ b/packages/core/src/api/routes/permissions.ts @@ -13,24 +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; - // Optional here and required on its siblings: this is the only relay read - // a dApp makes for itself. The writes are made where a key is always present. 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/rpc/permissions.ts b/packages/core/src/rpc/permissions.ts index 6e3411050..21af18e57 100644 --- a/packages/core/src/rpc/permissions.ts +++ b/packages/core/src/rpc/permissions.ts @@ -615,7 +615,7 @@ async function storePermissionInRelay( permissionHash: Hex, permission: Permission, chainId: string, - apiKey: string + apiKey?: string ): Promise { const requestData: StorePermissionApiRequest = { permissionId: permissionHash, @@ -644,7 +644,7 @@ async function storePermissionInRelay( 'STORE_PERMISSION', 'POST', requestData, - { 'x-api-key': apiKey }, + apiKey ? { 'x-api-key': apiKey } : {}, undefined, undefined, permissionsBaseUrl @@ -654,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/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) From 79873ede84fbda9f07c74394e37a0609120eafd0 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:01:58 -0300 Subject: [PATCH 07/58] fix(core): keep the rpc error envelope on a failing status --- packages/core/src/utils/provider.test.ts | 15 +++++++++++++- packages/core/src/utils/provider.ts | 25 +++++++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts index 17f2777cf..1d7b36342 100644 --- a/packages/core/src/utils/provider.test.ts +++ b/packages/core/src/utils/provider.test.ts @@ -68,6 +68,19 @@ describe('fetchRPCRequest', () => { 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', + }); + }); + it('still fails when the rejection body cannot be read', async () => { vi.stubGlobal( 'fetch', @@ -96,7 +109,7 @@ describe('fetchRPCRequest and the calling dApp', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: async () => ({ result: '0x1' }), + text: async () => JSON.stringify({ result: '0x1' }), }); vi.stubGlobal('fetch', fetchMock); return () => fetchMock.mock.calls[0][1].headers as Record; diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 941c6ecb5..87e451134 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -34,21 +34,36 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) }, }); + // 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 { + envelope = JSON.parse(body); + } catch { + envelope = undefined; + } + + const rpcError = envelope?.error; + if (rpcError && typeof rpcError.code === 'number' && typeof rpcError.message === 'string') { + throw 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 detail = await res.text().catch(() => ''); - const message = `JAW RPC request failed with ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`; + const message = `JAW RPC request failed with ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`; throw res.status === 401 || res.status === 403 ? standardErrors.provider.unauthorized(message) : standardErrors.rpc.internal(message); } - const { result, error } = await res.json(); - if (error) throw error; - return result; + if (rpcError) throw rpcError; + return envelope?.result; } /** * Validates the arguments for an invalid request and returns an error if any validation fails. From b51bb62a14a725490cd42eaaad9ba6d4a43a2465 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:01:59 -0300 Subject: [PATCH 08/58] fix(core): refuse a keyless app-specific config when it is written --- packages/core/src/sdk/createJAWSDK.test.ts | 8 ++++++++ packages/core/src/sdk/createJAWSDK.ts | 7 +++++++ packages/core/src/signer/utils.test.ts | 8 ++++++++ packages/core/src/signer/utils.ts | 12 ++++++------ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/core/src/sdk/createJAWSDK.test.ts b/packages/core/src/sdk/createJAWSDK.test.ts index 2e0728f42..82ff616bf 100644 --- a/packages/core/src/sdk/createJAWSDK.test.ts +++ b/packages/core/src/sdk/createJAWSDK.test.ts @@ -1,6 +1,7 @@ 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'; @@ -37,6 +38,13 @@ describe('create() chain registration', () => { } }); + // 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 }); diff --git a/packages/core/src/sdk/createJAWSDK.ts b/packages/core/src/sdk/createJAWSDK.ts index ee98c7b84..6bb256da2 100644 --- a/packages/core/src/sdk/createJAWSDK.ts +++ b/packages/core/src/sdk/createJAWSDK.ts @@ -80,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, diff --git a/packages/core/src/signer/utils.test.ts b/packages/core/src/signer/utils.test.ts index 1c2d7081b..a001d2765 100644 --- a/packages/core/src/signer/utils.test.ts +++ b/packages/core/src/signer/utils.test.ts @@ -17,6 +17,14 @@ describe('createSigner api-key requirement', () => { ).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; diff --git a/packages/core/src/signer/utils.ts b/packages/core/src/signer/utils.ts index 7662ef221..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'; @@ -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,13 +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'); } - // App-specific mode hands the key to the dApp's own UIHandler, which has - // nowhere to get one, so it stays required even though the type allows it - // to be absent. + // 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 new Error('API key is required for appSpecific signer'); + throw standardErrors.rpc.internal('API key is required for appSpecific signer'); } return new AppSpecificSigner({ metadata, From 8421771763a649adc4bb34bd9abccda0ca4a864e Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:01:59 -0300 Subject: [PATCH 09/58] fix(core): tie the dApp origin to the request being served --- apps/keys-jaw-id/src/app/page.tsx | 4 ++++ packages/core/src/store/store.test.ts | 15 +++++++++++++++ packages/core/src/store/store.ts | 8 +++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index 01d41f1e8..8d5950d27 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -727,6 +727,10 @@ function KeysJawIdAppContent({ // 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); // 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/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; }, From 912c35de177b6183a7728124b68a33d3766a432d Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:15:03 -0300 Subject: [PATCH 10/58] fix(core): keep the dApp header off routes that do not need it --- packages/core/src/api/rest.test.ts | 15 ++++++++++++++- packages/core/src/api/rest.ts | 7 +++++-- packages/core/src/provider/JAWProvider.test.ts | 3 +++ packages/core/src/rpc/capabilities.ts | 5 ++++- packages/core/src/utils/provider.ts | 11 ++++++++++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts index 05b7fc482..ffb2a6158 100644 --- a/packages/core/src/api/rest.test.ts +++ b/packages/core/src/api/rest.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { restCall } from './rest.js'; import { setDappOrigin } from '../dappOrigin.js'; +import { JAW_PROXY_URL } from '../constants.js'; const request = vi.fn(); @@ -28,7 +29,7 @@ describe('restCall and the calling dApp', () => { async function callThrough(headers?: Record) { request.mockResolvedValue({ data: { result: { data: {} } } }); - await restCall('GET_PERMISSION', 'GET', {}, headers, { hash: '0xabc' }); + await restCall('GET_PERMISSION', 'GET', {}, headers, { hash: '0xabc' }, undefined, JAW_PROXY_URL); } it('names the dApp alongside the key it was given', async () => { @@ -52,4 +53,16 @@ describe('restCall and the calling dApp', () => { expect(headersSent()).toEqual({ 'x-api-key': 'k1' }); }); + + // `x-dapp-origin` is not CORS-safelisted, so a route whose service does not list + // it in Access-Control-Allow-Headers would fail preflight and never leave the + // browser. The proxy is the only one that has to identify a keyless caller. + it('sends no dApp header to the wallet API', async () => { + setDappOrigin('https://dapp.example'); + request.mockResolvedValue({ data: { result: { data: {} } } }); + + await restCall('LOG_SIGNATURE', 'POST', { address: '0xabc' }, { 'x-api-key': 'k1' }); + + expect(headersSent()).toEqual({ 'x-api-key': 'k1' }); + }); }); diff --git a/packages/core/src/api/rest.ts b/packages/core/src/api/rest.ts index c4673c8c1..b1bf5bc64 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -1,6 +1,7 @@ import { backendInstance, controlledAxiosPromise } from './axiosController.js'; import { Routes, ROUTES } from './routes/index.js'; import { store } from '../store/index.js'; +import { JAW_PROXY_URL } from '../constants.js'; import qs from 'qs'; /** @@ -49,7 +50,10 @@ export const restCall = < // POST/DELETE: request goes to data const params = method === 'GET' ? request : method === 'PATCH' && queryParams ? queryParams : undefined; - const dappOrigin = store.config.get().dappOrigin; + // Only on the proxy, which is where a caller with no key has to be identified. + // `x-dapp-origin` is not CORS-safelisted, so sending it to the wallet API would + // make every passkey and analytics call depend on that service allowing it too. + const dappOrigin = serverUrl?.startsWith(JAW_PROXY_URL) ? store.config.get().dappOrigin : undefined; return controlledAxiosPromise( backendInstance(dev, serverUrl).request({ @@ -62,7 +66,6 @@ export const restCall = < data: method === 'POST' || method === 'PATCH' ? request : undefined, // 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. - // Every route here is ours; a dApp's own page sets no origin. headers: { ...(headers ?? {}), ...(dappOrigin ? { 'x-dapp-origin': dappOrigin } : {}) }, }) ); diff --git a/packages/core/src/provider/JAWProvider.test.ts b/packages/core/src/provider/JAWProvider.test.ts index 0d3daa2c3..860db40e8 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(), diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 45d858b9e..86b2a8ad4 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -3,6 +3,7 @@ import type { RequestArguments } from '../provider/index.js'; import { JAW_RPC_URL } from '../constants.js'; import { buildHandleJawRpcUrl, fetchRPCRequest, hexStringFromNumber } from '../utils/index.js'; import { MAINNET_CHAINS } from '../account/smartAccount.js'; +import { store } from '../store/index.js'; /** * Chain metadata capability returned by wallet_getCapabilities @@ -83,7 +84,9 @@ 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 `undefined|...` entry. + const cacheKey = `${apiKey}|${store.config.get().dappOrigin ?? ''}|${JSON.stringify(requestArgs.params ?? [])}`; // 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 diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 87e451134..d8f33e24d 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -63,7 +63,16 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) } if (rpcError) throw rpcError; - return envelope?.result; + + // A 2xx whose body will not parse is the same silence as a refusal: returning + // undefined here is what wallet_getCapabilities would memoize for a minute. + if (!envelope) { + throw standardErrors.rpc.internal( + `JAW RPC request returned a body that is not JSON${body ? `: ${body.slice(0, 200)}` : ''}` + ); + } + + return envelope.result; } /** * Validates the arguments for an invalid request and returns an error if any validation fails. From 74c0b05c3af0c2529872ca7e5a15beb4c23f33b8 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 9 Sep 2026 20:31:02 -0300 Subject: [PATCH 11/58] fix(core): treat a body that is not an rpc response as a failure --- packages/core/src/utils/jawHttp.ts | 4 +++- packages/core/src/utils/provider.test.ts | 10 ++++++++++ packages/core/src/utils/provider.ts | 11 +++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/jawHttp.ts b/packages/core/src/utils/jawHttp.ts index 1b736fc92..d23339e18 100644 --- a/packages/core/src/utils/jawHttp.ts +++ b/packages/core/src/utils/jawHttp.ts @@ -10,8 +10,10 @@ import { store } from '../store/index.js'; * 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?: HttpTransportConfig) { +export function jawHttp(url?: string, config?: Omit) { return http(url, { ...config, onFetchRequest: (_request, init) => { diff --git a/packages/core/src/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts index 1d7b36342..e8374bbdb 100644 --- a/packages/core/src/utils/provider.test.ts +++ b/packages/core/src/utils/provider.test.ts @@ -81,6 +81,16 @@ describe('fetchRPCRequest', () => { }); }); + // 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/ + ); + }); + it('still fails when the rejection body cannot be read', async () => { vi.stubGlobal( 'fetch', diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index d8f33e24d..63ae79468 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -41,11 +41,13 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) // 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 { - envelope = JSON.parse(body); + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object') envelope = parsed; } catch { - envelope = undefined; + // Not JSON at all. Either the status below explains it, or the envelope check does. } + // 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 rpcError; @@ -62,13 +64,14 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) : 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 will not parse is the same silence as a refusal: returning + // 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. if (!envelope) { throw standardErrors.rpc.internal( - `JAW RPC request returned a body that is not JSON${body ? `: ${body.slice(0, 200)}` : ''}` + `JAW RPC request returned a body that is not a JSON-RPC response${body ? `: ${body.slice(0, 200)}` : ''}` ); } From afd819aea2e093a500de3bfb7f2b54293bcfc6b2 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Mon, 14 Sep 2026 18:40:52 -0300 Subject: [PATCH 12/58] fix(core): name the calling dApp on any backend of ours --- packages/core/src/api/rest.test.ts | 37 ++++++++++++++++++++++-------- packages/core/src/api/rest.ts | 13 ++++++----- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts index ffb2a6158..aaa5bb6b8 100644 --- a/packages/core/src/api/rest.test.ts +++ b/packages/core/src/api/rest.test.ts @@ -14,9 +14,9 @@ vi.mock('./axiosController.js', async () => { }; }); -// The relay calls are made from the keys origin too — grantPermissions writes -// through here — so without the header a keyless dApp's permission is refused on -// the relay write, after the transaction has already been signed and sent. +// 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); @@ -54,15 +54,34 @@ describe('restCall and the calling dApp', () => { expect(headersSent()).toEqual({ 'x-api-key': 'k1' }); }); - // `x-dapp-origin` is not CORS-safelisted, so a route whose service does not list - // it in Access-Control-Allow-Headers would fail preflight and never leave the - // browser. The proxy is the only one that has to identify a keyless caller. - it('sends no dApp header to the wallet API', async () => { + // 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' }, { 'x-api-key': 'k1' }); + await restCall('LOG_SIGNATURE', 'POST', { address: '0xabc' }); - expect(headersSent()).toEqual({ 'x-api-key': 'k1' }); + expect(headersSent()).toEqual({ 'x-dapp-origin': 'https://dapp.example' }); + }); + + // 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({}); }); }); diff --git a/packages/core/src/api/rest.ts b/packages/core/src/api/rest.ts index b1bf5bc64..76887e2bf 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -1,7 +1,6 @@ -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 { JAW_PROXY_URL } from '../constants.js'; import qs from 'qs'; /** @@ -50,10 +49,12 @@ export const restCall = < // POST/DELETE: request goes to data const params = method === 'GET' ? request : method === 'PATCH' && queryParams ? queryParams : undefined; - // Only on the proxy, which is where a caller with no key has to be identified. - // `x-dapp-origin` is not CORS-safelisted, so sending it to the wallet API would - // make every passkey and analytics call depend on that service allowing it too. - const dappOrigin = serverUrl?.startsWith(JAW_PROXY_URL) ? store.config.get().dappOrigin : 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 || serverUrl.startsWith(getBaseUrl()) || serverUrl.startsWith(getBaseUrl(true)); + const dappOrigin = serverIsOurs ? store.config.get().dappOrigin : undefined; return controlledAxiosPromise( backendInstance(dev, serverUrl).request({ From fed2a7267827c2708a1753da5e7a7aa3d22e6e0d Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Mon, 14 Sep 2026 18:40:52 -0300 Subject: [PATCH 13/58] feat(core): report analytics without an api key --- packages/core/etc/core.api.md | 4 ++-- packages/core/src/analytics/index.ts | 12 ++++++------ packages/core/src/signer/JAWSigner.test.ts | 17 ++++++++++------- packages/core/src/signer/JAWSigner.ts | 11 ++++++----- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index d174fe55b..5b71c4dc4 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -801,7 +801,7 @@ export function logAccountIssuance(params: LogAccountIssuanceParams): void; // @public export interface LogAccountIssuanceParams { address: Address_2; - apiKey: string; + apiKey?: string; type: IssuanceType; } @@ -811,7 +811,7 @@ export function logSignature(params: LogSignatureParams): void; // @public export interface LogSignatureParams { address: Address_2; - apiKey: string; + apiKey?: string; } // @public diff --git a/packages/core/src/analytics/index.ts b/packages/core/src/analytics/index.ts index 9ea679fd4..9cf20a563 100644 --- a/packages/core/src/analytics/index.ts +++ b/packages/core/src/analytics/index.ts @@ -13,8 +13,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; } /** @@ -37,7 +37,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 +52,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; } /** @@ -68,7 +68,7 @@ export function logSignature(params: LogSignatureParams): void { try { const { address, apiKey } = params; - 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/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 5bc71bfff..9a8a733f5 100644 --- a/packages/core/src/signer/JAWSigner.ts +++ b/packages/core/src/signer/JAWSigner.ts @@ -171,12 +171,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 }); } /** @@ -190,7 +191,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) { From 84f2e5c10d254a050e002d57ba56ebaac824ee4e Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 13:48:16 -0300 Subject: [PATCH 14/58] feat(core): report receipts without an api key --- .../src/analytics/receiptNotification.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/core/src/analytics/receiptNotification.test.ts diff --git a/packages/core/src/analytics/receiptNotification.test.ts b/packages/core/src/analytics/receiptNotification.test.ts new file mode 100644 index 000000000..c83e85b17 --- /dev/null +++ b/packages/core/src/analytics/receiptNotification.test.ts @@ -0,0 +1,51 @@ +// 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, +}; + +function queryParamsOfLastCall() { + return restCallMock.mock.calls.at(-1)?.[7]; +} + +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(queryParamsOfLastCall()).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(queryParamsOfLastCall()).toBeUndefined(); + }); + + it('reports a revert as status 500', () => { + notifyReceiptReceived({ ...receipt, success: false }); + + expect(restCallMock.mock.calls[0][2]).toMatchObject({ status: 500 }); + }); +}); From 71857514107ffe2c60bb139fcce5a179c86a8d07 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 13:48:17 -0300 Subject: [PATCH 15/58] fix(ui): resolve chain icon and addresses without a key --- .../OnboardingDialog/accountHelpers.ts | 5 +- .../src/components/OnboardingDialog/index.tsx | 2 +- .../ui/src/hooks/useChainIconURI.test.tsx | 74 +++++++++++++++++++ packages/ui/src/hooks/useChainIconURI.tsx | 4 +- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/hooks/useChainIconURI.test.tsx diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts index 4f177438b..0304eb3e8 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts @@ -26,12 +26,9 @@ 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, + apiKey: params.apiKey ?? '', }); const byCredentialId: Record = {}; for (const account of accounts) { diff --git a/packages/ui/src/components/OnboardingDialog/index.tsx b/packages/ui/src/components/OnboardingDialog/index.tsx index 153794375..b3b3b05ff 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/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx new file mode 100644 index 000000000..680d38510 --- /dev/null +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -0,0 +1,74 @@ +// @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, 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 { useChainIconURI } from './useChainIconURI'; + +const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); + +const ICON = 'https://icons.example/base.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()); +} + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + vi.clearAllMocks(); +}); + +describe('useChainIconURI', () => { + // Each case uses its own chain id: the hook caches per chain and key. + 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) => { + const chainId = apiKey === undefined ? 10 : 137; + capabilitiesMock.mockResolvedValue({ + [`0x${chainId.toString(16)}`]: { chainMetadata: { icon: ICON } }, + } as never); + await mount(chainId, apiKey); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(capabilitiesMock.mock.calls[0][1]).toBe(apiKey); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + 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..7315f22e6 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -9,7 +9,7 @@ const chainIconCache = new Map(); * Returns a JSX element (img or fallback) similar to useChainIcon * * @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 */ @@ -24,7 +24,7 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) const [isLoading, setIsLoading] = useState(!chainIconCache.has(cacheKey)); useEffect(() => { - if (!apiKey || !chainId) { + if (!chainId) { setIsLoading(false); return; } From 0ab142af77e7b744354ae3e9f693ffb4c7d89090 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 13:52:00 -0300 Subject: [PATCH 16/58] test(keys): dispatch storage events without the jsdom IDL check --- apps/keys-jaw-id/src/lib/session-manager.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 1e5bc8d66..9879a5615 100644 --- a/apps/keys-jaw-id/src/lib/session-manager.test.ts +++ b/apps/keys-jaw-id/src/lib/session-manager.test.ts @@ -175,6 +175,16 @@ describe('two SessionManagers over one storage', () => { return new SessionManager(); } + // jsdom's StorageEvent constructor only accepts its own Storage in + // `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) { + const event = new StorageEvent('storage', { key }); + Object.defineProperty(event, 'storageArea', { value: window.localStorage }); + window.dispatchEvent(event); + } + it('does not erase the other document’s session when creating one', async () => { const docA = secondDocument(); const docB = secondDocument(); @@ -243,7 +253,7 @@ describe('two SessionManagers over one storage', () => { // Another document deletes it. jsdom does not raise `storage` across // SessionManagers (same window), so dispatch what a real browser would. await new SessionManager().deleteSession(ORIGIN); - window.dispatchEvent(new StorageEvent('storage', { key: 'jaw:sessions:apps', storageArea: localStorage })); + dispatchStorageEvent('jaw:sessions:apps'); expect(await reader.isAuthenticated(ORIGIN)).toBe(false); }); @@ -252,7 +262,7 @@ describe('two SessionManagers over one storage', () => { const reader = secondDocument(); await reader.createSession({ origin: ORIGIN, peerPublicKey: '04aabbccdd', account: AUTH }); - window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated:key', storageArea: localStorage })); + dispatchStorageEvent('unrelated:key'); expect(await reader.isAuthenticated(ORIGIN)).toBe(true); }); From e861410d2bd70e2cf103bfbef6fbe334a70b3d51 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 13:53:49 -0300 Subject: [PATCH 17/58] feat(core): drop the api key gate on receipt reporting --- packages/core/src/account/smartAccount.ts | 40 +++++++++---------- .../core/src/analytics/receiptNotification.ts | 6 +-- packages/core/src/api/routes/callsHistory.ts | 2 +- packages/core/src/rpc/wallet_sendCalls.ts | 17 ++++---- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/core/src/account/smartAccount.ts b/packages/core/src/account/smartAccount.ts index 3b01cffed..4d7c4f086 100644 --- a/packages/core/src/account/smartAccount.ts +++ b/packages/core/src/account/smartAccount.ts @@ -414,27 +414,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; } 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/routes/callsHistory.ts b/packages/core/src/api/routes/callsHistory.ts index f3702c0b6..ac7464a2d 100644 --- a/packages/core/src/api/routes/callsHistory.ts +++ b/packages/core/src/api/routes/callsHistory.ts @@ -60,7 +60,7 @@ export interface CallsHistoryRoutes { response: void; headers?: Record; pathParams: { id: string }; - queryParams: { 'api-key': string }; + queryParams: { 'api-key'?: string }; }; GET_CALLS_HISTORY: { request: GetCallsHistoryRequest; diff --git a/packages/core/src/rpc/wallet_sendCalls.ts b/packages/core/src/rpc/wallet_sendCalls.ts index 0dacb2967..fec425f06 100644 --- a/packages/core/src/rpc/wallet_sendCalls.ts +++ b/packages/core/src/rpc/wallet_sendCalls.ts @@ -237,15 +237,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 From d85f6d6bfbde2a1f388882f7813dd8bba8d613f6 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:19:22 -0300 Subject: [PATCH 18/58] perf(core): run one receipt waiter per user operation --- packages/core/src/api/routes/callsHistory.ts | 2 +- .../src/rpc/wallet_sendCalls.receipt.test.ts | 90 +++++++++++++++++++ packages/core/src/rpc/wallet_sendCalls.ts | 22 ++++- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/rpc/wallet_sendCalls.receipt.test.ts diff --git a/packages/core/src/api/routes/callsHistory.ts b/packages/core/src/api/routes/callsHistory.ts index ac7464a2d..be9d8cdaf 100644 --- a/packages/core/src/api/routes/callsHistory.ts +++ b/packages/core/src/api/routes/callsHistory.ts @@ -67,6 +67,6 @@ export interface CallsHistoryRoutes { response: CallsHistoryItem[]; headers?: Record; pathParams?: never; - queryParams: { 'api-key': string }; + queryParams: { 'api-key'?: string }; }; } 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 fec425f06..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); From ecb65fd45f6a9dfea419880e258508c7e62294fe Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:19:40 -0300 Subject: [PATCH 19/58] test(core): cover receipt reporting on the direct send path --- .../core/src/account/smartAccount.test.ts | 34 +++++++++++++++++++ .../src/analytics/receiptNotification.test.ts | 13 ++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/core/src/account/smartAccount.test.ts b/packages/core/src/account/smartAccount.test.ts index 113c5407d..01ca7ffde 100644 --- a/packages/core/src/account/smartAccount.test.ts +++ b/packages/core/src/account/smartAccount.test.ts @@ -88,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; @@ -385,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/analytics/receiptNotification.test.ts b/packages/core/src/analytics/receiptNotification.test.ts index c83e85b17..e4b0dd16d 100644 --- a/packages/core/src/analytics/receiptNotification.test.ts +++ b/packages/core/src/analytics/receiptNotification.test.ts @@ -18,8 +18,11 @@ const receipt = { success: true, }; -function queryParamsOfLastCall() { - return restCallMock.mock.calls.at(-1)?.[7]; +/** 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', () => { @@ -31,7 +34,7 @@ describe('notifyReceiptReceived', () => { notifyReceiptReceived({ ...receipt, apiKey: 'real-key' }); expect(restCallMock).toHaveBeenCalledTimes(1); - expect(queryParamsOfLastCall()).toEqual({ 'api-key': 'real-key' }); + expect(lastCall().queryParams).toEqual({ 'api-key': 'real-key' }); }); // Keys builds the account with `preference?.apiKey || ''`, so a keyless dApp @@ -40,12 +43,12 @@ describe('notifyReceiptReceived', () => { notifyReceiptReceived({ ...receipt, apiKey }); expect(restCallMock).toHaveBeenCalledTimes(1); - expect(queryParamsOfLastCall()).toBeUndefined(); + expect(lastCall().queryParams).toBeUndefined(); }); it('reports a revert as status 500', () => { notifyReceiptReceived({ ...receipt, success: false }); - expect(restCallMock.mock.calls[0][2]).toMatchObject({ status: 500 }); + expect(lastCall().body).toMatchObject({ status: 500 }); }); }); From c8bbfcdb88d457030aabf62fcfc01cd3a40d6698 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:19:50 -0300 Subject: [PATCH 20/58] fix(ui): retry a chain icon lookup that failed --- .../ui/src/hooks/useChainIconURI.test.tsx | 53 ++++++++++++++++--- packages/ui/src/hooks/useChainIconURI.tsx | 12 +++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx index 680d38510..a1447e0a0 100644 --- a/packages/ui/src/hooks/useChainIconURI.test.tsx +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -2,7 +2,7 @@ // 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, describe, expect, it, vi } from 'vitest'; +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'; @@ -14,7 +14,7 @@ vi.mock('@jaw.id/core', () => ({ })); import { handleGetCapabilitiesRequest } from '@jaw.id/core'; -import { useChainIconURI } from './useChainIconURI'; +import { clearChainIconCache, useChainIconURI } from './useChainIconURI'; const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); @@ -36,6 +36,10 @@ async function mount(chainId: number, apiKey?: string) { await act(() => Promise.resolve()); } +beforeEach(() => { + clearChainIconCache(); +}); + afterEach(() => { if (root) act(() => root!.unmount()); root = null; @@ -43,7 +47,6 @@ afterEach(() => { }); describe('useChainIconURI', () => { - // Each case uses its own chain id: the hook caches per chain and key. it('renders the icon a keyed caller gets back', async () => { capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); await mount(1, 'test-key'); @@ -54,17 +57,51 @@ describe('useChainIconURI', () => { // 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) => { - const chainId = apiKey === undefined ? 10 : 137; - capabilitiesMock.mockResolvedValue({ - [`0x${chainId.toString(16)}`]: { chainMetadata: { icon: ICON } }, - } as never); - await mount(chainId, 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 two spellings of "no key" are the same caller, so they share an entry. + it('serves the cached icon whichever way the missing key is spelled', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': { chainMetadata: { icon: ICON } } } as never); + await mount(1, ''); + if (root) act(() => root.unmount()); + await mount(1, undefined); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); + }); + + // A refused lookup used to be cached, which pinned the '?' placeholder for the + // rest of the page over one offline moment. + 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); + }); + + // A chain the backend knows nothing about is an answer, and it is cached. + it('asks once for a chain that has no icon', async () => { + capabilitiesMock.mockResolvedValue({ '0x1': {} } as never); + await mount(1, 'test-key'); + if (root) act(() => root.unmount()); + await mount(1, 'test-key'); + + expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('img')).toBeNull(); + }); + it('does not fetch without a chain', async () => { await mount(0, 'test-key'); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 7315f22e6..6c29421d8 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -4,6 +4,11 @@ import { handleGetCapabilitiesRequest, type ChainMetadataCapability } from '@jaw // Simple in-memory cache for chain icons to avoid redundant API calls const chainIconCache = new Map(); +/** Drops the cached icons. For tests, which would otherwise share them. */ +export function clearChainIconCache(): void { + chainIconCache.clear(); +} + /** * Hook to fetch chain icon from wallet_getCapabilities chainMetadata * Returns a JSX element (img or fallback) similar to useChainIcon @@ -15,7 +20,7 @@ const chainIconCache = new Map(); */ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number): JSX.Element => { const iconSize = size ?? 24; - const cacheKey = `${chainId}-${apiKey}`; + const cacheKey = `${chainId}-${apiKey ?? ''}`; const [iconURI, setIconURI] = useState(() => { // Check cache first @@ -64,8 +69,9 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) } 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); + // A failed lookup is not an answer. Caching it would pin the '?' for + // the rest of the page's life over one offline moment; leaving it out + // costs one request the next time a dialog opens. setIconURI(null); setIsLoading(false); } From 06b7ecc58255fdc61c1de662e1c4906f2ba5b6ca Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:19:59 -0300 Subject: [PATCH 21/58] perf(ui): stop retrying a backfill that resolved nothing --- .../OnboardingDialog/accountHelpers.test.ts | 63 +++++++++++++++++++ .../OnboardingDialog/accountHelpers.ts | 10 +++ 2 files changed, 73 insertions(+) create mode 100644 packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts 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..45df554f6 --- /dev/null +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts @@ -0,0 +1,63 @@ +// The address backfill runs for keyless callers too, so it also has to stop +// hammering the rpc when it turns out nothing can be derived: the dialog reopens +// on every request, and a refused derivation stays refused until a reload. +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 WITH_ADDRESS = [{ credentialId: 'cred-1', address: '0x1111111111111111111111111111111111111111' }]; +const WITHOUT_ADDRESS = [{ credentialId: 'cred-1' }]; + +// The memo is keyed by chain and key, so each case picks its own pair. +describe('backfillLocalAccountAddresses', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('derives with no api key', async () => { + backfillMock.mockResolvedValue(WITH_ADDRESS as never); + + const byCredentialId = await backfillLocalAccountAddresses({ chainId: 1 }); + + expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: '' }); + expect(byCredentialId).toEqual({ 'cred-1': WITH_ADDRESS[0].address }); + }); + + it('keeps deriving while the answers keep coming', async () => { + backfillMock.mockResolvedValue(WITH_ADDRESS as never); + + await backfillLocalAccountAddresses({ chainId: 10 }); + await backfillLocalAccountAddresses({ chainId: 10 }); + + expect(backfillMock).toHaveBeenCalledTimes(2); + }); + + it('stops asking once a derivation resolves nothing', async () => { + backfillMock.mockResolvedValue(WITHOUT_ADDRESS as never); + + expect(await backfillLocalAccountAddresses({ chainId: 137 })).toEqual({}); + expect(await backfillLocalAccountAddresses({ chainId: 137 })).toEqual({}); + + expect(backfillMock).toHaveBeenCalledTimes(1); + }); + + it('memoizes per chain and key, not globally', async () => { + backfillMock.mockResolvedValue(WITHOUT_ADDRESS as never); + await backfillLocalAccountAddresses({ chainId: 8453, apiKey: 'a-key' }); + + backfillMock.mockResolvedValue(WITH_ADDRESS as never); + const byCredentialId = await backfillLocalAccountAddresses({ chainId: 8453, apiKey: 'another-key' }); + + expect(byCredentialId).toEqual({ 'cred-1': WITH_ADDRESS[0].address }); + }); +}); diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts index 0304eb3e8..698dfa805 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts @@ -17,6 +17,9 @@ export function getStoredLocalAccounts(apiKey?: string): LocalStorageAccount[] { return Account.getStoredAccounts(apiKey).map(toLocalStorageAccount); } +/** Chain + key pairs whose derivation came back with nothing. */ +const resolvedNothing = new Set(); + /** * Derive + persist addresses for stored records that predate address * persistence (ceremony-free factory derivation), returning credentialId → @@ -26,6 +29,9 @@ export async function backfillLocalAccountAddresses(params: { chainId?: number; apiKey?: string; }): Promise> { + const attempt = `${params.chainId ?? 1}-${params.apiKey ?? ''}`; + if (resolvedNothing.has(attempt)) return {}; + const accounts = await Account.backfillStoredAccountAddresses({ chainId: params.chainId ?? 1, apiKey: params.apiKey ?? '', @@ -34,6 +40,10 @@ export async function backfillLocalAccountAddresses(params: { for (const account of accounts) { if (account.credentialId && account.address) byCredentialId[account.credentialId] = account.address; } + // Every derivation failed, so the rpc refused them all (an origin the proxy + // does not know, or no network). The dialog reopens often; retrying the same + // call per credential on each open buys nothing until the page reloads. + if (Object.keys(byCredentialId).length === 0) resolvedNothing.add(attempt); return byCredentialId; } From 689294a09f8e9cc0d3fb3210389d0b13009368ab Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:20:10 -0300 Subject: [PATCH 22/58] test(keys): cover the storage area and clear guards --- .../src/lib/session-manager.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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 9879a5615..557a84e82 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); } @@ -266,4 +266,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); + }); }); From d8b0e4ad2cbc4b4177538ad6f9962a772d052578 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 14:50:39 -0300 Subject: [PATCH 23/58] feat(core): make the account api key optional --- packages/core/etc/core.api.md | 2 +- packages/core/src/account/Account.ts | 8 ++++---- packages/core/src/account/smartAccount.ts | 4 ++-- packages/core/src/rpc/permissions.ts | 4 ++-- .../components/OnboardingDialog/accountHelpers.test.ts | 2 +- .../ui/src/components/OnboardingDialog/accountHelpers.ts | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 5b71c4dc4..6f32b2793 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; diff --git a/packages/core/src/account/Account.ts b/packages/core/src/account/Account.ts index d4d13fc8c..a554dc3ad 100644 --- a/packages/core/src/account/Account.ts +++ b/packages/core/src/account/Account.ts @@ -57,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 */ @@ -149,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; /** @@ -158,7 +158,7 @@ export class Account { private constructor( smartAccount: SmartAccount, chain: Chain, - apiKey: string, + apiKey: string | undefined, passkeyAccount?: PasskeyAccount, localAccount?: LocalAccount ) { diff --git a/packages/core/src/account/smartAccount.ts b/packages/core/src/account/smartAccount.ts index 4d7c4f086..e8309c48d 100644 --- a/packages/core/src/account/smartAccount.ts +++ b/packages/core/src/account/smartAccount.ts @@ -530,7 +530,7 @@ export async function sendCallsWithPermission( }>, chain: Chain, permissionId: Hex, - apiKey: string, + apiKey: string | undefined, paymasterUrlOverride?: string, paymasterContextOverride?: Record, localAccount?: LocalAccount, @@ -625,7 +625,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/rpc/permissions.ts b/packages/core/src/rpc/permissions.ts index 21af18e57..0a2e096ae 100644 --- a/packages/core/src/rpc/permissions.ts +++ b/packages/core/src/rpc/permissions.ts @@ -352,7 +352,7 @@ export async function grantPermissions( spender: Address, permissions: PermissionsDetail, chain: Chain, - apiKey: string, + apiKey: string | undefined, paymasterUrlOverride?: string, paymasterContextOverride?: 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 } diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts index 45df554f6..d65b86f39 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts @@ -29,7 +29,7 @@ describe('backfillLocalAccountAddresses', () => { const byCredentialId = await backfillLocalAccountAddresses({ chainId: 1 }); - expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: '' }); + expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: undefined }); expect(byCredentialId).toEqual({ 'cred-1': WITH_ADDRESS[0].address }); }); diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts index 698dfa805..e0293c9d6 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts @@ -34,7 +34,7 @@ export async function backfillLocalAccountAddresses(params: { const accounts = await Account.backfillStoredAccountAddresses({ chainId: params.chainId ?? 1, - apiKey: params.apiKey ?? '', + apiKey: params.apiKey, }); const byCredentialId: Record = {}; for (const account of accounts) { From 02cb6e0d9388aae201268a193886fff966da68ac Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 15:04:03 -0300 Subject: [PATCH 24/58] fix(ui): drop a backfill memo that misread failures --- .../OnboardingDialog/accountHelpers.test.ts | 43 ++++++------------- .../OnboardingDialog/accountHelpers.ts | 10 ----- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts index d65b86f39..a233b44b3 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.test.ts @@ -1,6 +1,5 @@ -// The address backfill runs for keyless callers too, so it also has to stop -// hammering the rpc when it turns out nothing can be derived: the dialog reopens -// on every request, and a refused derivation stays refused until a reload. +// 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', () => ({ @@ -15,49 +14,35 @@ import { backfillLocalAccountAddresses } from './accountHelpers'; const backfillMock = vi.mocked(Account.backfillStoredAccountAddresses); -const WITH_ADDRESS = [{ credentialId: 'cred-1', address: '0x1111111111111111111111111111111111111111' }]; -const WITHOUT_ADDRESS = [{ credentialId: 'cred-1' }]; +const ADDRESS = '0x1111111111111111111111111111111111111111'; -// The memo is keyed by chain and key, so each case picks its own pair. describe('backfillLocalAccountAddresses', () => { beforeEach(() => { vi.clearAllMocks(); }); it('derives with no api key', async () => { - backfillMock.mockResolvedValue(WITH_ADDRESS as never); + 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': WITH_ADDRESS[0].address }); + expect(byCredentialId).toEqual({ 'cred-1': ADDRESS }); }); - it('keeps deriving while the answers keep coming', async () => { - backfillMock.mockResolvedValue(WITH_ADDRESS as never); + it('defaults to mainnet when the dialog has no chain yet', async () => { + backfillMock.mockResolvedValue([] as never); - await backfillLocalAccountAddresses({ chainId: 10 }); - await backfillLocalAccountAddresses({ chainId: 10 }); + await backfillLocalAccountAddresses({ apiKey: 'test-key' }); - expect(backfillMock).toHaveBeenCalledTimes(2); + expect(backfillMock).toHaveBeenCalledWith({ chainId: 1, apiKey: 'test-key' }); }); - it('stops asking once a derivation resolves nothing', async () => { - backfillMock.mockResolvedValue(WITHOUT_ADDRESS as never); + // 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: 137 })).toEqual({}); - expect(await backfillLocalAccountAddresses({ chainId: 137 })).toEqual({}); - - expect(backfillMock).toHaveBeenCalledTimes(1); - }); - - it('memoizes per chain and key, not globally', async () => { - backfillMock.mockResolvedValue(WITHOUT_ADDRESS as never); - await backfillLocalAccountAddresses({ chainId: 8453, apiKey: 'a-key' }); - - backfillMock.mockResolvedValue(WITH_ADDRESS as never); - const byCredentialId = await backfillLocalAccountAddresses({ chainId: 8453, apiKey: 'another-key' }); - - expect(byCredentialId).toEqual({ 'cred-1': WITH_ADDRESS[0].address }); + 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 e0293c9d6..fa7c21fd1 100644 --- a/packages/ui/src/components/OnboardingDialog/accountHelpers.ts +++ b/packages/ui/src/components/OnboardingDialog/accountHelpers.ts @@ -17,9 +17,6 @@ export function getStoredLocalAccounts(apiKey?: string): LocalStorageAccount[] { return Account.getStoredAccounts(apiKey).map(toLocalStorageAccount); } -/** Chain + key pairs whose derivation came back with nothing. */ -const resolvedNothing = new Set(); - /** * Derive + persist addresses for stored records that predate address * persistence (ceremony-free factory derivation), returning credentialId → @@ -29,9 +26,6 @@ export async function backfillLocalAccountAddresses(params: { chainId?: number; apiKey?: string; }): Promise> { - const attempt = `${params.chainId ?? 1}-${params.apiKey ?? ''}`; - if (resolvedNothing.has(attempt)) return {}; - const accounts = await Account.backfillStoredAccountAddresses({ chainId: params.chainId ?? 1, apiKey: params.apiKey, @@ -40,10 +34,6 @@ export async function backfillLocalAccountAddresses(params: { for (const account of accounts) { if (account.credentialId && account.address) byCredentialId[account.credentialId] = account.address; } - // Every derivation failed, so the rpc refused them all (an origin the proxy - // does not know, or no network). The dialog reopens often; retrying the same - // call per credential on each open buys nothing until the page reloads. - if (Object.keys(byCredentialId).length === 0) resolvedNothing.add(attempt); return byCredentialId; } From 78a1cdbdb7bce53d80081114c92f33f443247d94 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 15:14:06 -0300 Subject: [PATCH 25/58] docs: make the api key optional in cross-platform mode --- apps/docs/docs/pages/account/index.mdx | 4 ++-- apps/docs/docs/pages/configuration/apiKey.mdx | 16 ++++++++++++++-- apps/docs/docs/pages/configuration/index.mdx | 4 +++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/docs/docs/pages/account/index.mdx b/apps/docs/docs/pages/account/index.mdx index c9b57f409..34579d7d4 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..1efe55c78 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. Without one, the caller is identified by the domain it runs on, so that domain has to be on the allowed list described below. + +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 domain your application runs on is what names it. + ## How to Get an API Key 1. Visit the [JAW Dashboard](https://dashboard.jaw.id/) @@ -45,7 +57,7 @@ For security, you must configure which domains are allowed to use your API key: - `yourdomain.com` for production - `staging.yourdomain.com` for staging environments -Requests from domains not in your allowed list will be rejected. +Requests from domains not in your allowed list will be rejected. The list is also what identifies a request that carries no key at all. ## Related Configuration diff --git a/apps/docs/docs/pages/configuration/index.mdx b/apps/docs/docs/pages/configuration/index.mdx index 4e3b54bbc..53a516da4 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 domain it runs on, which has to be on the allowed domains list in the dashboard. ```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 From c92f1c57ffa563ab1217c33302af27f7b9f58d16 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 15:23:29 -0300 Subject: [PATCH 26/58] docs: name the origin registry as what serves a keyless call --- apps/docs/docs/pages/configuration/apiKey.mdx | 6 +++--- apps/docs/docs/pages/configuration/index.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/docs/pages/configuration/apiKey.mdx b/apps/docs/docs/pages/configuration/apiKey.mdx index 1efe55c78..c04c607ea 100644 --- a/apps/docs/docs/pages/configuration/apiKey.mdx +++ b/apps/docs/docs/pages/configuration/apiKey.mdx @@ -5,7 +5,7 @@ Your JAW API key for authentication with JAW services. **Type:** `string` **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. Without one, the caller is identified by the domain it runs on, so that domain has to be on the allowed list described below. +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. @@ -37,7 +37,7 @@ const jaw = JAW.create({ const connector = jaw({ appName: 'My DApp' }); ``` -Requests then carry no `api-key` parameter, and the domain your application runs on is what names it. +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 @@ -57,7 +57,7 @@ For security, you must configure which domains are allowed to use your API key: - `yourdomain.com` for production - `staging.yourdomain.com` for staging environments -Requests from domains not in your allowed list will be rejected. The list is also what identifies a request that carries no key at all. +Requests from domains not in your allowed list will be rejected. ## Related Configuration diff --git a/apps/docs/docs/pages/configuration/index.mdx b/apps/docs/docs/pages/configuration/index.mdx index 53a516da4..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 -Every option is optional in cross-platform mode, the default. A request without an [apiKey](/configuration/apiKey) is identified by the domain it runs on, which has to be on the allowed domains list in the dashboard. +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 From cee4d994268e8d074dd49057e216a1c31d96eba3 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Wed, 16 Sep 2026 15:28:11 -0300 Subject: [PATCH 27/58] refactor(ui): keep one cache for chain capabilities (#342) --- packages/core/src/api/rest.test.ts | 34 +++++++++++++++++ packages/core/src/rpc/capabilities.test.ts | 11 ++++++ packages/core/src/rpc/capabilities.ts | 6 ++- .../ui/src/hooks/useChainIconURI.test.tsx | 27 +++++--------- packages/ui/src/hooks/useChainIconURI.tsx | 37 ++++--------------- 5 files changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts index aaa5bb6b8..fbafc5a0d 100644 --- a/packages/core/src/api/rest.test.ts +++ b/packages/core/src/api/rest.test.ts @@ -1,6 +1,7 @@ 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_PROXY_URL } from '../constants.js'; @@ -66,6 +67,39 @@ describe('restCall and the calling dApp', () => { 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 () => { diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 14c57f22a..187cde30f 100644 --- a/packages/core/src/rpc/capabilities.test.ts +++ b/packages/core/src/rpc/capabilities.test.ts @@ -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(); diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 86b2a8ad4..932749b69 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -85,8 +85,10 @@ 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. // 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 `undefined|...` entry. - const cacheKey = `${apiKey}|${store.config.get().dappOrigin ?? ''}|${JSON.stringify(requestArgs.params ?? [])}`; + // 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 ?? [])}`; // 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 diff --git a/packages/ui/src/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx index a1447e0a0..1dcd34c6d 100644 --- a/packages/ui/src/hooks/useChainIconURI.test.tsx +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -2,7 +2,7 @@ // 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 { afterEach, describe, expect, it, vi } from 'vitest'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { act } from 'react'; @@ -14,7 +14,7 @@ vi.mock('@jaw.id/core', () => ({ })); import { handleGetCapabilitiesRequest } from '@jaw.id/core'; -import { clearChainIconCache, useChainIconURI } from './useChainIconURI'; +import { useChainIconURI } from './useChainIconURI'; const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); @@ -36,10 +36,6 @@ async function mount(chainId: number, apiKey?: string) { await act(() => Promise.resolve()); } -beforeEach(() => { - clearChainIconCache(); -}); - afterEach(() => { if (root) act(() => root!.unmount()); root = null; @@ -65,19 +61,18 @@ describe('useChainIconURI', () => { expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); }); - // The two spellings of "no key" are the same caller, so they share an entry. - it('serves the cached icon whichever way the missing key is spelled', async () => { + // 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, ''); + await mount(1, 'test-key'); if (root) act(() => root.unmount()); - await mount(1, undefined); + await mount(1, 'test-key'); - expect(capabilitiesMock).toHaveBeenCalledTimes(1); + expect(capabilitiesMock).toHaveBeenCalledTimes(2); expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); }); - // A refused lookup used to be cached, which pinned the '?' placeholder for the - // rest of the page over one offline moment. it('tries again after a failed lookup', async () => { capabilitiesMock.mockRejectedValueOnce(new Error('offline')); await mount(1, 'test-key'); @@ -91,14 +86,10 @@ describe('useChainIconURI', () => { expect(container.querySelector('img')?.getAttribute('src')).toBe(ICON); }); - // A chain the backend knows nothing about is an answer, and it is cached. - it('asks once for a chain that has no icon', async () => { + it('falls back for a chain the backend has no icon for', async () => { capabilitiesMock.mockResolvedValue({ '0x1': {} } as never); await mount(1, 'test-key'); - if (root) act(() => root.unmount()); - await mount(1, 'test-key'); - expect(capabilitiesMock).toHaveBeenCalledTimes(1); expect(container.querySelector('img')).toBeNull(); }); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 6c29421d8..4644e01e4 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -1,18 +1,13 @@ import { JSX, useState, useEffect, useMemo } from 'react'; import { handleGetCapabilitiesRequest, type ChainMetadataCapability } from '@jaw.id/core'; -// Simple in-memory cache for chain icons to avoid redundant API calls -const chainIconCache = new Map(); - -/** Drops the cached icons. For tests, which would otherwise share them. */ -export function clearChainIconCache(): void { - chainIconCache.clear(); -} - /** * 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, so this asks on every mount. + * * @param chainId - The chain ID to get the icon for * @param apiKey - The API key for authentication, if the caller has one * @param size - The size of the icon in pixels (default: 24) @@ -20,13 +15,9 @@ export function clearChainIconCache(): void { */ 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)); + const [iconURI, setIconURI] = useState(null); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { if (!chainId) { @@ -34,13 +25,6 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) return; } - // If already cached, don't refetch - if (chainIconCache.has(cacheKey)) { - setIconURI(chainIconCache.get(cacheKey) ?? null); - setIsLoading(false); - return; - } - let isMounted = true; const fetchCapabilities = async () => { @@ -59,19 +43,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) { - // A failed lookup is not an answer. Caching it would pin the '?' for - // the rest of the page's life over one offline moment; leaving it out - // costs one request the next time a dialog opens. setIconURI(null); setIsLoading(false); } @@ -83,7 +60,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(() => { From cd4fb2494e040c7379268ba2645b6867a7a6c3db Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:42:42 -0300 Subject: [PATCH 28/58] fix(core): reject a 2xx body that carries no rpc result --- packages/core/src/utils/provider.test.ts | 22 ++++++++++++++++++++++ packages/core/src/utils/provider.ts | 5 +++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts index e8374bbdb..0b042a089 100644 --- a/packages/core/src/utils/provider.test.ts +++ b/packages/core/src/utils/provider.test.ts @@ -91,6 +91,28 @@ describe('fetchRPCRequest', () => { ); }); + // `{}` 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', diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 63ae79468..12c6eea56 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -68,8 +68,9 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) 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. - if (!envelope) { + // 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)}` : ''}` ); From 376fce6b6e8a43ffd2cac7d6e8f9aacdba160447 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:42:53 -0300 Subject: [PATCH 29/58] fix(core): match our backend by origin, not by prefix --- packages/core/src/api/rest.test.ts | 21 ++++++++++++++++++++- packages/core/src/api/rest.ts | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/rest.test.ts b/packages/core/src/api/rest.test.ts index fbafc5a0d..cf04a7781 100644 --- a/packages/core/src/api/rest.test.ts +++ b/packages/core/src/api/rest.test.ts @@ -3,7 +3,7 @@ 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_PROXY_URL } from '../constants.js'; +import { JAW_BASE_URL, JAW_PROXY_URL } from '../constants.js'; const request = vi.fn(); @@ -118,4 +118,23 @@ describe('restCall and the calling dApp', () => { 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 76887e2bf..00d824071 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -3,6 +3,21 @@ import { Routes, ROUTES } from './routes/index.js'; import { store } from '../store/index.js'; import qs from 'qs'; +/** + * 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 { + const { origin } = new URL(serverUrl); + return origin === new URL(getBaseUrl()).origin || origin === new URL(getBaseUrl(true)).origin; + } catch { + return false; + } +} + /** * Makes a REST call to the Backend API. * @typeparam T - The type of the route. @@ -53,7 +68,7 @@ export const restCall = < // 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 || serverUrl.startsWith(getBaseUrl()) || serverUrl.startsWith(getBaseUrl(true)); + const serverIsOurs = !serverUrl || isOurHost(serverUrl); const dappOrigin = serverIsOurs ? store.config.get().dappOrigin : undefined; return controlledAxiosPromise( From b192136c5c43df2b638f531f9d44d8e723f819f6 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:43:04 -0300 Subject: [PATCH 30/58] perf(core): hold a failed capabilities lookup briefly --- packages/core/src/rpc/capabilities.test.ts | 20 ++++++++++++- packages/core/src/rpc/capabilities.ts | 35 ++++++++++++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 187cde30f..4a003bc8a 100644 --- a/packages/core/src/rpc/capabilities.test.ts +++ b/packages/core/src/rpc/capabilities.test.ts @@ -91,7 +91,21 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it('does not cache failures, so the next caller retries', async () => { + // Failing on an unregistered origin is persistent, 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 failure for its window instead of asking again', async () => { + const fetchSpy = vi.fn(async () => { + throw new Error('network down'); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toThrow('network down'); + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toThrow('network down'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries once the failure goes stale', async () => { let calls = 0; const fetchSpy = vi.fn(async () => { calls++; @@ -104,6 +118,10 @@ describe('handleGetCapabilitiesRequest caching', () => { vi.stubGlobal('fetch', fetchSpy); await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toThrow('network down'); + // The failure 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); }); diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 932749b69..5beb679ff 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -26,13 +26,25 @@ export type CapabilitiesResult = Record<`0x${string}`, Record>; */ const CAPABILITIES_TTL_MS = 60_000; +/** + * How long a failed lookup keeps the next caller from repeating it. + * + * Short, because most failures here are transient and pinning one for a minute would + * leave a dialog without its fee row for that long. Not zero, because the keyless + * path fails persistently when the origin is not registered: the icon hook asks per + * chain, so a chain picker re-fires one refused request per chain on every mount. + */ +const CAPABILITIES_FAILURE_TTL_MS = 30_000; + const capabilitiesCache = new Map(); +const capabilitiesFailures = 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(); + capabilitiesFailures.clear(); capabilitiesInflight.clear(); } @@ -49,7 +61,9 @@ export function clearCapabilitiesCache(): void { * 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. + * A failure is held for `CAPABILITIES_FAILURE_TTL_MS` and rethrown, so a persistent + * refusal is asked about once per window instead of once per mount. 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 @@ -99,15 +113,24 @@ export async function handleGetCapabilitiesRequest( return structuredClone(cached.value); } + const failed = capabilitiesFailures.get(cacheKey); + if (failed && Date.now() - failed.at < CAPABILITIES_FAILURE_TTL_MS) throw failed.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 }); + capabilitiesFailures.delete(cacheKey); + return result; + } catch (error) { + // The rejection propagates to every sharer, and the next caller within the + // window gets it back without a second request. + capabilitiesFailures.set(cacheKey, { at: Date.now(), error }); + throw error; + } })(); capabilitiesInflight.set(cacheKey, pending); From 753bb36b00be760cd391393fbae11f927d2baa0c Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:43:14 -0300 Subject: [PATCH 31/58] fix(ui): drop the old chain icon while the next loads --- .../ui/src/hooks/useChainIconURI.test.tsx | 22 +++++++++++++++++++ packages/ui/src/hooks/useChainIconURI.tsx | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/packages/ui/src/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx index 1dcd34c6d..abd87fe20 100644 --- a/packages/ui/src/hooks/useChainIconURI.test.tsx +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -19,6 +19,7 @@ import { useChainIconURI } from './useChainIconURI'; const capabilitiesMock = vi.mocked(handleGetCapabilitiesRequest); 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); @@ -93,6 +94,27 @@ describe('useChainIconURI', () => { 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); + }); + it('does not fetch without a chain', async () => { await mount(0, 'test-key'); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 4644e01e4..66f54f617 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -25,6 +25,11 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) return; } + // The icon on screen belongs to the chain we were rendering before, and a + // mounted dialog can switch chain: drop it rather than keep it up through + // the lookup. + setIconURI(null); + let isMounted = true; const fetchCapabilities = async () => { From c9ac459944b394db9e987d2745e93c2ed823c960 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:43:24 -0300 Subject: [PATCH 32/58] refactor(core): move the dapp origin setter off the public api --- apps/keys-jaw-id/src/app/page.tsx | 17 +++++++++++------ packages/core/etc/core.api.md | 3 --- packages/core/package.json | 7 +++++++ packages/core/src/index.ts | 1 - packages/core/src/internal.ts | 7 +++++++ packages/core/tsup.config.ts | 2 +- 6 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/internal.ts diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index 8d5950d27..da8f1d32a 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -10,7 +10,8 @@ import { extractTransactionData } from '../lib/tx-handler'; import type { TransactionRequestData } from '../components/TransactionModal'; import { useAuth, usePasskeys } from '../hooks'; import { SignInScreen, type AuthenticatedAccount } from '../components/OnboardingSection'; -import { PasskeyManager, setDappOrigin, type PasskeyAccount } from '@jaw.id/core'; +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'; @@ -560,12 +561,15 @@ function KeysJawIdAppContent({ } // Get origin and set it as current context - const origin = communicator.getOrigin() || ''; + const dappOrigin = communicator.getOrigin() ?? undefined; + const origin = dappOrigin ?? ''; 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. - setDappOrigin(origin); + // cannot tell which one a call belongs to unless we say. Passed through + // unknown rather than as '': a keyless call the backend cannot attribute + // is refused, and an empty header would read as us calling for ourselves. + setDappOrigin(dappOrigin); const peerPublicKey = request.sender; const method = request.content.handshake.method; @@ -723,14 +727,15 @@ function KeysJawIdAppContent({ try { // Load session for this origin - const origin = communicator.getOrigin() || ''; + const dappOrigin = communicator.getOrigin() ?? undefined; + const origin = dappOrigin ?? ''; // 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); + setDappOrigin(dappOrigin); // 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/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 6f32b2793..b3fb83852 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -1196,9 +1196,6 @@ export interface ServerErrorOptions extends EthereumErrorOptions { code: number; } -// @public -export function setDappOrigin(origin: string | undefined): void; - // @public export interface SignatureUIRequest extends BaseUIRequest { // (undocumented) diff --git a/packages/core/package.json b/packages/core/package.json index a5a6b12f0..59669ed8b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,13 @@ "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": "./dist/cjs/internal.cjs", + "default": "./dist/internal.js" } }, "repository": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af72dd4ae..1193abed2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,7 +127,6 @@ export * from './utils/index.js'; /** Store exports **/ export { type Chain, type FeeToken, type FeeTokenCapability } from './store/index.js'; -export { setDappOrigin } from './dappOrigin.js'; /** Analytics exports **/ export { diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts new file mode 100644 index 000000000..d6fbf13bc --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,7 @@ +/** + * 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. + */ +export { setDappOrigin } from './dappOrigin.js'; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 8e4466017..d480773a2 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/internal.ts'], format: ['cjs'], outDir: 'dist/cjs', dts: false, From 2ba364023568d677f195c31ad11dd0b33831f544 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:54:31 -0300 Subject: [PATCH 33/58] fix(core): keep the internal entry point on one store --- packages/core/package.json | 4 +--- packages/core/src/internal.ts | 5 +++++ packages/core/tsup.config.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 59669ed8b..babb34bb4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,9 +19,7 @@ "./internal": { "@jaw-mono/source": "./src/internal.ts", "types": "./dist/internal.d.ts", - "import": "./dist/internal.js", - "require": "./dist/cjs/internal.cjs", - "default": "./dist/internal.js" + "import": "./dist/internal.js" } }, "repository": { diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index d6fbf13bc..45c27dfa9 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -3,5 +3,10 @@ * * 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'; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index d480773a2..8e4466017 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts', 'src/internal.ts'], + entry: ['src/index.ts'], format: ['cjs'], outDir: 'dist/cjs', dts: false, From a24df55a9affdcfdbbe3a53d323c7d24b335fca4 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:54:41 -0300 Subject: [PATCH 34/58] refactor(core): parse our own origins once --- packages/core/src/api/rest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/rest.ts b/packages/core/src/api/rest.ts index 00d824071..287952403 100644 --- a/packages/core/src/api/rest.ts +++ b/packages/core/src/api/rest.ts @@ -3,6 +3,8 @@ 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. * @@ -11,8 +13,7 @@ import qs from 'qs'; */ function isOurHost(serverUrl: string): boolean { try { - const { origin } = new URL(serverUrl); - return origin === new URL(getBaseUrl()).origin || origin === new URL(getBaseUrl(true)).origin; + return OUR_ORIGINS.includes(new URL(serverUrl).origin); } catch { return false; } From 92499ae975a4095d4ea7627ba6f311bd20c17378 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:54:51 -0300 Subject: [PATCH 35/58] refactor(core): drop a delete that cannot be observed --- packages/core/src/rpc/capabilities.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 5beb679ff..f5a4e84e9 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -123,7 +123,6 @@ export async function handleGetCapabilitiesRequest( try { const result = (await fetchRPCRequest(requestArgs, rpcUrl)) as CapabilitiesResult; capabilitiesCache.set(cacheKey, { at: Date.now(), value: result }); - capabilitiesFailures.delete(cacheKey); return result; } catch (error) { // The rejection propagates to every sharer, and the next caller within the From 5caf04d0c54e755c991af4dc7cad2843f851ec77 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 12:55:01 -0300 Subject: [PATCH 36/58] refactor(keys): keep one name for the request origin --- apps/keys-jaw-id/src/app/page.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index da8f1d32a..7f64d7437 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -561,15 +561,14 @@ function KeysJawIdAppContent({ } // Get origin and set it as current context - const dappOrigin = communicator.getOrigin() ?? undefined; - const origin = dappOrigin ?? ''; + 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. Passed through - // unknown rather than as '': a keyless call the backend cannot attribute - // is refused, and an empty header would read as us calling for ourselves. - setDappOrigin(dappOrigin); + // 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; @@ -727,15 +726,14 @@ function KeysJawIdAppContent({ try { // Load session for this origin - const dappOrigin = communicator.getOrigin() ?? undefined; - const origin = dappOrigin ?? ''; + 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(dappOrigin); + 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 From 77f7beea634b0b07df582d4ed42c10d6cff25952 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 13:09:29 -0300 Subject: [PATCH 37/58] fix(ui): stop bundling a second copy of core --- packages/ui/vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index a68b2b3df..2752e6598 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -38,7 +38,11 @@ export default defineConfig(() => ({ }, 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. + external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@jaw.id/core'], }, }, })); From 0e048c4611ae1714cef1fd9fdf36397bdfbb7b77 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 13:09:41 -0300 Subject: [PATCH 38/58] build(core): say why a cjs require of internal fails --- packages/core/cjs-error-internal.cjs | 3 +++ packages/core/package.json | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/core/cjs-error-internal.cjs diff --git a/packages/core/cjs-error-internal.cjs b/packages/core/cjs-error-internal.cjs new file mode 100644 index 000000000..db4f124f8 --- /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/package.json b/packages/core/package.json index babb34bb4..d5bc39edb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,7 +19,8 @@ "./internal": { "@jaw-mono/source": "./src/internal.ts", "types": "./dist/internal.d.ts", - "import": "./dist/internal.js" + "import": "./dist/internal.js", + "require": "./cjs-error-internal.cjs" } }, "repository": { @@ -32,6 +33,7 @@ }, "files": [ "dist", + "cjs-error-internal.cjs", "LICENSE", "NOTICE", "!**/*.tsbuildinfo" From 870bab034a0c9571e46010d469e0698d80bb7b8a Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 13:09:52 -0300 Subject: [PATCH 39/58] fix(ui): clear the chain icon when the chain goes away --- packages/ui/src/hooks/useChainIconURI.test.tsx | 15 +++++++++++++++ packages/ui/src/hooks/useChainIconURI.tsx | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx index abd87fe20..9c8b7f808 100644 --- a/packages/ui/src/hooks/useChainIconURI.test.tsx +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -115,6 +115,21 @@ describe('useChainIconURI', () => { 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); + }); + it('does not fetch without a chain', async () => { await mount(0, 'test-key'); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 66f54f617..05e990bad 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -20,16 +20,16 @@ export const useChainIconURI = (chainId: number, apiKey?: string, size?: number) const [isLoading, setIsLoading] = useState(true); useEffect(() => { + // The icon on screen belongs to the chain we were rendering before, and a + // mounted dialog can switch chain: drop it rather than keep it up, both + // through the lookup and when the new chain is one we cannot ask about. + setIconURI(null); + if (!chainId) { setIsLoading(false); return; } - // The icon on screen belongs to the chain we were rendering before, and a - // mounted dialog can switch chain: drop it rather than keep it up through - // the lookup. - setIconURI(null); - let isMounted = true; const fetchCapabilities = async () => { From 1475041247e258aeafef285b5f43b2a5a7e8eb40 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 13:38:32 -0300 Subject: [PATCH 40/58] style(core): format the cjs error shim --- packages/core/cjs-error-internal.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/cjs-error-internal.cjs b/packages/core/cjs-error-internal.cjs index db4f124f8..1f997f8ed 100644 --- a/packages/core/cjs-error-internal.cjs +++ b/packages/core/cjs-error-internal.cjs @@ -1,3 +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." + '@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.' ); From f05b00ce0db9d28c5290d303c9e5c65d49b99da9 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 21:41:33 -0300 Subject: [PATCH 41/58] fix(keys): restore and sign in a session that has no api key --- .../src/hooks/useLogin/index.test.tsx | 76 +++++++++++++++++++ apps/keys-jaw-id/src/hooks/useLogin/index.ts | 16 ++-- .../src/hooks/usePasskeys/index.ts | 16 +--- .../hooks/useSessionAccount/index.test.tsx | 76 +++++++++++++++++++ .../src/hooks/useSessionAccount/index.ts | 20 ++--- 5 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 apps/keys-jaw-id/src/hooks/useLogin/index.test.tsx create mode 100644 apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx 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..808c2b670 100644 --- a/apps/keys-jaw-id/src/hooks/useLogin/index.ts +++ b/apps/keys-jaw-id/src/hooks/useLogin/index.ts @@ -15,20 +15,14 @@ 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 + // Keyless has no key to require: first-time users come through + // `useCreatePasskey`, and this is the path every later visit takes. + // Refusing here is what made the second visit fail where the first + // worked. 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.ts b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts index 0f9c2e4ce..e1053fb06 100644 --- a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts +++ b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts @@ -37,12 +37,9 @@ export const usePasskeys = (options?: UsePasskeysOptions) => { */ 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.' - ); - } + // No key is a caller too: keyless, the proxy answers on the origin keys + // forwards, and `Account` has taken the key as optional since. + const effectiveApiKey = overrideApiKey || apiKey || undefined; if (!credentialId) { throw new Error('credentialId is required to get an account'); } @@ -66,12 +63,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'); } 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..055a528d3 --- /dev/null +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx @@ -0,0 +1,76 @@ +// @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()); +} + +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'); + }); + + 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..d024bee19 100644 --- a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts @@ -65,29 +65,31 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe const isInitializingRef = useRef(false); const lastInitKeyRef = useRef(''); - // Extract API key from chain.rpcUrl if not provided + // Extract API key from chain.rpcUrl if there is one to extract. Undefined + // rather than '': a keyless session has no key anywhere, and treating that as + // missing data is what left `account` null for the whole session, so every + // dialog answered "Account not initialized" on Confirm. const effectiveApiKey = useMemo(() => { if (apiKey) return apiKey; if (chain?.rpcUrl) { try { - const url = new URL(chain.rpcUrl); - return url.searchParams.get('api-key') || ''; + return new URL(chain.rpcUrl).searchParams.get('api-key') ?? undefined; } catch { - return ''; + return undefined; } } - return ''; + return undefined; }, [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; } From c412f9c29527ff06149a402afd6cfdd62811ddc6 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 21:45:53 -0300 Subject: [PATCH 42/58] test(keys): cover the restore path a keyless session takes --- .../src/hooks/usePasskeys/index.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/keys-jaw-id/src/hooks/usePasskeys/index.test.tsx 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'); + }); +}); From 0961cfad104d98def168eb876bd2980098dda042 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Thu, 17 Sep 2026 21:52:27 -0300 Subject: [PATCH 43/58] fix(keys): restore again when the key lands mid-flight --- apps/keys-jaw-id/src/hooks/useLogin/index.ts | 6 ++-- .../src/hooks/usePasskeys/index.ts | 4 +-- .../hooks/useSessionAccount/index.test.tsx | 26 +++++++++++++++++ .../src/hooks/useSessionAccount/index.ts | 28 +++++++++++++------ 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/keys-jaw-id/src/hooks/useLogin/index.ts b/apps/keys-jaw-id/src/hooks/useLogin/index.ts index 808c2b670..41622e242 100644 --- a/apps/keys-jaw-id/src/hooks/useLogin/index.ts +++ b/apps/keys-jaw-id/src/hooks/useLogin/index.ts @@ -15,10 +15,8 @@ export const useLogin = () => { return useMutation({ mutationFn: async ({ chainId, credentialId, apiKey }: LoginParams) => { try { - // Keyless has no key to require: first-time users come through - // `useCreatePasskey`, and this is the path every later visit takes. - // Refusing here is what made the second visit fail where the first - // worked. + // 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, diff --git a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts index e1053fb06..51df9c6dd 100644 --- a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts +++ b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts @@ -37,8 +37,8 @@ export const usePasskeys = (options?: UsePasskeysOptions) => { */ const getAccount = useCallback( async (chain: chain, credentialId: string, overrideApiKey?: string) => { - // No key is a caller too: keyless, the proxy answers on the origin keys - // forwards, and `Account` has taken the key as optional since. + // No key is a caller too: the proxy answers it on the origin keys + // forwards, and `Account` takes the key as optional. const effectiveApiKey = overrideApiKey || apiKey || undefined; if (!credentialId) { throw new Error('credentialId is required to get an account'); diff --git a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx index 055a528d3..1580aa7b8 100644 --- a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.test.tsx @@ -43,6 +43,13 @@ async function mount(chain: typeof CHAIN, apiKey?: string) { 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' }); @@ -68,6 +75,25 @@ describe('useSessionAccount', () => { 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'); diff --git a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts index d024bee19..55c8581c4 100644 --- a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts +++ b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts @@ -64,11 +64,14 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe // Prevent double initialization const isInitializingRef = useRef(false); const lastInitKeyRef = useRef(''); - - // Extract API key from chain.rpcUrl if there is one to extract. Undefined - // rather than '': a keyless session has no key anywhere, and treating that as - // missing data is what left `account` null for the whole session, so every - // dialog answered "Account not initialized" on Confirm. + // 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); + + // The key from `chain.rpcUrl` when there is one, undefined when there is not. + // A keyless session has no key anywhere, and the restore below takes it + // optional. const effectiveApiKey = useMemo(() => { if (apiKey) return apiKey; if (chain?.rpcUrl) { @@ -94,8 +97,13 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe 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; } @@ -120,11 +128,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(() => { From 3728b68d3d2b65bde0c3c2fec566e8934b720227 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:35:17 -0300 Subject: [PATCH 44/58] fix(core): cache a refusal, not every failed lookup --- packages/core/src/rpc/capabilities.test.ts | 40 ++++++++++++++++------ packages/core/src/rpc/capabilities.ts | 40 +++++++++++++--------- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 4a003bc8a..56de69b41 100644 --- a/packages/core/src/rpc/capabilities.test.ts +++ b/packages/core/src/rpc/capabilities.test.ts @@ -91,21 +91,22 @@ describe('handleGetCapabilitiesRequest caching', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); - // Failing on an unregistered origin is persistent, 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 failure for its window instead of asking again', async () => { - const fetchSpy = vi.fn(async () => { - throw new Error('network down'); - }); + // 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.toThrow('network down'); - await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toThrow('network down'); + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: 4100 }); + await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toMatchObject({ code: 4100 }); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('retries once the failure goes stale', async () => { + // 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++; @@ -118,7 +119,24 @@ describe('handleGetCapabilitiesRequest caching', () => { vi.stubGlobal('fetch', fetchSpy); await expect(handleGetCapabilitiesRequest(request, 'key', true)).rejects.toThrow('network down'); - // The failure window is 30s; jump past it. + await expect(handleGetCapabilitiesRequest(request, 'key', true)).resolves.toEqual(CAPS); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + 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); diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index f5a4e84e9..504561273 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -4,6 +4,7 @@ import { JAW_RPC_URL } from '../constants.js'; import { buildHandleJawRpcUrl, fetchRPCRequest, hexStringFromNumber } from '../utils/index.js'; import { MAINNET_CHAINS } from '../account/smartAccount.js'; import { store } from '../store/index.js'; +import { standardErrorCodes } from '../errors/index.js'; /** * Chain metadata capability returned by wallet_getCapabilities @@ -27,24 +28,30 @@ export type CapabilitiesResult = Record<`0x${string}`, Record>; const CAPABILITIES_TTL_MS = 60_000; /** - * How long a failed lookup keeps the next caller from repeating it. + * How long a refusal keeps the next caller from repeating it. * - * Short, because most failures here are transient and pinning one for a minute would - * leave a dialog without its fee row for that long. Not zero, because the keyless - * path fails persistently when the origin is not registered: the icon hook asks per - * chain, so a chain picker re-fires one refused request per chain on every mount. + * 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_FAILURE_TTL_MS = 30_000; +const CAPABILITIES_REFUSAL_TTL_MS = 30_000; + +/** Whether the backend turned this caller down, rather than failing to answer. */ +function isRefusal(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === standardErrorCodes.provider.unauthorized; +} const capabilitiesCache = new Map(); -const capabilitiesFailures = 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(); - capabilitiesFailures.clear(); + capabilitiesRefusals.clear(); capabilitiesInflight.clear(); } @@ -61,9 +68,10 @@ export function clearCapabilitiesCache(): void { * 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 failure is held for `CAPABILITIES_FAILURE_TTL_MS` and rethrown, so a persistent - * refusal is asked about once per window instead of once per mount. Every caller gets - * its own copy of the response. + * 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 @@ -113,8 +121,8 @@ export async function handleGetCapabilitiesRequest( return structuredClone(cached.value); } - const failed = capabilitiesFailures.get(cacheKey); - if (failed && Date.now() - failed.at < CAPABILITIES_FAILURE_TTL_MS) throw failed.error; + 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); @@ -125,9 +133,9 @@ export async function handleGetCapabilitiesRequest( capabilitiesCache.set(cacheKey, { at: Date.now(), value: result }); return result; } catch (error) { - // The rejection propagates to every sharer, and the next caller within the - // window gets it back without a second request. - capabilitiesFailures.set(cacheKey, { at: Date.now(), 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 (isRefusal(error)) capabilitiesRefusals.set(cacheKey, { at: Date.now(), error }); throw error; } })(); From c8ee012dff1902f59829839966ae16b3ad70c1ec Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:35:29 -0300 Subject: [PATCH 45/58] fix(core): name the dapp on the token quote call too --- .../core/src/account/erc20Paymaster.test.ts | 60 ++++++++++++++++++- packages/core/src/account/erc20Paymaster.ts | 19 +++--- 2 files changed, 71 insertions(+), 8 deletions(-) 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 cfb24f638..c986bdf90 100644 --- a/packages/core/src/account/erc20Paymaster.ts +++ b/packages/core/src/account/erc20Paymaster.ts @@ -1,8 +1,8 @@ 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, @@ -165,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), }); @@ -254,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); From 19fefec5b7e5ee3249cd3b7efe8e67653dc62be7 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:35:41 -0300 Subject: [PATCH 46/58] fix(ui): let the chain stack resolve icons without a key --- packages/ui/src/hooks/useChainIcons.test.tsx | 71 ++++++++++++++++++++ packages/ui/src/hooks/useChainIcons.ts | 29 +++++--- 2 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/hooks/useChainIcons.test.tsx 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; } From c48e1de5aad629e3eda36620a5cbceeadd118fac Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:40:03 -0300 Subject: [PATCH 47/58] perf(ui): paint a cached chain icon on the first frame --- packages/core/etc/core.api.md | 5 ++- packages/core/src/index.ts | 1 + packages/core/src/rpc/capabilities.ts | 44 ++++++++++++++++--- packages/core/src/rpc/index.ts | 1 + .../src/components/Eip712Dialog/index.test.ts | 2 + .../ui/src/hooks/useChainIconURI.test.tsx | 27 +++++++++++- packages/ui/src/hooks/useChainIconURI.tsx | 36 +++++++++++---- 7 files changed, 99 insertions(+), 17 deletions(-) diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 7e7dc574d..66c2d3e50 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -587,7 +587,7 @@ export function handleGetAssetsRequest(request: RequestArguments, apiKey: string // @public export function handleGetCallsHistoryRequest(request: RequestArguments, apiKey: string | undefined, connectedAddress?: Address_2): Promise; -// @public +// @public (undocumented) export function handleGetCapabilitiesRequest(request: RequestArguments, apiKey: string | undefined, showTestnets?: boolean): Promise; // @public @@ -963,6 +963,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/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/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 504561273..2322c5d53 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -78,13 +78,16 @@ export function clearCapabilitiesCache(): void { * @param showTestnets - Whether to include testnet chains (default: false) * @returns Capabilities for all or filtered chains */ -export async function handleGetCapabilitiesRequest( +/** + * 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. + */ +function resolveRequest( request: RequestArguments, apiKey: string | undefined, - showTestnets = false -): Promise { - const rpcUrl = buildHandleJawRpcUrl(JAW_RPC_URL, apiKey); - + 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]; @@ -112,6 +115,37 @@ export async function handleGetCapabilitiesRequest( // 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); +} + +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 // `params: []` entry — so a single mutation by any one caller would otherwise be visible 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/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/hooks/useChainIconURI.test.tsx b/packages/ui/src/hooks/useChainIconURI.test.tsx index 9c8b7f808..9ea486c71 100644 --- a/packages/ui/src/hooks/useChainIconURI.test.tsx +++ b/packages/ui/src/hooks/useChainIconURI.test.tsx @@ -2,7 +2,7 @@ // 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, describe, expect, it, vi } from 'vitest'; +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'; @@ -11,12 +11,14 @@ import { act } from 'react'; vi.mock('@jaw.id/core', () => ({ handleGetCapabilitiesRequest: vi.fn(), + peekCapabilities: vi.fn(), })); -import { handleGetCapabilitiesRequest } from '@jaw.id/core'; +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'; @@ -37,6 +39,11 @@ async function mount(chainId: number, apiKey?: string) { 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; @@ -130,6 +137,22 @@ describe('useChainIconURI', () => { 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'); diff --git a/packages/ui/src/hooks/useChainIconURI.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 05e990bad..47a71772c 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -1,31 +1,49 @@ import { JSX, useState, useEffect, useMemo } from 'react'; -import { handleGetCapabilitiesRequest, type ChainMetadataCapability } from '@jaw.id/core'; +import { handleGetCapabilitiesRequest, peekCapabilities, type ChainMetadataCapability } from '@jaw.id/core'; /** * 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, so this asks on every mount. + * 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, 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 */ +/** 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 }; +} + export const useChainIconURI = (chainId: number, apiKey?: string, size?: number): JSX.Element => { const iconSize = size ?? 24; - const [iconURI, setIconURI] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [iconURI, setIconURI] = useState(() => cachedIcon(chainId, apiKey)?.icon ?? null); + const [isLoading, setIsLoading] = useState(() => !cachedIcon(chainId, apiKey)); useEffect(() => { - // The icon on screen belongs to the chain we were rendering before, and a - // mounted dialog can switch chain: drop it rather than keep it up, both - // through the lookup and when the new chain is one we cannot ask about. - setIconURI(null); + // 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 (!chainId) { + if (cached || !chainId) { setIsLoading(false); return; } From 17b6f8752095e77b569d283186a747b567a91ea1 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:42:36 -0300 Subject: [PATCH 48/58] refactor(keys): read the request api key in one place --- .../src/components/AddFundsModal/index.tsx | 13 ++------- .../src/components/ConnectModal/index.tsx | 14 ++------- .../src/components/Eip712Modal/index.tsx | 14 ++------- .../src/components/SignatureModal/index.tsx | 14 ++------- .../src/components/SiweModal/index.tsx | 14 ++------- .../src/components/TransactionModal/index.tsx | 14 ++------- .../src/hooks/useSessionAccount/index.ts | 16 ++-------- apps/keys-jaw-id/src/lib/api-key.test.ts | 29 +++++++++++++++++++ apps/keys-jaw-id/src/lib/api-key.ts | 21 ++++++++++++++ 9 files changed, 64 insertions(+), 85 deletions(-) create mode 100644 apps/keys-jaw-id/src/lib/api-key.test.ts create mode 100644 apps/keys-jaw-id/src/lib/api-key.ts diff --git a/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx b/apps/keys-jaw-id/src/components/AddFundsModal/index.tsx index 9b4dac3a9..2db1c4b11 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..517c962df 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,18 +34,7 @@ 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]); 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/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..787305aa0 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(() => { diff --git a/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts b/apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts index 55c8581c4..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 { @@ -69,20 +70,7 @@ export function useSessionAccount(options: UseSessionAccountOptions = {}): UseSe const supersededRef = useRef(false); const [restarts, setRestarts] = useState(0); - // The key from `chain.rpcUrl` when there is one, undefined when there is not. - // A keyless session has no key anywhere, and the restore below takes it - // optional. - const effectiveApiKey = useMemo(() => { - if (apiKey) return apiKey; - if (chain?.rpcUrl) { - try { - return new URL(chain.rpcUrl).searchParams.get('api-key') ?? undefined; - } catch { - return undefined; - } - } - return undefined; - }, [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(() => { 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; + } +} From 1518cbe5d2cf6a770bfc8db1b9c966426039870a Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 11:57:31 -0300 Subject: [PATCH 49/58] fix(core): mark a refusal at the transport, not by its code --- .../src/components/ConnectModal/index.tsx | 15 ++---- .../src/components/PermissionModal/index.tsx | 20 ++------ .../src/components/TransactionModal/index.tsx | 2 +- packages/core/etc/core.api.md | 2 +- packages/core/src/rpc/capabilities.test.ts | 25 ++++++++++ packages/core/src/rpc/capabilities.ts | 49 +++++++++---------- packages/core/src/utils/provider.ts | 13 +++-- packages/ui/src/hooks/index.ts | 4 +- packages/ui/src/hooks/useChainIconURI.tsx | 35 +++++++------ 9 files changed, 90 insertions(+), 75 deletions(-) diff --git a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx index 517c962df..bfe50009f 100644 --- a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx +++ b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx @@ -40,18 +40,11 @@ export const ConnectModal = ({ 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 + // Mainnet, for ENS, under whatever key this request carries. The prop is left + // out on purpose: the other modals build this from the url's key alone. 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`; + const key = apiKeyFromChain(undefined, chain?.rpcUrl); + return key ? `${JAW_RPC_URL}?chainId=1&api-key=${key}` : `${JAW_RPC_URL}?chainId=1`; }, [chain?.rpcUrl]); const handleConnect = async () => { diff --git a/apps/keys-jaw-id/src/components/PermissionModal/index.tsx b/apps/keys-jaw-id/src/components/PermissionModal/index.tsx index 712b1af9c..1b27b8708 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 @@ -504,7 +490,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/TransactionModal/index.tsx b/apps/keys-jaw-id/src/components/TransactionModal/index.tsx index 787305aa0..53c5d6d49 100644 --- a/apps/keys-jaw-id/src/components/TransactionModal/index.tsx +++ b/apps/keys-jaw-id/src/components/TransactionModal/index.tsx @@ -292,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/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 66c2d3e50..b0cba26b9 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -587,7 +587,7 @@ export function handleGetAssetsRequest(request: RequestArguments, apiKey: string // @public export function handleGetCallsHistoryRequest(request: RequestArguments, apiKey: string | undefined, connectedAddress?: Address_2): Promise; -// @public (undocumented) +// @public export function handleGetCapabilitiesRequest(request: RequestArguments, apiKey: string | undefined, showTestnets?: boolean): Promise; // @public diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 56de69b41..1e451e583 100644 --- a/packages/core/src/rpc/capabilities.test.ts +++ b/packages/core/src/rpc/capabilities.test.ts @@ -123,6 +123,31 @@ 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 () => { diff --git a/packages/core/src/rpc/capabilities.ts b/packages/core/src/rpc/capabilities.ts index 2322c5d53..1a540f098 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -4,7 +4,6 @@ import { JAW_RPC_URL } from '../constants.js'; import { buildHandleJawRpcUrl, fetchRPCRequest, hexStringFromNumber } from '../utils/index.js'; import { MAINNET_CHAINS } from '../account/smartAccount.js'; import { store } from '../store/index.js'; -import { standardErrorCodes } from '../errors/index.js'; /** * Chain metadata capability returned by wallet_getCapabilities @@ -40,7 +39,7 @@ const CAPABILITIES_REFUSAL_TTL_MS = 30_000; /** Whether the backend turned this caller down, rather than failing to answer. */ function isRefusal(error: unknown): boolean { - return (error as { code?: unknown } | null)?.code === standardErrorCodes.provider.unauthorized; + return (error as { refused?: unknown } | null)?.refused === true; } const capabilitiesCache = new Map(); @@ -55,29 +54,6 @@ export function clearCapabilitiesCache(): void { 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. - * 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 - */ /** * 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 @@ -138,6 +114,29 @@ export function peekCapabilities( 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, diff --git a/packages/core/src/utils/provider.ts b/packages/core/src/utils/provider.ts index 12c6eea56..cc5da64db 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -47,10 +47,17 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) // 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. Carried on the error rather than left to be + // read off its 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 see + // 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 rpcError; + throw refused ? Object.assign(rpcError, { refused }) : rpcError; } // A refusal from the proxy is not a JSON-RPC envelope, so destructuring it @@ -59,8 +66,8 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) // 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 res.status === 401 || res.status === 403 - ? standardErrors.provider.unauthorized(message) + throw refused + ? Object.assign(standardErrors.provider.unauthorized(message), { refused }) : standardErrors.rpc.internal(message); } 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.tsx b/packages/ui/src/hooks/useChainIconURI.tsx index 47a71772c..f65e85981 100644 --- a/packages/ui/src/hooks/useChainIconURI.tsx +++ b/packages/ui/src/hooks/useChainIconURI.tsx @@ -1,20 +1,6 @@ import { JSX, useState, useEffect, useMemo } from 'react'; import { handleGetCapabilitiesRequest, peekCapabilities, type ChainMetadataCapability } from '@jaw.id/core'; -/** - * 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, 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 - */ /** 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; @@ -29,11 +15,28 @@ function cachedIcon(chainId: number, apiKey?: string): { icon: string | null } | 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, 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 [iconURI, setIconURI] = useState(() => cachedIcon(chainId, apiKey)?.icon ?? null); - const [isLoading, setIsLoading] = useState(() => !cachedIcon(chainId, apiKey)); + // 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(() => { // Whatever the cache says about this chain, which is nothing at all when it From 6c3dda488c052f281cce6ecedcda72c93faacb74 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 12:55:07 -0300 Subject: [PATCH 50/58] refactor(core): keep the refusal marker off the dapp error --- packages/core/src/rpc/capabilities.test.ts | 39 ++++++++++++++++- packages/core/src/rpc/capabilities.ts | 9 ++-- packages/core/src/utils/provider.test.ts | 50 +++++++++++++++++++++- packages/core/src/utils/provider.ts | 34 +++++++++++---- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/packages/core/src/rpc/capabilities.test.ts b/packages/core/src/rpc/capabilities.test.ts index 1e451e583..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 } } }; @@ -211,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 1a540f098..1ee6cb5f7 100644 --- a/packages/core/src/rpc/capabilities.ts +++ b/packages/core/src/rpc/capabilities.ts @@ -2,6 +2,8 @@ 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'; @@ -37,11 +39,6 @@ const CAPABILITIES_TTL_MS = 60_000; */ const CAPABILITIES_REFUSAL_TTL_MS = 30_000; -/** Whether the backend turned this caller down, rather than failing to answer. */ -function isRefusal(error: unknown): boolean { - return (error as { refused?: unknown } | null)?.refused === true; -} - const capabilitiesCache = new Map(); const capabilitiesRefusals = new Map(); /** Requests in flight, so concurrent callers share one fetch instead of racing duplicates. */ @@ -168,7 +165,7 @@ export async function handleGetCapabilitiesRequest( } 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 (isRefusal(error)) capabilitiesRefusals.set(cacheKey, { at: Date.now(), error }); + if (isBackendRefusal(error)) capabilitiesRefusals.set(cacheKey, { at: Date.now(), error }); throw error; } })(); diff --git a/packages/core/src/utils/provider.test.ts b/packages/core/src/utils/provider.test.ts index 0b042a089..da2fc48ca 100644 --- a/packages/core/src/utils/provider.test.ts +++ b/packages/core/src/utils/provider.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { buildHandleJawRpcUrl, fetchRPCRequest } from './provider.js'; +import { buildHandleJawRpcUrl, fetchRPCRequest, isBackendRefusal } from './provider.js'; import { setDappOrigin } from '../dappOrigin.js'; describe('buildHandleJawRpcUrl', () => { @@ -176,3 +176,51 @@ describe('fetchRPCRequest and the calling dApp', () => { 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 cc5da64db..c14a22472 100644 --- a/packages/core/src/utils/provider.ts +++ b/packages/core/src/utils/provider.ts @@ -14,6 +14,26 @@ 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) { const requestBody = { ...request, @@ -48,16 +68,16 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) } // Whether the backend turned this caller down, which is the one failure that - // answering again cannot change. Carried on the error rather than left to be - // read off its 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 see - // the same refusal as a blip depending on what the backend wrapped it in. + // 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 ? Object.assign(rpcError, { refused }) : rpcError; + throw refused ? refusal(rpcError) : rpcError; } // A refusal from the proxy is not a JSON-RPC envelope, so destructuring it @@ -66,9 +86,7 @@ export async function fetchRPCRequest(request: RequestArguments, rpcUrl: string) // 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 - ? Object.assign(standardErrors.provider.unauthorized(message), { refused }) - : standardErrors.rpc.internal(message); + throw refused ? refusal(standardErrors.provider.unauthorized(message)) : standardErrors.rpc.internal(message); } // On a 2xx, anything sitting in `error` is still a failure, however malformed. From 3f5c3103b9dbab5dd6692248b2984f08b2957dac Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 13:54:07 -0300 Subject: [PATCH 51/58] fix(core): send analytics only for something shaped like an address --- packages/core/src/analytics/index.test.ts | 51 +++++++++++++++++++++++ packages/core/src/analytics/index.ts | 7 ++++ packages/core/src/rpc/paramUtils.ts | 16 +++++-- 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/analytics/index.test.ts 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 9cf20a563..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'; @@ -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', @@ -67,6 +73,7 @@ export interface LogSignatureParams { export function logSignature(params: LogSignatureParams): void { try { const { address, apiKey } = params; + if (!isHexAddress(address)) return; restCall('LOG_SIGNATURE', 'POST', { address }, apiKey ? { 'x-api-key': apiKey } : {}).catch(() => { // Silently swallow async errors diff --git a/packages/core/src/rpc/paramUtils.ts b/packages/core/src/rpc/paramUtils.ts index 07337079a..81c23af72 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 Date: Fri, 18 Sep 2026 13:56:49 -0300 Subject: [PATCH 52/58] refactor(keys): drop two passkey helpers nothing calls --- .../src/hooks/usePasskeys/index.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts index 51df9c6dd..701817fd2 100644 --- a/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts +++ b/apps/keys-jaw-id/src/hooks/usePasskeys/index.ts @@ -31,31 +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) => { - // No key is a caller too: the proxy answers it on the origin keys - // forwards, and `Account` takes the key as optional. - const effectiveApiKey = overrideApiKey || apiKey || undefined; - 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 @@ -81,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, }; }; From c379c68e376dba54b42a2778a0897feb79d72dab Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 14:46:07 -0300 Subject: [PATCH 53/58] fix(core): keep a backend refusal from ending the session --- .../core/src/provider/JAWProvider.test.ts | 22 +++++++++++++++++++ packages/core/src/provider/JAWProvider.ts | 12 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/core/src/provider/JAWProvider.test.ts b/packages/core/src/provider/JAWProvider.test.ts index 860db40e8..db75b19f4 100644 --- a/packages/core/src/provider/JAWProvider.test.ts +++ b/packages/core/src/provider/JAWProvider.test.ts @@ -1255,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 a6c7229f7..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'; @@ -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)); From 7659faef1ffb43484045d99fda4d59ccfc822554 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Fri, 18 Sep 2026 14:46:17 -0300 Subject: [PATCH 54/58] fix(keys): show the fee row in a keyless permission dialog --- .../src/components/ConnectModal/index.tsx | 12 ++++++------ .../src/components/PermissionModal/index.tsx | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx index bfe50009f..ea7bc47c0 100644 --- a/apps/keys-jaw-id/src/components/ConnectModal/index.tsx +++ b/apps/keys-jaw-id/src/components/ConnectModal/index.tsx @@ -40,12 +40,12 @@ export const ConnectModal = ({ const chainName = useMemo(() => (chain ? getChainNameFromId(chain.id) : undefined), [chain]); const chainIcon = useChainIconURI(chain?.id || 1, effectiveApiKey, 24); - // Mainnet, for ENS, under whatever key this request carries. The prop is left - // out on purpose: the other modals build this from the url's key alone. - const mainnetRpcUrl = useMemo(() => { - const key = apiKeyFromChain(undefined, chain?.rpcUrl); - return key ? `${JAW_RPC_URL}?chainId=1&api-key=${key}` : `${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/PermissionModal/index.tsx b/apps/keys-jaw-id/src/components/PermissionModal/index.tsx index 1b27b8708..416b2703a 100644 --- a/apps/keys-jaw-id/src/components/PermissionModal/index.tsx +++ b/apps/keys-jaw-id/src/components/PermissionModal/index.tsx @@ -479,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; } From 91abfa294c6785e5d6be8b959fb22878b6f2cf90 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Mon, 21 Sep 2026 16:18:48 -0300 Subject: [PATCH 55/58] fix(core): let a later chain entry replace the stored one --- .../src/account/Account.chainStore.test.ts | 96 +++++++++++++++++++ packages/core/src/account/Account.ts | 18 +++- .../src/store/chain-clients/utils.test.ts | 57 ++++++++++- .../core/src/store/chain-clients/utils.ts | 15 +++ packages/core/src/store/index.ts | 2 +- 5 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/account/Account.chainStore.test.ts 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..5d0d76f67 --- /dev/null +++ b/packages/core/src/account/Account.chainStore.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } 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'; + +// `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); + }); + + 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); + }); +}); diff --git a/packages/core/src/account/Account.ts b/packages/core/src/account/Account.ts index a554dc3ad..3c4cc3e09 100644 --- a/packages/core/src/account/Account.ts +++ b/packages/core/src/account/Account.ts @@ -48,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'; /** @@ -1433,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; diff --git a/packages/core/src/store/chain-clients/utils.test.ts b/packages/core/src/store/chain-clients/utils.test.ts index 5d853ff0e..b6eb76581 100644 --- a/packages/core/src/store/chain-clients/utils.test.ts +++ b/packages/core/src/store/chain-clients/utils.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { sepolia, optimismSepolia, arbitrumSepolia } from 'viem/chains'; import { ChainClients } from './store.js'; -import { createClients, createInitialChains, 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'; @@ -379,3 +380,57 @@ describe('naming the calling dApp on the wire', () => { 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 bcef46aab..a4a00e22a 100644 --- a/packages/core/src/store/chain-clients/utils.ts +++ b/packages/core/src/store/chain-clients/utils.ts @@ -135,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. 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'; From 76bde63621fa088d5fad7d9a2a25aea72c4cdb2f Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Mon, 21 Sep 2026 16:19:03 -0300 Subject: [PATCH 56/58] fix(keys): set the dapp origin at config message time --- apps/keys-jaw-id/src/app/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/keys-jaw-id/src/app/page.tsx b/apps/keys-jaw-id/src/app/page.tsx index 121c2fbed..62a72922c 100644 --- a/apps/keys-jaw-id/src/app/page.tsx +++ b/apps/keys-jaw-id/src/app/page.tsx @@ -318,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 From 3331ae25333cff51d2b7f5a5ec651c84980b637a Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 22 Sep 2026 18:11:29 -0300 Subject: [PATCH 57/58] fix(ui): name the dapp on reads to the jaw rpc --- apps/keys-jaw-id/vitest.config.mts | 3 + packages/core/src/internal.ts | 4 ++ packages/ui/src/utils/publicClient.test.ts | 71 +++++++++++++++++++++- packages/ui/src/utils/publicClient.ts | 11 +++- packages/ui/src/utils/resolveChainLabel.ts | 6 +- packages/ui/vite.config.ts | 12 +++- packages/ui/vitest.config.ts | 3 + 7 files changed, 102 insertions(+), 8 deletions(-) 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/src/internal.ts b/packages/core/src/internal.ts index 45c27dfa9..93704845f 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -10,3 +10,7 @@ * 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/ui/src/utils/publicClient.test.ts b/packages/ui/src/utils/publicClient.test.ts index e296404d6..a8b2b86f5 100644 --- a/packages/ui/src/utils/publicClient.test.ts +++ b/packages/ui/src/utils/publicClient.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { decodeFunctionData, encodeFunctionResult, erc20Abi, toFunctionSelector, 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'; @@ -88,14 +91,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 { @@ -103,7 +106,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]; @@ -140,6 +148,7 @@ function stubRpc(options: { revertFor?: string[]; unavailable?: boolean } = {}) afterEach(() => { vi.unstubAllGlobals(); + setDappOrigin(undefined); }); describe('getPublicClient', () => { @@ -177,6 +186,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); + }); +}); + describe('fetchTokenBalance batching', () => { it('folds a concurrent ERC-20 fan-out into one Multicall3 request', async () => { const bodies = stubRpc(); diff --git a/packages/ui/src/utils/publicClient.ts b/packages/ui/src/utils/publicClient.ts index cf9e18a5a..c40979038 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 { @@ -13,7 +22,7 @@ export function jawRpcUrl(chainId: number, apiKey?: string): string { // object — formatters included — is still what gets passed at runtime. function createClient(chainId: number, rpcUrl: string) { const chain: Chain | undefined = SUPPORTED_CHAINS.find((c) => c.id === chainId); - return createPublicClient({ chain, transport: http(rpcUrl), batch: { multicall: true } }); + return createPublicClient({ chain, transport: rpcTransport(rpcUrl), batch: { multicall: true } }); } 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 ({ // `@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. - external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@jaw.id/core'], + // 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'), }, }, From 6c5fbe10b4dc8277faf426b755997a70d5457a38 Mon Sep 17 00:00:00 2001 From: Mariano Aguero Date: Tue, 22 Sep 2026 18:11:43 -0300 Subject: [PATCH 58/58] fix(core): keep the paymaster context when backfilling addresses --- .../src/account/Account.chainStore.test.ts | 35 ++++++++++++++++++- packages/core/src/account/Account.ts | 4 +-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/core/src/account/Account.chainStore.test.ts b/packages/core/src/account/Account.chainStore.test.ts index 5d0d76f67..8e21ef6fe 100644 --- a/packages/core/src/account/Account.chainStore.test.ts +++ b/packages/core/src/account/Account.chainStore.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +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. @@ -33,6 +34,10 @@ describe('buildChainConfig and the stored chain entry', () => { ChainClients.setState({}, true); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('adds a chain that is not stored yet', () => { buildChainConfig(sepolia.id, 'k1'); @@ -93,4 +98,32 @@ describe('buildChainConfig and the stored chain entry', () => { 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 3c4cc3e09..ff1cb8695 100644 --- a/packages/core/src/account/Account.ts +++ b/packages/core/src/account/Account.ts @@ -184,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(