Add EphemeralBaseAccountProvider for isolated payment flows#188
Add EphemeralBaseAccountProvider for isolated payment flows#188spencerstock merged 13 commits intomasterfrom
Conversation
✅ Heimdall Review Status
|
…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
fan-zhang-sv
left a comment
There was a problem hiding this comment.
hmmm i think there might still be some issues with this approach.
i wonder if we could
- switch to dependency injection, so we inject
storeupon 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 }); |
There was a problem hiding this comment.
can we add an additional param isEphemeral to measure success for each component?
| */ | ||
| export class EphemeralSigner { | ||
| private readonly communicator: Communicator; | ||
| private readonly keyManager: SCWKeyManager; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
| */ | ||
| const paymentQueue = new Map<string, Promise<PaymentExecutionResult>>(); | ||
|
|
||
| function getQueueKey(testnet: boolean, walletUrl?: string): string { |
There was a problem hiding this comment.
nit: prefer named params
| export function createEphemeralSDK( | ||
| chainId: number, | ||
| walletUrl?: string, | ||
| telemetry: boolean = true | ||
| ): { getProvider: () => ProviderInterface } { |
There was a problem hiding this comment.
nit: prefer named params (also for consistency with createBaseAccountSDK)
There was a problem hiding this comment.
can we get test coverage for this file?
There was a problem hiding this comment.
missing test coverage
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
spencerstock
left a comment
There was a problem hiding this comment.
🔴 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:264 — critical
There is a classic race condition in the asynchronous queuing logic here.
By
awaitingwaitForPendingOperation(queueKey)before setting the new execution promise in the queue, an asynchronous gap is created. IfexecutePaymentWithSDKis called concurrently (e.g., viaPromise.allor a double-click), both calls will synchronously evaluatepaymentQueue.get(...)asundefinedand proceed to yield. When they resume, they will execute concurrently and overwrite each other in thepaymentQueuemap, 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 enterexecutePaymentWithSDKwith the same queueKey, both will seepaymentQueue.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 theif (paymentQueue.get(queueKey) === execution)guard.
✅ packages/account-sdk/src/sign/base-account/Signer.ts:85 — nit
Typo in variable name
isGloabalStore, should beisGlobalStore.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:290 — suggestion
The
storecomposite object re-spreadssdkstoreand then explicitly setspersist: (sdkstore as any).persist. Thisas anycast is fragile — ifcreateStoreInstancereturns a non-persisted store (which is now possible withpersist: false), the.persistproperty won't exist on that type. While this specific line only applies to the global singleton (which always usespersist: true), the cast masks the type gap and could mislead future callers ofcreateStoreInstancewho try to access.persiston an ephemeral store. Consider typingStoreInstancemore 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:288 — critical
Even ignoring the race condition (addressed separately), the
finallyblock at line 295 unconditionally callspaymentQueue.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
| 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); |
There was a problem hiding this comment.
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);
}
}| // 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); | ||
| } |
There was a problem hiding this comment.
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.
| this.communicator = params.communicator; | ||
| this.callback = params.callback; |
There was a problem hiding this comment.
nit
Typo: isGloabalStore should be isGlobalStore.
| export const config = globalStoreHelpers.config; | ||
|
|
||
| export const store = { | ||
| ...sdkstore, |
There was a problem hiding this comment.
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
spencerstock
left a comment
There was a problem hiding this comment.
🫡 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
| const subAccountsConfig = this.storeHelpers.subAccountsConfig.get(); | ||
| if (subAccountsConfig?.funding === 'spend-permissions') { | ||
| const storedSpendPermissions = spendPermissions.get(); | ||
| if (storedSpendPermissions.length === 0) { |
There was a problem hiding this comment.
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
spencerstock
left a comment
There was a problem hiding this comment.
⛴️ 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
What changed? Why?
Adds
EphemeralBaseAccountProviderandEphemeralSignerfor single-use payment flows (pay()andsubscribe()).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 storesdkManager.tsto prevent concurrent payment operations from interferingcreateBaseAccountSDK.tsto avoid redundant setup callsSigner.cleanup()to preservestore.chainssince chain clients are shared infrastructureHow was this tested?
Existing test coverage for
createBaseAccountSDK.test.tsupdated to reset global initialization state between tests.How can reviewers manually test these changes?
pay()orsubscribe()multiple times in sequenceDemo/screenshots