Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c0f6e6d
feature: add capabilities to keyring endowment
hmalik88 Mar 19, 2026
ea8c62f
refactor: remove keyring-api dep and add local definition for capabil…
hmalik88 Mar 20, 2026
e698fdc
Merge remote-tracking branch 'origin/main' into hm/add-keyring-capabi…
hmalik88 Mar 20, 2026
1e2eff9
chore: update changelog
hmalik88 Mar 20, 2026
cfded47
chore: update snap-rpc-methods changelog
hmalik88 Mar 20, 2026
1b7c802
fix: fix capabilities getter
hmalik88 Mar 20, 2026
d122b58
chore: update coverage
hmalik88 Mar 20, 2026
7cfe31b
fix: update type
hmalik88 Mar 20, 2026
9210d91
fix: add capabilities export
hmalik88 Mar 20, 2026
7be44a0
fix: update snapshot
hmalik88 Mar 20, 2026
1662a28
test: add tests for assertIsKeyringCapabilities
hmalik88 Mar 20, 2026
cdc079f
fix: update initial permissions type
hmalik88 Mar 20, 2026
5f1946d
fix: fix types
hmalik88 Mar 20, 2026
61dc185
fix: add missing word to changelog
hmalik88 Apr 2, 2026
1853c08
refactor: apply code review
hmalik88 Apr 2, 2026
5303524
Merge remote-tracking branch 'origin/main' into hm/add-keyring-capabi…
hmalik88 Apr 2, 2026
9dcd36c
fix: lint fix
hmalik88 Apr 2, 2026
04a58cd
test: add test for uncovered line
hmalik88 Apr 2, 2026
9fb1f9f
Merge remote-tracking branch 'origin/main' into hm/add-keyring-capabi…
hmalik88 Apr 2, 2026
a06ad51
chore: update test coverage
hmalik88 Apr 2, 2026
7402845
refactor: apply code review
hmalik88 Apr 2, 2026
00733ae
refactor: update if statement
hmalik88 Apr 2, 2026
2fae117
fix: revert deriving values
hmalik88 Apr 6, 2026
e629c04
Merge remote-tracking branch 'origin/main' into hm/add-keyring-capabi…
hmalik88 Apr 6, 2026
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
6 changes: 6 additions & 0 deletions packages/snaps-rpc-methods/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `capabilities` caveat support to `endowment:keyring` ([#3903](https://github.com/MetaMask/snaps/pull/3903))
- Snap manifests can now declare a `capabilities` object under `endowment:keyring` describing the capabilities supported by the keyring.
- New `getKeyringCaveatCapabilities` function to retrieve the capabilities caveat value from a permission.

### Changed

- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.0` ([#3934](https://github.com/MetaMask/snaps/pull/3934))
Expand Down
8 changes: 4 additions & 4 deletions packages/snaps-rpc-methods/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
],
coverageThreshold: {
global: {
branches: 96.57,
functions: 99.19,
lines: 99.05,
statements: 98.77,
branches: 96.66,
functions: 99.2,
lines: 99.06,
statements: 98.78,
},
},
});
5 changes: 4 additions & 1 deletion packages/snaps-rpc-methods/src/endowments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ export { getRpcCaveatOrigins } from './rpc';
export { getSignatureOriginCaveat } from './signature-insight';
export { getTransactionOriginCaveat } from './transaction-insight';
export { getChainIdsCaveat, getLookupMatchersCaveat } from './name-lookup';
export { getKeyringCaveatOrigins } from './keyring';
export {
getKeyringCaveatOrigins,
getKeyringCaveatCapabilities,
} from './keyring';
export { getMaxRequestTimeCaveat } from './caveats';
export { getCronjobCaveatJobs } from './cronjob';
export { getProtocolCaveatScopes } from './protocol';
158 changes: 155 additions & 3 deletions packages/snaps-rpc-methods/src/endowments/keyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SnapCaveatType } from '@metamask/snaps-utils';

import { SnapEndowments } from './enum';
import {
getKeyringCaveatCapabilities,
getKeyringCaveatMapper,
getKeyringCaveatOrigins,
keyringCaveatSpecifications,
Expand All @@ -18,6 +19,7 @@ describe('endowment:keyring', () => {
endowmentGetter: expect.any(Function),
allowedCaveats: [
SnapCaveatType.KeyringOrigin,
SnapCaveatType.KeyringCapabilities,
SnapCaveatType.MaxRequestTime,
],
subjectTypes: [SubjectType.Snap],
Expand All @@ -44,7 +46,7 @@ describe('endowment:keyring', () => {
caveats: [{ type: 'foo', value: 'bar' }],
}),
).toThrow(
'Expected the following caveats: "keyringOrigin", "maxRequestTime", received "foo".',
'Expected the following caveats: "keyringOrigin", "keyringCapabilities", "maxRequestTime", received "foo".',
);

expect(() =>
Expand All @@ -61,7 +63,16 @@ describe('endowment:keyring', () => {
});

describe('getKeyringCaveatMapper', () => {
it('maps a value to a caveat', () => {
it.each([null, undefined, false, 0, '', [], {}])(
'returns null caveats for %p',
(value) => {
expect(getKeyringCaveatMapper(value as never)).toStrictEqual({
caveats: null,
});
},
);

it('maps a value to a caveat without capabilities', () => {
expect(
getKeyringCaveatMapper({ allowedOrigins: ['foo.com'] }),
).toStrictEqual({
Expand All @@ -73,6 +84,36 @@ describe('getKeyringCaveatMapper', () => {
],
});
});

it('maps a value to caveats including capabilities', () => {
expect(
getKeyringCaveatMapper({
allowedOrigins: ['foo.com'],
capabilities: {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
bip44: { derivePath: true },
},
}),
).toStrictEqual({
caveats: [
{
type: SnapCaveatType.KeyringOrigin,
value: {
allowedOrigins: ['foo.com'],
},
},
{
type: SnapCaveatType.KeyringCapabilities,
value: {
capabilities: {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
bip44: { derivePath: true },
},
},
},
],
});
});
});

describe('getKeyringCaveatOrigins', () => {
Expand Down Expand Up @@ -118,8 +159,52 @@ describe('getKeyringCaveatOrigins', () => {
});
});

describe('getKeyringCaveatCapabilities', () => {
it('returns the capabilities from the caveat', () => {
expect(
// @ts-expect-error Missing other required permission types.
getKeyringCaveatCapabilities({
caveats: [
{
type: SnapCaveatType.KeyringCapabilities,
value: {
capabilities: {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
bip44: { derivePath: true },
},
},
},
],
}),
).toStrictEqual({
capabilities: {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
bip44: { derivePath: true },
},
});
});

it('returns null when the capabilities caveat is absent', () => {
expect(
// @ts-expect-error Missing other required permission types.
getKeyringCaveatCapabilities({
caveats: [
{
type: SnapCaveatType.KeyringOrigin,
value: { allowedOrigins: ['foo.com'] },
},
],
}),
).toBeNull();
});

it('returns null when permission is undefined', () => {
expect(getKeyringCaveatCapabilities(undefined)).toBeNull();
});
});

describe('keyringCaveatSpecifications', () => {
describe('validator', () => {
describe('keyringOrigin validator', () => {
it('throws if the caveat values are invalid', () => {
expect(() =>
keyringCaveatSpecifications[SnapCaveatType.KeyringOrigin].validator?.(
Expand All @@ -142,4 +227,71 @@ describe('keyringCaveatSpecifications', () => {
);
});
});

describe('keyringCapabilities validator', () => {
it('throws if the caveat value is not a plain object', () => {
expect(() =>
keyringCaveatSpecifications[
SnapCaveatType.KeyringCapabilities
].validator?.(
// @ts-expect-error Missing value type.
{
type: SnapCaveatType.KeyringCapabilities,
},
),
).toThrow('Invalid keyring capabilities: Expected a plain object.');
});

it('throws if the caveat value has invalid fields', () => {
expect(() =>
keyringCaveatSpecifications[
SnapCaveatType.KeyringCapabilities
].validator?.({
type: SnapCaveatType.KeyringCapabilities,
value: { foo: 'bar' },
}),
).toThrow('Invalid keyring capabilities');
});

it('throws if scopes is missing', () => {
expect(() =>
keyringCaveatSpecifications[
SnapCaveatType.KeyringCapabilities
].validator?.({
type: SnapCaveatType.KeyringCapabilities,
value: { capabilities: { bip44: { derivePath: true } } },
}),
).toThrow('Invalid keyring capabilities');
});

it('does not throw for a valid capabilities value', () => {
expect(() =>
keyringCaveatSpecifications[
SnapCaveatType.KeyringCapabilities
].validator?.({
type: SnapCaveatType.KeyringCapabilities,
value: {
capabilities: {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
bip44: {
derivePath: true,
deriveIndex: true,
deriveIndexRange: true,
discover: true,
},
privateKey: {
importFormats: [
{
encoding: 'base58',
type: 'bip122:p2pkh',
},
],
exportFormats: [{ encoding: 'base58' }],
},
},
},
}),
).not.toThrow();
});
});
});
90 changes: 76 additions & 14 deletions packages/snaps-rpc-methods/src/endowments/keyring.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Caveat,
CaveatConstraint,
CaveatSpecificationConstraint,
EndowmentGetterParams,
PermissionConstraint,
Expand All @@ -9,10 +10,17 @@ import type {
} from '@metamask/permission-controller';
import { PermissionType, SubjectType } from '@metamask/permission-controller';
import { rpcErrors } from '@metamask/rpc-errors';
import type { KeyringOrigins } from '@metamask/snaps-utils';
import { assertIsKeyringOrigins, SnapCaveatType } from '@metamask/snaps-utils';
import type {
KeyringCapabilities,
KeyringOrigins,
} from '@metamask/snaps-utils';
import {
assertIsKeyringCapabilities,
assertIsKeyringOrigins,
SnapCaveatType,
} from '@metamask/snaps-utils';
import type { Json, NonEmptyArray } from '@metamask/utils';
import { assert, hasProperty, isPlainObject } from '@metamask/utils';
import { assert, hasProperty, isObject, isPlainObject } from '@metamask/utils';

import { createGenericPermissionValidator } from './caveats';
import { SnapEndowments } from './enum';
Expand Down Expand Up @@ -45,11 +53,13 @@ const specificationBuilder: PermissionSpecificationBuilder<
targetName: permissionName,
allowedCaveats: [
SnapCaveatType.KeyringOrigin,
SnapCaveatType.KeyringCapabilities,
SnapCaveatType.MaxRequestTime,
],
endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null,
validator: createGenericPermissionValidator([
{ type: SnapCaveatType.KeyringOrigin },
{ type: SnapCaveatType.KeyringCapabilities, optional: true },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did we decide on behaviour if this is not defined? Since we are making it optional

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, omission of the capabilities is an implicit indication of using keyring v1

{ type: SnapCaveatType.MaxRequestTime, optional: true },
]),
subjectTypes: [SubjectType.Snap],
Expand All @@ -62,8 +72,8 @@ export const keyringEndowmentBuilder = Object.freeze({
} as const);

/**
* Validate the value of a caveat. This does not validate the type of the
* caveat itself, only the value of the caveat.
* Validate the value of a keyring origins caveat. This does not validate the
* type of the caveat itself, only the value of the caveat.
*
* @param caveat - The caveat to validate.
* @throws If the caveat value is invalid.
Expand All @@ -79,6 +89,24 @@ function validateCaveatOrigins(caveat: Caveat<string, any>) {
assertIsKeyringOrigins(value, rpcErrors.invalidParams);
}

/**
* Validate the value of a keyring capabilities caveat. This does not validate
* the type of the caveat itself, only the value of the caveat.
*
* @param caveat - The caveat to validate.
* @throws If the caveat value is invalid.
*/
function validateCaveatCapabilities(caveat: Caveat<string, any>) {
if (!hasProperty(caveat, 'value') || !isPlainObject(caveat.value)) {
throw rpcErrors.invalidParams({
message: 'Invalid keyring capabilities: Expected a plain object.',
});
}

const { value } = caveat;
assertIsKeyringCapabilities(value, rpcErrors.invalidParams);
}

/**
* Map a raw value from the `initialPermissions` to a caveat specification.
* Note that this function does not do any validation, that's handled by the
Expand All @@ -90,14 +118,25 @@ function validateCaveatOrigins(caveat: Caveat<string, any>) {
export function getKeyringCaveatMapper(
value: Json,
): Pick<PermissionConstraint, 'caveats'> {
return {
caveats: [
{
type: SnapCaveatType.KeyringOrigin,
value,
},
],
};
if (!value || !isObject(value) || Object.keys(value).length === 0) {
return { caveats: null };
}

const caveats = [];

caveats.push({
type: SnapCaveatType.KeyringOrigin,
value: { allowedOrigins: value.allowedOrigins },
});

if (hasProperty(value, 'capabilities')) {
caveats.push({
type: SnapCaveatType.KeyringCapabilities,
value: { capabilities: value.capabilities },
});
}

return { caveats: caveats as unknown as NonEmptyArray<CaveatConstraint> };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's probably a better way of doing this that doesn't require the ugly type cast at the end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I followed the pattern Frederik suggested: #3903 (comment)

Copy link
Copy Markdown
Contributor

@GuillaumeRx GuillaumeRx Apr 2, 2026

Choose a reason for hiding this comment

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

You can do that to remove the need to cast to unkown :

Suggested change
const caveats = [];
caveats.push({
type: SnapCaveatType.KeyringOrigin,
value: { allowedOrigins: value.allowedOrigins },
});
if (hasProperty(value, 'capabilities')) {
caveats.push({
type: SnapCaveatType.KeyringCapabilities,
value: { capabilities: value.capabilities },
});
}
return { caveats: caveats as unknown as NonEmptyArray<CaveatConstraint> };
const caveats = [];
caveats.push({
type: SnapCaveatType.KeyringOrigin,
value,
});
if (value.capabilities) {
caveats.push({
type: SnapCaveatType.KeyringCapabilities,
value,
});
}
return { caveats: caveats as NonEmptyArray<CaveatConstraint> };

Also I've noticed in Frederik's example that the value is extracted so I don't know if we should do that instead :

Suggested change
const caveats = [];
caveats.push({
type: SnapCaveatType.KeyringOrigin,
value: { allowedOrigins: value.allowedOrigins },
});
if (hasProperty(value, 'capabilities')) {
caveats.push({
type: SnapCaveatType.KeyringCapabilities,
value: { capabilities: value.capabilities },
});
}
return { caveats: caveats as unknown as NonEmptyArray<CaveatConstraint> };
const caveats = [];
caveats.push({
type: SnapCaveatType.KeyringOrigin,
value: value.allowedOrigins,
});
if (value.capabilities) {
caveats.push({
type: SnapCaveatType.KeyringCapabilities,
value: value.capabilities,
});
}
return { caveats: caveats as NonEmptyArray<CaveatConstraint> };

Both works TBH, I'm just wondering why there's a difference between the two

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The second one seems to be the standard btw

Copy link
Copy Markdown
Contributor Author

@hmalik88 hmalik88 Apr 6, 2026

Choose a reason for hiding this comment

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

Undid extracting the value since it seems we weren't doing that with allowedOrigins before so I'll still follow that pattern. I did however get the unknown cast removed.

}

/**
Expand All @@ -120,12 +159,35 @@ export function getKeyringCaveatOrigins(
return caveat.value;
}

/**
* Getter function to get the {@link KeyringCapabilities} caveat value from a
* permission.
*
* @param permission - The permission to get the caveat value from.
* @returns The caveat value, or `null` if the permission does not have a
* {@link KeyringCapabilities} caveat.
*/
export function getKeyringCaveatCapabilities(
permission?: PermissionConstraint,
): KeyringCapabilities | null {
const caveat = permission?.caveats?.find(
(permCaveat) => permCaveat.type === SnapCaveatType.KeyringCapabilities,
) as Caveat<string, KeyringCapabilities> | undefined;

return caveat?.value ?? null;
}

export const keyringCaveatSpecifications: Record<
SnapCaveatType.KeyringOrigin,
SnapCaveatType.KeyringOrigin | SnapCaveatType.KeyringCapabilities,
CaveatSpecificationConstraint
> = {
[SnapCaveatType.KeyringOrigin]: Object.freeze({
type: SnapCaveatType.KeyringOrigin,
validator: (caveat: Caveat<string, any>) => validateCaveatOrigins(caveat),
}),
[SnapCaveatType.KeyringCapabilities]: Object.freeze({
type: SnapCaveatType.KeyringCapabilities,
validator: (caveat: Caveat<string, any>) =>
validateCaveatCapabilities(caveat),
}),
};
Loading
Loading