Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get test coverage for this file?

Original file line number Diff line number Diff line change
@@ -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<ConstructorOptions>) {
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<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() {
// 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;
}
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