diff --git a/packages/snaps-controllers/src/snaps/constants.ts b/packages/snaps-controllers/src/snaps/constants.ts index bb7c8d2e69..7dd1fc6a89 100644 --- a/packages/snaps-controllers/src/snaps/constants.ts +++ b/packages/snaps-controllers/src/snaps/constants.ts @@ -3,6 +3,8 @@ import { HandlerType } from '@metamask/snaps-utils'; // These permissions are allowed without being on the allowlist. export const ALLOWED_PERMISSIONS = Object.freeze([ + 'snap_confirmTransaction', + 'snap_updateConfirmTransaction', 'snap_dialog', 'snap_manageState', 'snap_notify', diff --git a/packages/snaps-rpc-methods/src/restricted/confirmTransaction.ts b/packages/snaps-rpc-methods/src/restricted/confirmTransaction.ts new file mode 100644 index 0000000000..98f57b5694 --- /dev/null +++ b/packages/snaps-rpc-methods/src/restricted/confirmTransaction.ts @@ -0,0 +1,205 @@ +import type { + PermissionSpecificationBuilder, + RestrictedMethodOptions, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { + create, + object, + optional, + record, + string, +} from '@metamask/superstruct'; +import type { + CaipAssetType, + CaipChainId, + Json, + NonEmptyArray, +} from '@metamask/utils'; +import { + CaipAssetTypeStruct, + CaipChainIdStruct, + isObject, + JsonStruct, +} from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_confirmTransaction'; + +export type ConfirmTransactionParams = { + id?: string; + chainId: CaipChainId; + accountId: string; + to: string; + amount: string; + assetId?: CaipAssetType; + fee?: { + amount: string; + assetId?: CaipAssetType; + }; + custom?: Record; +}; + +const ConfirmTransactionParametersStruct = object({ + id: optional(string()), + chainId: CaipChainIdStruct, + accountId: string(), + to: string(), + amount: string(), + assetId: optional(CaipAssetTypeStruct), + fee: optional( + object({ + amount: string(), + assetId: optional(CaipAssetTypeStruct), + }), + ), + custom: optional(record(string(), JsonStruct)), +}); + +export type ConfirmTransactionMethodHooks = { + showUniversalTransactionConfirmation: ( + snapId: string, + params: ConfirmTransactionParams, + ) => Promise; +}; + +type ConfirmTransactionSpecificationBuilderOptions = { + allowedCaveats?: Readonly> | null; + methodHooks: ConfirmTransactionMethodHooks; +}; + +type ConfirmTransactionSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.RestrictedMethod; + targetName: typeof methodName; + methodImplementation: ReturnType; + allowedCaveats: Readonly> | null; +}>; + +/** + * The specification builder for the `snap_confirmTransaction` permission. + * `snap_confirmTransaction` lets the Snap request user confirmation for a + * transaction on a supported non-EVM chain. + * + * @param options - The specification builder options. + * @param options.allowedCaveats - The optional allowed caveats for the + * permission. + * @param options.methodHooks - The RPC method hooks needed by the method + * implementation. + * @returns The specification for the `snap_confirmTransaction` permission. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + ConfirmTransactionSpecificationBuilderOptions, + ConfirmTransactionSpecification +> = ({ + allowedCaveats = null, + methodHooks, +}: ConfirmTransactionSpecificationBuilderOptions) => { + return { + permissionType: PermissionType.RestrictedMethod, + targetName: methodName, + allowedCaveats, + methodImplementation: getConfirmTransactionImplementation({ methodHooks }), + subjectTypes: [SubjectType.Snap], + }; +}; + +const methodHooks: MethodHooksObject = { + showUniversalTransactionConfirmation: true, +}; + +/** + * Request user confirmation for a non-EVM transaction. + * + * @example + * ```json name="Manifest" + * { + * "initialPermissions": { + * "snap_confirmTransaction": {} + * } + * } + * ``` + * ```ts name="Usage" + * const approved = await snap.request({ + * method: 'snap_confirmTransaction', + * params: { + * chainId: 'solana:mainnet', + * accountId: 'solana:mainnet:account', + * to: 'to-address', + * amount: '1000000', + * fee: { + * amount: '5000', + * }, + * }, + * }); + * ``` + */ +export const confirmTransactionBuilder = Object.freeze({ + targetName: methodName, + specificationBuilder, + methodHooks, +} as const); + +/** + * Builds the method implementation for `snap_confirmTransaction`. + * + * @param options - The options. + * @param options.methodHooks - The RPC method hooks. + * @param options.methodHooks.showUniversalTransactionConfirmation - A function + * that shows the universal transaction confirmation UI. + * @returns The method implementation which returns `true` if approved, or + * `false` if rejected. + * @throws If the params are invalid, or the confirmation hook fails. + */ +export function getConfirmTransactionImplementation({ + methodHooks: { showUniversalTransactionConfirmation }, +}: ConfirmTransactionSpecificationBuilderOptions) { + return async function confirmTransactionImplementation( + args: RestrictedMethodOptions, + ): Promise { + const { + params, + context: { origin: snapId }, + } = args; + + const validatedParams = getValidatedParams(params); + + try { + return await showUniversalTransactionConfirmation( + snapId, + validatedParams, + ); + } catch (error) { + throw rpcErrors.internal({ + message: `Unable to confirm transaction: ${error.message}`, + }); + } + }; +} + +/** + * Validates the confirm transaction method `params` and returns them cast to the + * correct type. Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated confirm transaction method parameter object. + * @throws If the params are invalid. + */ +function getValidatedParams(params: unknown): ConfirmTransactionParams { + if (!isObject(params)) { + throw rpcErrors.invalidParams({ + message: 'Invalid params: Expected params to be a single object.', + }); + } + + try { + return create(params, ConfirmTransactionParametersStruct); + } catch (error) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}`, + }); + } +} diff --git a/packages/snaps-rpc-methods/src/restricted/index.ts b/packages/snaps-rpc-methods/src/restricted/index.ts index e76281f355..cf0785adba 100644 --- a/packages/snaps-rpc-methods/src/restricted/index.ts +++ b/packages/snaps-rpc-methods/src/restricted/index.ts @@ -5,6 +5,10 @@ import type { RestrictedMethodSpecificationConstraint, } from '@metamask/permission-controller'; +import type { ConfirmTransactionMethodHooks } from './confirmTransaction'; +import { confirmTransactionBuilder } from './confirmTransaction'; +import type { UpdateConfirmTransactionMethodHooks } from './updateConfirmTransaction'; +import { updateConfirmTransactionBuilder } from './updateConfirmTransaction'; import type { DialogMessengerActions } from './dialog'; import { dialogBuilder } from './dialog'; import type { @@ -43,6 +47,8 @@ import type { MethodHooksObject } from '../utils'; export { WALLET_SNAP_PERMISSION_KEY } from './invokeSnap'; export { getEncryptionEntropy } from './manageState'; +export type { ConfirmTransactionParams } from './confirmTransaction'; +export type { UpdateConfirmTransactionParams } from './updateConfirmTransaction'; export type RestrictedMethodActions = | DialogMessengerActions @@ -60,6 +66,8 @@ export type RestrictedMethodMessenger = Messenger< >; export type RestrictedMethodHooks = GetBip32EntropyMethodHooks & + ConfirmTransactionMethodHooks & + UpdateConfirmTransactionMethodHooks & GetBip32PublicKeyMethodHooks & GetBip44EntropyMethodHooks & GetEntropyHooks & @@ -84,6 +92,8 @@ export const restrictedMethodPermissionBuilders: Record< string, RestrictedMethodPermissionBuilder > = { + [confirmTransactionBuilder.targetName]: confirmTransactionBuilder, + [updateConfirmTransactionBuilder.targetName]: updateConfirmTransactionBuilder, [dialogBuilder.targetName]: dialogBuilder, [getBip32EntropyBuilder.targetName]: getBip32EntropyBuilder, [getBip32PublicKeyBuilder.targetName]: getBip32PublicKeyBuilder, diff --git a/packages/snaps-rpc-methods/src/restricted/updateConfirmTransaction.ts b/packages/snaps-rpc-methods/src/restricted/updateConfirmTransaction.ts new file mode 100644 index 0000000000..c062507bc2 --- /dev/null +++ b/packages/snaps-rpc-methods/src/restricted/updateConfirmTransaction.ts @@ -0,0 +1,122 @@ +import type { + PermissionSpecificationBuilder, + RestrictedMethodOptions, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { create, object, optional, record, string } from '@metamask/superstruct'; +import type { CaipAssetType, Json, NonEmptyArray } from '@metamask/utils'; +import { CaipAssetTypeStruct, isObject, JsonStruct } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_updateConfirmTransaction'; + +export type UpdateConfirmTransactionParams = { + id: string; + fee?: { + amount: string; + assetId?: CaipAssetType; + }; + custom?: Record; +}; + +const UpdateConfirmTransactionParametersStruct = object({ + id: string(), + fee: optional( + object({ + amount: string(), + assetId: optional(CaipAssetTypeStruct), + }), + ), + custom: optional(record(string(), JsonStruct)), +}); + +export type UpdateConfirmTransactionMethodHooks = { + updateUniversalTransactionConfirmation: ( + snapId: string, + params: UpdateConfirmTransactionParams, + ) => Promise; +}; + +type UpdateConfirmTransactionSpecificationBuilderOptions = { + allowedCaveats?: Readonly> | null; + methodHooks: UpdateConfirmTransactionMethodHooks; +}; + +type UpdateConfirmTransactionSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.RestrictedMethod; + targetName: typeof methodName; + methodImplementation: ReturnType; + allowedCaveats: Readonly> | null; +}>; + +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + UpdateConfirmTransactionSpecificationBuilderOptions, + UpdateConfirmTransactionSpecification +> = ({ + allowedCaveats = null, + methodHooks, +}: UpdateConfirmTransactionSpecificationBuilderOptions) => { + return { + permissionType: PermissionType.RestrictedMethod, + targetName: methodName, + allowedCaveats, + methodImplementation: getUpdateConfirmTransactionImplementation({ + methodHooks, + }), + subjectTypes: [SubjectType.Snap], + }; +}; + +const methodHooks: MethodHooksObject = { + updateUniversalTransactionConfirmation: true, +}; + +export const updateConfirmTransactionBuilder = Object.freeze({ + targetName: methodName, + specificationBuilder, + methodHooks, +} as const); + +export function getUpdateConfirmTransactionImplementation({ + methodHooks: { updateUniversalTransactionConfirmation }, +}: UpdateConfirmTransactionSpecificationBuilderOptions) { + return async function updateConfirmTransactionImplementation( + args: RestrictedMethodOptions, + ): Promise { + const { + params, + context: { origin: snapId }, + } = args; + + const validatedParams = getValidatedParams(params); + + try { + await updateUniversalTransactionConfirmation(snapId, validatedParams); + return null; + } catch (error) { + throw rpcErrors.internal({ + message: `Unable to update transaction confirmation: ${error.message}`, + }); + } + }; +} + +function getValidatedParams(params: unknown): UpdateConfirmTransactionParams { + if (!isObject(params)) { + throw rpcErrors.invalidParams({ + message: 'Invalid params: Expected params to be a single object.', + }); + } + + try { + return create(params, UpdateConfirmTransactionParametersStruct); + } catch (error) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}`, + }); + } +} diff --git a/packages/snaps-sdk/src/types/methods/confirm-transaction.ts b/packages/snaps-sdk/src/types/methods/confirm-transaction.ts new file mode 100644 index 0000000000..0734b22b91 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/confirm-transaction.ts @@ -0,0 +1,17 @@ +import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; + +export type ConfirmTransactionParams = { + id?: string; + chainId: CaipChainId; + accountId: string; + to: string; + amount: string; + assetId?: CaipAssetType; + fee?: { + amount: string; + assetId?: CaipAssetType; + }; + custom?: Record; +}; + +export type ConfirmTransactionResult = boolean; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 8b3e97b2aa..9825f7f92b 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -1,4 +1,5 @@ export type * from './clear-state'; +export type * from './confirm-transaction'; export type * from './create-interface'; export * from './dialog'; export type * from './end-trace'; @@ -32,6 +33,7 @@ export type * from './get-background-events'; export type * from './set-state'; export type * from './track-error'; export type * from './track-event'; +export type * from './update-confirm-transaction'; export type * from './open-web-socket'; export type * from './close-web-socket'; export type * from './send-web-socket-message'; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index 147353fdc9..74d90ed78b 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -7,6 +7,10 @@ import type { CloseWebSocketParams, CloseWebSocketResult, } from './close-web-socket'; +import type { + ConfirmTransactionParams, + ConfirmTransactionResult, +} from './confirm-transaction'; import type { CreateInterfaceParams, CreateInterfaceResult, @@ -104,6 +108,10 @@ import type { UpdateInterfaceParams, UpdateInterfaceResult, } from './update-interface'; +import type { + UpdateConfirmTransactionParams, + UpdateConfirmTransactionResult, +} from './update-confirm-transaction'; import type { Method } from '../../internals'; /** @@ -141,7 +149,15 @@ export type SnapMethods = { GetBackgroundEventsResult, ]; snap_createInterface: [CreateInterfaceParams, CreateInterfaceResult]; + snap_confirmTransaction: [ + ConfirmTransactionParams, + ConfirmTransactionResult, + ]; snap_updateInterface: [UpdateInterfaceParams, UpdateInterfaceResult]; + snap_updateConfirmTransaction: [ + UpdateConfirmTransactionParams, + UpdateConfirmTransactionResult, + ]; snap_getInterfaceState: [GetInterfaceStateParams, GetInterfaceStateResult]; snap_getInterfaceContext: [ GetInterfaceContextParams, diff --git a/packages/snaps-sdk/src/types/methods/update-confirm-transaction.ts b/packages/snaps-sdk/src/types/methods/update-confirm-transaction.ts new file mode 100644 index 0000000000..717e414a44 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/update-confirm-transaction.ts @@ -0,0 +1,12 @@ +import type { CaipAssetType, Json } from '@metamask/utils'; + +export type UpdateConfirmTransactionParams = { + id: string; + fee?: { + amount: string; + assetId?: CaipAssetType; + }; + custom?: Record; +}; + +export type UpdateConfirmTransactionResult = null; diff --git a/packages/snaps-sdk/src/types/permissions.ts b/packages/snaps-sdk/src/types/permissions.ts index 9e8fcfb843..22fa2d7e63 100644 --- a/packages/snaps-sdk/src/types/permissions.ts +++ b/packages/snaps-sdk/src/types/permissions.ts @@ -173,6 +173,8 @@ export type InitialPermissions = Partial<{ 'endowment:webassembly': EmptyObject; /* eslint-disable @typescript-eslint/naming-convention */ + snap_confirmTransaction: EmptyObject; + snap_updateConfirmTransaction: EmptyObject; snap_dialog: EmptyObject; snap_getBip32Entropy: Bip32Entropy[]; snap_getBip32PublicKey: Bip32Entropy[]; diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 51cda496df..2afe3e8c80 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -261,6 +261,8 @@ export const PermissionsStruct: Describe = type({ ), ), 'endowment:webassembly': optional(EmptyObjectStruct), + snap_confirmTransaction: optional(EmptyObjectStruct), + snap_updateConfirmTransaction: optional(EmptyObjectStruct), snap_dialog: optional(EmptyObjectStruct), snap_manageState: optional(EmptyObjectStruct), snap_manageAccounts: optional(EmptyObjectStruct),