Skip to content
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
11 changes: 11 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/images/metamask-rewards-points.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
193 changes: 190 additions & 3 deletions app/scripts/controllers/rewards/rewards-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
RewardsControllerMessenger,
} from '../../controller-init/messengers';
import { getRootMessenger } from '../../lib/messenger';
import { SeasonDtoState } from '../../../../shared/types/rewards';
import {
RewardsController,
getRewardsControllerDefaultState,
Expand All @@ -42,7 +43,6 @@ import type {
SubscriptionDto,
EstimatePointsDto,
EstimatedPointsDto,
SeasonDtoState,
DiscoverSeasonsDto,
SeasonMetadataDto,
SeasonStateDto,
Expand Down Expand Up @@ -1075,6 +1075,34 @@ describe('RewardsController', () => {
);
});
});

it('should throw AuthorizationFailedError when subscription token is missing', async () => {
const state: Partial<RewardsControllerState> = {
rewardsSeasons: {
[MOCK_SEASON_ID]: {
id: MOCK_SEASON_ID,
name: 'Season 1',
startDate: new Date('2024-01-01').getTime(),
endDate: new Date('2024-12-31').getTime(),
tiers: MOCK_SEASON_TIERS,
},
},
};

await withController(
{ state, isDisabled: false },
async ({ controller }) => {
await expect(
controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID),
).rejects.toThrow(
`No subscription token found for subscription ID: ${MOCK_SUBSCRIPTION_ID}`,
);
await expect(
controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID),
).rejects.toBeInstanceOf(AuthorizationFailedError);
},
);
});
});

describe('optIn', () => {
Expand Down Expand Up @@ -1676,6 +1704,102 @@ describe('RewardsController', () => {
expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
});
});

it('should force fresh check for not-opted-in accounts checked more than 5 minutes ago', async () => {
const state: Partial<RewardsControllerState> = {
rewardsAccounts: {
[MOCK_CAIP_ACCOUNT]: {
account: MOCK_CAIP_ACCOUNT,
hasOptedIn: false,
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold)
},
},
};

await withController({ state, isDisabled: false }, ({ controller }) => {
const addressToAccountMap = new Map<string, InternalAccount>();
addressToAccountMap.set(
MOCK_ACCOUNT_ADDRESS.toLowerCase(),
MOCK_INTERNAL_ACCOUNT,
);

const result = controller.checkOptInStatusAgainstCache(
[MOCK_ACCOUNT_ADDRESS],
addressToAccountMap,
);

expect(result.cachedOptInResults).toEqual([null]);
expect(result.cachedSubscriptionIds).toEqual([null]);
expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
});
});

it('should use cached data for not-opted-in accounts checked within 5 minutes', async () => {
const state: Partial<RewardsControllerState> = {
rewardsAccounts: {
[MOCK_CAIP_ACCOUNT]: {
account: MOCK_CAIP_ACCOUNT,
hasOptedIn: false,
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold)
},
},
};

await withController({ state, isDisabled: false }, ({ controller }) => {
const addressToAccountMap = new Map<string, InternalAccount>();
addressToAccountMap.set(
MOCK_ACCOUNT_ADDRESS.toLowerCase(),
MOCK_INTERNAL_ACCOUNT,
);

const result = controller.checkOptInStatusAgainstCache(
[MOCK_ACCOUNT_ADDRESS],
addressToAccountMap,
);

expect(result.cachedOptInResults).toEqual([false]);
expect(result.cachedSubscriptionIds).toEqual([null]);
expect(result.addressesNeedingFresh).toEqual([]);
});
});

it('should force fresh check for not-opted-in accounts without lastFreshOptInStatusCheck', async () => {
const state: Partial<RewardsControllerState> = {
rewardsAccounts: {
[MOCK_CAIP_ACCOUNT]: {
account: MOCK_CAIP_ACCOUNT,
hasOptedIn: false,
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
lastFreshOptInStatusCheck: undefined,
},
},
};

await withController({ state, isDisabled: false }, ({ controller }) => {
const addressToAccountMap = new Map<string, InternalAccount>();
addressToAccountMap.set(
MOCK_ACCOUNT_ADDRESS.toLowerCase(),
MOCK_INTERNAL_ACCOUNT,
);

const result = controller.checkOptInStatusAgainstCache(
[MOCK_ACCOUNT_ADDRESS],
addressToAccountMap,
);

expect(result.cachedOptInResults).toEqual([null]);
expect(result.cachedSubscriptionIds).toEqual([null]);
expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
});
});
});

describe('shouldSkipSilentAuth', () => {
Expand Down Expand Up @@ -1724,6 +1848,30 @@ describe('RewardsController', () => {
});
});

it('should skip for not-opted-in accounts checked within 5 minutes', async () => {
const state: Partial<RewardsControllerState> = {
rewardsAccounts: {
[MOCK_CAIP_ACCOUNT]: {
account: MOCK_CAIP_ACCOUNT,
hasOptedIn: false,
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold)
},
},
};

await withController({ state, isDisabled: false }, ({ controller }) => {
const result = controller.shouldSkipSilentAuth(
MOCK_CAIP_ACCOUNT,
MOCK_INTERNAL_ACCOUNT,
);

expect(result).toBe(true);
});
});

it('should not skip for stale not-opted-in accounts', async () => {
const state: Partial<RewardsControllerState> = {
rewardsAccounts: {
Expand All @@ -1733,7 +1881,7 @@ describe('RewardsController', () => {
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 60 * 24 * 2, // 2 days ago
lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold)
},
},
};
Expand Down Expand Up @@ -2532,11 +2680,14 @@ describe('Additional RewardsController edge cases', () => {
});

describe('getCandidateSubscriptionId - error scenarios', () => {
it('should return subscription ID from cache if session token exists', async () => {
it('should return subscription ID from cache if session token and subscription exist', async () => {
const state: Partial<RewardsControllerState> = {
rewardsSubscriptionTokens: {
[MOCK_SUBSCRIPTION_ID]: MOCK_SESSION_TOKEN,
},
rewardsSubscriptions: {
[MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION,
},
};

await withController(
Expand Down Expand Up @@ -2579,6 +2730,42 @@ describe('Additional RewardsController edge cases', () => {
},
);
});

it('should continue to silent auth when subscription exists but session token is missing', async () => {
const state: Partial<RewardsControllerState> = {
rewardsSubscriptions: {
[MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION,
},
};

await withController(
{ state, isDisabled: false },
async ({ controller, mockMessengerCall }) => {
mockMessengerCall.mockImplementation((actionType) => {
if (actionType === 'AccountsController:listMultichainAccounts') {
return [MOCK_INTERNAL_ACCOUNT];
}
if (actionType === 'RewardsDataService:getOptInStatus') {
return Promise.resolve({
ois: [true],
sids: [MOCK_SUBSCRIPTION_ID],
});
}
if (actionType === 'KeyringController:signPersonalMessage') {
return Promise.resolve('0xmocksignature');
}
if (actionType === 'RewardsDataService:login') {
return Promise.resolve(MOCK_LOGIN_RESPONSE);
}
return undefined;
});

const result = await controller.getCandidateSubscriptionId();

expect(result).toBe(MOCK_SUBSCRIPTION_ID);
},
);
});
});

describe('linkAccountsToSubscriptionCandidate - error handling', () => {
Expand Down
27 changes: 14 additions & 13 deletions app/scripts/controllers/rewards/rewards-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ import { base58, isAddress as isEvmAddress } from 'ethers/lib/utils';
import { HandleSnapRequest } from '@metamask/snaps-controllers';
import { RewardsControllerMessenger } from '../../controller-init/messengers/rewards-controller-messenger';
import { isHardwareAccount } from '../../../../shared/lib/accounts';
import {
SeasonDtoState,
SeasonStatusState,
SeasonTierState,
} from '../../../../shared/types/rewards';
import {
type RewardsControllerState,
type RewardsAccountState,
type LoginResponseDto,
type EstimatePointsDto,
type EstimatedPointsDto,
type SeasonDtoState,
type SeasonStatusState,
type SeasonTierState,
type SeasonTierDto,
type SeasonStatusDto,
type SubscriptionDto,
Expand Down Expand Up @@ -49,8 +51,8 @@ const SEASON_STATUS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute
// Season metadata cache threshold
const SEASON_METADATA_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes

// Opt-in status stale threshold for not opted-in accounts to force a fresh check
const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60 * 24; // 24 hours
// Opt-in status stale threshold for not opted-in accounts to force a fresh check (less strict than in mobile for now)
const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes

/**
* State metadata for the RewardsController
Expand Down Expand Up @@ -845,7 +847,6 @@ export class RewardsController extends BaseController<
// Update state with successful authentication
subscription = loginResponse.subscription;

// TODO: Re-enable multi-subscription token vault when implemented
// Store the session token for this subscription
this.#storeSubscriptionToken(subscription.id, loginResponse.sessionId);

Expand Down Expand Up @@ -1263,11 +1264,6 @@ export class RewardsController extends BaseController<
if (!cached) {
return undefined;
}
log.info(
'RewardsController: Using cached season status data for',
subscriptionId,
seasonId,
);
return { payload: cached, lastFetched: cached.lastFetched };
},
fetchFresh: async () => {
Expand All @@ -1279,7 +1275,7 @@ export class RewardsController extends BaseController<
);
const subscriptionToken = this.#getSubscriptionToken(subscriptionId);
if (!subscriptionToken) {
throw new Error(
throw new AuthorizationFailedError(
`No subscription token found for subscription ID: ${subscriptionId}`,
);
}
Expand Down Expand Up @@ -1406,6 +1402,7 @@ export class RewardsController extends BaseController<
}
state.rewardsAccounts = {};
state.rewardsSubscriptions = {};
state.rewardsSubscriptionTokens = {};
});
log.info('RewardsController: Invalidated accounts and subscriptions');
}
Expand Down Expand Up @@ -1804,7 +1801,11 @@ export class RewardsController extends BaseController<
const sessionToken = subscriptionId
? this.state.rewardsSubscriptionTokens[subscriptionId]
: undefined;
if (subscriptionId && Boolean(sessionToken)) {
if (
subscriptionId &&
Boolean(sessionToken) &&
this.state.rewardsSubscriptions[subscriptionId]
) {
return subscriptionId;
}
try {
Expand Down
Loading
Loading