Skip to content

Add EphemeralBaseAccountProvider for isolated payment flows#188

Merged
spencerstock merged 13 commits intomasterfrom
spencer/ephemeral-provider-isolation
Apr 14, 2026
Merged

Add EphemeralBaseAccountProvider for isolated payment flows#188
spencerstock merged 13 commits intomasterfrom
spencer/ephemeral-provider-isolation

Conversation

@spencerstock
Copy link
Copy Markdown
Collaborator

@spencerstock spencerstock commented Nov 25, 2025

What changed? Why?

Adds EphemeralBaseAccountProvider and EphemeralSigner for single-use payment flows (pay() and subscribe()).

Problem: When multiple payment flows execute concurrently or sequentially, they share global state which causes race conditions and cleanup of one flow affects others.

Solution:

  • EphemeralBaseAccountProvider: Provider with isolated state that only supports payment methods (wallet_sendCalls, wallet_sign, wallet_getCallsStatus)
  • EphemeralSigner: Signer that maintains instance-local state and cleans up without affecting global store
  • Request queuing in sdkManager.ts to prevent concurrent payment operations from interfering
  • One-time initialization in createBaseAccountSDK.ts to avoid redundant setup calls
  • Updated Signer.cleanup() to preserve store.chains since chain clients are shared infrastructure

How was this tested?

Existing test coverage for createBaseAccountSDK.test.ts updated to reset global initialization state between tests.

How can reviewers manually test these changes?

  1. Call pay() or subscribe() multiple times in sequence
  2. Verify each payment flow completes without affecting subsequent flows
  3. Verify chain clients remain available after payment completion

Demo/screenshots

@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Nov 25, 2025

✅ Heimdall Review Status

Requirement Status More Info
Reviews 2/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

…o 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
@spencerstock spencerstock marked this pull request as ready for review November 25, 2025 17:43
Copy link
Copy Markdown
Collaborator

@fan-zhang-sv fan-zhang-sv left a comment

Choose a reason for hiding this comment

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

hmmm i think there might still be some issues with this approach.

i wonder if we could

  • switch to dependency injection, so we inject store upon creating sdk instance
  • and have ephemeral sdk use its own store instance upon creation
  • and have ephemeral sdk removing this instance store upon clear()
  • this way, there might be a chance we dont need to duplicate Provider/Signer code (not 100% sure)


async handshake(args: RequestArguments) {
const correlationId = correlationIds.get(args);
logHandshakeStarted({ method: args.method, correlationId });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we add an additional param isEphemeral to measure success for each component?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added!

*/
export class EphemeralSigner {
private readonly communicator: Communicator;
private readonly keyManager: SCWKeyManager;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hmm i think keyManager could still be an issue

  • keyManager is still reading and writing to global state in localStorage
  • keyManager.clear() rotate the global key pair, which may intervene with concurrent operation

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good call. Added createStoreInstance() that can create either persistent (localStorage) or ephemeral (in-memory) store instances.

Now Signer/EphemeralSigner: Both accept a storeInstance parameter and pass it down to KeyManager

fan-zhang-sv
fan-zhang-sv previously approved these changes Dec 15, 2025
Copy link
Copy Markdown
Collaborator

@fan-zhang-sv fan-zhang-sv left a comment

Choose a reason for hiding this comment

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

LGTM!

*/
const paymentQueue = new Map<string, Promise<PaymentExecutionResult>>();

function getQueueKey(testnet: boolean, walletUrl?: string): string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: prefer named params

Comment on lines +127 to +131
export function createEphemeralSDK(
chainId: number,
walletUrl?: string,
telemetry: boolean = true
): { getProvider: () => ProviderInterface } {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: prefer named params (also for consistency with createBaseAccountSDK)

Copy link
Copy Markdown
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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

missing test coverage

fan-zhang-sv and others added 4 commits December 16, 2025 13:48
Resolve conflicts in payment SDK manager and signer capabilities handling, switch ephemeral SDK creation to named params, and add focused tests for EphemeralBaseAccountProvider and EphemeralSigner.

Made-with: Cursor
Apply formatter-compliant test formatting and add explicit result typing in Signer tests so strict TS no longer treats request results as unknown.

Made-with: Cursor
Copy link
Copy Markdown
Collaborator Author

@spencerstock spencerstock left a comment

Choose a reason for hiding this comment

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

🔴 Changes requested · 72/100

The architectural approach of isolating ephemeral payment state via EphemeralBaseAccountProvider and EphemeralSigner is well-designed and solves the shared state problem cleanly. The store refactoring to support both persistent and ephemeral instances is thoughtful, and the telemetry instrumentation via withMeasurement / withSignerRequestMeasurement reduces duplication nicely.

However, the payment queue in sdkManager.ts has a critical race condition that must be fixed before merge: the await before setting the queue entry creates an async gap where concurrent calls bypass serialization entirely, and the unconditional paymentQueue.delete() in the finally block can break chaining for subsequent operations. The fix is straightforward — synchronous promise chaining as described in the review comments.

Minor: there's a typo (isGloabalStore) in Signer.ts.

Confidence: 72/100  ·  🔴 2 critical  ·  🟡 1 suggestion  ·  💬 1 nit

Pass 1 → Pass 2 full details

Pass 1 found 2 comments:

packages/account-sdk/src/interface/payment/utils/sdkManager.ts:264critical

There is a classic race condition in the asynchronous queuing logic here.

By awaiting waitForPendingOperation(queueKey) before setting the new execution promise in the queue, an asynchronous gap is created. If executePaymentWithSDK is called concurrently (e.g., via Promise.all or a double-click), both calls will synchronously evaluate paymentQueue.get(...) as undefined and proceed to yield. When they resume, they will execute concurrently and overwrite each other in the paymentQueue map, completely bypassing the intended concurrency protection.

To fix this, you must chain the promises synchronously before yielding so the queue is atomically updated:

const previousTask = paymentQueue.get(queueKey) ?? Promise.resolve();

const execution = (async (): Promise<PaymentExecutionResult> => {
  // Wait for previous operation in queue to finish (ignore errors to keep queue moving)
  await previousTask.catch(() => {});

  const network = testnet ? 'baseSepolia' : 'base';
  const chainId = CHAIN_IDS[network];
  const sdk = createEphemeralSDK({ chainId, walletUrl, telemetry, dataSuffix });
  const provider = sdk.getProvider();

  try {
    return await executePaymentWithProvider(provider, requestParams);
  } finally {
    await provider.disconnect();
  }
})();

// Synchronously put the new task in the queue
paymentQueue.set(queueKey, execution);

try {
  return await execution;
} finally {
  // Only clear the queue if this is still the most recent task
  if (paymentQueue.get(queueKey) === execution) {
    paymentQueue.delete(queueKey);
  }
}

Pass 2: The race condition is real and clearly present in the code. Looking at lines 261-289 of sdkManager.ts: await waitForPendingOperation(queueKey) yields execution at line 264, and only after resuming does the code create the execution promise and set it in the queue at line 289. If two concurrent calls enter executePaymentWithSDK with the same queueKey, both will see paymentQueue.get(queueKey) as undefined (or the same value), both will pass the await, and both will execute concurrently. The fix proposed by the reviewer (synchronous promise chaining before yielding) is the correct pattern. Additionally, the cleanup at line 295 unconditionally deletes the queue entry, which means if a third call queued behind a second, the second's finally block would delete the third's entry. The reviewer's suggested fix also addresses this with the if (paymentQueue.get(queueKey) === execution) guard.

packages/account-sdk/src/sign/base-account/Signer.ts:85nit

Typo in variable name isGloabalStore, should be isGlobalStore.

Pass 2: Confirmed at line 91 of Signer.ts: const isGloabalStore = this.storeInstance === store; — 'Gloabal' should be 'Global'. Minor typo but worth fixing.

Pass 2 added 2 new findings:

🆕 packages/account-sdk/src/store/store.ts:290suggestion

The store composite object re-spreads sdkstore and then explicitly sets persist: (sdkstore as any).persist. This as any cast is fragile — if createStoreInstance returns a non-persisted store (which is now possible with persist: false), the .persist property won't exist on that type. While this specific line only applies to the global singleton (which always uses persist: true), the cast masks the type gap and could mislead future callers of createStoreInstance who try to access .persist on an ephemeral store. Consider typing StoreInstance more precisely to distinguish persistent vs. ephemeral stores, or at minimum add a comment explaining why the cast is safe here.

🆕 packages/account-sdk/src/interface/payment/utils/sdkManager.ts:288critical

Even ignoring the race condition (addressed separately), the finally block at line 295 unconditionally calls paymentQueue.delete(queueKey). If a newer operation has already been queued under the same key (e.g., a third payment started while the second was running), this delete removes the newer entry, breaking the queue chain for subsequent operations. The fix should only delete if the current execution is still the most recent entry: if (paymentQueue.get(queueKey) === execution) paymentQueue.delete(queueKey);

🔧 Fix with prompt
A reviewer gave these comments as feedback. Validate them and fix all the ones that need to be fixed.

- [critical] packages/account-sdk/src/interface/payment/utils/sdkManager.ts:264-289
  **Critical: Race condition in payment queue — concurrent calls bypass serialization.**
  
  By `await`ing `waitForPendingOperation(queueKey)` *before* setting the new execution promise in the queue, an asynchronous gap is created. If `executePaymentWithSDK` is called concurrently (e.g., via `Promise.all` or a double-click), both calls will synchronously evaluate `paymentQueue.get(...)` as `undefined` and proceed to yield. When they resume, they will execute concurrently and overwrite each other in the `paymentQueue` map, completely bypassing the intended concurrency protection.
  
  To fix this, you must chain the promises synchronously before yielding so the queue is atomically updated:
  
  ```typescript
  const previousTask = paymentQueue.get(queueKey) ?? Promise.resolve();
  
  const execution = (async (): Promise<PaymentExecutionResult> => {
    // Wait for previous operation in queue to finish (ignore errors to keep queue moving)
    await previousTask.catch(() => {});
  
    const network = testnet ? 'baseSepolia' : 'base';
    const chainId = CHAIN_IDS[network];
    const sdk = createEphemeralSDK({ chainId, walletUrl, telemetry, dataSuffix });
    const provider = sdk.getProvider();
  
    try {
      return await executePaymentWithProvider(provider, requestParams);
    } finally {
      await provider.disconnect();
    }
  })();
  
  // Synchronously put the new task in the queue
  paymentQueue.set(queueKey, execution);
  
  try {
    return await execution;
  } finally {
    // Only clear the queue if this is still the most recent task
    if (paymentQueue.get(queueKey) === execution) {
      paymentQueue.delete(queueKey);
    }
  }
  ```

- [critical] packages/account-sdk/src/interface/payment/utils/sdkManager.ts:288-296
  **Critical: Unconditional queue cleanup breaks chaining for subsequent operations.**
  
  The `finally` block at line 295 unconditionally calls `paymentQueue.delete(queueKey)`. If a newer operation has already been queued under the same key (e.g., a third payment started while the second was executing), this `delete` removes the newer entry, breaking the queue chain. The cleanup should be guarded:
  
  ```typescript
  if (paymentQueue.get(queueKey) === execution) {
    paymentQueue.delete(queueKey);
  }
  ```
  
  This is part of the same race condition fix — the suggested rewrite in the previous comment addresses both issues together.

- [nit] packages/account-sdk/src/sign/base-account/Signer.ts:85-86
  Typo: `isGloabalStore` should be `isGlobalStore`.

- [suggestion] packages/account-sdk/src/store/store.ts:290-293
  The `store` composite object re-spreads `sdkstore` and then explicitly sets `persist: (sdkstore as any).persist`. This `as any` cast is fragile — if `createStoreInstance` returns a non-persisted store (which is now possible with `persist: false`), the `.persist` property won't exist on that type. While this specific line only applies to the global singleton (which always uses `persist: true`), the cast masks the type gap and could mislead future callers of `createStoreInstance` who try to access `.persist` on an ephemeral store. Consider typing `StoreInstance` more precisely to distinguish persistent vs. ephemeral stores, or at minimum add a comment explaining why the cast is safe here.

SHA a4d78206 · gemini-3.1-pro-preview → claude-opus-4-6

Comment on lines +264 to +289
await waitForPendingOperation(queueKey);

const network = testnet ? 'baseSepolia' : 'base';
const chainId = CHAIN_IDS[network];

const sdk = createEphemeralSDK(chainId, walletUrl, telemetry, dataSuffix);
const sdk = createEphemeralSDK({
chainId,
walletUrl,
telemetry,
dataSuffix,
});
const provider = sdk.getProvider();

// Create the execution promise and add it to the queue
const execution = (async (): Promise<PaymentExecutionResult> => {
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);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

critical

Critical: Race condition in payment queue — concurrent calls bypass serialization.

By awaiting waitForPendingOperation(queueKey) before setting the new execution promise in the queue, an asynchronous gap is created. If executePaymentWithSDK is called concurrently (e.g., via Promise.all or a double-click), both calls will synchronously evaluate paymentQueue.get(...) as undefined and proceed to yield. When they resume, they will execute concurrently and overwrite each other in the paymentQueue map, completely bypassing the intended concurrency protection.

To fix this, you must chain the promises synchronously before yielding so the queue is atomically updated:

const previousTask = paymentQueue.get(queueKey) ?? Promise.resolve();

const execution = (async (): Promise<PaymentExecutionResult> => {
  // Wait for previous operation in queue to finish (ignore errors to keep queue moving)
  await previousTask.catch(() => {});

  const network = testnet ? 'baseSepolia' : 'base';
  const chainId = CHAIN_IDS[network];
  const sdk = createEphemeralSDK({ chainId, walletUrl, telemetry, dataSuffix });
  const provider = sdk.getProvider();

  try {
    return await executePaymentWithProvider(provider, requestParams);
  } finally {
    await provider.disconnect();
  }
})();

// Synchronously put the new task in the queue
paymentQueue.set(queueKey, execution);

try {
  return await execution;
} finally {
  // Only clear the queue if this is still the most recent task
  if (paymentQueue.get(queueKey) === execution) {
    paymentQueue.delete(queueKey);
  }
}

Comment on lines 288 to 296
// 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);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

critical

Critical: Unconditional queue cleanup breaks chaining for subsequent operations.

The finally block at line 295 unconditionally calls paymentQueue.delete(queueKey). If a newer operation has already been queued under the same key (e.g., a third payment started while the second was executing), this delete removes the newer entry, breaking the queue chain. The cleanup should be guarded:

if (paymentQueue.get(queueKey) === execution) {
  paymentQueue.delete(queueKey);
}

This is part of the same race condition fix — the suggested rewrite in the previous comment addresses both issues together.

Comment on lines 85 to 86
this.communicator = params.communicator;
this.callback = params.callback;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

nit

Typo: isGloabalStore should be isGlobalStore.

Comment on lines +290 to 293
export const config = globalStoreHelpers.config;

export const store = {
...sdkstore,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The store composite object re-spreads sdkstore and then explicitly sets persist: (sdkstore as any).persist. This as any cast is fragile — if createStoreInstance returns a non-persisted store (which is now possible with persist: false), the .persist property won't exist on that type. While this specific line only applies to the global singleton (which always uses persist: true), the cast masks the type gap and could mislead future callers of createStoreInstance who try to access .persist on an ephemeral store. Consider typing StoreInstance more precisely to distinguish persistent vs. ephemeral stores, or at minimum add a comment explaining why the cast is safe here.

…r typo

- Serialize executePaymentWithSDK by chaining previousTask synchronously before
  awaiting, fixing concurrent callers bypassing the queue.
- Delete paymentQueue entries only when the finished task is still the tail.
- Type global store.persist without as any; ephemeral StoreInstance omits persist.
- Rename isGloabalStore to isGlobalStore in Signer.

Made-with: Cursor
Copy link
Copy Markdown
Collaborator Author

@spencerstock spencerstock left a comment

Choose a reason for hiding this comment

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

🫡 Great work, one suggestion · 94/100 · Re-review

Re-review summary: both prior critical queue-race findings are now fixed with the correct promise-chaining pattern and guarded tail deletion in executePaymentWithSDK (sdkManager.ts), and the prior typo in Signer.ts was corrected (isGlobalStore). I re-checked the changed call paths (pay, subscribe, ephemeral provider/signer, store helpers, key manager wiring) and did not find regressions in the originally reported areas.

Net-new issue found: during the store-instance refactor, Signer.sendRequestToSubAccountSigner still reads spendPermissions from the global singleton (spendPermissions.get()) instead of the injected store instance (this.storeHelpers.spendPermissions.get()). That creates cross-instance leakage risk and breaks full store isolation semantics for non-global signer instances.

Confidence: 94/100  ·  🟡 1 suggestion

🔧 Fix with prompt
A reviewer gave these comments as feedback. Validate them and fix all the ones that need to be fixed.

- [suggestion] packages/account-sdk/src/sign/base-account/Signer.ts:749-752
  This path still reads spend permissions from the global singleton (`spendPermissions.get()`), even though `Signer` was refactored to support injected `storeInstance`. That can leak state across signer instances and violates store isolation for non-global signers. Please switch this to `this.storeHelpers.spendPermissions.get()` so behavior is scoped to the signer’s configured store.

SHA d6155183 · gpt-5.3-codex

Comment on lines +749 to 752
const subAccountsConfig = this.storeHelpers.subAccountsConfig.get();
if (subAccountsConfig?.funding === 'spend-permissions') {
const storedSpendPermissions = spendPermissions.get();
if (storedSpendPermissions.length === 0) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This path still reads spend permissions from the global singleton (spendPermissions.get()), even though Signer was refactored to support injected storeInstance. That can leak state across signer instances and violates store isolation for non-global signers. Please switch this to this.storeHelpers.spendPermissions.get() so behavior is scoped to the signer’s configured store.

Use store helpers bound to the signer's configured store when checking spend permissions so non-global signers remain fully isolated.

Made-with: Cursor
Copy link
Copy Markdown
Collaborator Author

@spencerstock spencerstock left a comment

Choose a reason for hiding this comment

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

⛴️ Ship it · 95/100 · Re-review

Re-review complete: the two previously-blocking queue race issues in sdkManager.ts are now fixed with the correct synchronous promise chaining pattern and guarded tail cleanup, and the typo in Signer.ts was corrected. I also did a targeted sweep across affected integration seams (Signer/SCWKeyManager store injection, ephemeral provider path, and payment call sites in pay()/subscribe()), and did not find a new correctness regression in changed logic. APPROVED — no material concerns.

Confidence: 95/100

SHA 996e3444 · gpt-5.3-codex

Copy link
Copy Markdown
Collaborator

@fan-zhang-sv fan-zhang-sv left a comment

Choose a reason for hiding this comment

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

so clean! 🙌

@spencerstock spencerstock merged commit 23ab5b8 into master Apr 14, 2026
10 checks passed
@spencerstock spencerstock deleted the spencer/ephemeral-provider-isolation branch April 14, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants