Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: cache incoming transaction timestamps #5582

Merged
merged 2 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fix incoming transaction support with `queryEntireHistory` set to `false` ([#5582](https://github.com/MetaMask/core/pull/5582))

## [54.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ jest.mock('../api/accounts-api');
jest.useFakeTimers();

const ADDRESS_MOCK = '0x123';
const NOW_MOCK = 789000;
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
const NOW_MOCK = 789000 + ONE_DAY_MS;
const CURSOR_MOCK = 'abcdef';
const CACHED_TIMESTAMP_MOCK = 456;
const INITIAL_TIMESTAMP_MOCK = 789;

const REQUEST_MOCK: RemoteTransactionSourceRequest = {
address: ADDRESS_MOCK,
Expand Down Expand Up @@ -141,7 +144,7 @@ describe('AccountsApiRemoteTransactionSource', () => {
expect(getAccountTransactionsMock).toHaveBeenCalledTimes(1);
expect(getAccountTransactionsMock).toHaveBeenCalledWith(
expect.objectContaining({
startTimestamp: 789,
startTimestamp: INITIAL_TIMESTAMP_MOCK,
}),
);
});
Expand All @@ -163,6 +166,24 @@ describe('AccountsApiRemoteTransactionSource', () => {
);
});

it('queries accounts API with timestamp from cache', async () => {
await new AccountsApiRemoteTransactionSource().fetchTransactions({
...REQUEST_MOCK,
queryEntireHistory: false,
cache: {
[`accounts-api#timestamp#${SUPPORTED_CHAIN_IDS.join(',')}#${ADDRESS_MOCK}`]:
CACHED_TIMESTAMP_MOCK,
},
});

expect(getAccountTransactionsMock).toHaveBeenCalledTimes(1);
expect(getAccountTransactionsMock).toHaveBeenCalledWith(
expect.objectContaining({
startTimestamp: CACHED_TIMESTAMP_MOCK,
}),
);
});

it('returns normalized standard transaction', async () => {
getAccountTransactionsMock.mockResolvedValue({
data: [RESPONSE_STANDARD_MOCK],
Expand Down Expand Up @@ -250,6 +271,58 @@ describe('AccountsApiRemoteTransactionSource', () => {
});
});

it('removes timestamp cache entry if response has cursor', async () => {
getAccountTransactionsMock.mockResolvedValueOnce({
data: [RESPONSE_STANDARD_MOCK],
pageInfo: { hasNextPage: false, count: 1, cursor: CURSOR_MOCK },
});

const cacheMock = {
[`accounts-api#timestamp#${SUPPORTED_CHAIN_IDS.join(',')}#${ADDRESS_MOCK}`]:
CACHED_TIMESTAMP_MOCK,
};

const updateCacheMock = jest
.fn()
.mockImplementation((fn) => fn(cacheMock));

await new AccountsApiRemoteTransactionSource().fetchTransactions({
...REQUEST_MOCK,
updateCache: updateCacheMock,
});

expect(updateCacheMock).toHaveBeenCalledTimes(1);
expect(cacheMock).toStrictEqual({
[`accounts-api#${SUPPORTED_CHAIN_IDS.join(',')}#${ADDRESS_MOCK}`]:
CURSOR_MOCK,
});
});

it('updates cache with timestamp if response does not have cursor', async () => {
getAccountTransactionsMock.mockResolvedValueOnce({
data: [],
pageInfo: { hasNextPage: false, count: 0, cursor: undefined },
});

const cacheMock = {};

const updateCacheMock = jest
.fn()
.mockImplementation((fn) => fn(cacheMock));

await new AccountsApiRemoteTransactionSource().fetchTransactions({
...REQUEST_MOCK,
queryEntireHistory: false,
updateCache: updateCacheMock,
});

expect(updateCacheMock).toHaveBeenCalledTimes(1);
expect(cacheMock).toStrictEqual({
[`accounts-api#timestamp#${SUPPORTED_CHAIN_IDS.join(',')}#${ADDRESS_MOCK}`]:
INITIAL_TIMESTAMP_MOCK,
});
});

it('ignores outgoing transactions if updateTransactions is false', async () => {
getAccountTransactionsMock.mockResolvedValue({
data: [{ ...RESPONSE_STANDARD_MOCK, to: '0x456' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
} from '../types';
import { TransactionStatus, TransactionType } from '../types';

const RECENT_HISTORY_DURATION_MS = 1000 * 60 * 60 * 24; // 1 Day

export const SUPPORTED_CHAIN_IDS: Hex[] = [
CHAIN_IDS.MAINNET,
CHAIN_IDS.POLYGON,
Expand Down Expand Up @@ -84,32 +86,49 @@ export class AccountsApiRemoteTransactionSource

const cursor = this.#getCacheCursor(cache, SUPPORTED_CHAIN_IDS, address);

const timestamp = this.#getCacheTimestamp(
cache,
SUPPORTED_CHAIN_IDS,
address,
);

if (cursor) {
log('Using cached cursor', cursor);
} else if (timestamp) {
log('Using cached timestamp', timestamp);
} else {
log('No cached cursor or timestamp found');
}

return await this.#queryTransactions(request, SUPPORTED_CHAIN_IDS, cursor);
return await this.#queryTransactions(
request,
SUPPORTED_CHAIN_IDS,
cursor,
timestamp,
);
}

async #queryTransactions(
request: RemoteTransactionSourceRequest,
chainIds: Hex[],
cursor?: string,
timestamp?: number,
): Promise<TransactionResponse[]> {
const { address, queryEntireHistory, updateCache } = request;
const { address, queryEntireHistory } = request;
const transactions: TransactionResponse[] = [];

let hasNextPage = true;
let currentCursor = cursor;
let pageCount = 0;

const startTimestamp =
queryEntireHistory || cursor
? undefined
: this.#getTimestampSeconds(Date.now());

while (hasNextPage) {
try {
const startTimestamp = this.#getStartTimestamp({
cursor: currentCursor,
queryEntireHistory,
timestamp,
});

const response = await getAccountTransactions({
address,
chainIds,
Expand All @@ -127,15 +146,12 @@ export class AccountsApiRemoteTransactionSource
hasNextPage = response?.pageInfo?.hasNextPage;
currentCursor = response?.pageInfo?.cursor;

if (currentCursor) {
// eslint-disable-next-line no-loop-func
updateCache((cache) => {
const key = this.#getCacheKey(chainIds, address);
cache[key] = currentCursor;

log('Updated cache', { key, newCursor: currentCursor });
});
}
this.#updateCache({
chainIds,
cursor: currentCursor,
request,
startTimestamp,
});
} catch (error) {
log('Error while fetching transactions', error);
break;
Expand Down Expand Up @@ -248,7 +264,64 @@ export class AccountsApiRemoteTransactionSource
};
}

#getCacheKey(chainIds: Hex[], address: Hex): string {
#updateCache({
chainIds,
cursor,
request,
startTimestamp,
}: {
chainIds: Hex[];
cursor?: string;
request: RemoteTransactionSourceRequest;
startTimestamp?: number;
}) {
if (!cursor && !startTimestamp) {
log('Cache not updated');
return;
}

const { address, updateCache } = request;
const cursorCacheKey = this.#getCursorCacheKey(chainIds, address);
const timestampCacheKey = this.#getTimestampCacheKey(chainIds, address);

updateCache((cache) => {
if (cursor) {
cache[cursorCacheKey] = cursor;
delete cache[timestampCacheKey];

log('Updated cursor in cache', { cursorCacheKey, newCursor: cursor });
} else {
cache[timestampCacheKey] = startTimestamp;

log('Updated timestamp in cache', {
timestampCacheKey,
newTimestamp: startTimestamp,
});
}
});
}

#getStartTimestamp({
cursor,
queryEntireHistory,
timestamp,
}: {
cursor?: string;
queryEntireHistory: boolean;
timestamp?: number;
}): number | undefined {
if (queryEntireHistory || cursor) {
return undefined;
}

if (timestamp) {
return timestamp;
}

return this.#getTimestampSeconds(Date.now() - RECENT_HISTORY_DURATION_MS);
}

#getCursorCacheKey(chainIds: Hex[], address: Hex): string {
return `accounts-api#${chainIds.join(',')}#${address}`;
}

Expand All @@ -257,10 +330,23 @@ export class AccountsApiRemoteTransactionSource
chainIds: Hex[],
address: Hex,
): string | undefined {
const key = this.#getCacheKey(chainIds, address);
const key = this.#getCursorCacheKey(chainIds, address);
return cache[key] as string | undefined;
}

#getTimestampCacheKey(chainIds: Hex[], address: Hex): string {
return `accounts-api#timestamp#${chainIds.join(',')}#${address}`;
}

#getCacheTimestamp(
cache: Record<string, unknown>,
chainIds: Hex[],
address: Hex,
): number | undefined {
const key = this.#getTimestampCacheKey(chainIds, address);
return cache[key] as number | undefined;
}

#getTimestampSeconds(timestampMs: number): number {
return Math.floor(timestampMs / 1000);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,7 +922,7 @@ export interface RemoteTransactionSourceRequest {
address: Hex;

/**
* Numerical cache to optimize fetching transactions.
* Cache to optimize fetching transactions.
*/
cache: Record<string, unknown>;

Expand Down
Loading