Skip to content
Open
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/chain-agnostic-permission/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]

### Added

- Add optional `sortAccountIdsByLastSelected` parameter to `getSessionScopes` function to enable custom account ordering within session scopes ([#8255](https://github.com/MetaMask/core/pull/8255))

### Changed

- Bump `@metamask/permission-controller` from `^12.2.0` to `^12.2.1` ([#8225](https://github.com/MetaMask/core/pull/8225))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe('CAIP-25 session scopes adapters', () => {

describe('getSessionScopes', () => {
const getNonEvmSupportedMethods = jest.fn();
const mockSortAccountIdsByLastSelected = jest.fn();

it('returns a NormalizedScopesObject for the wallet scope', () => {
const result = getSessionScopes(
Expand Down Expand Up @@ -203,6 +204,118 @@ describe('CAIP-25 session scopes adapters', () => {
},
});
});

it('sorts accounts using sortAccountIdsByLastSelected when provided', () => {
const unsortedAccounts = ['eip155:1:0xbeef', 'eip155:1:0xdead'];
const sortedAccounts = ['eip155:1:0xdead', 'eip155:1:0xbeef'];

mockSortAccountIdsByLastSelected.mockReturnValue(sortedAccounts);

const result = getSessionScopes(
{
requiredScopes: {
'eip155:1': {
accounts: unsortedAccounts,
},
},
optionalScopes: {},
},
{
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected: mockSortAccountIdsByLastSelected,
},
);

expect(mockSortAccountIdsByLastSelected).toHaveBeenCalledWith(
unsortedAccounts,
);
expect(result).toStrictEqual({
'eip155:1': {
methods: KnownRpcMethods.eip155,
notifications: KnownNotifications.eip155,
accounts: sortedAccounts,
},
});
});

it('does not sort accounts when sortAccountIdsByLastSelected is not provided', () => {
const accounts = ['eip155:1:0xbeef', 'eip155:1:0xdead'];

const result = getSessionScopes(
{
requiredScopes: {
'eip155:1': {
accounts,
},
},
optionalScopes: {},
},
{
getNonEvmSupportedMethods,
},
);

expect(mockSortAccountIdsByLastSelected).not.toHaveBeenCalled();
expect(result).toStrictEqual({
'eip155:1': {
methods: KnownRpcMethods.eip155,
notifications: KnownNotifications.eip155,
accounts, // Original order preserved
},
});
});

it('sorts accounts in both required and optional scopes', () => {
const unsortedAccounts1 = ['eip155:1:0xbeef', 'eip155:1:0xdead'];
const unsortedAccounts2 = ['eip155:137:0xcafe', 'eip155:137:0xbabe'];
const sortedAccounts1 = ['eip155:1:0xdead', 'eip155:1:0xbeef'];
const sortedAccounts2 = ['eip155:137:0xbabe', 'eip155:137:0xcafe'];

mockSortAccountIdsByLastSelected
.mockReturnValueOnce(sortedAccounts1)
.mockReturnValueOnce(sortedAccounts2);

const result = getSessionScopes(
{
requiredScopes: {
'eip155:1': {
accounts: unsortedAccounts1,
},
},
optionalScopes: {
'eip155:137': {
accounts: unsortedAccounts2,
},
},
},
{
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected: mockSortAccountIdsByLastSelected,
},
);

expect(mockSortAccountIdsByLastSelected).toHaveBeenCalledTimes(2);
expect(mockSortAccountIdsByLastSelected).toHaveBeenNthCalledWith(
1,
unsortedAccounts1,
);
expect(mockSortAccountIdsByLastSelected).toHaveBeenNthCalledWith(
2,
unsortedAccounts2,
);
expect(result).toStrictEqual({
'eip155:1': {
methods: KnownRpcMethods.eip155,
notifications: KnownNotifications.eip155,
accounts: sortedAccounts1,
},
'eip155:137': {
methods: KnownRpcMethods.eip155,
notifications: KnownNotifications.eip155,
accounts: sortedAccounts2,
},
});
});
});

describe('getPermittedAccountsForScopes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,19 @@ export const getInternalScopesObject = (
* @param internalScopesObject - The InternalScopesObject to convert.
* @param hooks - An object containing the following properties:
* @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope.
* @param [hooks.sortAccountIdsByLastSelected] - Optional function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected.
* @returns A NormalizedScopesObject.
*/
const getNormalizedScopesObject = (
internalScopesObject: InternalScopesObject,
{
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
}: {
getNonEvmSupportedMethods: (scope: CaipChainId) => string[];
sortAccountIdsByLastSelected?: (
accounts: CaipAccountId[],
) => CaipAccountId[];
},
) => {
const normalizedScopes: NormalizedScopesObject = {};
Expand Down Expand Up @@ -83,10 +88,14 @@ const getNormalizedScopesObject = (
notifications = [];
}

const sortedAccounts = sortAccountIdsByLastSelected
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] this name can be misleading, since we are not always sorting these accounts, sortedAccounts could actually be unsorted if sortAccountIdsByLastSelected is not provided.

we could name the function param caipAccountIds, and this variable accounts for example (open to other suggestions)

? sortAccountIdsByLastSelected(accounts)
: accounts;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting before merge loses order for overlapping scopes

Low Severity

sortAccountIdsByLastSelected is applied inside getNormalizedScopesObject (per-scope, before merge), but getSessionScopes then calls mergeNormalizedScopes which concatenates and deduplicates accounts from required and optional scopes via getUniqueArrayItems. When the same scope string appears in both requiredScopes and optionalScopes with different accounts, the merge disrupts the sorted order — the final array reflects required-first insertion order, not the result of sorting all accounts together.

Additional Locations (1)
Fix in Cursor Fix in Web


normalizedScopes[scopeString] = {
methods,
notifications,
accounts,
accounts: sortedAccounts,
};
},
);
Expand All @@ -101,6 +110,7 @@ const getNormalizedScopesObject = (
* @param caip25CaveatValue - The CAIP-25 CaveatValue to convert.
* @param hooks - An object containing the following properties:
* @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope.
* @param [hooks.sortAccountIdsByLastSelected] - Optional function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected.
* @returns A NormalizedScopesObject.
*/
export const getSessionScopes = (
Expand All @@ -110,16 +120,22 @@ export const getSessionScopes = (
>,
{
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
}: {
getNonEvmSupportedMethods: (scope: CaipChainId) => string[];
sortAccountIdsByLastSelected?: (
accounts: CaipAccountId[],
) => CaipAccountId[];
},
) => {
return mergeNormalizedScopes(
getNormalizedScopesObject(caip25CaveatValue.requiredScopes, {
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
}),
getNormalizedScopesObject(caip25CaveatValue.optionalScopes, {
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
}),
);
};
Expand Down
4 changes: 4 additions & 0 deletions packages/multichain-api-middleware/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]

### Added

- Add required `sortAccountIdsByLastSelected` hook to `wallet_getSession` and `wallet_createSession` handlers to enable custom account ordering in session scopes ([#8255](https://github.com/MetaMask/core/pull/8255))

### Changed

- Bump `@metamask/permission-controller` from `^12.2.0` to `^12.2.1` ([#8225](https://github.com/MetaMask/core/pull/8225))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const createMockedHandler = () => {
sessionProperties?: Record<string, Json>;
}>;
const getNonEvmAccountAddresses = jest.fn().mockReturnValue([]);
const sortAccountIdsByLastSelected = jest.fn((accounts) => accounts);
const handler = (
request: JsonRpcRequest<Caip25Authorization> & { origin: string },
) =>
Expand All @@ -114,6 +115,7 @@ const createMockedHandler = () => {
getNonEvmSupportedMethods,
isNonEvmScopeSupported,
getNonEvmAccountAddresses,
sortAccountIdsByLastSelected,
trackSessionCreatedEvent,
});

Expand All @@ -128,6 +130,7 @@ const createMockedHandler = () => {
getNonEvmSupportedMethods,
isNonEvmScopeSupported,
getNonEvmAccountAddresses,
sortAccountIdsByLastSelected,
handler,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
* @param hooks.getNonEvmSupportedMethods - The hook that returns the supported methods for a non EVM scope.
* @param hooks.isNonEvmScopeSupported - The hook that returns true if a non EVM scope is supported.
* @param hooks.getNonEvmAccountAddresses - The hook that returns a list of CaipAccountIds that are supported for a CaipChainId.
* @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected.
* @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. Can be undefined.
* @returns A promise with wallet_createSession handler
*/
Expand All @@ -87,6 +88,9 @@ async function walletCreateSessionHandler(
getNonEvmSupportedMethods: (scope: CaipChainId) => string[];
isNonEvmScopeSupported: (scope: CaipChainId) => boolean;
getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[];
sortAccountIdsByLastSelected: (
accounts: CaipAccountId[],
) => CaipAccountId[];
trackSessionCreatedEvent?: (
approvedCaip25CaveatValue: Caip25CaveatValue,
) => void;
Expand Down Expand Up @@ -263,6 +267,7 @@ async function walletCreateSessionHandler(

const sessionScopes = getSessionScopes(approvedCaip25CaveatValue, {
getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods,
sortAccountIdsByLastSelected: hooks.sortAccountIdsByLastSelected,
});

const { sessionProperties: approvedSessionProperties = {} } =
Expand Down Expand Up @@ -290,6 +295,7 @@ export const walletCreateSession = {
getNonEvmSupportedMethods: true,
isNonEvmScopeSupported: true,
getNonEvmAccountAddresses: true,
sortAccountIdsByLastSelected: true,
trackSessionCreatedEvent: true,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const createMockedHandler = () => {
const next = jest.fn();
const end = jest.fn();
const getNonEvmSupportedMethods = jest.fn();
const sortAccountIdsByLastSelected = jest.fn((accounts) => accounts);
const getCaveatForOrigin = jest.fn().mockReturnValue({
value: {
requiredScopes: {
Expand Down Expand Up @@ -54,6 +55,7 @@ const createMockedHandler = () => {
walletGetSession.implementation(request, response, next, end, {
getCaveatForOrigin,
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
});

return {
Expand All @@ -62,6 +64,7 @@ const createMockedHandler = () => {
end,
getCaveatForOrigin,
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
handler,
};
};
Expand Down Expand Up @@ -96,7 +99,8 @@ describe('wallet_getSession', () => {
});

it('gets the session scopes from the CAIP-25 caveat value', async () => {
const { handler, getNonEvmSupportedMethods } = createMockedHandler();
const { handler, getNonEvmSupportedMethods, sortAccountIdsByLastSelected } =
createMockedHandler();

await handler(baseRequest);
expect(chainAgnosticPermissionModule.getSessionScopes).toHaveBeenCalledWith(
Expand All @@ -120,6 +124,7 @@ describe('wallet_getSession', () => {
},
{
getNonEvmSupportedMethods,
sortAccountIdsByLastSelected,
},
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getSessionScopes,
} from '@metamask/chain-agnostic-permission';
import type { Caveat } from '@metamask/permission-controller';
import type { CaipAccountId } from '@metamask/utils';
import type {
CaipChainId,
JsonRpcRequest,
Expand All @@ -27,6 +28,7 @@ import type {
* @param hooks - The hooks object.
* @param hooks.getCaveatForOrigin - Function to retrieve a caveat for the origin.
* @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope.
* @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected.
* @returns Nothing.
*/
async function walletGetSessionHandler(
Expand All @@ -40,6 +42,9 @@ async function walletGetSessionHandler(
caveatType: string,
) => Caveat<typeof Caip25CaveatType, Caip25CaveatValue>;
getNonEvmSupportedMethods: (scope: CaipChainId) => string[];
sortAccountIdsByLastSelected: (
accounts: CaipAccountId[],
) => CaipAccountId[];
},
) {
let caveat;
Expand All @@ -60,6 +65,7 @@ async function walletGetSessionHandler(
response.result = {
sessionScopes: getSessionScopes(caveat.value, {
getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods,
sortAccountIdsByLastSelected: hooks.sortAccountIdsByLastSelected,
}),
};
return end();
Expand All @@ -71,5 +77,6 @@ export const walletGetSession = {
hookNames: {
getCaveatForOrigin: true,
getNonEvmSupportedMethods: true,
sortAccountIdsByLastSelected: true,
},
};
Loading