UNPKG

@metamask/snaps-sdk

Version:

A library containing the core functionality for building MetaMask Snaps

785 lines 22.7 kB
import { is, boolean, optional, array, lazy, nullable, number, object, record, string, tuple, refine, assign, union } from "@metamask/superstruct"; import { CaipAccountIdStruct, CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, isPlainObject, JsonStruct } from "@metamask/utils"; import { IconName } from "./components/index.mjs"; import { literal, nullUnion, selectiveUnion, svg, typedUnion } from "../internals/index.mjs"; import { NonEip155AssetTypeStruct, NonEip155ChainIdStruct, NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct } from "../types/index.mjs"; /** * A struct for the {@link Key} type. */ export const KeyStruct = nullUnion([string(), number()]); /** * A struct for the {@link StringElement} type. */ export const StringElementStruct = children([ string(), ]); /** * A struct for the {@link GenericSnapElement} type. */ export const ElementStruct = object({ type: string(), props: record(string(), JsonStruct), key: nullable(KeyStruct), }); /** * A helper function for creating a struct for a {@link Nestable} type. * * @param struct - The struct for the type to test. * @returns The struct for the nestable type. */ function nestable(struct) { const nestableStruct = selectiveUnion((value) => { if (Array.isArray(value)) { return array(lazy(() => nestableStruct)); } return struct; }); return nestableStruct; } /** * A helper function for creating a struct which allows children of a specific * type, as well as `null` and `boolean`. * * @param structs - The structs to allow as children. * @returns The struct for the children. */ function children(structs) { const potentialUnion = structs.length === 1 ? structs[0] : nullUnion(structs); return nestable(nullable(selectiveUnion((value) => { if (typeof value === 'boolean') { return boolean(); } return potentialUnion; }))); } /** * A helper function for creating a struct which allows a single child of a specific * type, as well as `null` and `boolean`. * * @param struct - The struct to allow as a single child. * @returns The struct for the children. */ function singleChild(struct) { return nullable(selectiveUnion((value) => { if (typeof value === 'boolean') { return boolean(); } return struct; })); } /** * A helper function for creating a struct for a JSX element. * * @param name - The name of the element. * @param props - The props of the element. * @returns The struct for the element. */ function element(name, props = {}) { return object({ type: literal(name), props: object(props), key: nullable(KeyStruct), }); } /** * A helper function for creating a struct for a JSX element with selective props. * * @param name - The name of the element. * @param selector - The selector function choosing the struct to validate with. * @returns The struct for the element. */ function elementWithSelectiveProps(name, selector) { return object({ type: literal(name), props: selectiveUnion(selector), key: nullable(KeyStruct), }); } /** * Shared struct used to validate border radius values used by various Snaps components. */ export const BorderRadiusStruct = nullUnion([ literal('none'), literal('medium'), literal('full'), ]); /** * A struct for the {@link ImageElement} type. */ export const ImageStruct = element('Image', { src: svg(), alt: optional(string()), borderRadius: optional(BorderRadiusStruct), }); const IconNameStruct = nullUnion(Object.values(IconName).map((name) => literal(name))); /** * A struct for the {@link IconElement} type. */ export const IconStruct = element('Icon', { name: IconNameStruct, color: optional(nullUnion([literal('default'), literal('primary'), literal('muted')])), size: optional(nullUnion([literal('md'), literal('inherit')])), }); /** * A struct for the {@link ButtonElement} type. */ export const ButtonStruct = element('Button', { children: children([StringElementStruct, ImageStruct, IconStruct]), name: optional(string()), type: optional(nullUnion([literal('button'), literal('submit')])), variant: optional(nullUnion([literal('primary'), literal('destructive')])), size: optional(nullUnion([literal('sm'), literal('md')])), disabled: optional(boolean()), loading: optional(boolean()), form: optional(string()), }); /** * A struct for the {@link CheckboxElement} type. */ export const CheckboxStruct = element('Checkbox', { name: string(), checked: optional(boolean()), label: optional(string()), variant: optional(nullUnion([literal('default'), literal('toggle')])), disabled: optional(boolean()), }); /** * A struct for the generic input element props. */ export const GenericInputPropsStruct = object({ name: string(), value: optional(string()), placeholder: optional(string()), disabled: optional(boolean()), }); /** * A struct for the text type input props. */ export const TextInputPropsStruct = assign(GenericInputPropsStruct, object({ type: literal('text'), })); /** * A struct for the password type input props. */ export const PasswordInputPropsStruct = assign(GenericInputPropsStruct, object({ type: literal('password'), })); /** * A struct for the number type input props. */ export const NumberInputPropsStruct = assign(GenericInputPropsStruct, object({ type: literal('number'), min: optional(number()), max: optional(number()), step: optional(number()), })); /** * A struct for the {@link InputElement} type. */ export const InputStruct = elementWithSelectiveProps('Input', (value) => { if (isPlainObject(value) && hasProperty(value, 'type')) { switch (value.type) { case 'text': return TextInputPropsStruct; case 'password': return PasswordInputPropsStruct; case 'number': return NumberInputPropsStruct; default: return GenericInputPropsStruct; } } return GenericInputPropsStruct; }); /** * A struct for the {@link AddressInputElement} type. */ export const AddressInputStruct = element('AddressInput', { name: string(), chainId: CaipChainIdStruct, value: optional(string()), placeholder: optional(string()), disabled: optional(boolean()), displayAvatar: optional(boolean()), }); /** * A struct for the {@link OptionElement} type. */ export const OptionStruct = element('Option', { value: string(), children: string(), disabled: optional(boolean()), }); /** * A struct for the {@link DropdownElement} type. */ export const DropdownStruct = element('Dropdown', { name: string(), value: optional(string()), children: children([OptionStruct]), disabled: optional(boolean()), }); /** * A struct for the {@link AddressElement} type. */ export const AddressStruct = element('Address', { address: selectiveUnion((value) => { if (typeof value === 'string' && value.startsWith('0x')) { return HexChecksumAddressStruct; } return CaipAccountIdStruct; }), truncate: optional(boolean()), displayName: optional(boolean()), avatar: optional(boolean()), }); /** * A struct for the {@link CardElement} type. */ export const CardStruct = element('Card', { image: optional(string()), title: selectiveUnion((value) => { if (typeof value === 'object') { return AddressStruct; } return string(); }), description: optional(string()), value: string(), extra: optional(string()), }); /** * A struct for the {@link SelectorOptionElement} type. */ export const SelectorOptionStruct = element('SelectorOption', { value: string(), children: CardStruct, disabled: optional(boolean()), }); /** * A struct for the {@link SelectorElement} type. */ export const SelectorStruct = element('Selector', { name: string(), title: string(), value: optional(string()), children: children([SelectorOptionStruct]), disabled: optional(boolean()), }); /** * A struct for the {@link AssetSelectorElement} type. */ export const AssetSelectorStruct = element('AssetSelector', { name: string(), addresses: NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, chainIds: optional(array(NonEip155ChainIdStruct)), value: optional(NonEip155AssetTypeStruct), disabled: optional(boolean()), }); /** * A struct for the {@link RadioElement} type. */ export const RadioStruct = element('Radio', { value: string(), children: string(), disabled: optional(boolean()), }); /** * A struct for the {@link RadioGroupElement} type. */ export const RadioGroupStruct = element('RadioGroup', { name: string(), value: optional(string()), children: children([RadioStruct]), disabled: optional(boolean()), }); /** * A struct for the {@link FileInputElement} type. */ export const FileInputStruct = element('FileInput', { name: string(), accept: nullUnion([optional(array(string()))]), compact: optional(boolean()), disabled: optional(boolean()), }); /** * A subset of JSX elements that represent the tuple Box + Input of the Field children. */ const BOX_INPUT_LEFT = [ // eslint-disable-next-line @typescript-eslint/no-use-before-define singleChild(lazy(() => BoxChildStruct)), InputStruct, ]; /** * A subset of JSX elements that represent the tuple Input + Box of the Field children. */ const BOX_INPUT_RIGHT = [ InputStruct, // eslint-disable-next-line @typescript-eslint/no-use-before-define singleChild(lazy(() => BoxChildStruct)), ]; /** * A subset of JSX elements that represent the tuple Box + Input + Box of the Field children. */ const BOX_INPUT_BOTH = [ // eslint-disable-next-line @typescript-eslint/no-use-before-define singleChild(lazy(() => BoxChildStruct)), InputStruct, // eslint-disable-next-line @typescript-eslint/no-use-before-define singleChild(lazy(() => BoxChildStruct)), ]; /** * A subset of JSX elements that are allowed as single children of the Field component. */ const FIELD_CHILDREN_ARRAY = [ AssetSelectorStruct, AddressInputStruct, InputStruct, DropdownStruct, RadioGroupStruct, FileInputStruct, CheckboxStruct, SelectorStruct, ]; /** * A union of the allowed children of the Field component. * This is mainly used in the simulator for validation purposes. */ export const FieldChildUnionStruct = nullUnion([ ...FIELD_CHILDREN_ARRAY, ...BOX_INPUT_LEFT, ...BOX_INPUT_RIGHT, ...BOX_INPUT_BOTH, ]); /** * A subset of JSX elements that are allowed as children of the Field component. */ const FieldChildStruct = selectiveUnion((value) => { const isArray = Array.isArray(value); if (isArray && value.length === 3) { return tuple(BOX_INPUT_BOTH); } if (isArray && value.length === 2) { return value[0]?.type === 'Box' ? tuple(BOX_INPUT_LEFT) : tuple(BOX_INPUT_RIGHT); } return typedUnion(FIELD_CHILDREN_ARRAY); }); /** * A struct for the {@link FieldElement} type. */ export const FieldStruct = element('Field', { label: optional(string()), error: optional(string()), children: FieldChildStruct, }); /** * A struct for the {@link BoldElement} type. */ export const BoldStruct = element('Bold', { children: children([ string(), // eslint-disable-next-line @typescript-eslint/no-use-before-define lazy(() => ItalicStruct), ]), }); /** * A struct for the {@link ItalicElement} type. */ export const ItalicStruct = element('Italic', { children: children([ string(), lazy(() => BoldStruct), ]), }); export const FormattingStruct = typedUnion([BoldStruct, ItalicStruct]); /** * A struct for the {@link AvatarElement} type. */ export const AvatarStruct = element('Avatar', { address: CaipAccountIdStruct, size: optional(nullUnion([literal('sm'), literal('md'), literal('lg')])), }); export const BoxChildrenStruct = children( // eslint-disable-next-line @typescript-eslint/no-use-before-define [lazy(() => BoxChildStruct)]); /** * A struct for the {@link BoxElement} type. */ export const BoxStruct = element('Box', { children: BoxChildrenStruct, direction: optional(nullUnion([literal('horizontal'), literal('vertical')])), alignment: optional(nullUnion([ literal('start'), literal('center'), literal('end'), literal('space-between'), literal('space-around'), ])), crossAlignment: optional(nullUnion([literal('start'), literal('center'), literal('end')])), center: optional(boolean()), }); /** * A subset of JSX elements that are allowed as children of the Form component. */ export const FormChildStruct = BoxChildrenStruct; /** * A struct for the {@link FormElement} type. */ export const FormStruct = element('Form', { children: FormChildStruct, name: string(), }); const FooterButtonStruct = refine(ButtonStruct, 'FooterButton', (value) => { if (typeof value.props.children === 'string' || typeof value.props.children === 'boolean' || value.props.children === null) { return true; } if (Array.isArray(value.props.children)) { const hasNonTextElements = value.props.children.some((child) => typeof child !== 'string' && typeof child !== 'boolean' && child !== null); if (!hasNonTextElements) { return true; } } return 'Footer buttons may only contain text.'; }); /** * A struct for the {@link SectionElement} type. */ export const SectionStruct = element('Section', { children: BoxChildrenStruct, direction: optional(nullUnion([literal('horizontal'), literal('vertical')])), alignment: optional(nullUnion([ literal('start'), literal('center'), literal('end'), literal('space-between'), literal('space-around'), ])), }); /** * A subset of JSX elements that are allowed as children of the Footer component. * This set should include a single button or a tuple of two buttons. */ export const FooterChildStruct = selectiveUnion((value) => { if (Array.isArray(value)) { return tuple([FooterButtonStruct, FooterButtonStruct]); } return FooterButtonStruct; }); /** * A struct for the {@link FooterElement} type. */ export const FooterStruct = element('Footer', { children: FooterChildStruct, }); /** * A struct for the {@link CopyableElement} type. */ export const CopyableStruct = element('Copyable', { value: string(), sensitive: optional(boolean()), }); /** * A struct for the {@link DividerElement} type. */ export const DividerStruct = element('Divider'); /** * A struct for the {@link HeadingElement} type. */ export const HeadingStruct = element('Heading', { children: StringElementStruct, size: optional(nullUnion([literal('sm'), literal('md'), literal('lg')])), }); /** * A struct for the {@link LinkElement} type. */ export const LinkStruct = element('Link', { href: string(), children: children([ FormattingStruct, string(), IconStruct, ImageStruct, AddressStruct, ]), }); /** * A struct for the {@link SkeletonElement} type. */ export const SkeletonStruct = element('Skeleton', { width: optional(union([number(), string()])), height: optional(union([number(), string()])), borderRadius: optional(BorderRadiusStruct), }); /** * A struct for the {@link TextElement} type. */ export const TextStruct = element('Text', { children: children([ selectiveUnion((value) => { if (typeof value === 'string') { return string(); } return typedUnion([ BoldStruct, ItalicStruct, LinkStruct, IconStruct, SkeletonStruct, ]); }), ]), alignment: optional(nullUnion([literal('start'), literal('center'), literal('end')])), color: optional(nullUnion([ literal('default'), literal('alternative'), literal('muted'), literal('error'), literal('success'), literal('warning'), ])), size: optional(nullUnion([literal('sm'), literal('md')])), fontWeight: optional(nullUnion([literal('regular'), literal('medium'), literal('bold')])), }); /** * A struct for the {@link ValueElement} type. */ export const ValueStruct = element('Value', { value: selectiveUnion((value) => { if (typeof value === 'string') { return string(); } return TextStruct; }), extra: selectiveUnion((value) => { if (typeof value === 'string') { return string(); } return TextStruct; }), }); /** * A subset of JSX elements that are allowed as children of the Tooltip component. * This set should include all text components and the Image. */ export const TooltipChildStruct = selectiveUnion((value) => { if (typeof value === 'boolean') { return boolean(); } return typedUnion([ TextStruct, BoldStruct, ItalicStruct, LinkStruct, ImageStruct, IconStruct, ]); }); /** * A subset of JSX elements that are allowed as content of the Tooltip component. * This set should include all text components. */ export const TooltipContentStruct = selectiveUnion((value) => { if (typeof value === 'string') { return string(); } return typedUnion([ TextStruct, BoldStruct, ItalicStruct, LinkStruct, IconStruct, ]); }); /** * A struct for the {@link TooltipElement} type. */ export const TooltipStruct = element('Tooltip', { children: nullable(TooltipChildStruct), content: TooltipContentStruct, }); /** * A struct for the {@link BannerElement} type. */ export const BannerStruct = element('Banner', { children: children([ TextStruct, LinkStruct, IconStruct, ButtonStruct, BoldStruct, ItalicStruct, SkeletonStruct, ]), title: string(), severity: union([ literal('danger'), literal('info'), literal('success'), literal('warning'), ]), }); /** * A struct for the {@link RowElement} type. */ export const RowStruct = element('Row', { label: string(), children: typedUnion([ AddressStruct, ImageStruct, TextStruct, ValueStruct, LinkStruct, SkeletonStruct, ]), variant: optional(nullUnion([literal('default'), literal('warning'), literal('critical')])), tooltip: optional(string()), }); /** * A struct for the {@link SpinnerElement} type. */ export const SpinnerStruct = element('Spinner'); /** * A subset of JSX elements that are allowed as children of the Box component. * This set includes all components, except components that need to be nested in * another component (e.g., Field must be contained in a Form). */ export const BoxChildStruct = typedUnion([ AddressStruct, AssetSelectorStruct, AddressInputStruct, BoldStruct, BoxStruct, ButtonStruct, CopyableStruct, DividerStruct, DropdownStruct, RadioGroupStruct, FieldStruct, FileInputStruct, FormStruct, HeadingStruct, InputStruct, ImageStruct, ItalicStruct, LinkStruct, RowStruct, SpinnerStruct, TextStruct, TooltipStruct, CheckboxStruct, CardStruct, IconStruct, SelectorStruct, SectionStruct, AvatarStruct, BannerStruct, SkeletonStruct, ]); /** * A struct for the {@link ContainerElement} type. */ export const ContainerStruct = element('Container', { children: selectiveUnion((value) => { if (Array.isArray(value)) { return tuple([BoxChildStruct, FooterStruct]); } return BoxChildStruct; }), backgroundColor: optional(nullUnion([literal('default'), literal('alternative')])), }); /** * For now, the allowed JSX elements at the root are the same as the allowed * children of the Box component. */ export const RootJSXElementStruct = typedUnion([ BoxChildStruct, ContainerStruct, ]); /** * A struct for the {@link JSXElement} type. */ export const JSXElementStruct = typedUnion([ AssetSelectorStruct, AddressInputStruct, ButtonStruct, InputStruct, FileInputStruct, FieldStruct, FormStruct, BoldStruct, ItalicStruct, AddressStruct, BoxStruct, CopyableStruct, DividerStruct, HeadingStruct, ImageStruct, LinkStruct, RowStruct, SpinnerStruct, TextStruct, DropdownStruct, OptionStruct, RadioGroupStruct, RadioStruct, ValueStruct, TooltipStruct, CheckboxStruct, FooterStruct, ContainerStruct, CardStruct, IconStruct, SelectorStruct, SelectorOptionStruct, SectionStruct, AvatarStruct, BannerStruct, SkeletonStruct, ]); /** * Check if a value is a JSX element. * * @param value - The value to check. * @returns True if the value is a JSX element, false otherwise. */ export function isJSXElement(value) { return is(value, JSXElementStruct); } /** * Check if a value is a JSX element, without validating all of its contents. * This is useful when you want to validate the structure of a value, but not * all the children. * * This should only be used when you are sure that the value is safe to use, * i.e., after using {@link isJSXElement}. * * @param value - The value to check. * @returns True if the value is a JSX element, false otherwise. */ export function isJSXElementUnsafe(value) { return (isPlainObject(value) && hasProperty(value, 'type') && hasProperty(value, 'props') && hasProperty(value, 'key')); } /** * Assert that a value is a JSX element. * * @param value - The value to check. * @throws If the value is not a JSX element. */ export function assertJSXElement(value) { // TODO: We should use the error parsing utils from `snaps-utils` to improve // the error messages. It currently includes colours and potentially other // formatting that we might not want to include in the SDK. if (!isJSXElement(value)) { throw new Error(`Expected a JSX element, but received ${JSON.stringify(value)}. Please refer to the documentation for the supported JSX elements and their props.`); } } //# sourceMappingURL=validation.mjs.map