diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 968def20..81c4dd8c 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -512,6 +512,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { where: { streamId: parsedStreamId }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, @@ -597,6 +598,7 @@ export const getUserStreamSummary = async ( where: { sender: address }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, @@ -613,6 +615,7 @@ export const getUserStreamSummary = async ( where: { recipient: address }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, diff --git a/backend/src/routes/v1/streams/withdraw.ts b/backend/src/routes/v1/streams/withdraw.ts index 9abc268c..85667531 100644 --- a/backend/src/routes/v1/streams/withdraw.ts +++ b/backend/src/routes/v1/streams/withdraw.ts @@ -68,6 +68,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) streamId: true, sender: true, recipient: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, diff --git a/backend/src/services/claimable.service.ts b/backend/src/services/claimable.service.ts index 78b75839..569b5e5b 100644 --- a/backend/src/services/claimable.service.ts +++ b/backend/src/services/claimable.service.ts @@ -5,6 +5,7 @@ const I128_MIN = -(1n << 127n); export interface ClaimableStreamState { streamId: number; + tokenAddress: string; ratePerSecond: string; depositedAmount: string; withdrawnAmount: string; @@ -102,7 +103,7 @@ export class ClaimableAmountService { ? Math.floor(requestedAt) : Math.floor(this.nowMs() / 1000); - const cacheKey = `claimable:${stream.streamId}:${getStateFingerprint(stream)}:${calculatedAt}`; + const cacheKey = `claimable:${stream.tokenAddress}:${stream.streamId}:${getStateFingerprint(stream)}:${calculatedAt}`; const cachedEntry = cache.get>(cacheKey); if (cachedEntry) { diff --git a/backend/tests/claimable.service.test.ts b/backend/tests/claimable.service.test.ts index c4628b5a..a296015f 100644 --- a/backend/tests/claimable.service.test.ts +++ b/backend/tests/claimable.service.test.ts @@ -4,6 +4,7 @@ import { ClaimableAmountService } from '../src/services/claimable.service.js'; function makeStreamState(overrides: Partial[0]> = {}) { return { streamId: 1, + tokenAddress: 'TEST_TOKEN_ADDRESS', ratePerSecond: '10', depositedAmount: '100', withdrawnAmount: '0', @@ -196,4 +197,48 @@ describe('ClaimableAmountService', () => { expect(cancelRefund + withdrawn + claimable <= deposited, `iteration ${iteration}: cancel settlement exceeded deposit`).toBe(true); } }); + + it('maintains independent cache entries for same streamId across different tokens', () => { + vi.setSystemTime(5_000); + const service = new ClaimableAmountService({ + cacheTtlMs: 10_000, + }); + + // Two streams with the same numeric ID but different token contracts + const streamTokenA = makeStreamState({ + streamId: 42, + tokenAddress: 'TOKEN_A', + ratePerSecond: '10', + depositedAmount: '1000', + withdrawnAmount: '0', + }); + + const streamTokenB = makeStreamState({ + streamId: 42, + tokenAddress: 'TOKEN_B', + ratePerSecond: '5', + depositedAmount: '2000', + withdrawnAmount: '500', + }); + + const resultA = service.getClaimableAmount(streamTokenA, 100); + const resultB = service.getClaimableAmount(streamTokenB, 100); + + // Both should be cache misses (different token = different cache key) + expect(resultA.cached).toBe(false); + expect(resultB.cached).toBe(false); + + // The values should differ because the streams have different parameters + expect(resultA.claimableAmount).not.toBe(resultB.claimableAmount); + + // Second call for Token A should be a cache hit + const resultA2 = service.getClaimableAmount(streamTokenA, 100); + expect(resultA2.cached).toBe(true); + expect(resultA2.claimableAmount).toBe(resultA.claimableAmount); + + // Second call for Token B should also be a cache hit (independent of A) + const resultB2 = service.getClaimableAmount(streamTokenB, 100); + expect(resultB2.cached).toBe(true); + expect(resultB2.claimableAmount).toBe(resultB.claimableAmount); + }); });