Description
NonceManager.reserve() uses a check-then-act lock that isn't atomic, so two concurrent reservations for the same account can both pass the guard and come back with the identical sequence number — the exact collision this class exists to prevent.
Location
engine-bridge/src/nonce-manager.ts:24-39 (reserve), engine-bridge/src/nonce-manager.ts:65-68 (waitForLock)
Current Behavior
async reserve(accountId: string): Promise<bigint> {
await this.waitForLock(accountId); // (1) check
let resolve!: () => void;
const lock = new Promise<void>(r => { resolve = r; });
this.locks.set(accountId, lock); // (2) act
try {
const seq = await this.nextSequence(accountId);
this.cache.set(accountId, seq + 1n);
return seq;
} finally {
this.locks.delete(accountId);
resolve();
}
}
private async waitForLock(accountId: string): Promise<void> {
const existing = this.locks.get(accountId);
if (existing) await existing;
}
The lock is only written in step (2), inside reserve itself, after waitForLock has already returned. If two calls to reserve(accountId) are made back-to-back (e.g. Promise.all([mgr.reserve(acc), mgr.reserve(acc)])), both invocations read this.locks.get(accountId) as undefined before either one reaches the locks.set(...) line, because await this.waitForLock(...) yields a microtask even when there's nothing to wait for. Both calls then proceed to set their own lock entries (the second overwrites the first in the Map) and both call nextSequence concurrently.
Expected Behavior
Only one in-flight reserve() call per accountId should ever be inside the critical section at a time; a second concurrent call must wait for the first to fully release its lock before reading/incrementing the cached sequence.
Repro / Evidence
const mgr = new NonceManager(rpc);
const [a, b] = await Promise.all([
mgr.reserve("GABC..."),
mgr.reserve("GABC..."),
]);
// a === b in the racing case — two transactions built with the same
// sequence number, one of which will be rejected by the network (or worse,
// silently reordered if submitted through different RPC nodes).
This traces directly from the code: the lock map is only mutated after the async gap that both callers pass through together, so the "lock" never actually excludes the second caller in the race window.
Impact
This is the module whose entire purpose is "serialise reservation... to prevent races" (per its own doc comment). A race here produces duplicate Stellar sequence numbers under concurrent load — transaction failures at best, misordered fund-moving transactions at worst. nonce-manager.ts also currently has no test file (engine-bridge/src/__tests__/ has no nonce-manager.test.ts), so this has no regression coverage.
Suggested Fix
Register the lock synchronously before any await, e.g. by building a single promise chain per account (this.locks.set(accountId, (this.locks.get(accountId) ?? Promise.resolve()).then(...))) so the "check" and "act" happen in the same synchronous tick, instead of splitting them across waitForLock + a later locks.set.
Acceptance Criteria
Definition of Done
Labels: bug
Description
NonceManager.reserve()uses a check-then-act lock that isn't atomic, so two concurrent reservations for the same account can both pass the guard and come back with the identical sequence number — the exact collision this class exists to prevent.Location
engine-bridge/src/nonce-manager.ts:24-39(reserve),engine-bridge/src/nonce-manager.ts:65-68(waitForLock)Current Behavior
The lock is only written in step (2), inside
reserveitself, afterwaitForLockhas already returned. If two calls toreserve(accountId)are made back-to-back (e.g.Promise.all([mgr.reserve(acc), mgr.reserve(acc)])), both invocations readthis.locks.get(accountId)asundefinedbefore either one reaches thelocks.set(...)line, becauseawait this.waitForLock(...)yields a microtask even when there's nothing to wait for. Both calls then proceed to set their own lock entries (the second overwrites the first in theMap) and both callnextSequenceconcurrently.Expected Behavior
Only one in-flight
reserve()call peraccountIdshould ever be inside the critical section at a time; a second concurrent call must wait for the first to fully release its lock before reading/incrementing the cached sequence.Repro / Evidence
This traces directly from the code: the lock map is only mutated after the async gap that both callers pass through together, so the "lock" never actually excludes the second caller in the race window.
Impact
This is the module whose entire purpose is "serialise reservation... to prevent races" (per its own doc comment). A race here produces duplicate Stellar sequence numbers under concurrent load — transaction failures at best, misordered fund-moving transactions at worst.
nonce-manager.tsalso currently has no test file (engine-bridge/src/__tests__/has nononce-manager.test.ts), so this has no regression coverage.Suggested Fix
Register the lock synchronously before any
await, e.g. by building a single promise chain per account (this.locks.set(accountId, (this.locks.get(accountId) ?? Promise.resolve()).then(...))) so the "check" and "act" happen in the same synchronous tick, instead of splitting them acrosswaitForLock+ a laterlocks.set.Acceptance Criteria
reserve()calls for the sameaccountIdnever return the same sequence number.engine-bridge/src/__tests__/nonce-manager.test.tsreproduces the race (e.g. fires N concurrentreserve()calls and asserts all N returned sequences are unique) and fails against the current implementation.release/refreshbehavior is unchanged for the single-caller case.Definition of Done
eslintwarnings introduced.Labels:
bug