Skip to content
Draft
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
226 changes: 226 additions & 0 deletions packages/assets-controller/src/data-sources/SnapDataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
KEYRING_PERMISSION,
ASSETS_PERMISSION,
getSnapBalanceRetryDelay,
SNAP_BALANCE_REVALIDATE_AFTER_MS,
SNAP_EMPTY_BALANCES_ERROR,
} from './SnapDataSource.js';

// Test chain IDs
Expand Down Expand Up @@ -210,6 +212,7 @@
getAssetsState?: () => AssetsControllerState;
onBalanceFetchStatusChange?: SnapDataSourceOptions['onBalanceFetchStatusChange'];
retryBackoff?: SnapDataSourceOptions['retryBackoff'];
revalidation?: SnapDataSourceOptions['revalidation'];
} = {},
): SetupResult {
const {
Expand Down Expand Up @@ -300,6 +303,7 @@
getAssetsState,
onBalanceFetchStatusChange: options.onBalanceFetchStatusChange,
retryBackoff: options.retryBackoff,
revalidation: options.revalidation,
};

const controller = new SnapDataSource(controllerOptions);
Expand Down Expand Up @@ -547,10 +551,10 @@
snapId: SOLANA_SNAP_ID,
origin: 'metamask',
handler: 'onKeyringRequest',
request: expect.objectContaining({
method: 'keyring_listAccountAssets',
params: { id: 'mock-account-id' },
}),

Check failure on line 557 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:554:18: Unsafe assignment of an any value.
}),
);
// Verify keyring_getAccountBalances was called
Expand All @@ -559,10 +563,10 @@
snapId: SOLANA_SNAP_ID,
origin: 'metamask',
handler: 'onKeyringRequest',
request: expect.objectContaining({
method: 'keyring_getAccountBalances',
params: { id: 'mock-account-id', assets: [MOCK_SOL_ASSET] },
}),

Check failure on line 569 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:566:18: Unsafe assignment of an any value.
}),
);
expect(
Expand Down Expand Up @@ -598,13 +602,13 @@
);
expect(mockHandleRequest).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
method: 'keyring_getAccountBalances',
params: {
id: 'mock-account-id',
assets: [MOCK_SOL_ASSET, MOCK_SOL_PIN],
},
}),

Check failure on line 611 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:605:18: Unsafe assignment of an any value.
}),
);
expect(response).toStrictEqual({
Expand Down Expand Up @@ -824,13 +828,13 @@

expect(mockHandleRequest).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
method: 'keyring_getAccountBalances',
params: {
id: 'mock-account-id',
assets: [MOCK_SOL_ASSET],
},
}),

Check failure on line 837 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:831:18: Unsafe assignment of an any value.
}),
);
expect(response.assetsBalance?.['mock-account-id']).toStrictEqual({
Expand Down Expand Up @@ -905,7 +909,7 @@

// Check that keyring_getAccountBalances was NOT called (since no assets)
const getBalancesCalls = mockHandleRequest.mock.calls.filter((call) => {
const params = call[0] as { request?: { method: string } };

Check failure on line 912 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-member-access)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:912:27: Unsafe member access [0] on an `any` value.
return params.request?.method === 'keyring_getAccountBalances';
});
expect(getBalancesCalls).toHaveLength(0);
Expand Down Expand Up @@ -1281,14 +1285,14 @@
});
await new Promise(process.nextTick);

const next = jest.fn().mockImplementation(async (context) => context);

Check failure on line 1288 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-return)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:1288:66: Unsafe return of a value of type `any`.
const context = createMiddlewareContext();

await controller.assetsMiddleware(context, next);

expect(next).toHaveBeenCalledWith(
expect.objectContaining({
response: expect.objectContaining({ updateMode: 'full' }),

Check failure on line 1295 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:1295:19: Unsafe assignment of an any value.
}),
);

Expand Down Expand Up @@ -1318,9 +1322,9 @@

expect(next).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
chainIds: [CHAIN_MAINNET],
}),

Check failure on line 1327 in packages/assets-controller/src/data-sources/SnapDataSource.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:oxlint) (24.x)

typescript(no-unsafe-assignment)

packages/assets-controller/src/data-sources/SnapDataSource.test.ts:1325:18: Unsafe assignment of an any value.
}),
);

Expand Down Expand Up @@ -1398,6 +1402,7 @@
isBalanceV6Enabled?: () => boolean;
retryBackoff?: SnapDataSourceOptions['retryBackoff'];
onBalanceFetchStatusChange?: jest.Mock;
revalidation?: SnapDataSourceOptions['revalidation'];
} = {},
): SetupResult & {
failing: { value: boolean };
Expand All @@ -1417,6 +1422,7 @@
onBalanceFetchStatusChange: onStatus,
// Long default so automatic retries never fire unless a test wants them.
retryBackoff: options.retryBackoff ?? { initialDelayMs: 60_000 },
revalidation: options.revalidation,
});
setup.mockHandleRequest.mockImplementation(failingHandler(failing));
return { ...setup, failing, onStatus };
Expand Down Expand Up @@ -1724,6 +1730,226 @@

cleanup();
});

describe('revalidation of subscribed accounts (v3)', () => {
const balanceCalls = (mock: jest.Mock): number =>
mock.mock.calls.filter(
([params]) => params.request?.method === 'keyring_getAccountBalances',
).length;

const subscribe = async (controller: SnapDataSource): Promise<void> => {
await controller.subscribe({
request: createDataRequest(),
subscriptionId: 'reval-sub',
isUpdate: false,
onAssetsUpdate: jest.fn(),
});
};

it('flags a quiet, failing snap account unavailable after the revalidation window (TRX case)', async () => {
const { controller, failing, onStatus, mockHandleRequest, cleanup } =
setupFailing();
failing.value = false;
await flush();
await subscribe(controller);
expect(controller.getAccountBalanceErrors()).toStrictEqual({});

// The snap's own syncs start failing: it stops pushing balances.
failing.value = true;
const before = balanceCalls(mockHandleRequest);

// Still within the window: nothing is asked.
await controller.revalidateStaleAccounts(
Date.now() + SNAP_BALANCE_REVALIDATE_AFTER_MS - 10_000,
);
expect(balanceCalls(mockHandleRequest)).toBe(before);

// Past the window: the snap is asked again, and the failure flags the
// cached balance unavailable.
await controller.revalidateStaleAccounts(
Date.now() + SNAP_BALANCE_REVALIDATE_AFTER_MS + 1,
);
expect(balanceCalls(mockHandleRequest)).toBe(before + 1);
expect(controller.getAccountBalanceErrors()).toStrictEqual({
'mock-account-id': { chainIds: [SOLANA_MAINNET], attempts: 0 },
});
expect(lastError(onStatus).error).toBe(
'the account has not been synchronized yet',
);

cleanup();
});

it('flags a synced account unavailable when the snap reports its balance as stale, Retry asks the snap, and recovery clears it (v6)', async () => {
const STALE_MESSAGE =
'Account balance unavailable: synchronization has been failing since the last successful sync';
const snapState = { stale: false };
const { controller, onStatus, mockHandleRequest, cleanup } =
setupFailing();
mockHandleRequest.mockImplementation(
(params: { request?: { method: string } }): Promise<unknown> => {
if (params.request?.method === 'keyring_listAccountAssets') {
return Promise.resolve([MOCK_SOL_ASSET]);
}
if (params.request?.method === 'keyring_getAccountBalances') {
// As re-created by the keyring SDK: a plain Error with the message.
return snapState.stale
? Promise.reject(new Error(STALE_MESSAGE))
: Promise.resolve({ [MOCK_SOL_ASSET]: SOL_BALANCE });
}
return Promise.resolve(null);
},
);
await flush();
await subscribe(controller);
expect(controller.getAccountBalanceErrors()).toStrictEqual({});

// The connection breaks after the account was synchronized: ~90 s
// later the snap answers with the stale-balance error.
snapState.stale = true;
await controller.revalidateStaleAccounts(
Date.now() + SNAP_BALANCE_REVALIDATE_AFTER_MS + 1,
);
expect(controller.getAccountBalanceErrors()).toStrictEqual({
'mock-account-id': { chainIds: [SOLANA_MAINNET], attempts: 0 },
});
expect(lastError(onStatus).error).toBe(STALE_MESSAGE);

// Retry while still failing: the snap is asked again (it schedules a
// sync) and the account stays unavailable.
const before = balanceCalls(mockHandleRequest);
await controller.retryFailedAccounts(['mock-account-id']);
expect(balanceCalls(mockHandleRequest)).toBe(before + 1);
expect(controller.getAccountBalanceErrors()).toHaveProperty(
'mock-account-id',
);

// The snap syncs again: the next Retry clears the error.
snapState.stale = false;
await controller.retryFailedAccounts(['mock-account-id']);
expect(controller.getAccountBalanceErrors()).toStrictEqual({});
expect(onStatus).toHaveBeenLastCalledWith(
expect.objectContaining({
accountId: 'mock-account-id',
error: null,
}),
);

cleanup();
});

it('does not ask again when the snap pushed balances recently', async () => {
const {
controller,
failing,
mockHandleRequest,
triggerBalancesUpdated,
cleanup,
} = setupFailing();
failing.value = false;
await flush();
await subscribe(controller);
const start = Date.now();

await flush(5);
triggerBalancesUpdated({
balances: { 'mock-account-id': { [MOCK_SOL_ASSET]: SOL_BALANCE } },
});
const before = balanceCalls(mockHandleRequest);

// 90 s after the subscription, but less than 90 s after the push.
await controller.revalidateStaleAccounts(
start + SNAP_BALANCE_REVALIDATE_AFTER_MS + 1,
);

expect(balanceCalls(mockHandleRequest)).toBe(before);
cleanup();
});

it('leaves failing accounts to their scheduled retries', async () => {
const { controller, mockHandleRequest, cleanup } = setupFailing();
await flush();
await subscribe(controller);
expect(controller.getAccountBalanceErrors()).toHaveProperty(
'mock-account-id',
);
const before = balanceCalls(mockHandleRequest);

await controller.revalidateStaleAccounts(Date.now() + 10 * 60_000);

expect(balanceCalls(mockHandleRequest)).toBe(before);
cleanup();
});

it('runs on its own timer, and stops after unsubscribe', async () => {
const { controller, failing, mockHandleRequest, cleanup } =
setupFailing({ revalidation: { afterMs: 20, checkIntervalMs: 10 } });
failing.value = false;
await flush();
await subscribe(controller);
const before = balanceCalls(mockHandleRequest);

await flush(80);
expect(balanceCalls(mockHandleRequest)).toBeGreaterThan(before);

await controller.unsubscribe('reval-sub');
await controller.unsubscribe('test-sub');
await flush(5);
const afterUnsubscribe = balanceCalls(mockHandleRequest);
await flush(60);
expect(balanceCalls(mockHandleRequest)).toBe(afterUnsubscribe);

cleanup();
});

it('can be disabled', async () => {
const { controller, failing, mockHandleRequest, cleanup } =
setupFailing({ revalidation: false });
failing.value = false;
await flush();
await subscribe(controller);
const before = balanceCalls(mockHandleRequest);

await controller.revalidateStaleAccounts(Date.now() + 10 * 60_000);

expect(balanceCalls(mockHandleRequest)).toBe(before);
cleanup();
});

it.each([
['v6', true],
['v5', false],
])(
'an empty balance map for requested assets is a failure, not a confirmed balance (SOL case, %s)',
async (_label, v6) => {
const { controller, mockHandleRequest, onStatus, cleanup } =
setupFailing({ isBalanceV6Enabled: (): boolean => v6 });
mockHandleRequest.mockImplementation(
(params: { request?: { method: string } }) => {
if (params.request?.method === 'keyring_listAccountAssets') {
return Promise.resolve([MOCK_SOL_ASSET]);
}
if (params.request?.method === 'keyring_getAccountBalances') {
return Promise.resolve({});
}
return Promise.resolve(null);
},
);
await flush();

const response = await controller.fetch(createDataRequest());

expect(response.assetsBalance).toStrictEqual({});
expect(controller.getAccountBalanceErrors()).toHaveProperty(
'mock-account-id',
);
expect(lastError(onStatus).error).toContain(
SNAP_EMPTY_BALANCES_ERROR,
);
cleanup();
},
);
});
});

it('createSnapDataSource factory creates instance', async () => {
Expand Down
Loading
Loading