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/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/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 new file mode 100644 index 000000000..44a7fa6ef --- /dev/null +++ b/packages/account-sdk/src/interface/builder/core/EphemeralBaseAccountProvider.ts @@ -0,0 +1,147 @@ +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 { 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. 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 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 + implements ProviderInterface +{ + private readonly communicator: Communicator; + private readonly signer: EphemeralSigner; + private readonly ephemeralStore: StoreInstance; + + constructor({ + metadata, + preference: { walletUrl, ...preference }, + }: Readonly) { + super(); + this.communicator = new Communicator({ + url: walletUrl, + 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, + }); + } + + 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() { + // Cleanup ephemeral signer state and its isolated store + await this.signer.cleanup(); + // 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')); + } + + 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..59f5438cd 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts @@ -23,6 +23,55 @@ export type CreateProviderOptions = Partial & { paymasterUrls?: Record; }; +// ==================================================================== +// One-time initialization tracking +// These operations only need to run once per page load +// ==================================================================== + +let globalInitialized = false; +let telemetryInitialized = false; +let rehydrationPromise: Promise | null = null; + +/** + * Performs one-time global initialization for the SDK (excluding telemetry). + * Safe to call multiple times - will only execute once. + */ +function initializeGlobalOnce(): void { + if (globalInitialized) return; + globalInitialized = true; + + // Check COOP policy once + void checkCrossOriginOpenerPolicy(); + + // Rehydrate store from localStorage once + if (!rehydrationPromise) { + const result = store.persist.rehydrate(); + rehydrationPromise = result instanceof Promise ? result : Promise.resolve(); + } +} + +/** + * 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; +} + /** * Create Base AccountSDK instance with EIP-1193 compliant provider * @param params - Options to create a base account SDK instance. @@ -60,20 +109,20 @@ export function createBaseAccountSDK(params: CreateProviderOptions) { store.config.set(options); - void store.persist.rehydrate(); - // ==================================================================== - // Validation and telemetry + // One-time initialization and validation // ==================================================================== - void checkCrossOriginOpenerPolicy(); - - validatePreferences(options.preference); + 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) { - void loadTelemetryScript(); + initializeTelemetryOnce(); } + validatePreferences(options.preference); + // ==================================================================== // 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..4133ba27d 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,136 @@ export interface PaymentExecutionResult { payerInfoResponses?: PayerInfoResponses; } +// ==================================================================== +// One-time initialization for ephemeral SDK operations +// ==================================================================== + +let ephemeralInitialized = false; +let ephemeralTelemetryInitialized = false; + +/** + * Performs one-time global initialization for ephemeral SDKs (excluding telemetry). + */ +function initializeEphemeralOnce(): void { + if (ephemeralInitialized) return; + ephemeralInitialized = true; + + // Check COOP policy once + void checkCrossOriginOpenerPolicy(); +} + +/** + * 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(); +} + +/** + * Resets the ephemeral initialization state. + * @internal This is only intended for testing purposes. + */ +export function _resetEphemeralInitialization(): void { + ephemeralInitialized = false; + ephemeralTelemetryInitialized = false; +} + +// ==================================================================== +// Request queuing to prevent race conditions +// ==================================================================== + +/** + * 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 SDK instance configured for payments + * 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 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({ + 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 +208,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 +241,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..c6c8c6a09 --- /dev/null +++ b/packages/account-sdk/src/sign/base-account/EphemeralSigner.ts @@ -0,0 +1,263 @@ +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 { createStoreHelpers, type StoreInstance } 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; + storeInstance: StoreInstance; +}; + +/** + * EphemeralSigner is designed for single-use payment flows. + * + * Key differences from Signer: + * 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 + * - 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 other signers) + private readonly chainId: number; + + constructor(params: ConstructorOptions) { + this.communicator = params.communicator; + 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 + + // Use the first chain from metadata as default + this.chainId = params.metadata.appChainIds?.[0] ?? 1; + + // Ensure chain clients exist for this chain + // Chain clients are shared infrastructure and can be safely shared + const chains = this.storeInstance.getState().chains; + if (chains && chains.length > 0) { + createClients(chains); + } + } + + async handshake(args: RequestArguments) { + const correlationId = correlationIds.get(args); + logHandshakeStarted({ method: args.method, correlationId, isEphemeral: true }); + + 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, isEphemeral: true }); + } catch (error) { + logHandshakeError({ + method: args.method, + correlationId, + errorMessage: parseErrorMessageFromAny(error), + isEphemeral: true, + }); + throw error; + } + } + + async request(request: RequestArguments) { + const correlationId = correlationIds.get(request); + logRequestStarted({ method: request.method, correlationId, isEphemeral: true }); + + try { + const result = await this._request(request); + logRequestCompleted({ method: request.method, correlationId, isEphemeral: true }); + return result; + } catch (error) { + logRequestError({ + method: request.method, + correlationId, + errorMessage: parseErrorMessageFromAny(error), + isEphemeral: true, + }); + 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 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() { + // Clear the key manager (instance-specific cryptographic state) + await this.keyManager.clear(); + + // 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 { + 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/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.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 d5a5ad1d6..ac73c897f 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -37,7 +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 { 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 +72,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 +88,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, @@ -106,7 +115,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. @@ -136,12 +145,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; } @@ -149,17 +159,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; } @@ -178,7 +189,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 +232,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 +276,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 +288,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 +307,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 +325,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, @@ -331,7 +342,7 @@ export class Signer { // 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 +375,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 +386,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 +397,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 +418,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 +427,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,14 +443,18 @@ export class Signer { } async cleanup() { - const metadata = store.config.get().metadata; + const metadata = this.storeHelpers.config.get().metadata; await this.keyManager.clear(); - // clear the store - store.account.clear(); - store.subAccounts.clear(); - store.spendPermissions.clear(); - store.chains.clear(); + // Clear session-specific store data + this.storeHelpers.account.clear(); + this.storeHelpers.subAccounts.clear(); + this.storeHelpers.spendPermissions.clear(); + + // 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. // reset the signer this.accounts = []; @@ -478,7 +493,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) { @@ -572,7 +587,7 @@ export class Signer { }; }); - store.chains.set(chains); + this.storeHelpers.chains.set(chains); this.updateChain(this.chain.id, chains); createClients(chains); @@ -580,7 +595,7 @@ export class Signer { const walletCapabilities = response.data?.capabilities; if (walletCapabilities) { - store.account.set({ + this.storeHelpers.account.set({ capabilities: walletCapabilities, }); } @@ -588,14 +603,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)); @@ -608,9 +623,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 = @@ -650,7 +665,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(); @@ -678,7 +693,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(); } @@ -686,9 +701,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, @@ -748,7 +763,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) { @@ -816,7 +831,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..b67391157 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; + }, + }) + ); + } + // 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, + persist: (sdkstore as any).persist, };