Skip to content

Commit 292749b

Browse files
authored
feat: add SnapKeyring.setSelectedAccounts and getSelectedAccounts support (#387)
This PR adds methods for setting selected accounts and getting selected accounts for a snap. ## Examples N/A <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces a new `keyring_setSelectedAccounts` RPC with client/server handling, adds Snap-side `getSelectedAccounts`, and updates the Snap keyring to track and propagate per-snap selected accounts. > > - **API (`@metamask/keyring-api`)**: > - Add `KeyringRpcMethod.SetSelectedAccounts` and structs `SetSelectedAccountsRequest/Response`. > - Extend `Keyring` interface with `setSelectedAccounts(accounts: AccountId[])`. > - **Client (`@metamask/keyring-snap-client`)**: > - Implement `setSelectedAccounts(accounts)` sending `keyring_setSelectedAccounts`. > - **Snap SDK (`@metamask/keyring-snap-sdk`)**: > - New `methods` module with `SnapManageAccountsMethod.GetSelectedAccounts` and request/response structs. > - Add `getSelectedAccounts(snap)` helper; export from SDK index. > - Update keyring RPC handler to support `keyring_setSelectedAccounts`. > - **Snap Bridge (`@metamask/eth-snap-keyring`)**: > - Track selected accounts per Snap; implement `setSelectedAccounts` to update in-memory map and call each Snap via internal client. > - Handle `snap_manageAccounts` method `getSelectedAccounts` to return selection for the requesting Snap. > - Enhance `SnapIdMap` with `getSnapId(key)`. > - Add dependency on `@metamask/keyring-snap-sdk`; update tsconfig references and README dependency graph. > - **Tests**: > - Add/extend tests for new RPC, client method, Snap keyring selection behavior, `getSelectedAccounts`, and `SnapIdMap.getSnapId()`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 582d075. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 848d07d commit 292749b

19 files changed

Lines changed: 332 additions & 7 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ linkStyle default opacity:0.5
6666
eth_snap_keyring --> keyring_api;
6767
eth_snap_keyring --> keyring_internal_api;
6868
eth_snap_keyring --> keyring_internal_snap_client;
69+
eth_snap_keyring --> keyring_snap_sdk;
6970
eth_snap_keyring --> keyring_utils;
7071
keyring_snap_client --> keyring_api;
7172
keyring_snap_client --> keyring_utils;

packages/keyring-api/src/api/keyring.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
22
// This rule seems to be triggering a false positive on the `KeyringAccount`.
33

4-
import type { JsonRpcRequest } from '@metamask/keyring-utils';
4+
import type { AccountId, JsonRpcRequest } from '@metamask/keyring-utils';
55
import type { Json } from '@metamask/utils';
66

77
import type { KeyringAccount } from './account';
@@ -159,6 +159,13 @@ export type Keyring = {
159159
request: JsonRpcRequest,
160160
): Promise<ResolvedAccountAddress | null>;
161161

162+
/**
163+
* Set the selected accounts.
164+
*
165+
* @param accounts - The accounts to set as selected.
166+
*/
167+
setSelectedAccounts?(accounts: AccountId[]): Promise<void>;
168+
162169
/**
163170
* Filter supported chains for a given account.
164171
*

packages/keyring-api/src/rpc.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export enum KeyringRpcMethod {
5151
SubmitRequest = 'keyring_submitRequest',
5252
ApproveRequest = 'keyring_approveRequest',
5353
RejectRequest = 'keyring_rejectRequest',
54+
SetSelectedAccounts = 'keyring_setSelectedAccounts',
5455
}
5556

5657
/**
@@ -118,6 +119,27 @@ export const CreateAccountResponseStruct = KeyringAccountStruct;
118119

119120
export type CreateAccountResponse = Infer<typeof CreateAccountResponseStruct>;
120121

122+
// ----------------------------------------------------------------------------
123+
// Set selected accounts
124+
125+
export const SetSelectedAccountsRequestStruct = object({
126+
...CommonHeader,
127+
method: literal('keyring_setSelectedAccounts'),
128+
params: object({
129+
accounts: array(string()),
130+
}),
131+
});
132+
133+
export type SetSelectedAccountsRequest = Infer<
134+
typeof SetSelectedAccountsRequestStruct
135+
>;
136+
137+
export const SetSelectedAccountsResponseStruct = literal(null);
138+
139+
export type SetSelectedAccountsResponse = Infer<
140+
typeof SetSelectedAccountsResponseStruct
141+
>;
142+
121143
// ----------------------------------------------------------------------------
122144
// Discover accounts
123145

packages/keyring-snap-bridge/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@metamask/keyring-api": "workspace:^",
4444
"@metamask/keyring-internal-api": "workspace:^",
4545
"@metamask/keyring-internal-snap-client": "workspace:^",
46+
"@metamask/keyring-snap-sdk": "workspace:^",
4647
"@metamask/keyring-utils": "workspace:^",
4748
"@metamask/superstruct": "^3.1.0",
4849
"@metamask/utils": "^11.1.0",

packages/keyring-snap-bridge/src/SnapIdMap.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ describe('SnapIdMap', () => {
9797
});
9898
});
9999

100+
describe('getSnapId', () => {
101+
it('returns undefined when the key is not in the map', () => {
102+
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
103+
const snapId = map.getSnapId('foo');
104+
expect(snapId).toBeUndefined();
105+
});
106+
107+
it('returns the snapId when the key is in the map', () => {
108+
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();
109+
map.set('foo', { snapId: SNAP_1_ID, value: 1 });
110+
const snapId = map.getSnapId('foo');
111+
expect(snapId).toBe(SNAP_1_ID);
112+
});
113+
});
114+
100115
describe('pop', () => {
101116
it('returns undefined when the key is not in the map', () => {
102117
const map = new SnapIdMap<{ snapId: SnapId; value: number }>();

packages/keyring-snap-bridge/src/SnapIdMap.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ export class SnapIdMap<Value extends { snapId: SnapId }> {
132132
return value?.snapId === snapId ? value : undefined;
133133
}
134134

135+
/**
136+
* Gets a Snap ID that's associated with the given key from the map.
137+
*
138+
* @param key - Key of the element to get.
139+
* @returns The Snap ID associated with the given key.
140+
*/
141+
getSnapId(key: string): SnapId | undefined {
142+
return this.#map.get(key)?.snapId;
143+
}
144+
135145
/**
136146
* Gets and delete a value from the map.
137147
*

packages/keyring-snap-bridge/src/SnapKeyring.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
TrxMethod,
3131
TrxAccountType,
3232
} from '@metamask/keyring-api';
33+
import { SnapManageAccountsMethod } from '@metamask/keyring-snap-sdk';
3334
import type { JsonRpcRequest } from '@metamask/keyring-utils';
3435
import type { HandleSnapRequest } from '@metamask/snaps-controllers';
3536
import { type SnapId } from '@metamask/snaps-sdk';
@@ -1359,6 +1360,37 @@ describe('SnapKeyring', () => {
13591360
});
13601361
});
13611362

1363+
describe('#handleGetSelectedAccounts', () => {
1364+
it('gets the selected accounts', async () => {
1365+
mockMessenger.handleRequest.mockResolvedValue(null);
1366+
await keyring.setSelectedAccounts([solDataAccount.id]);
1367+
const result = await keyring.handleKeyringSnapMessage(snapId, {
1368+
method: SnapManageAccountsMethod.GetSelectedAccounts,
1369+
});
1370+
expect(result).toStrictEqual([solDataAccount.id]);
1371+
});
1372+
1373+
it('returns an empty array if no accounts are selected', async () => {
1374+
mockMessenger.handleRequest.mockResolvedValue(null);
1375+
const result = await keyring.handleKeyringSnapMessage(snapId, {
1376+
method: SnapManageAccountsMethod.GetSelectedAccounts,
1377+
});
1378+
expect(result).toStrictEqual([]);
1379+
});
1380+
1381+
it('ignores an account that does not belong to a snap', async () => {
1382+
mockMessenger.handleRequest.mockResolvedValue(null);
1383+
await keyring.setSelectedAccounts([
1384+
unknownAccount.id,
1385+
ethEoaAccount1.id,
1386+
]);
1387+
const result = await keyring.handleKeyringSnapMessage(snapId, {
1388+
method: SnapManageAccountsMethod.GetSelectedAccounts,
1389+
});
1390+
expect(result).toStrictEqual([ethEoaAccount1.id]);
1391+
});
1392+
});
1393+
13621394
it('fails when the method is invalid', async () => {
13631395
await expect(
13641396
keyring.handleKeyringSnapMessage(snapId, {
@@ -1387,6 +1419,33 @@ describe('SnapKeyring', () => {
13871419
});
13881420
});
13891421

1422+
describe('setSelectedAccounts', () => {
1423+
beforeEach(() => {
1424+
mockMessengerHandleRequest({
1425+
[KeyringRpcMethod.SetSelectedAccounts]: () => null,
1426+
});
1427+
});
1428+
1429+
it('sets the selected accounts', async () => {
1430+
await keyring.setSelectedAccounts([ethEoaAccount1.id]);
1431+
const result = await keyring.handleKeyringSnapMessage(snapId, {
1432+
method: SnapManageAccountsMethod.GetSelectedAccounts,
1433+
});
1434+
expect(result).toStrictEqual([ethEoaAccount1.id]);
1435+
});
1436+
1437+
it('logs an error if the setSelectedAccounts call for a snap fails', async () => {
1438+
const spy = jest.spyOn(console, 'error').mockImplementation();
1439+
mockMessenger.handleRequest.mockImplementation(() => {
1440+
throw new Error('Failed to set selected accounts');
1441+
});
1442+
await keyring.setSelectedAccounts([ethEoaAccount1.id]);
1443+
expect(spy).toHaveBeenCalledWith(
1444+
`Failed to set selected accounts for ${snapId} snap: 'Failed to set selected accounts'`,
1445+
);
1446+
});
1447+
});
1448+
13901449
describe('serialize', () => {
13911450
it('returns the keyring state', async () => {
13921451
const expectedState = {

packages/keyring-snap-bridge/src/SnapKeyring.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import {
3939
type InternalAccount,
4040
} from '@metamask/keyring-internal-api';
4141
import { KeyringInternalSnapClient } from '@metamask/keyring-internal-snap-client';
42+
import {
43+
type GetSelectedAccountsResponse,
44+
GetSelectedAccountsRequestStruct,
45+
SnapManageAccountsMethod,
46+
} from '@metamask/keyring-snap-sdk';
4247
import type { AccountId, JsonRpcRequest } from '@metamask/keyring-utils';
4348
import { strictMask } from '@metamask/keyring-utils';
4449
import type { SnapId } from '@metamask/snaps-sdk';
@@ -183,6 +188,11 @@ export class SnapKeyring {
183188
snapId: SnapId;
184189
}>;
185190

191+
/**
192+
* Mapping between Snap IDs and the selected accounts.
193+
*/
194+
readonly #selectedAccounts: Map<SnapId, AccountId[]>;
195+
186196
/**
187197
* Mapping between request IDs and their deferred promises.
188198
*/
@@ -238,6 +248,7 @@ export class SnapKeyring {
238248
this.#options = new SnapIdMap();
239249
this.#callbacks = callbacks;
240250
this.#isAnyAccountTypeAllowed = isAnyAccountTypeAllowed;
251+
this.#selectedAccounts = new Map();
241252
}
242253

243254
/**
@@ -497,6 +508,21 @@ export class SnapKeyring {
497508
return null;
498509
}
499510

511+
/**
512+
* Handle a Get Selected Accounts method call from a Snap.
513+
*
514+
* @param snapId - Snap ID.
515+
* @param message - Method call message.
516+
* @returns The selected accounts.
517+
*/
518+
async #handleGetSelectedAccounts(
519+
snapId: SnapId,
520+
message: SnapMessage,
521+
): Promise<GetSelectedAccountsResponse> {
522+
assert(message, GetSelectedAccountsRequestStruct);
523+
return this.#selectedAccounts.get(snapId) ?? [];
524+
}
525+
500526
/**
501527
* Handle an Request Approved event from a Snap.
502528
*
@@ -701,6 +727,10 @@ export class SnapKeyring {
701727
return this.#handleAccountTransactionsUpdated(snapId, message);
702728
}
703729

730+
case `${SnapManageAccountsMethod.GetSelectedAccounts}`: {
731+
return this.#handleGetSelectedAccounts(snapId, message);
732+
}
733+
704734
default:
705735
throw new Error(`Method not supported: ${message.method}`);
706736
}
@@ -1497,6 +1527,48 @@ export class SnapKeyring {
14971527
);
14981528
}
14991529

1530+
/**
1531+
* Update the in-memory selected accounts map.
1532+
*
1533+
* @param accounts - The accounts to update the map with.
1534+
*/
1535+
#updateSelectedAccountsMap(accounts: AccountId[]): void {
1536+
const selectedAccounts = this.#selectedAccounts;
1537+
selectedAccounts.clear();
1538+
for (const account of accounts) {
1539+
const snapId = this.#accounts.getSnapId(account);
1540+
if (!snapId) {
1541+
continue;
1542+
}
1543+
const snapAccounts = selectedAccounts.get(snapId) ?? [];
1544+
snapAccounts.push(account);
1545+
selectedAccounts.set(snapId, snapAccounts);
1546+
}
1547+
}
1548+
1549+
/**
1550+
* Set the selected accounts.
1551+
*
1552+
* @param accounts - The accounts to set as selected.
1553+
*/
1554+
async setSelectedAccounts(accounts: AccountId[]): Promise<void> {
1555+
this.#updateSelectedAccountsMap(accounts);
1556+
const entries = [...this.#selectedAccounts.entries()];
1557+
await Promise.all(
1558+
entries.map(async ([snapId, accountIds]) => {
1559+
try {
1560+
await this.#snapClient
1561+
.withSnapId(snapId)
1562+
.setSelectedAccounts(accountIds);
1563+
} catch (error: any) {
1564+
console.error(
1565+
`Failed to set selected accounts for ${snapId} snap: '${error.message}'`,
1566+
);
1567+
}
1568+
}),
1569+
);
1570+
}
1571+
15001572
/**
15011573
* Get the Snap associated with the given Snap ID.
15021574
*

packages/keyring-snap-bridge/tsconfig.build.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
{ "path": "../keyring-api/tsconfig.build.json" },
1515
{ "path": "../keyring-internal-api/tsconfig.build.json" },
1616
{ "path": "../keyring-internal-snap-client/tsconfig.build.json" },
17-
{ "path": "../keyring-utils/tsconfig.build.json" }
17+
{ "path": "../keyring-utils/tsconfig.build.json" },
18+
{ "path": "../keyring-snap-sdk/tsconfig.build.json" }
1819
],
1920
"include": ["./src/**/*.ts"],
2021
"exclude": ["./src/**/*.test.ts"]

packages/keyring-snap-bridge/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
{ "path": "../keyring-api/tsconfig.build.json" },
1111
{ "path": "../keyring-internal-api/tsconfig.build.json" },
1212
{ "path": "../keyring-internal-snap-client/tsconfig.build.json" },
13-
{ "path": "../keyring-utils/tsconfig.build.json" }
13+
{ "path": "../keyring-utils/tsconfig.build.json" },
14+
{ "path": "../keyring-snap-sdk/tsconfig.build.json" }
1415
],
1516
"include": ["./src"],
1617
"exclude": ["./dist/**/*"]

0 commit comments

Comments
 (0)