Skip to content

Commit 6d32cb0

Browse files
grypezclaude
andcommitted
test(evm-wallet-experiment): expose spend tracker race condition
Two concurrent transfer calls both pass the budget check before either commits, so spent never exceeds max during the check and both succeed even when their combined amount exceeds the limit. The rollback method exists for exactly this pattern but is never used. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a498c96 commit 6d32cb0

1 file changed

Lines changed: 30 additions & 0 deletions

File tree

packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,36 @@ describe('makeDelegationTwin', () => {
142142
const result = await twin.transfer(BOB, 1000n);
143143
expect(result).toBe(TX_HASH);
144144
});
145+
146+
it('does not allow concurrent calls to exceed the budget', async () => {
147+
// Two calls issued concurrently both pass the budget check before either
148+
// commits — unless the budget is reserved before the await. With a max of
149+
// 5 and two concurrent calls for 3, the second must be rejected even
150+
// though the first hasn't settled yet.
151+
let resolveFirst!: (hash: Hex) => void;
152+
const redeemFn = vi
153+
.fn()
154+
.mockImplementationOnce(
155+
async () =>
156+
new Promise<Hex>((resolve) => {
157+
resolveFirst = resolve;
158+
}),
159+
)
160+
.mockResolvedValue(TX_HASH);
161+
162+
const twin = makeDelegationTwin({
163+
grant: makeTransferGrant(5n),
164+
redeemFn,
165+
}) as Record<string, (...args: unknown[]) => Promise<Hex>>;
166+
167+
const first = twin.transfer(BOB, 3n);
168+
// Second call issued before first resolves — must see only 2 remaining.
169+
await expect(twin.transfer(BOB, 3n)).rejects.toThrow(
170+
'Insufficient budget',
171+
);
172+
resolveFirst(TX_HASH);
173+
expect(await first).toBe(TX_HASH);
174+
});
145175
});
146176

147177
describe('discoverability', () => {

0 commit comments

Comments
 (0)