From 84a8f2011d39a17dd35698b1e2ccd53bade12ff6 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Tue, 25 Nov 2025 09:53:49 -0700 Subject: [PATCH 1/5] Add EphemeralBaseAccountProvider and EphemeralSigner for isolated payment flows --- .../core/EphemeralBaseAccountProvider.ts | 138 ++++++++++ .../builder/core/createBaseAccountSDK.test.ts | 8 +- .../builder/core/createBaseAccountSDK.ts | 50 +++- .../src/interface/payment/utils/sdkManager.ts | 163 ++++++++++-- .../src/sign/base-account/EphemeralSigner.ts | 251 ++++++++++++++++++ .../src/sign/base-account/Signer.ts | 8 +- 6 files changed, 583 insertions(+), 35 deletions(-) create mode 100644 packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts create mode 100644 packages/account-sdk/src/sign/base-account/EphemeralSigner.ts diff --git a/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts new file mode 100644 index 000000000..ff3b51b8e --- /dev/null +++ b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts @@ -0,0 +1,138 @@ +import { Communicator } from ':core/communicator/Communicator.js'; +import { CB_WALLET_RPC_URL } from ':core/constants.js'; +import { standardErrorCodes } from ':core/error/constants.js'; +import { standardErrors } from ':core/error/errors.js'; +import { serializeError } from ':core/error/serialize.js'; +import { + ConstructorOptions, + ProviderEventEmitter, + ProviderInterface, + RequestArguments, +} from ':core/provider/interface.js'; +import { + logRequestError, + logRequestResponded, + logRequestStarted, +} from ':core/telemetry/events/provider.js'; +import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; +import { hexStringFromNumber } from ':core/type/util.js'; +import { EphemeralSigner } from ':sign/base-account/EphemeralSigner.js'; +import { correlationIds } from ':store/correlation-ids/store.js'; +import { fetchRPCRequest } from ':util/provider.js'; + +/** + * EphemeralBaseAccountProvider is a provider designed for single-use payment flows. + * + * Key differences from BaseAccountProvider: + * 1. Uses EphemeralSigner which maintains isolated state (doesn't pollute global store) + * 2. Cleanup only clears instance-specific state, not shared global state + * 3. Optimized for one-shot operations like pay() and subscribe() + * + * This prevents race conditions when multiple ephemeral payment flows + * are executed concurrently. + */ +export class EphemeralBaseAccountProvider + extends ProviderEventEmitter + implements ProviderInterface +{ + private readonly communicator: Communicator; + private readonly signer: EphemeralSigner; + + constructor({ + metadata, + preference: { walletUrl, ...preference }, + }: Readonly) { + super(); + this.communicator = new Communicator({ + url: walletUrl, + metadata, + preference, + }); + this.signer = new EphemeralSigner({ + metadata, + communicator: this.communicator, + callback: this.emit.bind(this), + }); + } + + public async request(args: RequestArguments): Promise { + // correlation id across the entire request lifecycle + const correlationId = crypto.randomUUID(); + correlationIds.set(args, correlationId); + logRequestStarted({ method: args.method, correlationId }); + + try { + const result = await this._request(args); + logRequestResponded({ + method: args.method, + correlationId, + }); + return result as T; + } catch (error) { + logRequestError({ + method: args.method, + correlationId, + errorMessage: parseErrorMessageFromAny(error), + }); + throw error; + } finally { + correlationIds.delete(args); + } + } + + private async _request(args: RequestArguments): Promise { + try { + // For ephemeral providers, we only support a subset of methods + // that are needed for payment flows + switch (args.method) { + case 'wallet_sendCalls': + case 'wallet_sign': { + try { + await this.signer.handshake({ method: 'handshake' }); // exchange session keys + const result = await this.signer.request(args); // send diffie-hellman encrypted request + return result as T; + } finally { + await this.signer.cleanup(); // clean up (rotate) the ephemeral session keys + } + } + case 'wallet_getCallsStatus': { + const result = await fetchRPCRequest(args, CB_WALLET_RPC_URL); + return result as T; + } + case 'eth_accounts': { + return [] as T; + } + case 'net_version': { + const result = 1 as T; // default value + return result; + } + case 'eth_chainId': { + const result = hexStringFromNumber(1) as T; // default value + return result; + } + default: { + throw standardErrors.provider.unauthorized( + `Method '${args.method}' is not supported by ephemeral provider. ` + + `Ephemeral providers only support: wallet_sendCalls, wallet_sign, wallet_getCallsStatus` + ); + } + } + } catch (error) { + const { code } = error as { code?: number }; + if (code === standardErrorCodes.provider.unauthorized) { + await this.disconnect(); + } + return Promise.reject(serializeError(error)); + } + } + + async disconnect() { + // Only cleanup ephemeral signer state - don't touch global store + await this.signer.cleanup(); + // Clear only the correlation IDs for this provider instance + // Note: correlationIds is already scoped per-request, so this is safe + this.emit('disconnect', standardErrors.provider.disconnected('User initiated disconnection')); + } + + readonly isBaseAccount = true; +} diff --git a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts index 90854ed4f..915f8a80c 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts @@ -4,7 +4,11 @@ import * as checkCrossOriginModule from ':util/checkCrossOriginOpenerPolicy.js'; import * as validatePreferencesModule from ':util/validatePreferences.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseAccountProvider } from './BaseAccountProvider.js'; -import { CreateProviderOptions, createBaseAccountSDK } from './createBaseAccountSDK.js'; +import { + CreateProviderOptions, + createBaseAccountSDK, + _resetGlobalInitialization, +} from './createBaseAccountSDK.js'; import * as getInjectedProviderModule from './getInjectedProvider.js'; // Mock all dependencies @@ -54,6 +58,8 @@ const mockGetInjectedProvider = getInjectedProviderModule.getInjectedProvider as describe('createProvider', () => { beforeEach(() => { vi.clearAllMocks(); + // Reset the one-time initialization state so each test can verify initialization behavior + _resetGlobalInitialization(); mockBaseAccountProvider.mockReturnValue({ mockProvider: true, }); diff --git a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts index 547186b26..d4c7d95ce 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts @@ -23,6 +23,46 @@ export type CreateProviderOptions = Partial & { paymasterUrls?: Record; }; +// ==================================================================== +// One-time initialization tracking +// These operations only need to run once per page load +// ==================================================================== + +let globalInitialized = false; +let rehydrationPromise: Promise | null = null; + +/** + * Performs one-time global initialization for the SDK. + * Safe to call multiple times - will only execute once. + */ +function initializeGlobalOnce(telemetryEnabled: boolean): void { + if (globalInitialized) return; + globalInitialized = true; + + // Check COOP policy once + void checkCrossOriginOpenerPolicy(); + + // Load telemetry script once if enabled + if (telemetryEnabled) { + void loadTelemetryScript(); + } + + // Rehydrate store from localStorage once + if (!rehydrationPromise) { + const result = store.persist.rehydrate(); + rehydrationPromise = result instanceof Promise ? result : Promise.resolve(); + } +} + +/** + * Resets the global initialization state. + * @internal This is only intended for testing purposes. + */ +export function _resetGlobalInitialization(): void { + globalInitialized = false; + rehydrationPromise = null; +} + /** * Create Base AccountSDK instance with EIP-1193 compliant provider * @param params - Options to create a base account SDK instance. @@ -60,20 +100,14 @@ export function createBaseAccountSDK(params: CreateProviderOptions) { store.config.set(options); - void store.persist.rehydrate(); - // ==================================================================== - // Validation and telemetry + // One-time initialization and validation // ==================================================================== - void checkCrossOriginOpenerPolicy(); + initializeGlobalOnce(options.preference.telemetry !== false); validatePreferences(options.preference); - if (options.preference.telemetry !== false) { - void loadTelemetryScript(); - } - // ==================================================================== // Return the provider // ==================================================================== diff --git a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts index d020d8d83..7ba96d7a3 100644 --- a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts +++ b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts @@ -1,5 +1,8 @@ import type { Hex } from 'viem'; -import { createBaseAccountSDK } from '../../builder/core/createBaseAccountSDK.js'; +import { EphemeralBaseAccountProvider } from '../../builder/core/EphemeralBaseAccountProvider.js'; +import { ProviderInterface } from ':core/provider/interface.js'; +import { loadTelemetryScript } from ':core/telemetry/initCCA.js'; +import { checkCrossOriginOpenerPolicy } from ':util/checkCrossOriginOpenerPolicy.js'; import { CHAIN_IDS } from '../constants.js'; import type { PayerInfoResponses } from '../types.js'; @@ -36,44 +39,118 @@ export interface PaymentExecutionResult { payerInfoResponses?: PayerInfoResponses; } +// ==================================================================== +// One-time initialization for ephemeral SDK operations +// ==================================================================== + +let ephemeralInitialized = false; + +function initializeEphemeralOnce(telemetryEnabled: boolean): void { + if (ephemeralInitialized) return; + ephemeralInitialized = true; + + // Check COOP policy once + void checkCrossOriginOpenerPolicy(); + + // Load telemetry script once if enabled + if (telemetryEnabled) { + void loadTelemetryScript(); + } +} + +/** + * Resets the ephemeral initialization state. + * @internal This is only intended for testing purposes. + */ +export function _resetEphemeralInitialization(): void { + ephemeralInitialized = false; +} + +// ==================================================================== +// Request queuing to prevent race conditions +// ==================================================================== + /** - * Creates an ephemeral SDK instance configured for payments + * Queue of pending payment operations. + * Keyed by a combination of network and wallet URL to prevent + * concurrent operations to the same destination from interfering. + */ +const paymentQueue = new Map>(); + +function getQueueKey(testnet: boolean, walletUrl?: string): string { + const network = testnet ? 'testnet' : 'mainnet'; + return `payment:${network}:${walletUrl ?? 'default'}`; +} + +/** + * Waits for any pending operation with the same queue key to complete. + * This prevents race conditions from concurrent payment calls. + */ +async function waitForPendingOperation(queueKey: string): Promise { + const pending = paymentQueue.get(queueKey); + if (pending) { + // Wait for the pending operation to complete, ignoring any errors + // (we still want to proceed even if the previous one failed) + await pending.catch(() => {}); + } +} + +// ==================================================================== +// Ephemeral SDK creation +// ==================================================================== + +/** + * Creates an ephemeral provider configured for payments. + * + * Uses EphemeralBaseAccountProvider which: + * - Maintains isolated state (doesn't pollute global store) + * - Only supports payment-related methods (wallet_sendCalls, wallet_sign) + * - Cleans up without affecting other SDK instances + * * @param chainId - The chain ID to use * @param walletUrl - Optional wallet URL to use * @param telemetry - Whether to enable telemetry (defaults to true) - * @returns The configured SDK instance + * @returns The configured ephemeral provider */ -export function createEphemeralSDK(chainId: number, walletUrl?: string, telemetry: boolean = true) { +export function createEphemeralSDK( + chainId: number, + walletUrl?: string, + telemetry: boolean = true +): { getProvider: () => ProviderInterface } { const appName = typeof window !== 'undefined' ? window.location.origin : 'Base Pay SDK'; - const sdk = createBaseAccountSDK({ - appName: appName, - appChainIds: [chainId], + // Perform one-time initialization + initializeEphemeralOnce(telemetry); + + // Create ephemeral provider with isolated state + const provider = new EphemeralBaseAccountProvider({ + metadata: { + appName, + appLogoUrl: '', + appChainIds: [chainId], + }, preference: { - telemetry: telemetry, + telemetry, walletUrl, }, }); - // Chain clients will be automatically created when needed by getClient - // This ensures that the chain client is available for operations like getHash - // even when the wallet hasn't been connected yet - - return sdk; + // Return SDK-like interface for compatibility + return { + getProvider: () => provider, + }; } /** - * Executes a payment using the SDK - * @param sdk - The SDK instance + * Executes a payment using the provider + * @param provider - The provider instance * @param requestParams - The wallet_sendCalls request parameters * @returns The payment execution result with transaction hash and optional info responses */ -export async function executePayment( - sdk: ReturnType, +export async function executePaymentWithProvider( + provider: ProviderInterface, requestParams: WalletSendCallsRequestParams ): Promise { - const provider = sdk.getProvider(); - const result = await provider.request({ method: 'wallet_sendCalls', params: [requestParams], @@ -113,7 +190,27 @@ export async function executePayment( } /** - * Manages the complete payment flow with SDK lifecycle + * Executes a payment using the SDK (legacy compatibility wrapper) + * @param sdk - The SDK instance + * @param requestParams - The wallet_sendCalls request parameters + * @returns The payment execution result with transaction hash and optional info responses + * @deprecated Use executePaymentWithProvider instead + */ +export async function executePayment( + sdk: { getProvider: () => ProviderInterface }, + requestParams: WalletSendCallsRequestParams +): Promise { + return executePaymentWithProvider(sdk.getProvider(), requestParams); +} + +/** + * Manages the complete payment flow with SDK lifecycle and request queuing. + * + * Features: + * - Uses ephemeral provider with isolated state + * - Queues concurrent requests to prevent race conditions + * - Properly cleans up resources after each payment + * * @param requestParams - The wallet_sendCalls request parameters * @param testnet - Whether to use testnet * @param walletUrl - Optional wallet URL to use @@ -126,17 +223,35 @@ export async function executePaymentWithSDK( walletUrl?: string, telemetry: boolean = true ): Promise { + const queueKey = getQueueKey(testnet, walletUrl); + + // Wait for any pending operation to the same destination + await waitForPendingOperation(queueKey); + const network = testnet ? 'baseSepolia' : 'base'; const chainId = CHAIN_IDS[network]; const sdk = createEphemeralSDK(chainId, walletUrl, telemetry); const provider = sdk.getProvider(); + // Create the execution promise and add it to the queue + const execution = (async (): Promise => { + try { + const result = await executePaymentWithProvider(provider, requestParams); + return result; + } finally { + // Clean up provider state for subsequent payments + await provider.disconnect(); + } + })(); + + // Track this operation in the queue + paymentQueue.set(queueKey, execution); + try { - const result = await executePayment(sdk, requestParams); - return result; + return await execution; } finally { - // Clean up provider state for subsequent payments - await provider.disconnect(); + // Remove from queue when complete + paymentQueue.delete(queueKey); } } diff --git a/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts new file mode 100644 index 000000000..6e61eaf86 --- /dev/null +++ b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts @@ -0,0 +1,251 @@ +import { Communicator } from ':core/communicator/Communicator.js'; +import { standardErrors } from ':core/error/errors.js'; +import { RPCRequestMessage, RPCResponseMessage } from ':core/message/RPCMessage.js'; +import { RPCResponse } from ':core/message/RPCResponse.js'; +import { AppMetadata, ProviderEventCallback, RequestArguments } from ':core/provider/interface.js'; +import { + logHandshakeCompleted, + logHandshakeError, + logHandshakeStarted, + logRequestCompleted, + logRequestError, + logRequestStarted, +} from ':core/telemetry/events/scw-signer.js'; +import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; +import { SDKChain, createClients } from ':store/chain-clients/utils.js'; +import { correlationIds } from ':store/correlation-ids/store.js'; +import { store } from ':store/store.js'; +import { + decryptContent, + encryptContent, + exportKeyToHexString, + importKeyFromHexString, +} from ':util/cipher.js'; +import { SCWKeyManager } from './SCWKeyManager.js'; + +type ConstructorOptions = { + metadata: AppMetadata; + communicator: Communicator; + callback: ProviderEventCallback | null; +}; + +/** + * EphemeralSigner is designed for single-use payment flows. + * + * Key differences from Signer: + * 1. Maintains isolated instance state instead of relying on global store + * 2. Cleanup only clears instance-specific state (key manager) + * 3. Does NOT clear global store state (chains, accounts, etc.) + * 4. Optimized for one-shot operations like wallet_sendCalls and wallet_sign + * + * This prevents: + * - Race conditions when multiple ephemeral flows run concurrently + * - Pollution of global store state by ephemeral operations + * - Accidental clearing of shared chain client configurations + */ +export class EphemeralSigner { + private readonly communicator: Communicator; + private readonly keyManager: SCWKeyManager; + + // Instance-local state (isolated from global store) + private readonly chainId: number; + + constructor(params: ConstructorOptions) { + this.communicator = params.communicator; + this.keyManager = new SCWKeyManager(); + // Note: We intentionally don't use the callback for ephemeral operations + // as we don't need to emit events for one-shot payment flows + + // Use the first chain from metadata as default + this.chainId = params.metadata.appChainIds?.[0] ?? 1; + + // Ensure chain clients exist for this chain + // This uses the shared ChainClients store which is fine to share + const chains = store.getState().chains; + if (chains && chains.length > 0) { + createClients(chains); + } + } + + async handshake(args: RequestArguments) { + const correlationId = correlationIds.get(args); + logHandshakeStarted({ method: args.method, correlationId }); + + try { + // Open the popup before constructing the request message. + await this.communicator.waitForPopupLoaded?.(); + + const handshakeMessage = await this.createRequestMessage( + { + handshake: { + method: args.method, + params: args.params ?? [], + }, + }, + correlationId + ); + const response: RPCResponseMessage = + await this.communicator.postRequestAndWaitForResponse(handshakeMessage); + + // store peer's public key + if ('failure' in response.content) { + throw response.content.failure; + } + + const peerPublicKey = await importKeyFromHexString('public', response.sender); + await this.keyManager.setPeerPublicKey(peerPublicKey); + + await this.decryptResponseMessage(response); + + logHandshakeCompleted({ method: args.method, correlationId }); + } catch (error) { + logHandshakeError({ + method: args.method, + correlationId, + errorMessage: parseErrorMessageFromAny(error), + }); + throw error; + } + } + + async request(request: RequestArguments) { + const correlationId = correlationIds.get(request); + logRequestStarted({ method: request.method, correlationId }); + + try { + const result = await this._request(request); + logRequestCompleted({ method: request.method, correlationId }); + return result; + } catch (error) { + logRequestError({ + method: request.method, + correlationId, + errorMessage: parseErrorMessageFromAny(error), + }); + throw error; + } + } + + private async _request(request: RequestArguments) { + // Ephemeral signer only supports sending requests to popup + // for wallet_sendCalls and wallet_sign methods + switch (request.method) { + case 'wallet_sendCalls': + case 'wallet_sign': { + return this.sendRequestToPopup(request); + } + default: + throw standardErrors.provider.unauthorized( + `Method '${request.method}' is not supported by ephemeral signer` + ); + } + } + + private async sendRequestToPopup(request: RequestArguments) { + // Open the popup before constructing the request message. + await this.communicator.waitForPopupLoaded?.(); + + const response = await this.sendEncryptedRequest(request); + const decrypted = await this.decryptResponseMessage(response); + + return this.handleResponse(decrypted); + } + + private handleResponse(decrypted: RPCResponse) { + const result = decrypted.result; + + if ('error' in result) throw result.error; + + // For ephemeral signer, we don't update global store with response data + // We simply return the result value + return result.value; + } + + /** + * Cleanup only clears instance-specific state. + * Does NOT clear global store state to prevent affecting other SDK instances. + */ + async cleanup() { + // Only clear the key manager (instance-specific cryptographic state) + await this.keyManager.clear(); + + // NOTE: We intentionally do NOT clear: + // - store.account + // - store.subAccounts + // - store.spendPermissions + // - store.chains + // These are shared global state that other SDK instances may depend on. + } + + private async sendEncryptedRequest(request: RequestArguments): Promise { + const sharedSecret = await this.keyManager.getSharedSecret(); + if (!sharedSecret) { + throw standardErrors.provider.unauthorized('No shared secret found when encrypting request'); + } + + const encrypted = await encryptContent( + { + action: request, + chainId: this.chainId, + }, + sharedSecret + ); + const correlationId = correlationIds.get(request); + const message = await this.createRequestMessage({ encrypted }, correlationId); + + return this.communicator.postRequestAndWaitForResponse(message); + } + + private async createRequestMessage( + content: RPCRequestMessage['content'], + correlationId: string | undefined + ): Promise { + const publicKey = await exportKeyToHexString('public', await this.keyManager.getOwnPublicKey()); + + return { + id: crypto.randomUUID(), + correlationId, + sender: publicKey, + content, + timestamp: new Date(), + }; + } + + private async decryptResponseMessage(message: RPCResponseMessage): Promise { + const content = message.content; + + // throw protocol level error + if ('failure' in content) { + throw content.failure; + } + + const sharedSecret = await this.keyManager.getSharedSecret(); + if (!sharedSecret) { + throw standardErrors.provider.unauthorized( + 'Invalid session: no shared secret found when decrypting response' + ); + } + + const response: RPCResponse = await decryptContent(content.encrypted, sharedSecret); + + // Process chain data from response if available + // We still update the shared ChainClients store since chain clients are meant to be shared + const availableChains = response.data?.chains; + if (availableChains) { + const nativeCurrencies = response.data?.nativeCurrencies; + const chains: SDKChain[] = Object.entries(availableChains).map(([id, rpcUrl]) => { + const nativeCurrency = nativeCurrencies?.[Number(id)]; + return { + id: Number(id), + rpcUrl, + ...(nativeCurrency ? { nativeCurrency } : {}), + }; + }); + + // Update shared chain clients (this is intentionally shared) + createClients(chains); + } + + return response; + } +} diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index d5a5ad1d6..afec44b5e 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -435,11 +435,15 @@ export class Signer { const metadata = store.config.get().metadata; await this.keyManager.clear(); - // clear the store + // Clear session-specific store data store.account.clear(); store.subAccounts.clear(); store.spendPermissions.clear(); - store.chains.clear(); + + // NOTE: We intentionally do NOT clear store.chains here. + // Chains are shared infrastructure used by ChainClients and may be + // needed by other SDK instances or subsequent operations. + // Clearing them could cause failures in concurrent or subsequent operations. // reset the signer this.accounts = []; From a2c8486dde5cfdbbfe91b6ae5d11f87da0463817 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Tue, 25 Nov 2025 10:10:22 -0700 Subject: [PATCH 2/5] fix: separate telemetry initialization to allow later SDK instances to enable it Separates telemetry initialization from global initialization so that: - First SDK call with telemetry: false doesn't prevent later calls from enabling telemetry - Telemetry is only loaded when at least one SDK instance requests it --- .../builder/core/createBaseAccountSDK.ts | 31 ++++++++++++++----- .../src/interface/payment/utils/sdkManager.ts | 30 ++++++++++++++---- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts index d4c7d95ce..59f5438cd 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts @@ -29,24 +29,20 @@ export type CreateProviderOptions = Partial & { // ==================================================================== let globalInitialized = false; +let telemetryInitialized = false; let rehydrationPromise: Promise | null = null; /** - * Performs one-time global initialization for the SDK. + * Performs one-time global initialization for the SDK (excluding telemetry). * Safe to call multiple times - will only execute once. */ -function initializeGlobalOnce(telemetryEnabled: boolean): void { +function initializeGlobalOnce(): void { if (globalInitialized) return; globalInitialized = true; // Check COOP policy once void checkCrossOriginOpenerPolicy(); - // Load telemetry script once if enabled - if (telemetryEnabled) { - void loadTelemetryScript(); - } - // Rehydrate store from localStorage once if (!rehydrationPromise) { const result = store.persist.rehydrate(); @@ -54,12 +50,25 @@ function initializeGlobalOnce(telemetryEnabled: boolean): void { } } +/** + * Initializes telemetry if not already initialized. + * Separated from global init so telemetry can be enabled by later SDK instances + * even if the first instance had telemetry disabled. + */ +function initializeTelemetryOnce(): void { + if (telemetryInitialized) return; + telemetryInitialized = true; + + void loadTelemetryScript(); +} + /** * Resets the global initialization state. * @internal This is only intended for testing purposes. */ export function _resetGlobalInitialization(): void { globalInitialized = false; + telemetryInitialized = false; rehydrationPromise = null; } @@ -104,7 +113,13 @@ export function createBaseAccountSDK(params: CreateProviderOptions) { // One-time initialization and validation // ==================================================================== - initializeGlobalOnce(options.preference.telemetry !== false); + initializeGlobalOnce(); + + // Telemetry is initialized separately so it can be enabled by later SDK instances + // even if earlier instances had telemetry disabled + if (options.preference.telemetry !== false) { + initializeTelemetryOnce(); + } validatePreferences(options.preference); diff --git a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts index 7ba96d7a3..4133ba27d 100644 --- a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts +++ b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts @@ -44,18 +44,29 @@ export interface PaymentExecutionResult { // ==================================================================== let ephemeralInitialized = false; +let ephemeralTelemetryInitialized = false; -function initializeEphemeralOnce(telemetryEnabled: boolean): void { +/** + * Performs one-time global initialization for ephemeral SDKs (excluding telemetry). + */ +function initializeEphemeralOnce(): void { if (ephemeralInitialized) return; ephemeralInitialized = true; // Check COOP policy once void checkCrossOriginOpenerPolicy(); +} - // Load telemetry script once if enabled - if (telemetryEnabled) { - void loadTelemetryScript(); - } +/** + * Initializes telemetry for ephemeral SDKs if not already initialized. + * Separated from global init so telemetry can be enabled by later requests + * even if earlier requests had telemetry disabled. + */ +function initializeEphemeralTelemetryOnce(): void { + if (ephemeralTelemetryInitialized) return; + ephemeralTelemetryInitialized = true; + + void loadTelemetryScript(); } /** @@ -64,6 +75,7 @@ function initializeEphemeralOnce(telemetryEnabled: boolean): void { */ export function _resetEphemeralInitialization(): void { ephemeralInitialized = false; + ephemeralTelemetryInitialized = false; } // ==================================================================== @@ -120,7 +132,13 @@ export function createEphemeralSDK( const appName = typeof window !== 'undefined' ? window.location.origin : 'Base Pay SDK'; // Perform one-time initialization - initializeEphemeralOnce(telemetry); + initializeEphemeralOnce(); + + // Telemetry is initialized separately so it can be enabled by later requests + // even if earlier requests had telemetry disabled + if (telemetry) { + initializeEphemeralTelemetryOnce(); + } // Create ephemeral provider with isolated state const provider = new EphemeralBaseAccountProvider({ From 794b951a82cf7c7b3ba0dd1f987734d04637f618 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Wed, 26 Nov 2025 12:37:27 -0700 Subject: [PATCH 3/5] felix feedback --- .../builder/core/BaseAccountProvider.ts | 3 + .../core/EphemeralBaseAccountProvider.ts | 26 +- .../src/sign/base-account/EphemeralSigner.ts | 46 +-- .../sign/base-account/SCWKeyManager.test.ts | 12 +- .../src/sign/base-account/SCWKeyManager.ts | 13 +- .../src/sign/base-account/Signer.ts | 94 +++--- packages/account-sdk/src/store/store.ts | 271 ++++++++++-------- 7 files changed, 271 insertions(+), 194 deletions(-) diff --git a/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.ts b/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.ts index 369cdab43..27b57c307 100644 --- a/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.ts +++ b/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.ts @@ -36,10 +36,13 @@ export class BaseAccountProvider extends ProviderEventEmitter implements Provide metadata, preference, }); + // Use the global persistent store for BaseAccountProvider + // This maintains backwards compatibility and persists state across sessions this.signer = new Signer({ metadata, communicator: this.communicator, callback: this.emit.bind(this), + storeInstance: store, }); } diff --git a/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts index ff3b51b8e..310d88886 100644 --- a/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts +++ b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts @@ -18,18 +18,22 @@ import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { hexStringFromNumber } from ':core/type/util.js'; import { EphemeralSigner } from ':sign/base-account/EphemeralSigner.js'; import { correlationIds } from ':store/correlation-ids/store.js'; +import { createStoreInstance, type StoreInstance } from ':store/store.js'; import { fetchRPCRequest } from ':util/provider.js'; /** * EphemeralBaseAccountProvider is a provider designed for single-use payment flows. * * Key differences from BaseAccountProvider: - * 1. Uses EphemeralSigner which maintains isolated state (doesn't pollute global store) - * 2. Cleanup only clears instance-specific state, not shared global state - * 3. Optimized for one-shot operations like pay() and subscribe() + * 1. Creates its own isolated store instance (no persistence, no global state pollution) + * 2. Uses EphemeralSigner with the isolated store to prevent concurrent operation interference + * 3. Cleanup clears the entire ephemeral store instance + * 4. Optimized for one-shot operations like pay() and subscribe() * - * This prevents race conditions when multiple ephemeral payment flows - * are executed concurrently. + * This prevents: + * - Race conditions when multiple ephemeral payment flows run concurrently + * - KeyManager interference (each instance has its own isolated keys) + * - Memory leaks (store instance is garbage collected after cleanup) */ export class EphemeralBaseAccountProvider extends ProviderEventEmitter @@ -37,6 +41,7 @@ export class EphemeralBaseAccountProvider { private readonly communicator: Communicator; private readonly signer: EphemeralSigner; + private readonly ephemeralStore: StoreInstance; constructor({ metadata, @@ -48,10 +53,15 @@ export class EphemeralBaseAccountProvider metadata, preference, }); + // Create an isolated ephemeral store for this provider instance + // persist: false means no localStorage persistence + this.ephemeralStore = createStoreInstance({ persist: false }); + this.signer = new EphemeralSigner({ metadata, communicator: this.communicator, callback: this.emit.bind(this), + storeInstance: this.ephemeralStore, }); } @@ -127,10 +137,10 @@ export class EphemeralBaseAccountProvider } async disconnect() { - // Only cleanup ephemeral signer state - don't touch global store + // Cleanup ephemeral signer state and its isolated store await this.signer.cleanup(); - // Clear only the correlation IDs for this provider instance - // Note: correlationIds is already scoped per-request, so this is safe + // Note: The ephemeral store instance will be garbage collected + // when this provider instance is no longer referenced this.emit('disconnect', standardErrors.provider.disconnected('User initiated disconnection')); } diff --git a/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts index 6e61eaf86..432474ba2 100644 --- a/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts +++ b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts @@ -14,7 +14,7 @@ import { import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { SDKChain, createClients } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; -import { store } from ':store/store.js'; +import { createStoreHelpers, type StoreInstance } from ':store/store.js'; import { decryptContent, encryptContent, @@ -27,32 +27,37 @@ type ConstructorOptions = { metadata: AppMetadata; communicator: Communicator; callback: ProviderEventCallback | null; + storeInstance: StoreInstance; }; /** * EphemeralSigner is designed for single-use payment flows. * * Key differences from Signer: - * 1. Maintains isolated instance state instead of relying on global store - * 2. Cleanup only clears instance-specific state (key manager) - * 3. Does NOT clear global store state (chains, accounts, etc.) + * 1. Uses its own isolated store instance to prevent race conditions + * 2. Cleanup clears the entire instance store (keys, state) + * 3. Does NOT pollute global store state used by other SDK instances * 4. Optimized for one-shot operations like wallet_sendCalls and wallet_sign * * This prevents: * - Race conditions when multiple ephemeral flows run concurrently * - Pollution of global store state by ephemeral operations - * - Accidental clearing of shared chain client configurations + * - KeyManager interference between concurrent ephemeral operations */ export class EphemeralSigner { private readonly communicator: Communicator; private readonly keyManager: SCWKeyManager; + private readonly storeHelpers: ReturnType; + private readonly storeInstance: StoreInstance; - // Instance-local state (isolated from global store) + // Instance-local state (isolated from other signers) private readonly chainId: number; constructor(params: ConstructorOptions) { this.communicator = params.communicator; - this.keyManager = new SCWKeyManager(); + this.storeInstance = params.storeInstance; + this.storeHelpers = createStoreHelpers(this.storeInstance); + this.keyManager = new SCWKeyManager(this.storeInstance); // Note: We intentionally don't use the callback for ephemeral operations // as we don't need to emit events for one-shot payment flows @@ -60,8 +65,8 @@ export class EphemeralSigner { this.chainId = params.metadata.appChainIds?.[0] ?? 1; // Ensure chain clients exist for this chain - // This uses the shared ChainClients store which is fine to share - const chains = store.getState().chains; + // Chain clients are shared infrastructure and can be safely shared + const chains = this.storeInstance.getState().chains; if (chains && chains.length > 0) { createClients(chains); } @@ -162,19 +167,24 @@ export class EphemeralSigner { } /** - * Cleanup only clears instance-specific state. - * Does NOT clear global store state to prevent affecting other SDK instances. + * Cleanup clears all instance-specific state including the entire ephemeral store. + * Since this signer has its own isolated store instance, we can safely clear everything. + * This prevents memory leaks and ensures complete isolation between ephemeral operations. */ async cleanup() { - // Only clear the key manager (instance-specific cryptographic state) + // Clear the key manager (instance-specific cryptographic state) await this.keyManager.clear(); - // NOTE: We intentionally do NOT clear: - // - store.account - // - store.subAccounts - // - store.spendPermissions - // - store.chains - // These are shared global state that other SDK instances may depend on. + // Clear all store state - safe because this is an ephemeral store instance + // not shared with any other SDK instance + this.storeHelpers.account.clear(); + this.storeHelpers.subAccounts.clear(); + this.storeHelpers.spendPermissions.clear(); + this.storeHelpers.chains.clear(); + this.storeHelpers.subAccountsConfig.clear(); + + // Note: The store instance itself will be garbage collected when + // the EphemeralSigner is no longer referenced } private async sendEncryptedRequest(request: RequestArguments): Promise { diff --git a/packages/account-sdk/src/sign/base-account/SCWKeyManager.test.ts b/packages/account-sdk/src/sign/base-account/SCWKeyManager.test.ts index 520de83a8..1a26db7d8 100644 --- a/packages/account-sdk/src/sign/base-account/SCWKeyManager.test.ts +++ b/packages/account-sdk/src/sign/base-account/SCWKeyManager.test.ts @@ -1,11 +1,15 @@ import { SCWKeyManager } from './SCWKeyManager.js'; import { generateKeyPair } from ':util/cipher.js'; +import { createStoreInstance } from ':store/store.js'; describe('KeyStorage', () => { let keyStorage: SCWKeyManager; + let storeInstance: ReturnType; beforeEach(() => { - keyStorage = new SCWKeyManager(); + // Create a fresh ephemeral store instance for each test + storeInstance = createStoreInstance({ persist: false }); + keyStorage = new SCWKeyManager(storeInstance); }); describe('getOwnPublicKey', () => { @@ -32,7 +36,8 @@ describe('KeyStorage', () => { it('should load the same public key from storage with new instance', async () => { const firstPublicKey = await keyStorage.getOwnPublicKey(); - const anotherKeyStorage = new SCWKeyManager(); + // Create a new KeyManager with the same store instance to simulate persistence + const anotherKeyStorage = new SCWKeyManager(storeInstance); const secondPublicKey = await anotherKeyStorage.getOwnPublicKey(); expect(firstPublicKey).toStrictEqual(secondPublicKey); @@ -60,7 +65,8 @@ describe('KeyStorage', () => { const sharedSecret = await keyStorage.getSharedSecret(); - const anotherKeyStorage = new SCWKeyManager(); + // Create a new KeyManager with the same store instance to simulate persistence + const anotherKeyStorage = new SCWKeyManager(storeInstance); const sharedSecretFromAnotherStorage = await anotherKeyStorage.getSharedSecret(); expect(sharedSecret).toStrictEqual(sharedSecretFromAnotherStorage); diff --git a/packages/account-sdk/src/sign/base-account/SCWKeyManager.ts b/packages/account-sdk/src/sign/base-account/SCWKeyManager.ts index 247dfe0e3..2f34e7486 100644 --- a/packages/account-sdk/src/sign/base-account/SCWKeyManager.ts +++ b/packages/account-sdk/src/sign/base-account/SCWKeyManager.ts @@ -1,4 +1,4 @@ -import { store } from ':store/store.js'; +import { createStoreHelpers, type StoreInstance } from ':store/store.js'; import { deriveSharedSecret, exportKeyToHexString, @@ -28,6 +28,11 @@ export class SCWKeyManager { private ownPublicKey: CryptoKey | null = null; private peerPublicKey: CryptoKey | null = null; private sharedSecret: CryptoKey | null = null; + private readonly storeHelpers: ReturnType; + + constructor(storeInstance: StoreInstance) { + this.storeHelpers = createStoreHelpers(storeInstance); + } async getOwnPublicKey(): Promise { await this.loadKeysIfNeeded(); @@ -53,7 +58,7 @@ export class SCWKeyManager { this.peerPublicKey = null; this.sharedSecret = null; - store.keys.clear(); + this.storeHelpers.keys.clear(); } private async generateKeyPair() { @@ -89,7 +94,7 @@ export class SCWKeyManager { // storage methods private async loadKey(item: StorageItem): Promise { - const key = store.keys.get(item.storageKey); + const key = this.storeHelpers.keys.get(item.storageKey); if (!key) return null; return importKeyFromHexString(item.keyType, key); @@ -97,6 +102,6 @@ export class SCWKeyManager { private async storeKey(item: StorageItem, key: CryptoKey) { const hexString = await exportKeyToHexString(item.keyType, key); - store.keys.set(item.storageKey, hexString); + this.storeHelpers.keys.set(item.storageKey, hexString); } } diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index afec44b5e..2308db2dc 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -37,7 +37,12 @@ import { Address } from ':core/type/index.js'; import { ensureIntNumber, hexStringFromNumber } from ':core/type/util.js'; import { SDKChain, createClients, getClient } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; -import { spendPermissions, store } from ':store/store.js'; +import { + createStoreHelpers, + spendPermissions, + store, + type StoreInstance, +} from ':store/store.js'; import { assertArrayPresence, assertPresence } from ':util/assertPresence.js'; import { assertSubAccount } from ':util/assertSubAccount.js'; import { @@ -72,12 +77,15 @@ type ConstructorOptions = { metadata: AppMetadata; communicator: Communicator; callback: ProviderEventCallback | null; + storeInstance?: StoreInstance; }; export class Signer { private readonly communicator: Communicator; private readonly keyManager: SCWKeyManager; private callback: ProviderEventCallback | null; + private readonly storeHelpers: ReturnType; + private readonly storeInstance: StoreInstance; private accounts: Address[]; private chain: SDKChain; @@ -85,9 +93,15 @@ export class Signer { constructor(params: ConstructorOptions) { this.communicator = params.communicator; this.callback = params.callback; - this.keyManager = new SCWKeyManager(); - - const { account, chains } = store.getState(); + // Use provided store instance or fall back to global store + this.storeInstance = params.storeInstance ?? store; + // Reuse global store helpers if using global store (important for testing/mocking) + // Otherwise create new helpers for the custom store instance + this.storeHelpers = + this.storeInstance === store ? store : createStoreHelpers(this.storeInstance); + this.keyManager = new SCWKeyManager(this.storeInstance); + + const { account, chains } = this.storeInstance.getState(); this.accounts = account.accounts ?? []; this.chain = account.chain ?? { id: params.metadata.appChainIds?.[0] ?? 1, @@ -178,7 +192,7 @@ export class Signer { await this.communicator.waitForPopupLoaded?.(); await initSubAccountConfig(); - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); // Inject capabilities from config (e.g., addSubAccount when creation: 'on-connect') const modifiedRequest = injectRequestCapabilities( request, @@ -221,8 +235,8 @@ export class Signer { switch (request.method) { case 'eth_requestAccounts': case 'eth_accounts': { - const subAccount = store.subAccounts.get(); - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccount = this.storeHelpers.subAccounts.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); if (subAccount?.address) { // if defaultAccount is 'sub' and we have a sub account, we need to return it as the first account // otherwise, we just append it to the accounts array @@ -265,7 +279,7 @@ export class Signer { // Wait for the popup to be loaded before making async calls await this.communicator.waitForPopupLoaded?.(); await initSubAccountConfig(); - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); const modifiedRequest = injectRequestCapabilities( request, subAccountsConfig?.capabilities ?? {} @@ -277,7 +291,7 @@ export class Signer { } // Sub Account Support case 'wallet_getSubAccounts': { - const subAccount = store.subAccounts.get(); + const subAccount = this.storeHelpers.subAccounts.get(); if (subAccount?.address) { return { subAccounts: [subAccount], @@ -296,7 +310,7 @@ export class Signer { // cache the sub account assertSubAccount(response.subAccounts[0]); const subAccount = response.subAccounts[0]; - store.subAccounts.set({ + this.storeHelpers.subAccounts.set({ address: subAccount.address, factory: subAccount.factory, factoryData: subAccount.factoryData, @@ -314,7 +328,7 @@ export class Signer { CB_WALLET_RPC_URL )) as FetchPermissionsResponse; const requestedChainId = hexToNumber(completeRequest.params?.[0].chainId); - store.spendPermissions.set( + this.storeHelpers.spendPermissions.set( permissions.permissions.map((permission) => ({ ...permission, chainId: requestedChainId, @@ -327,11 +341,11 @@ export class Signer { const response = (await fetchRPCRequest( fetchPermissionRequest, CB_WALLET_RPC_URL - )) as FetchPermissionResponse; + ) ) as FetchPermissionResponse; // Store the single permission if it has a chainId if (response.permission && response.permission.chainId) { - store.spendPermissions.set([response.permission]); + this.storeHelpers.spendPermissions.set([response.permission]); } return response; @@ -364,7 +378,7 @@ export class Signer { case 'eth_requestAccounts': { const accounts = result.value as Address[]; this.accounts = accounts; - store.account.set({ + this.storeHelpers.account.set({ accounts, chain: this.chain, }); @@ -375,7 +389,7 @@ export class Signer { const response = result.value as WalletConnectResponse; const accounts = response.accounts.map((account) => account.address); this.accounts = accounts; - store.account.set({ + this.storeHelpers.account.set({ accounts, }); @@ -386,15 +400,15 @@ export class Signer { const capabilityResponse = capabilities?.subAccounts; assertArrayPresence(capabilityResponse, 'subAccounts'); assertSubAccount(capabilityResponse[0]); - store.subAccounts.set({ + this.storeHelpers.subAccounts.set({ address: capabilityResponse[0].address, factory: capabilityResponse[0].factory, factoryData: capabilityResponse[0].factoryData, }); } - const subAccount = store.subAccounts.get(); - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccount = this.storeHelpers.subAccounts.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); if (subAccount?.address) { // Sub account should be returned as the default account if defaultAccount is 'sub' @@ -407,7 +421,7 @@ export class Signer { const spendPermissions = response?.accounts?.[0].capabilities?.spendPermissions; if (spendPermissions && 'permissions' in spendPermissions) { - store.spendPermissions.set(spendPermissions?.permissions); + this.storeHelpers.spendPermissions.set(spendPermissions?.permissions); } this.callback?.('accountsChanged', this.accounts); @@ -416,8 +430,8 @@ export class Signer { case 'wallet_addSubAccount': { assertSubAccount(result.value); const subAccount = result.value; - store.subAccounts.set(subAccount); - const subAccountsConfig = store.subAccountsConfig.get(); + this.storeHelpers.subAccounts.set(subAccount); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); this.accounts = subAccountsConfig?.defaultAccount === 'sub' ? prependWithoutDuplicates(this.accounts, subAccount.address) @@ -432,15 +446,15 @@ export class Signer { } async cleanup() { - const metadata = store.config.get().metadata; + const metadata = this.storeHelpers.config.get().metadata; await this.keyManager.clear(); // Clear session-specific store data - store.account.clear(); - store.subAccounts.clear(); - store.spendPermissions.clear(); + this.storeHelpers.account.clear(); + this.storeHelpers.subAccounts.clear(); + this.storeHelpers.spendPermissions.clear(); - // NOTE: We intentionally do NOT clear store.chains here. + // NOTE: We intentionally do NOT clear this.storeHelpers.chains here. // Chains are shared infrastructure used by ChainClients and may be // needed by other SDK instances or subsequent operations. // Clearing them could cause failures in concurrent or subsequent operations. @@ -482,7 +496,7 @@ export class Signer { ); } - const capabilities = store.getState().account.capabilities; + const capabilities = this.storeInstance.getState().account.capabilities; // Return empty object if capabilities is undefined if (!capabilities) { @@ -576,7 +590,7 @@ export class Signer { }; }); - store.chains.set(chains); + this.storeHelpers.chains.set(chains); this.updateChain(this.chain.id, chains); createClients(chains); @@ -584,7 +598,7 @@ export class Signer { const walletCapabilities = response.data?.capabilities; if (walletCapabilities) { - store.account.set({ + this.storeHelpers.account.set({ capabilities: walletCapabilities, }); } @@ -592,14 +606,14 @@ export class Signer { } private updateChain(chainId: number, newAvailableChains?: SDKChain[]): boolean { - const state = store.getState(); + const state = this.storeInstance.getState(); const chains = newAvailableChains ?? state.chains; const chain = chains?.find((chain) => chain.id === chainId); if (!chain) return false; if (chain !== this.chain) { this.chain = chain; - store.account.set({ + this.storeHelpers.account.set({ chain, }); this.callback?.('chainChanged', hexStringFromNumber(chain.id)); @@ -612,9 +626,9 @@ export class Signer { factory?: Address; factoryData?: Hex; }> { - const state = store.getState(); + const state = this.storeInstance.getState(); const cachedSubAccount = state.subAccount; - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); // Extract requested address from params (for deployed/undeployed types) const requestedAddress = @@ -654,7 +668,7 @@ export class Signer { if (request.params[0].account.keys && request.params[0].account.keys.length > 0) { keys = request.params[0].account.keys; } else { - const config = store.subAccountsConfig.get() ?? {}; + const config = this.storeHelpers.subAccountsConfig.get() ?? {}; const { account: ownerAccount } = config.toOwnerAccount ? await config.toOwnerAccount() : await getCryptoKeyAccount(); @@ -682,7 +696,7 @@ export class Signer { private shouldRequestUseSubAccountSigner(request: RequestArguments) { const sender = getSenderFromRequest(request); - const subAccount = store.subAccounts.get(); + const subAccount = this.storeHelpers.subAccounts.get(); if (sender) { return sender.toLowerCase() === subAccount?.address.toLowerCase(); } @@ -690,9 +704,9 @@ export class Signer { } private async sendRequestToSubAccountSigner(request: RequestArguments) { - const subAccount = store.subAccounts.get(); - const subAccountsConfig = store.subAccountsConfig.get(); - const config = store.config.get(); + const subAccount = this.storeHelpers.subAccounts.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); + const config = this.storeHelpers.config.get(); assertPresence( subAccount?.address, @@ -752,7 +766,7 @@ export class Signer { if (['eth_sendTransaction', 'wallet_sendCalls'].includes(request.method)) { // If we have never had a spend permission, we need to do this tx through the global account // Only perform this check if funding mode is 'spend-permissions' - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); if (subAccountsConfig?.funding === 'spend-permissions') { const storedSpendPermissions = spendPermissions.get(); if (storedSpendPermissions.length === 0) { @@ -820,7 +834,7 @@ export class Signer { return result; } catch (error) { // Skip insufficient balance error handling if funding mode is 'manual' - const subAccountsConfig = store.subAccountsConfig.get(); + const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); if (subAccountsConfig?.funding === 'manual') { throw error; } diff --git a/packages/account-sdk/src/store/store.ts b/packages/account-sdk/src/store/store.ts index 6dfdec628..3d64eb0ad 100644 --- a/packages/account-sdk/src/store/store.ts +++ b/packages/account-sdk/src/store/store.ts @@ -132,136 +132,165 @@ export type StoreState = MergeTypes< ] >; -export const sdkstore = createStore( - persist( - (...args) => ({ - ...createChainSlice(...args), - ...createKeysSlice(...args), - ...createAccountSlice(...args), - ...createSubAccountSlice(...args), - ...createSpendPermissionsSlice(...args), - ...createConfigSlice(...args), - ...createSubAccountConfigSlice(...args), - }), - { - name: 'base-acc-sdk.store', - storage: createJSONStorage(() => localStorage), - partialize: (state) => { - // Explicitly select only the data properties we want to persist - // (not the methods) - return { - chains: state.chains, - keys: state.keys, - account: state.account, - subAccount: state.subAccount, - spendPermissions: state.spendPermissions, - config: state.config, - } as StoreState; +/** + * Factory function to create a store instance. + * Allows creating either persistent (for regular SDK) or ephemeral (for payment flows) stores. + */ +export function createStoreInstance(options?: { + persist?: boolean; + storageName?: string; +}) { + const { persist: shouldPersist = true, storageName = 'base-acc-sdk.store' } = options ?? {}; + + const storeCreator = (...args: Parameters>) => ({ + ...createChainSlice(...args), + ...createKeysSlice(...args), + ...createAccountSlice(...args), + ...createSubAccountSlice(...args), + ...createSpendPermissionsSlice(...args), + ...createConfigSlice(...args), + ...createSubAccountConfigSlice(...args), + }); + + if (shouldPersist) { + return createStore( + persist(storeCreator, { + name: storageName, + storage: createJSONStorage(() => localStorage), + partialize: (state) => { + // Explicitly select only the data properties we want to persist + // (not the methods) + return { + chains: state.chains, + keys: state.keys, + account: state.account, + subAccount: state.subAccount, + spendPermissions: state.spendPermissions, + config: state.config, + } as StoreState; + }, + }) + ); + } else { + // Create ephemeral store without persistence + return createStore(storeCreator); + } +} + +// Global singleton store for backwards compatibility and persistent SDK instances +export const sdkstore = createStoreInstance({ persist: true }); + +// Type for store instance returned by createStoreInstance +export type StoreInstance = ReturnType; + +/** + * Creates store accessor helpers for a given store instance. + * This allows both the global store and ephemeral stores to use the same API. + */ +export function createStoreHelpers(storeInstance: StoreInstance) { + return { + subAccountsConfig: { + get: () => storeInstance.getState().subAccountConfig, + set: (subAccountConfig: Partial) => { + storeInstance.setState((state) => ({ + subAccountConfig: { ...state.subAccountConfig, ...subAccountConfig }, + })); }, - } - ) -); - -// Non-persisted subaccount configuration - -export const subAccountsConfig = { - get: () => sdkstore.getState().subAccountConfig, - set: (subAccountConfig: Partial) => { - sdkstore.setState((state) => ({ - subAccountConfig: { ...state.subAccountConfig, ...subAccountConfig }, - })); - }, - clear: () => { - sdkstore.setState({ - subAccountConfig: {}, - }); - }, -}; + clear: () => { + storeInstance.setState({ + subAccountConfig: {}, + }); + }, + }, -export const subAccounts = { - get: () => sdkstore.getState().subAccount, - set: (subAccount: Partial) => { - sdkstore.setState((state) => ({ - subAccount: state.subAccount - ? { ...state.subAccount, ...subAccount } - : { address: subAccount.address as Address, ...subAccount }, - })); - }, - clear: () => { - sdkstore.setState({ - subAccount: undefined, - }); - }, -}; + subAccounts: { + get: () => storeInstance.getState().subAccount, + set: (subAccount: Partial) => { + storeInstance.setState((state) => ({ + subAccount: state.subAccount + ? { ...state.subAccount, ...subAccount } + : { address: subAccount.address as Address, ...subAccount }, + })); + }, + clear: () => { + storeInstance.setState({ + subAccount: undefined, + }); + }, + }, -export const spendPermissions = { - get: () => sdkstore.getState().spendPermissions, - set: (spendPermissions: SpendPermission[]) => { - sdkstore.setState({ spendPermissions }); - }, - clear: () => { - sdkstore.setState({ - spendPermissions: [], - }); - }, -}; + spendPermissions: { + get: () => storeInstance.getState().spendPermissions, + set: (spendPermissions: SpendPermission[]) => { + storeInstance.setState({ spendPermissions }); + }, + clear: () => { + storeInstance.setState({ + spendPermissions: [], + }); + }, + }, -export const account = { - get: () => sdkstore.getState().account, - set: (account: Partial) => { - sdkstore.setState((state) => ({ - account: { ...state.account, ...account }, - })); - }, - clear: () => { - sdkstore.setState({ - account: {}, - }); - }, -}; + account: { + get: () => storeInstance.getState().account, + set: (account: Partial) => { + storeInstance.setState((state) => ({ + account: { ...state.account, ...account }, + })); + }, + clear: () => { + storeInstance.setState({ + account: {}, + }); + }, + }, -export const chains = { - get: () => sdkstore.getState().chains, - set: (chains: Chain[]) => { - sdkstore.setState({ chains }); - }, - clear: () => { - sdkstore.setState({ - chains: [], - }); - }, -}; + chains: { + get: () => storeInstance.getState().chains, + set: (chains: Chain[]) => { + storeInstance.setState({ chains }); + }, + clear: () => { + storeInstance.setState({ + chains: [], + }); + }, + }, -export const keys = { - get: (key: string) => sdkstore.getState().keys[key], - set: (key: string, value: string | null) => { - sdkstore.setState((state) => ({ keys: { ...state.keys, [key]: value } })); - }, - clear: () => { - sdkstore.setState({ - keys: {}, - }); - }, -}; + keys: { + get: (key: string) => storeInstance.getState().keys[key], + set: (key: string, value: string | null) => { + storeInstance.setState((state) => ({ keys: { ...state.keys, [key]: value } })); + }, + clear: () => { + storeInstance.setState({ + keys: {}, + }); + }, + }, -export const config = { - get: () => sdkstore.getState().config, - set: (config: Partial) => { - sdkstore.setState((state) => ({ config: { ...state.config, ...config } })); - }, -}; + config: { + get: () => storeInstance.getState().config, + set: (config: Partial) => { + storeInstance.setState((state) => ({ config: { ...state.config, ...config } })); + }, + }, + }; +} -const actions = { - subAccounts, - subAccountsConfig, - spendPermissions, - account, - chains, - keys, - config, -}; +// Global store with helpers for backwards compatibility +const globalStoreHelpers = createStoreHelpers(sdkstore); + +// Re-export global helpers for backwards compatibility +export const subAccountsConfig = globalStoreHelpers.subAccountsConfig; +export const subAccounts = globalStoreHelpers.subAccounts; +export const spendPermissions = globalStoreHelpers.spendPermissions; +export const account = globalStoreHelpers.account; +export const chains = globalStoreHelpers.chains; +export const keys = globalStoreHelpers.keys; +export const config = globalStoreHelpers.config; export const store = { ...sdkstore, - ...actions, + ...globalStoreHelpers, }; From eabcfc4197170162330b4cad4bd3b7eaeea91d6e Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Wed, 26 Nov 2025 12:41:14 -0700 Subject: [PATCH 4/5] add isEphemeral to analytic events --- .../src/core/telemetry/events/scw-signer.ts | 18 ++++++++++++++++++ .../src/sign/base-account/EphemeralSigner.ts | 10 ++++++---- .../src/sign/base-account/Signer.ts | 10 ++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/account-sdk/src/core/telemetry/events/scw-signer.ts b/packages/account-sdk/src/core/telemetry/events/scw-signer.ts index dc58d5170..df89b855f 100644 --- a/packages/account-sdk/src/core/telemetry/events/scw-signer.ts +++ b/packages/account-sdk/src/core/telemetry/events/scw-signer.ts @@ -4,9 +4,11 @@ import { ActionType, AnalyticsEventImportance, ComponentType, logEvent } from '. export const logHandshakeStarted = ({ method, correlationId, + isEphemeral = false, }: { method: string; correlationId: string | undefined; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -16,6 +18,7 @@ export const logHandshakeStarted = ({ componentType: ComponentType.unknown, method, correlationId, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, @@ -28,10 +31,12 @@ export const logHandshakeError = ({ method, correlationId, errorMessage, + isEphemeral = false, }: { method: string; correlationId: string | undefined; errorMessage: string; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -42,6 +47,7 @@ export const logHandshakeError = ({ method, correlationId, errorMessage, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, @@ -53,9 +59,11 @@ export const logHandshakeError = ({ export const logHandshakeCompleted = ({ method, correlationId, + isEphemeral = false, }: { method: string; correlationId: string | undefined; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -65,6 +73,7 @@ export const logHandshakeCompleted = ({ componentType: ComponentType.unknown, method, correlationId, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, @@ -76,9 +85,11 @@ export const logHandshakeCompleted = ({ export const logRequestStarted = ({ method, correlationId, + isEphemeral = false, }: { method: string; correlationId: string | undefined; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -88,6 +99,7 @@ export const logRequestStarted = ({ componentType: ComponentType.unknown, method, correlationId, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, @@ -100,10 +112,12 @@ export const logRequestError = ({ method, correlationId, errorMessage, + isEphemeral = false, }: { method: string; correlationId: string | undefined; errorMessage: string; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -114,6 +128,7 @@ export const logRequestError = ({ method, correlationId, errorMessage, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, @@ -125,9 +140,11 @@ export const logRequestError = ({ export const logRequestCompleted = ({ method, correlationId, + isEphemeral = false, }: { method: string; correlationId: string | undefined; + isEphemeral?: boolean; }) => { const config = store.subAccountsConfig.get(); logEvent( @@ -137,6 +154,7 @@ export const logRequestCompleted = ({ componentType: ComponentType.unknown, method, correlationId, + isEphemeral, subAccountCreation: config?.creation, subAccountDefaultAccount: config?.defaultAccount, subAccountFunding: config?.funding, diff --git a/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts index 432474ba2..c6c8c6a09 100644 --- a/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts +++ b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts @@ -74,7 +74,7 @@ export class EphemeralSigner { async handshake(args: RequestArguments) { const correlationId = correlationIds.get(args); - logHandshakeStarted({ method: args.method, correlationId }); + logHandshakeStarted({ method: args.method, correlationId, isEphemeral: true }); try { // Open the popup before constructing the request message. @@ -102,12 +102,13 @@ export class EphemeralSigner { await this.decryptResponseMessage(response); - logHandshakeCompleted({ method: args.method, correlationId }); + logHandshakeCompleted({ method: args.method, correlationId, isEphemeral: true }); } catch (error) { logHandshakeError({ method: args.method, correlationId, errorMessage: parseErrorMessageFromAny(error), + isEphemeral: true, }); throw error; } @@ -115,17 +116,18 @@ export class EphemeralSigner { async request(request: RequestArguments) { const correlationId = correlationIds.get(request); - logRequestStarted({ method: request.method, correlationId }); + logRequestStarted({ method: request.method, correlationId, isEphemeral: true }); try { const result = await this._request(request); - logRequestCompleted({ method: request.method, correlationId }); + logRequestCompleted({ method: request.method, correlationId, isEphemeral: true }); return result; } catch (error) { logRequestError({ method: request.method, correlationId, errorMessage: parseErrorMessageFromAny(error), + isEphemeral: true, }); throw error; } diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index 2308db2dc..2be8be3f9 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -120,7 +120,7 @@ export class Signer { async handshake(args: RequestArguments) { const correlationId = correlationIds.get(args); - logHandshakeStarted({ method: args.method, correlationId }); + logHandshakeStarted({ method: args.method, correlationId, isEphemeral: false }); try { // Open the popup before constructing the request message. @@ -150,12 +150,13 @@ export class Signer { const decrypted = await this.decryptResponseMessage(response); this.handleResponse(args, decrypted); - logHandshakeCompleted({ method: args.method, correlationId }); + logHandshakeCompleted({ method: args.method, correlationId, isEphemeral: false }); } catch (error) { logHandshakeError({ method: args.method, correlationId, errorMessage: parseErrorMessageFromAny(error), + isEphemeral: false, }); throw error; } @@ -163,17 +164,18 @@ export class Signer { async request(request: RequestArguments) { const correlationId = correlationIds.get(request); - logRequestStarted({ method: request.method, correlationId }); + logRequestStarted({ method: request.method, correlationId, isEphemeral: false }); try { const result = await this._request(request); - logRequestCompleted({ method: request.method, correlationId }); + logRequestCompleted({ method: request.method, correlationId, isEphemeral: false }); return result; } catch (error) { logRequestError({ method: request.method, correlationId, errorMessage: parseErrorMessageFromAny(error), + isEphemeral: false, }); throw error; } From 1e69f663dbf3874eee5022098183204f784544a2 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Wed, 26 Nov 2025 12:45:28 -0700 Subject: [PATCH 5/5] fix ci --- packages/account-sdk/src/core/telemetry/logEvent.ts | 1 + .../builder/core/EphemeralBaseAccountProvider.ts | 5 ++--- .../account-sdk/src/sign/base-account/Signer.test.ts | 3 ++- packages/account-sdk/src/sign/base-account/Signer.ts | 9 ++------- packages/account-sdk/src/store/store.ts | 6 +++--- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/account-sdk/src/core/telemetry/logEvent.ts b/packages/account-sdk/src/core/telemetry/logEvent.ts index 66b48423f..5a1cb5554 100644 --- a/packages/account-sdk/src/core/telemetry/logEvent.ts +++ b/packages/account-sdk/src/core/telemetry/logEvent.ts @@ -63,6 +63,7 @@ type CCAEventData = { errorMessage?: string; dialogContext?: string; dialogAction?: string; + isEphemeral?: boolean; // Whether operation is using ephemeral signer subAccountCreation?: 'on-connect' | 'manual'; subAccountDefaultAccount?: 'sub' | 'universal'; subAccountFunding?: 'spend-permissions' | 'manual'; diff --git a/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts index 310d88886..44a7fa6ef 100644 --- a/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts +++ b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts @@ -56,7 +56,7 @@ export class EphemeralBaseAccountProvider // Create an isolated ephemeral store for this provider instance // persist: false means no localStorage persistence this.ephemeralStore = createStoreInstance({ persist: false }); - + this.signer = new EphemeralSigner({ metadata, communicator: this.communicator, @@ -122,8 +122,7 @@ export class EphemeralBaseAccountProvider } default: { throw standardErrors.provider.unauthorized( - `Method '${args.method}' is not supported by ephemeral provider. ` + - `Ephemeral providers only support: wallet_sendCalls, wallet_sign, wallet_getCallsStatus` + `Method '${args.method}' is not supported by ephemeral provider. Ephemeral providers only support: wallet_sendCalls, wallet_sign, wallet_getCallsStatus` ); } } diff --git a/packages/account-sdk/src/sign/base-account/Signer.test.ts b/packages/account-sdk/src/sign/base-account/Signer.test.ts index b8e206279..e6b1696e7 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.test.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.test.ts @@ -176,7 +176,8 @@ describe('Signer', () => { mockCommunicator.postRequestAndWaitForResponse.mockResolvedValue(mockSuccessResponse); mockCallback = vi.fn(); - mockKeyManager = new SCWKeyManager() as Mocked; + // Mock SCWKeyManager - the actual store instance doesn't matter since it's mocked + mockKeyManager = new SCWKeyManager(store) as Mocked; (SCWKeyManager as Mock).mockImplementation(() => mockKeyManager); (importKeyFromHexString as Mock).mockResolvedValue(mockCryptoKey); diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index 2be8be3f9..ac73c897f 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -37,12 +37,7 @@ import { Address } from ':core/type/index.js'; import { ensureIntNumber, hexStringFromNumber } from ':core/type/util.js'; import { SDKChain, createClients, getClient } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; -import { - createStoreHelpers, - spendPermissions, - store, - type StoreInstance, -} from ':store/store.js'; +import { createStoreHelpers, spendPermissions, store, type StoreInstance } from ':store/store.js'; import { assertArrayPresence, assertPresence } from ':util/assertPresence.js'; import { assertSubAccount } from ':util/assertSubAccount.js'; import { @@ -343,7 +338,7 @@ export class Signer { const response = (await fetchRPCRequest( fetchPermissionRequest, CB_WALLET_RPC_URL - ) ) as FetchPermissionResponse; + )) as FetchPermissionResponse; // Store the single permission if it has a chainId if (response.permission && response.permission.chainId) { diff --git a/packages/account-sdk/src/store/store.ts b/packages/account-sdk/src/store/store.ts index 3d64eb0ad..b67391157 100644 --- a/packages/account-sdk/src/store/store.ts +++ b/packages/account-sdk/src/store/store.ts @@ -171,10 +171,9 @@ export function createStoreInstance(options?: { }, }) ); - } else { - // Create ephemeral store without persistence - return createStore(storeCreator); } + // Create ephemeral store without persistence + return createStore(storeCreator); } // Global singleton store for backwards compatibility and persistent SDK instances @@ -293,4 +292,5 @@ export const config = globalStoreHelpers.config; export const store = { ...sdkstore, ...globalStoreHelpers, + persist: (sdkstore as any).persist, };