Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/account-sdk/src/core/telemetry/events/scw-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -16,6 +18,7 @@ export const logHandshakeStarted = ({
componentType: ComponentType.unknown,
method,
correlationId,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand All @@ -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(
Expand All @@ -42,6 +47,7 @@ export const logHandshakeError = ({
method,
correlationId,
errorMessage,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand All @@ -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(
Expand All @@ -65,6 +73,7 @@ export const logHandshakeCompleted = ({
componentType: ComponentType.unknown,
method,
correlationId,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand All @@ -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(
Expand All @@ -88,6 +99,7 @@ export const logRequestStarted = ({
componentType: ComponentType.unknown,
method,
correlationId,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand All @@ -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(
Expand All @@ -114,6 +128,7 @@ export const logRequestError = ({
method,
correlationId,
errorMessage,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand All @@ -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(
Expand All @@ -137,6 +154,7 @@ export const logRequestCompleted = ({
componentType: ComponentType.unknown,
method,
correlationId,
isEphemeral,
subAccountCreation: config?.creation,
subAccountDefaultAccount: config?.defaultAccount,
subAccountFunding: config?.funding,
Expand Down
1 change: 1 addition & 0 deletions packages/account-sdk/src/core/telemetry/logEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConstructorOptions>) {
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<T>(args: RequestArguments): Promise<T> {
// 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<T>(args: RequestArguments): Promise<T> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,55 @@ export type CreateProviderOptions = Partial<AppMetadata> & {
paymasterUrls?: Record<number, string>;
};

// ====================================================================
// One-time initialization tracking
// These operations only need to run once per page load
// ====================================================================

let globalInitialized = false;
let telemetryInitialized = false;
let rehydrationPromise: Promise<void> | 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.
Expand Down Expand Up @@ -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
// ====================================================================
Expand Down
Loading
Loading