diff --git a/packages/snaps-controllers/src/snaps/endowments/index.ts b/packages/snaps-controllers/src/snaps/endowments/index.ts index 4b991fa04d..5d7a37b4e1 100644 --- a/packages/snaps-controllers/src/snaps/endowments/index.ts +++ b/packages/snaps-controllers/src/snaps/endowments/index.ts @@ -7,7 +7,11 @@ import { } from './cronjob'; import { longRunningEndowmentBuilder } from './long-running'; import { networkAccessEndowmentBuilder } from './network-access'; -import { transactionInsightEndowmentBuilder } from './transaction-insight'; +import { + getTransactionInsightCaveatMapper, + transactionInsightCaveatSpecifications, + transactionInsightEndowmentBuilder, +} from './transaction-insight'; import { keyringEndowmentBuilder, keyringCaveatSpecifications, @@ -29,6 +33,7 @@ export const endowmentPermissionBuilders = { export const endowmentCaveatSpecifications = { ...keyringCaveatSpecifications, ...cronjobCaveatSpecifications, + ...transactionInsightCaveatSpecifications, }; export const endowmentCaveatMappers: Record< @@ -37,6 +42,8 @@ export const endowmentCaveatMappers: Record< > = { [keyringEndowmentBuilder.targetKey]: getKeyringCaveatMapper, [cronjobEndowmentBuilder.targetKey]: getCronjobCaveatMapper, + [transactionInsightEndowmentBuilder.targetKey]: + getTransactionInsightCaveatMapper, }; export * from './enum'; diff --git a/packages/snaps-controllers/src/snaps/endowments/transaction-insight.test.ts b/packages/snaps-controllers/src/snaps/endowments/transaction-insight.test.ts index 63f6619f4b..9d31beca3d 100644 --- a/packages/snaps-controllers/src/snaps/endowments/transaction-insight.test.ts +++ b/packages/snaps-controllers/src/snaps/endowments/transaction-insight.test.ts @@ -1,18 +1,180 @@ -import { PermissionType } from '@metamask/controllers'; -import { transactionInsightEndowmentBuilder } from './transaction-insight'; +import { PermissionConstraint, PermissionType } from '@metamask/controllers'; +import { SnapCaveatType } from '@metamask/snaps-utils'; +import { + getTransactionInsightCaveatMapper, + getTransactionOriginCaveat, + transactionInsightCaveatSpecifications, + transactionInsightEndowmentBuilder, +} from './transaction-insight'; import { SnapEndowments } from '.'; describe('endowment:transaction-insight', () => { + const specification = transactionInsightEndowmentBuilder.specificationBuilder( + {}, + ); it('builds the expected permission specification', () => { - const specification = - transactionInsightEndowmentBuilder.specificationBuilder({}); expect(specification).toStrictEqual({ permissionType: PermissionType.Endowment, targetKey: SnapEndowments.TransactionInsight, + allowedCaveats: [SnapCaveatType.TransactionOrigin], endowmentGetter: expect.any(Function), - allowedCaveats: null, + validator: expect.any(Function), }); expect(specification.endowmentGetter()).toBeUndefined(); }); + + describe('validator', () => { + it('allows no caveats', () => { + expect(() => + // @ts-expect-error Missing required permission types. + specification.validator({}), + ).not.toThrow(); + }); + + it('throws if the caveat is not a single "transactionOrigin"', () => { + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow('Expected a single "transactionOrigin" caveat.'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: 'transactionOrigin', value: [] }, + { type: 'transactionOrigin', value: [] }, + ], + }), + ).toThrow('Expected a single "transactionOrigin" caveat.'); + }); + }); +}); + +describe('getTransactionOriginCaveat', () => { + it('returns the value from a transaction insight permission', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.TransactionOrigin, + value: true, + }, + ], + }; + + expect(getTransactionOriginCaveat(permission)).toStrictEqual(true); + }); + + it('returns null if the input is undefined', () => { + expect(getTransactionOriginCaveat(undefined)).toBeNull(); + }); + + it('returns null if the permission does not have caveats', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: null, + }; + + expect(getTransactionOriginCaveat(permission)).toBeNull(); + }); + + it('throws if the permission does not have exactly one caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.TransactionOrigin, + value: true, + }, + { + type: SnapCaveatType.TransactionOrigin, + value: true, + }, + ], + }; + + expect(() => getTransactionOriginCaveat(permission)).toThrow( + 'Assertion failed', + ); + }); + + it('throws if the first caveat is not a "snapKeyring" caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.PermittedCoinTypes, + value: 'foo', + }, + ], + }; + + expect(() => getTransactionOriginCaveat(permission)).toThrow( + 'Assertion failed', + ); + }); +}); + +describe('getTransactionInsightCaveatMapper', () => { + it('maps input to a caveat', () => { + expect( + getTransactionInsightCaveatMapper({ + allowTransactionOrigin: true, + }), + ).toStrictEqual({ + caveats: [ + { + type: 'transactionOrigin', + value: true, + }, + ], + }); + }); + + it('does not include caveat if input is empty object', () => { + expect(getTransactionInsightCaveatMapper({})).toStrictEqual({ + caveats: null, + }); + }); +}); + +describe('transactionInsightCaveatSpecifications', () => { + describe('validator', () => { + it('throws if the caveat values are invalid', () => { + expect(() => + transactionInsightCaveatSpecifications[ + SnapCaveatType.TransactionOrigin + ].validator?.( + // @ts-expect-error Missing value type. + { + type: SnapCaveatType.TransactionOrigin, + }, + ), + ).toThrow('Expected a plain object.'); + + expect(() => + transactionInsightCaveatSpecifications[ + SnapCaveatType.TransactionOrigin + ].validator?.({ + type: SnapCaveatType.TransactionOrigin, + value: undefined, + }), + ).toThrow('Expected caveat value to have type "boolean"'); + }); + }); }); diff --git a/packages/snaps-controllers/src/snaps/endowments/transaction-insight.ts b/packages/snaps-controllers/src/snaps/endowments/transaction-insight.ts index e3d644000e..7d49feebfe 100644 --- a/packages/snaps-controllers/src/snaps/endowments/transaction-insight.ts +++ b/packages/snaps-controllers/src/snaps/endowments/transaction-insight.ts @@ -3,7 +3,21 @@ import { PermissionType, EndowmentGetterParams, ValidPermissionSpecification, + PermissionValidatorConstraint, + PermissionConstraint, + CaveatSpecificationConstraint, + Caveat, } from '@metamask/controllers'; +import { + assert, + hasProperty, + isObject, + isPlainObject, + Json, + NonEmptyArray, +} from '@metamask/utils'; +import { SnapCaveatType } from '@metamask/snaps-utils'; +import { ethErrors } from 'eth-rpc-errors'; import { SnapEndowments } from './enum'; const permissionName = SnapEndowments.TransactionInsight; @@ -12,7 +26,8 @@ type TransactionInsightEndowmentSpecification = ValidPermissionSpecification<{ permissionType: PermissionType.Endowment; targetKey: typeof permissionName; endowmentGetter: (_options?: EndowmentGetterParams) => undefined; - allowedCaveats: null; + allowedCaveats: Readonly> | null; + validator: PermissionValidatorConstraint; }>; /** @@ -30,8 +45,19 @@ const specificationBuilder: PermissionSpecificationBuilder< return { permissionType: PermissionType.Endowment, targetKey: permissionName, - allowedCaveats: null, + allowedCaveats: [SnapCaveatType.TransactionOrigin], endowmentGetter: (_getterOptions?: EndowmentGetterParams) => undefined, + validator: ({ caveats }) => { + if ( + (caveats !== null && caveats?.length > 1) || + (caveats?.length === 1 && + caveats[0].type !== SnapCaveatType.TransactionOrigin) + ) { + throw ethErrors.rpc.invalidParams({ + message: `Expected a single "${SnapCaveatType.TransactionOrigin}" caveat.`, + }); + } + }, }; }; @@ -39,3 +65,90 @@ export const transactionInsightEndowmentBuilder = Object.freeze({ targetKey: permissionName, specificationBuilder, } as const); + +/** + * Validates the type of the caveat value. + * + * @param caveat - The caveat to validate. + * @throws If the caveat value is invalid. + */ +function validateCaveat(caveat: Caveat): void { + if (!hasProperty(caveat, 'value') || !isPlainObject(caveat)) { + throw ethErrors.rpc.invalidParams({ + message: 'Expected a plain object.', + }); + } + + const { value } = caveat; + + assert( + typeof value === 'boolean', + 'Expected caveat value to have type "boolean"', + ); +} + +/** + * 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 + * PermissionsController when the permission is requested. + * + * @param value - The raw value from the `initialPermissions`. + * @returns The caveat specification. + */ +export function getTransactionInsightCaveatMapper( + value: Json, +): Pick { + if ( + !value || + !isObject(value) || + (isObject(value) && Object.keys(value).length === 0) + ) { + return { caveats: null }; + } + return { + caveats: [ + { + type: SnapCaveatType.TransactionOrigin, + value: + hasProperty(value, 'allowTransactionOrigin') && + value.allowTransactionOrigin, + }, + ], + }; +} + +/** + * Getter function to get the transaction origin caveat from a permission. + * + * This does basic validation of the caveat, but does not validate the type or + * value of the namespaces object itself, as this is handled by the + * `PermissionsController` when the permission is requested. + * + * @param permission - The permission to get the transaction origin caveat from. + * @returns The transaction origin, or `null` if the permission does not have a + * transaction origin caveat. + */ +export function getTransactionOriginCaveat( + permission?: PermissionConstraint, +): boolean | null { + if (!permission?.caveats) { + return null; + } + + assert(permission.caveats.length === 1); + assert(permission.caveats[0].type === SnapCaveatType.TransactionOrigin); + + const caveat = permission.caveats[0] as Caveat; + + return caveat.value ?? null; +} + +export const transactionInsightCaveatSpecifications: Record< + SnapCaveatType.TransactionOrigin, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.TransactionOrigin]: Object.freeze({ + type: SnapCaveatType.TransactionOrigin, + validator: (caveat: Caveat) => validateCaveat(caveat), + }), +}; diff --git a/packages/snaps-execution-environments/jest.config.js b/packages/snaps-execution-environments/jest.config.js index e9c3866721..8dd2980d36 100644 --- a/packages/snaps-execution-environments/jest.config.js +++ b/packages/snaps-execution-environments/jest.config.js @@ -7,8 +7,8 @@ module.exports = deepmerge(baseConfig, { global: { branches: 89.7, functions: 90.43, - lines: 88.45, - statements: 88.45, + lines: 88.46, + statements: 88.46, }, }, testEnvironment: '/jest.environment.js', diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts index 4bef37fe67..20f0c8dfc3 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts @@ -853,7 +853,7 @@ describe('BaseSnapExecutor', () => { it('supports onTransaction export', async () => { const CODE = ` - module.exports.onTransaction = ({ transaction, chainId }) => ({ transaction, chainId }); + module.exports.onTransaction = ({ transaction, chainId, transactionOrigin }) => ({ transaction, chainId, transactionOrigin }); `; const executor = new TestSnapExecutor(); @@ -869,7 +869,11 @@ describe('BaseSnapExecutor', () => { // We also have to decide on the shape of that object. const transaction = { maxFeePerGas: '0x' }; - const params = { transaction, chainId: 'eip155:1' }; + const params = { + transaction, + chainId: 'eip155:1', + transactionOrigin: null, + }; await executor.writeCommand({ jsonrpc: '2.0', diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index 3365067ebe..38cc4093bc 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -36,10 +36,11 @@ export function getHandlerArguments( case HandlerType.OnTransaction: { assertIsOnTransactionRequestArguments(request.params); - const { transaction, chainId } = request.params; + const { transaction, chainId, transactionOrigin } = request.params; return { transaction, chainId, + transactionOrigin, }; } diff --git a/packages/snaps-execution-environments/src/common/validation.test.ts b/packages/snaps-execution-environments/src/common/validation.test.ts index 244cc199d5..9c740867d1 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -41,12 +41,17 @@ describe('isEndowmentsArray', () => { describe('assertIsOnTransactionRequestArguments', () => { it.each([ - { transaction: {}, chainId: 'eip155:1' }, + { transaction: {}, chainId: 'eip155:1', transactionOrigin: null }, { transaction: { foo: 'bar' }, chainId: 'bip122:000000000019d6689c085ae165831e93', + transactionOrigin: null, + }, + { + transaction: { bar: 'baz' }, + chainId: 'eip155:2', + transactionOrigin: null, }, - { transaction: { bar: 'baz' }, chainId: 'eip155:2' }, ])('does not throw for a valid transaction params object', (args) => { expect(() => assertIsOnTransactionRequestArguments(args)).not.toThrow(); }); diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index 4c7b6aad75..d45ef3e3a6 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -16,6 +16,7 @@ import { Infer, is, literal, + nullable, object, omit, optional, @@ -153,6 +154,7 @@ export const OnTransactionRequestArgumentsStruct = object({ // TODO: Improve `transaction` type. transaction: record(string(), JsonStruct), chainId: ChainIdStruct, + transactionOrigin: nullable(string()), }); export type OnTransactionRequestArguments = Infer< diff --git a/packages/snaps-types/src/types.d.ts b/packages/snaps-types/src/types.d.ts index 587d953c6f..5cffe9b9b5 100644 --- a/packages/snaps-types/src/types.d.ts +++ b/packages/snaps-types/src/types.d.ts @@ -16,6 +16,7 @@ export type OnTransactionResponse = { export type OnTransactionHandler = (args: { transaction: { [key: string]: unknown }; chainId: string; + transactionOrigin?: string; }) => Promise; export type OnCronjobHandler = (args: { diff --git a/packages/snaps-utils/src/caveats.ts b/packages/snaps-utils/src/caveats.ts index ec04ccf6af..5c2c10a400 100644 --- a/packages/snaps-utils/src/caveats.ts +++ b/packages/snaps-utils/src/caveats.ts @@ -18,4 +18,9 @@ export enum SnapCaveatType { * Caveat specifying a snap cronjob. */ SnapCronjob = 'snapCronjob', + + /** + * Caveat specifying access to the transaction origin, used by `endowment:transaction-insight`. + */ + TransactionOrigin = 'transactionOrigin', } diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 740b12e1f8..a407c35f1b 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -149,7 +149,11 @@ export type Bip32PublicKey = Infer; const PermissionsStruct = type({ 'endowment:long-running': optional(object({})), 'endowment:network-access': optional(object({})), - 'endowment:transaction-insight': optional(object({})), + 'endowment:transaction-insight': optional( + object({ + allowTransactionOrigin: optional(boolean()), + }), + ), 'endowment:cronjob': optional( object({ jobs: CronjobSpecificationArrayStruct }), ),