From 49ce0fad306df3640dccbc1b742e5af626135098 Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Mon, 22 Jun 2026 15:27:49 +1200 Subject: [PATCH 1/2] feat: add permission schema and MetaMask facilitator address utilities --- packages/7715-permission-types/src/index.ts | 1 + .../src/permissions/index.ts | 1 + .../src/permissions/schema/constants.ts | 12 + .../schema/facilitatorAddresses.ts | 44 ++ .../src/permissions/schema/index.ts | 698 ++++++++++++++++++ .../src/permissions/schema/types.ts | 170 +++++ .../src/permissions/schema/utils.ts | 136 ++++ .../test/permissions/schema.test.ts | 51 ++ 8 files changed, 1113 insertions(+) create mode 100644 packages/7715-permission-types/src/permissions/schema/constants.ts create mode 100644 packages/7715-permission-types/src/permissions/schema/facilitatorAddresses.ts create mode 100644 packages/7715-permission-types/src/permissions/schema/index.ts create mode 100644 packages/7715-permission-types/src/permissions/schema/types.ts create mode 100644 packages/7715-permission-types/src/permissions/schema/utils.ts create mode 100644 packages/7715-permission-types/test/permissions/schema.test.ts diff --git a/packages/7715-permission-types/src/index.ts b/packages/7715-permission-types/src/index.ts index fa595af4..27395d50 100644 --- a/packages/7715-permission-types/src/index.ts +++ b/packages/7715-permission-types/src/index.ts @@ -25,3 +25,4 @@ export { type DeployedContractsByName, type PermissionDecoderConfig, } from './permissions'; +export * from './permissions/schema'; diff --git a/packages/7715-permission-types/src/permissions/index.ts b/packages/7715-permission-types/src/permissions/index.ts index 12aeec06..525bf8b3 100644 --- a/packages/7715-permission-types/src/permissions/index.ts +++ b/packages/7715-permission-types/src/permissions/index.ts @@ -12,6 +12,7 @@ export type { ExpiryRule } from './rules/expiry'; export type { PayeeRule } from './rules/payee'; export type { RedeemerRule } from './rules/redeemer'; export type { DeployedContractsByName, PermissionDecoderConfig }; +export * from './schema'; /** * Builds the canonical set of permission decoders for a chain. * diff --git a/packages/7715-permission-types/src/permissions/schema/constants.ts b/packages/7715-permission-types/src/permissions/schema/constants.ts new file mode 100644 index 00000000..929840e3 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/schema/constants.ts @@ -0,0 +1,12 @@ +/** Milliseconds in common time periods. */ +export const SECOND = 1000; +export const HOUR = 60 * 60 * SECOND; +export const DAY = 24 * HOUR; +export const WEEK = 7 * DAY; +export const FORTNIGHT = 2 * WEEK; +export const MONTH = 30 * DAY; +export const YEAR = 365 * DAY; + +/** Maximum uint256 value as lowercase hex. */ +export const MAX_UINT256 = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; diff --git a/packages/7715-permission-types/src/permissions/schema/facilitatorAddresses.ts b/packages/7715-permission-types/src/permissions/schema/facilitatorAddresses.ts new file mode 100644 index 00000000..ec27cb10 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/schema/facilitatorAddresses.ts @@ -0,0 +1,44 @@ +export const METAMASK_FACILITATOR_ADDRESSES = [ + '0xB01caEa8c6C47bbf4F4b4c5080Ca642043359C2E', + '0xC066ac5D385419B1A8c43A0E146fA439837a8B8c', + '0xB42F812A44c22cc6b861478900401ee759EbEAD6', +] as const; + +export const METAMASK_FACILITATOR_ADDRESSES_DEV = [ + '0xb4827A2a066CD2Ef88560EFdf063dD05C6c41cC7', +] as const; + +export const ALL_METAMASK_FACILITATOR_ADDRESSES = [ + ...METAMASK_FACILITATOR_ADDRESSES, + ...METAMASK_FACILITATOR_ADDRESSES_DEV, +] as const; + +const METAMASK_FACILITATOR_ADDRESSES_LOWERCASE = new Set( + ALL_METAMASK_FACILITATOR_ADDRESSES.map((address) => address.toLowerCase()), +); + +/** + * Checks whether an address is a known MetaMask facilitator address. + * + * @param address - Address to check. + * @returns True if the address is a known MetaMask facilitator address. + */ +export function isMetaMaskFacilitatorAddress(address: string): boolean { + return METAMASK_FACILITATOR_ADDRESSES_LOWERCASE.has(address.toLowerCase()); +} + +/** + * Checks whether every provided address is a known MetaMask facilitator address. + * + * @param addresses - Addresses to check. + * @returns True when the list is non-empty and all entries are known facilitators. + */ +export function areOnlyMetaMaskFacilitatorAddresses( + addresses: string[] | null | undefined, +): boolean { + if (!addresses?.length) { + return false; + } + + return addresses.every(isMetaMaskFacilitatorAddress); +} diff --git a/packages/7715-permission-types/src/permissions/schema/index.ts b/packages/7715-permission-types/src/permissions/schema/index.ts new file mode 100644 index 00000000..046696bd --- /dev/null +++ b/packages/7715-permission-types/src/permissions/schema/index.ts @@ -0,0 +1,698 @@ +import type { Hex } from '@metamask/utils'; + +import { DAY, MAX_UINT256 } from './constants'; +import { areOnlyMetaMaskFacilitatorAddresses } from './facilitatorAddresses'; +import type { + I18nValue, + PermissionRenderContext, + PermissionSchemaEntry, + PermissionSchemaRegistry, + SchemaSection, +} from './types'; +import { + convertAmountPerSecondToAmountPerPeriod, + formatPermissionPeriodDuration, + getPeriodFrequencyValueTranslationKey, + parseHexPermissionAmount, +} from './utils'; + +export { + DAY, + FORTNIGHT, + HOUR, + MAX_UINT256, + MONTH, + SECOND, + WEEK, + YEAR, +} from './constants'; +export { + ALL_METAMASK_FACILITATOR_ADDRESSES, + METAMASK_FACILITATOR_ADDRESSES, + METAMASK_FACILITATOR_ADDRESSES_DEV, + areOnlyMetaMaskFacilitatorAddresses, + isMetaMaskFacilitatorAddress, +} from './facilitatorAddresses'; +export type { + AccountField, + AddressField, + AmountField, + DateField, + DeepNonNullable, + DividerElement, + ExpiryField, + FieldView, + I18nFunction, + I18nValue, + JustificationField, + ListField, + NetworkField, + OriginField, + PermissionRenderContext, + PermissionSchemaEntry, + PermissionSchemaRegistry, + RawTextField, + ReviewFieldView, + RuleAddressField, + SchemaElement, + SchemaSection, + TextField, + TokenResolution, + TokenVariant, +} from './types'; +export { + convertAmountPerSecondToAmountPerPeriod, + convertMillisecondsToSeconds, + formatPermissionPeriodDuration, + getPeriodFrequencyValueTranslationKey, + parseHexPermissionAmount, +} from './utils'; + +const getData = ( + ctx: PermissionRenderContext, + key: string, +): TReturn => ctx.permission.data[key] as TReturn; + +/** + * Reads stream total exposure from the schema context. + * + * @param ctx - Permission render context. + * @returns Total exposure, or null for unlimited exposure. + */ +function getStreamTotalExposure(ctx: PermissionRenderContext): bigint | null { + if (ctx.streamTotalExposure === undefined) { + throw new Error( + 'PermissionRenderContext.streamTotalExposure must be set when rendering stream permission fields', + ); + } + return ctx.streamTotalExposure; +} + +const requireStartTime = (permission: { + data: Record; +}): void => { + if (!permission.data.startTime) { + throw new Error('Start time is required'); + } +}; + +const alwaysVisible = (): boolean => true; + +const getJustificationValue = ( + ctx: PermissionRenderContext, +): string | I18nValue => { + if (ctx.permission.justification) { + return ctx.permission.justification; + } + return { key: 'gatorNoJustificationProvided' }; +}; + +const TOKEN_APPROVAL_REVOCATION_METHODS: { + key: string; + translationKey: string; +}[] = [ + { + key: 'erc20Approve', + translationKey: 'gatorPermissionsErc20ApproveRevocation', + }, + { + key: 'erc721Approve', + translationKey: 'gatorPermissionsErc721ApproveRevocation', + }, + { + key: 'erc721SetApprovalForAll', + translationKey: 'gatorPermissionsSetApprovalForAllRevocation', + }, + { + key: 'permit2Approve', + translationKey: 'gatorPermissionsPermit2ApproveRevocation', + }, + { + key: 'permit2Lockdown', + translationKey: 'gatorPermissionsPermit2Lockdown', + }, + { + key: 'permit2InvalidateNonces', + translationKey: 'gatorPermissionsPermit2InvalidateNonces', + }, +]; + +const TOKEN_APPROVAL_REVOCATION_PRIMITIVE_KEYS = + TOKEN_APPROVAL_REVOCATION_METHODS.map(({ key }) => key); + +/** + * Gets translation keys for enabled token approval revocation methods. + * + * @param ctx - Permission render context. + * @returns Translation keys for enabled revocation methods. + */ +function getEnabledTokenApprovalRevocationMethods( + ctx: PermissionRenderContext, +): string[] { + return TOKEN_APPROVAL_REVOCATION_METHODS.filter(({ key }) => + Boolean(getData(ctx, key)), + ).map(({ translationKey }) => translationKey); +} + +/** + * Checks whether all token approval revocation primitives are enabled. + * + * @param ctx - Permission render context. + * @returns True when every token approval revocation primitive is enabled. + */ +function hasAllTokenApprovalRevocationPrimitivesEnabled( + ctx: PermissionRenderContext, +): boolean { + return TOKEN_APPROVAL_REVOCATION_PRIMITIVE_KEYS.every((key) => + Boolean(getData(ctx, key)), + ); +} + +const justificationSection: SchemaSection = { + testId: 'confirmation_justification-section', + elements: [ + { + type: 'justification', + labelKey: 'gatorPermissionsJustification', + testId: 'review-gator-permission-justification', + getValue: getJustificationValue, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'account', + labelKey: 'account', + testId: 'review-gator-permission-account-name', + getValue: () => undefined, + isVisible: alwaysVisible, + includeInViews: ['confirmation'], + }, + ], +}; + +const reviewSummaryAccountSection: SchemaSection = { + testId: 'review_summary-account-section', + elements: [ + { + type: 'account', + labelKey: 'account', + testId: 'review-gator-permission-account-name', + getValue: () => undefined, + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + ], +}; + +const permissionInfoSection: SchemaSection = { + testId: 'confirmation_permission-section', + elements: [ + { + type: 'origin', + labelKey: 'requestFrom', + testId: 'confirmation-origin', + getValue: (ctx) => ctx.origin, + isVisible: alwaysVisible, + includeInViews: ['confirmation'], + }, + { + type: 'address', + labelKey: 'recipient', + testId: 'confirmation-recipient', + getValue: (ctx) => ctx.to, + isVisible: (ctx) => Boolean(ctx.to), + includeInViews: ['confirmation'], + }, + { type: 'network', includeInViews: ['confirmation', 'reviewDetail'] }, + { + type: 'text', + labelKey: 'redeemers', + testId: 'confirmation-redeemer-metamask-facilitator', + getValue: () => ({ key: 'gatorPermissionsMetaMaskFacilitator' }), + isVisible: (ctx) => + areOnlyMetaMaskFacilitatorAddresses(ctx.redeemerAddresses), + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'rule-address', + labelKey: 'redeemer', + testId: 'confirmation-redeemer', + getValue: (ctx) => ctx.redeemerAddresses ?? undefined, + isVisible: (ctx) => + Boolean(ctx.redeemerAddresses?.length) && + !areOnlyMetaMaskFacilitatorAddresses(ctx.redeemerAddresses), + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'rule-address', + labelKey: 'payee', + testId: 'confirmation-payee', + getValue: (ctx) => ctx.payeeAddresses ?? undefined, + isVisible: (ctx) => Boolean(ctx.payeeAddresses?.length), + includeInViews: ['confirmation', 'reviewDetail'], + }, + ], +}; + +const periodicDetailsSection = ( + testId: string, + tokenAddressKey?: string, +): SchemaSection => ({ + testId, + elements: [ + { + type: 'amount', + labelKey: 'amount', + testId: 'review-gator-permission-amount-label', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'periodAmount')), + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + { + type: 'text', + labelKey: 'gatorPermissionTokenPeriodicFrequencyLabel', + testId: 'review-gator-permission-frequency-label', + getValue: (ctx) => ({ + key: getPeriodFrequencyValueTranslationKey( + getData(ctx, 'periodDuration'), + ), + }), + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + { + type: 'amount', + labelKey: 'confirmFieldAllowance', + testId: 'confirmation-allowance', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'periodAmount')), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: alwaysVisible, + includeInViews: ['confirmation'], + }, + { + type: 'text', + labelKey: 'confirmFieldFrequency', + testId: 'confirmation-frequency', + getValue: (ctx) => + formatPermissionPeriodDuration(getData(ctx, 'periodDuration')), + isVisible: alwaysVisible, + includeInViews: ['confirmation'], + }, + { type: 'divider', includeInViews: ['confirmation'] }, + { + type: 'date', + labelKey: 'gatorPermissionsStartDate', + testId: 'review-gator-permission-start-date', + getValue: (ctx) => getData(ctx, 'startTime'), + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'expiry', + labelKey: 'gatorPermissionsExpirationDate', + testId: 'review-gator-permission-expiration-date', + getValue: (ctx) => ctx.expiry, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + ], +}); + +const streamDetailsSection = ( + testId: string, + tokenAddressKey?: string, +): SchemaSection => ({ + testId, + elements: [ + { + type: 'amount', + labelKey: 'gatorPermissionsStreamingAmountLabel', + testId: 'review-gator-permission-amount-label', + getValue: (ctx) => + parseHexPermissionAmount( + convertAmountPerSecondToAmountPerPeriod( + getData(ctx, 'amountPerSecond'), + 'weekly', + ), + ), + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + { + type: 'text', + labelKey: 'gatorPermissionTokenStreamFrequencyLabel', + testId: 'review-gator-permission-frequency-label', + getValue: () => ({ key: 'gatorPermissionWeeklyFrequency' }), + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + { + type: 'amount', + labelKey: 'gatorPermissionsInitialAllowance', + testId: 'review-gator-permission-initial-allowance', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'initialAmount')), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: (ctx): boolean => Boolean(getData(ctx, 'initialAmount')), + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'amount', + labelKey: 'gatorPermissionsMaxAllowance', + testId: 'review-gator-permission-max-allowance', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'maxAmount')), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: (ctx): boolean => { + const max = getData(ctx, 'maxAmount'); + return ( + max !== undefined && max !== null && max.toLowerCase() !== MAX_UINT256 + ); + }, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'text', + labelKey: 'gatorPermissionsMaxAllowance', + testId: 'review-gator-permission-max-allowance-unlimited', + getValue: () => ({ key: 'unlimited' }), + isVisible: (ctx): boolean => { + const max = getData(ctx, 'maxAmount'); + return Boolean(max?.toLowerCase() === MAX_UINT256); + }, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { type: 'divider', includeInViews: ['confirmation'] }, + { + type: 'date', + labelKey: 'gatorPermissionsStartDate', + testId: 'review-gator-permission-start-date', + getValue: (ctx) => getData(ctx, 'startTime'), + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'expiry', + labelKey: 'gatorPermissionsExpirationDate', + testId: 'review-gator-permission-expiration-date', + getValue: (ctx) => ctx.expiry, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + ], +}); + +const streamRateSection = ( + testId: string, + tokenAddressKey?: string, +): SchemaSection => ({ + testId, + elements: [ + { + type: 'amount', + labelKey: 'gatorPermissionsStreamRate', + testId: 'review-gator-permission-stream-rate', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'amountPerSecond')), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isRatePerSecond: true, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'amount', + labelKey: 'confirmFieldAvailablePerDay', + testId: 'confirmation-available-per-day', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'amountPerSecond')) * + BigInt(DAY / 1000), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: alwaysVisible, + includeInViews: ['confirmation'], + }, + { + type: 'amount', + labelKey: 'confirmFieldTotalExposure', + testId: 'confirmation-total-exposure', + getValue: (ctx) => getStreamTotalExposure(ctx) ?? 0n, + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: (ctx): boolean => getStreamTotalExposure(ctx) !== null, + includeInViews: ['confirmation'], + }, + { + type: 'text', + labelKey: 'confirmFieldTotalExposure', + testId: 'confirmation-total-exposure-unlimited', + getValue: () => ({ key: 'unlimited' }), + isVisible: (ctx): boolean => getStreamTotalExposure(ctx) === null, + includeInViews: ['confirmation'], + }, + ], +}); + +const allowanceDetailsSection = ( + testId: string, + tokenAddressKey?: string, +): SchemaSection => ({ + testId, + elements: [ + { + type: 'amount', + labelKey: 'amount', + testId: 'review-gator-permission-amount-label', + getValue: (ctx) => + parseHexPermissionAmount(getData(ctx, 'allowanceAmount')), + getTokenAddress: tokenAddressKey + ? (ctx): Hex => getData(ctx, tokenAddressKey) + : undefined, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewSummary'], + }, + { + type: 'date', + labelKey: 'gatorPermissionsStartDate', + testId: 'review-gator-permission-start-date', + getValue: (ctx) => getData(ctx, 'startTime'), + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'expiry', + labelKey: 'gatorPermissionsExpirationDate', + testId: 'review-gator-permission-expiration-date', + getValue: (ctx) => ctx.expiry, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + ], +}); + +const nativeTokenPeriodicSchema: PermissionSchemaEntry = { + tokenVariant: 'native', + tokenResolution: { kind: 'native' }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + periodicDetailsSection('native-token-periodic-details-section'), + reviewSummaryAccountSection, + ], +}; + +const nativeTokenStreamSchema: PermissionSchemaEntry = { + tokenVariant: 'native', + tokenResolution: { kind: 'native' }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + streamDetailsSection('native-token-stream-details-section'), + streamRateSection('native-token-stream-stream-rate-section'), + reviewSummaryAccountSection, + ], +}; + +const nativeTokenAllowanceSchema: PermissionSchemaEntry = { + tokenVariant: 'native', + tokenResolution: { kind: 'native' }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + allowanceDetailsSection('native-token-allowance-details-section'), + reviewSummaryAccountSection, + ], +}; + +const erc20TokenPeriodicSchema: PermissionSchemaEntry = { + tokenVariant: 'erc20', + tokenResolution: { + kind: 'erc20', + getTokenAddress: (permission) => permission.data.tokenAddress as string, + }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + periodicDetailsSection( + 'erc20-token-periodic-details-section', + 'tokenAddress', + ), + reviewSummaryAccountSection, + ], +}; + +const erc20TokenStreamSchema: PermissionSchemaEntry = { + tokenVariant: 'erc20', + tokenResolution: { + kind: 'erc20', + getTokenAddress: (permission) => permission.data.tokenAddress as string, + }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + streamDetailsSection('erc20-token-stream-details-section', 'tokenAddress'), + streamRateSection('erc20-token-stream-stream-rate-section', 'tokenAddress'), + reviewSummaryAccountSection, + ], +}; + +const erc20TokenAllowanceSchema: PermissionSchemaEntry = { + tokenVariant: 'erc20', + tokenResolution: { + kind: 'erc20', + getTokenAddress: (permission) => permission.data.tokenAddress as string, + }, + validate: requireStartTime, + sections: [ + justificationSection, + permissionInfoSection, + allowanceDetailsSection( + 'erc20-token-allowance-details-section', + 'tokenAddress', + ), + reviewSummaryAccountSection, + ], +}; + +const tokenApprovalRevocationSchema: PermissionSchemaEntry = { + tokenVariant: 'none', + tokenResolution: { kind: 'none' }, + sections: [ + justificationSection, + permissionInfoSection, + { + testId: 'token-approval-revocation-details-section', + elements: [ + { + type: 'text', + labelKey: 'revokeTokenApprovals', + testId: 'review-gator-permission-amount-label', + getValue: () => ({ key: 'allTokens' }), + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + { + type: 'text', + labelKey: 'gatorPermissionsRevocationMethods', + testId: + 'review-gator-permission-all-token-approval-revocation-primitives', + getValue: () => ({ + key: 'gatorPermissionsAllTokenApprovalRevocationPrimitives', + }), + isVisible: (ctx) => + hasAllTokenApprovalRevocationPrimitivesEnabled(ctx), + includeInViews: ['confirmation', 'reviewDetail'], + }, + { + type: 'list', + labelKey: 'gatorPermissionsRevocationMethods', + testId: 'review-gator-permission-revocation-methods', + getValue: (ctx) => getEnabledTokenApprovalRevocationMethods(ctx), + isVisible: (ctx) => + !hasAllTokenApprovalRevocationPrimitivesEnabled(ctx), + includeInViews: ['confirmation', 'reviewDetail'], + }, + { type: 'divider', includeInViews: ['confirmation'] }, + { + type: 'expiry', + labelKey: 'gatorPermissionsExpirationDate', + testId: 'review-gator-permission-expiration-date', + getValue: (ctx) => ctx.expiry, + isVisible: alwaysVisible, + includeInViews: ['confirmation', 'reviewDetail'], + }, + ], + }, + reviewSummaryAccountSection, + ], +}; + +const unknownPermissionTypeSchema: PermissionSchemaEntry = { + tokenVariant: 'none', + tokenResolution: { kind: 'none' }, + sections: [ + justificationSection, + permissionInfoSection, + { + testId: 'unknown-permission-type-details-section', + elements: [ + { + type: 'raw-text', + labelKey: 'unknownPermissionType', + testId: 'review-gator-permission-unknown-type', + getValue: (ctx) => ctx.permission.type, + isVisible: alwaysVisible, + includeInViews: ['reviewSummary'], + }, + ], + }, + ], +}; + +const PERMISSION_SCHEMAS: PermissionSchemaRegistry = { + 'native-token-periodic': nativeTokenPeriodicSchema, + 'native-token-stream': nativeTokenStreamSchema, + 'native-token-allowance': nativeTokenAllowanceSchema, + 'erc20-token-periodic': erc20TokenPeriodicSchema, + 'erc20-token-stream': erc20TokenStreamSchema, + 'erc20-token-allowance': erc20TokenAllowanceSchema, + 'token-approval-revocation': tokenApprovalRevocationSchema, +}; + +/** + * Gets the schema entry for a permission type. + * + * @param permissionType - Permission type identifier. + * @param throwIfUnknown - Whether to throw instead of returning the unknown schema. + * @returns The matching schema, or the unknown permission schema fallback. + */ +export function getPermissionSchemaEntry( + permissionType: string, + throwIfUnknown: boolean = false, +): PermissionSchemaEntry { + const matchingSchema = PERMISSION_SCHEMAS[permissionType]; + if (matchingSchema) { + return matchingSchema; + } + if (throwIfUnknown) { + throw new Error(`Unknown permission type: ${permissionType}`); + } + + return unknownPermissionTypeSchema; +} diff --git a/packages/7715-permission-types/src/permissions/schema/types.ts b/packages/7715-permission-types/src/permissions/schema/types.ts new file mode 100644 index 00000000..29d1c255 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/schema/types.ts @@ -0,0 +1,170 @@ +import type { Hex } from '@metamask/utils'; + +/** Recursively strips `null` and `undefined` from all properties. */ +export type DeepNonNullable = TObj extends object + ? { [K in keyof TObj]-?: DeepNonNullable> } + : NonNullable; + +export type I18nFunction = ( + key: string, + args?: (string | number | undefined | null)[], +) => string; + +/** A translatable value: an i18n key with optional interpolation args. */ +export type I18nValue = { + key: string; + args?: (string | number)[]; +}; + +/** Views in which a schema element can appear. */ +export type FieldView = 'confirmation' | 'reviewDetail' | 'reviewSummary'; + +/** Gator review surfaces only. */ +export type ReviewFieldView = Exclude; + +/** + * Context passed to schema accessors. Renderers build this from decoded + * permission data plus any pre-resolved async data. + */ +export type PermissionRenderContext = { + permission: { + type: string; + data: Record; + justification?: string; + }; + /** Expiry timestamp in Unix seconds, or null if no expiry. */ + expiry: number | null; + redeemerAddresses?: string[] | null; + payeeAddresses?: string[] | null; + /** Chain ID in hex format. */ + chainId: Hex; + /** The origin URL of the request. Only required for confirmation views. */ + origin?: string; + /** The recipient / delegate address, if present. */ + to?: string; + /** Pre-resolved token info. Present when tokenResolution.kind is native or erc20. */ + tokenInfo?: { + symbol: string; + decimals: number | undefined; + imageUrl?: string; + }; + /** + * Total exposure for stream permissions. Omitted for other permission types. + * Null means unlimited. + */ + streamTotalExposure?: bigint | null; +}; + +/** Whether an amount field is for a native token or an ERC20 token. */ +export type TokenVariant = 'native' | 'erc20'; + +/** Shared config for schema rows that read a value from render context. */ +export type BaseField = { + type: TType; + labelKey: string; + testId: string; + getValue: (ctx: PermissionRenderContext) => TValueType; + isVisible: (ctx: PermissionRenderContext) => boolean; + includeInViews: FieldView[]; +}; + +type TooltipFieldConfig = { + tooltip?: string; +}; + +/** An amount field. Renderers decide formatting. */ +export type AmountField = BaseField<'amount', bigint> & + TooltipFieldConfig & { + /** For ERC20 amounts, returns the token contract address. */ + getTokenAddress?: (ctx: PermissionRenderContext) => Hex; + /** If true, the review renderer appends "/sec" to the formatted value. */ + isRatePerSecond?: boolean; + }; + +/** A plain text row. */ +export type TextField = BaseField<'text', I18nValue> & TooltipFieldConfig; + +/** A plain text row whose value is rendered verbatim. */ +export type RawTextField = BaseField<'raw-text', string> & TooltipFieldConfig; + +/** A list row whose values are i18n keys. */ +export type ListField = BaseField<'list', string[]> & TooltipFieldConfig; + +/** A date/time row. */ +export type DateField = BaseField<'date', number> & TooltipFieldConfig; + +/** An expiry row. Renderers handle the "never expires" case. */ +export type ExpiryField = BaseField<'expiry', number | null>; + +/** A visual divider between rows. */ +export type DividerElement = { + type: 'divider'; + includeInViews: FieldView[]; +}; + +/** Displays the justification text. */ +export type JustificationField = BaseField<'justification', string | I18nValue>; + +/** Displays the account row. */ +export type AccountField = BaseField<'account', undefined>; + +/** Displays the request origin URL. */ +export type OriginField = BaseField<'origin', string | undefined> & + TooltipFieldConfig; + +/** Displays a recipient / delegate address. */ +export type AddressField = BaseField<'address', string | undefined>; + +/** Displays addresses extracted from permission rules. */ +export type RuleAddressField = BaseField<'rule-address', string[] | undefined>; + +/** Displays the network row. */ +export type NetworkField = { + type: 'network'; + includeInViews: FieldView[]; +}; + +/** Union of all renderable items within a section. */ +export type SchemaElement = + | AmountField + | TextField + | RawTextField + | ListField + | DateField + | ExpiryField + | DividerElement + | JustificationField + | AccountField + | OriginField + | AddressField + | NetworkField + | RuleAddressField; + +/** A section groups elements visually. */ +export type SchemaSection = { + testId: string; + elements: SchemaElement[]; +}; + +/** Token data the renderer should resolve before rendering. */ +export type TokenResolution = + | { kind: 'native' } + | { + kind: 'erc20'; + getTokenAddress: (permission: { + data: Record; + }) => string; + } + | { kind: 'none' }; + +/** A complete schema entry for one permission type. */ +export type PermissionSchemaEntry = { + tokenVariant: TokenVariant | 'none'; + tokenResolution: TokenResolution; + /** Optional validation run before rendering. Throw to trigger renderer error handling. */ + validate?: (permission: { data: Record }) => void; + sections: SchemaSection[]; +}; + +/** Maps permission type strings to their schema entries. */ +export type PermissionSchemaRegistry = Record; diff --git a/packages/7715-permission-types/src/permissions/schema/utils.ts b/packages/7715-permission-types/src/permissions/schema/utils.ts new file mode 100644 index 00000000..f11e1931 --- /dev/null +++ b/packages/7715-permission-types/src/permissions/schema/utils.ts @@ -0,0 +1,136 @@ +import { bigIntToHex, type Hex, hexToBigInt } from '@metamask/utils'; + +import { DAY, FORTNIGHT, HOUR, MONTH, SECOND, WEEK, YEAR } from './constants'; +import type { I18nValue } from './types'; + +/** + * Parses a permission amount string as an unsigned EVM integer. + * + * Strings without a `0x` prefix are still interpreted as hexadecimal, not + * decimal. That matches uint values from RPC / typed data. + * + * @param value - Hex string with or without `0x` / `0X` prefix. + * @returns The parsed amount as a bigint. + */ +export function parseHexPermissionAmount(value: string): bigint { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error('Cannot parse empty permission amount'); + } + + const hexValue = ( + trimmed.startsWith('0x') || trimmed.startsWith('0X') + ? trimmed + : `0x${trimmed}` + ) as Hex; + + return hexToBigInt(hexValue); +} + +/** + * Returns an i18n key for a period duration in seconds. + * + * @param periodDurationInSeconds - The period duration in seconds. + * @returns The translation key for the frequency description. + */ +export function getPeriodFrequencyValueTranslationKey( + periodDurationInSeconds: number, +): string { + const periodDurationMillisecond = periodDurationInSeconds * SECOND; + if (periodDurationMillisecond === DAY) { + return 'gatorPermissionDailyFrequency'; + } else if (periodDurationMillisecond === WEEK) { + return 'gatorPermissionWeeklyFrequency'; + } else if (periodDurationMillisecond === FORTNIGHT) { + return 'gatorPermissionFortnightlyFrequency'; + } else if (periodDurationMillisecond === MONTH) { + return 'gatorPermissionMonthlyFrequency'; + } else if (periodDurationMillisecond === YEAR) { + return 'gatorPermissionAnnualFrequency'; + } + return 'gatorPermissionCustomFrequency'; +} + +/** + * Returns an i18n key and optional args for a period duration in seconds. + * + * @param periodSeconds - The period duration in seconds. + * @returns A translatable period duration value. + */ +export function formatPermissionPeriodDuration( + periodSeconds: number, +): I18nValue { + if (periodSeconds === 0) { + throw new Error('Cannot format period duration of 0 seconds'); + } + + if (periodSeconds < 0) { + throw new Error('Cannot format negative period duration'); + } + + const periodMilliseconds = periodSeconds * SECOND; + + switch (periodMilliseconds) { + case HOUR: + return { key: 'confirmFieldPeriodDurationHourly' }; + case DAY: + return { key: 'confirmFieldPeriodDurationDaily' }; + case WEEK: + return { key: 'confirmFieldPeriodDurationWeekly' }; + case FORTNIGHT: + return { key: 'confirmFieldPeriodDurationBiWeekly' }; + case MONTH: + return { key: 'confirmFieldPeriodDurationMonthly' }; + case YEAR: + return { key: 'confirmFieldPeriodDurationYearly' }; + default: + return { + key: 'confirmFieldPeriodDurationSeconds', + args: [periodSeconds], + }; + } +} + +/** + * Converts milliseconds to seconds. + * + * @param milliseconds - The milliseconds to convert. + * @returns The seconds. + */ +export function convertMillisecondsToSeconds(milliseconds: number): number { + return milliseconds / SECOND; +} + +/** + * Converts an amount per second to an amount per period. + * + * @param amountPerSecond - The amount per second in hexadecimal format. + * @param period - The period to convert to. + * @returns The amount per period. + */ +export function convertAmountPerSecondToAmountPerPeriod( + amountPerSecond: Hex, + period: 'weekly' | 'monthly' | 'fortnightly' | 'yearly', +): Hex { + const amountBigInt = hexToBigInt(amountPerSecond); + switch (period) { + case 'weekly': + return bigIntToHex( + amountBigInt * BigInt(convertMillisecondsToSeconds(WEEK)), + ); + case 'monthly': + return bigIntToHex( + amountBigInt * BigInt(convertMillisecondsToSeconds(MONTH)), + ); + case 'fortnightly': + return bigIntToHex( + amountBigInt * BigInt(convertMillisecondsToSeconds(FORTNIGHT)), + ); + case 'yearly': + return bigIntToHex( + amountBigInt * BigInt(convertMillisecondsToSeconds(YEAR)), + ); + default: + throw new Error(`Invalid period: ${period as string}`); + } +} diff --git a/packages/7715-permission-types/test/permissions/schema.test.ts b/packages/7715-permission-types/test/permissions/schema.test.ts new file mode 100644 index 00000000..282e7cc5 --- /dev/null +++ b/packages/7715-permission-types/test/permissions/schema.test.ts @@ -0,0 +1,51 @@ +import { + ALL_METAMASK_FACILITATOR_ADDRESSES, + getPermissionSchemaEntry, + isMetaMaskFacilitatorAddress, +} from '../../src'; + +describe('permission schemas', () => { + it('returns the schema for a known permission type', () => { + const schema = getPermissionSchemaEntry('native-token-stream'); + + expect(schema.tokenVariant).toBe('native'); + expect(schema.tokenResolution).toStrictEqual({ kind: 'native' }); + }); + + it('falls back to the unknown schema when no matching type exists', () => { + const unknownSchema = getPermissionSchemaEntry('unregistered-type'); + const unknownTypeElement = unknownSchema.sections + .flatMap((section) => section.elements) + .find( + (element) => + 'testId' in element && + element.testId === 'review-gator-permission-unknown-type', + ); + + expect(unknownTypeElement?.type).toBe('raw-text'); + }); + + it('throws for an unknown permission type when requested', () => { + expect(() => getPermissionSchemaEntry('unregistered-type', true)).toThrow( + 'Unknown permission type: unregistered-type', + ); + }); +}); + +describe('MetaMask facilitator addresses', () => { + it('matches facilitator addresses case-insensitively', () => { + expect( + isMetaMaskFacilitatorAddress( + ALL_METAMASK_FACILITATOR_ADDRESSES[0].toLowerCase(), + ), + ).toBe(true); + }); + + it('returns false for unknown addresses', () => { + expect( + isMetaMaskFacilitatorAddress( + '0x0000000000000000000000000000000000000001', + ), + ).toBe(false); + }); +}); From 668318e750dcd7b489d25b467ddef8453bb74cf0 Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Mon, 22 Jun 2026 22:28:39 +1200 Subject: [PATCH 2/2] feat: enhance permission schema validation and add tests for inherited properties --- packages/7715-permission-types/CHANGELOG.md | 1 + .../src/permissions/schema/index.ts | 5 ++++- .../test/permissions/schema.test.ts | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/7715-permission-types/CHANGELOG.md b/packages/7715-permission-types/CHANGELOG.md index c7993153..8c675a2a 100644 --- a/packages/7715-permission-types/CHANGELOG.md +++ b/packages/7715-permission-types/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `makePermissionDecoderConfigs` to resolve the `PermissionDecoderConfig`s to be used with @metamask/gator-permissions-controller ([#259](https://github.com/MetaMask/smart-accounts-kit/pull/259)) +- Add permission schema metadata and MetaMask facilitator address utilities ([#267](https://github.com/MetaMask/smart-accounts-kit/pull/267)) ## [0.7.1] diff --git a/packages/7715-permission-types/src/permissions/schema/index.ts b/packages/7715-permission-types/src/permissions/schema/index.ts index 046696bd..26aef2d9 100644 --- a/packages/7715-permission-types/src/permissions/schema/index.ts +++ b/packages/7715-permission-types/src/permissions/schema/index.ts @@ -687,7 +687,10 @@ export function getPermissionSchemaEntry( throwIfUnknown: boolean = false, ): PermissionSchemaEntry { const matchingSchema = PERMISSION_SCHEMAS[permissionType]; - if (matchingSchema) { + if ( + Object.prototype.hasOwnProperty.call(PERMISSION_SCHEMAS, permissionType) && + matchingSchema + ) { return matchingSchema; } if (throwIfUnknown) { diff --git a/packages/7715-permission-types/test/permissions/schema.test.ts b/packages/7715-permission-types/test/permissions/schema.test.ts index 282e7cc5..4fe47490 100644 --- a/packages/7715-permission-types/test/permissions/schema.test.ts +++ b/packages/7715-permission-types/test/permissions/schema.test.ts @@ -30,6 +30,22 @@ describe('permission schemas', () => { 'Unknown permission type: unregistered-type', ); }); + + it('does not treat inherited object properties as matching permission types', () => { + const unknownSchema = getPermissionSchemaEntry('__proto__'); + const unknownTypeElement = unknownSchema.sections + .flatMap((section) => section.elements) + .find( + (element) => + 'testId' in element && + element.testId === 'review-gator-permission-unknown-type', + ); + + expect(unknownTypeElement?.type).toBe('raw-text'); + expect(() => getPermissionSchemaEntry('__proto__', true)).toThrow( + 'Unknown permission type: __proto__', + ); + }); }); describe('MetaMask facilitator addresses', () => {