diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index e777270690c..bfaeceb3350 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -374,12 +374,6 @@ "@typescript-eslint/no-unsafe-enum-comparison": 4, "jsdoc/tag-lines": 4 }, - "packages/multichain/src/scope/transform.ts": { - "jsdoc/tag-lines": 3 - }, - "packages/multichain/src/scope/types.ts": { - "jsdoc/tag-lines": 1 - }, "packages/multichain/src/scope/validation.ts": { "jsdoc/tag-lines": 2 }, diff --git a/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts index 7e05eb01ad3..cfabd575ce7 100644 --- a/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts +++ b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts @@ -7,7 +7,7 @@ import { KnownWalletNamespaceRpcMethods, KnownWalletRpcMethods, } from '../scope/constants'; -import { mergeScopes } from '../scope/transform'; +import { mergeNormalizedScopes } from '../scope/transform'; import type { InternalScopesObject, NonWalletKnownCaipNamespace, @@ -94,7 +94,7 @@ export const getSessionScopes = ( 'requiredScopes' | 'optionalScopes' >, ) => { - return mergeScopes( + return mergeNormalizedScopes( getNormalizedScopesObject(caip25CaveatValue.requiredScopes), getNormalizedScopesObject(caip25CaveatValue.optionalScopes), ); diff --git a/packages/multichain/src/caip25Permission.test.ts b/packages/multichain/src/caip25Permission.test.ts index 4ae70e7c018..a52127b901a 100644 --- a/packages/multichain/src/caip25Permission.test.ts +++ b/packages/multichain/src/caip25Permission.test.ts @@ -475,7 +475,7 @@ describe('caip25EndowmentBuilder', () => { describe('caip25CaveatBuilder', () => { const findNetworkClientIdByChainId = jest.fn(); const listAccounts = jest.fn(); - const { validator } = caip25CaveatBuilder({ + const { validator, merger } = caip25CaveatBuilder({ findNetworkClientIdByChainId, listAccounts, }); @@ -698,4 +698,297 @@ describe('caip25CaveatBuilder', () => { }), ).toBeUndefined(); }); + + describe('permission merger', () => { + describe('optionalScopes', () => { + it.each<{ + description: string; + rightValue: Caip25CaveatValue; + expectedMergedValue: Caip25CaveatValue; + expectedDiff: Caip25CaveatValue; + }>([ + { + description: + 'incremental request existing scope with a new account - should return merged scope with existing chain and both accounts', + rightValue: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedMergedValue: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedDiff: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + }, + }, + { + description: + 'incremental request a whole new scope without accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with no accounts', + rightValue: { + optionalScopes: { + 'eip155:10': { + accounts: [], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedMergedValue: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { + accounts: [], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedDiff: { + optionalScopes: { + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + }, + }, + { + description: + 'incremental request a whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with new account', + rightValue: { + optionalScopes: { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedMergedValue: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { accounts: ['eip155:10:0xbeef'] }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedDiff: { + optionalScopes: { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + }, + }, + { + description: + 'incremental request an existing scope with new accounts, and whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with new accounts', + rightValue: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedMergedValue: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedDiff: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + }, + }, + { + description: + 'incremental request an existing scope with new accounts, and 2 whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts', + rightValue: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedMergedValue: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }, + expectedDiff: { + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + }, + }, + ])( + '$description', + async ({ rightValue, expectedMergedValue, expectedDiff }) => { + const initLeftValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + requiredScopes: {}, + isMultichainOrigin: false, + }; + + const [newValue, diff] = merger(initLeftValue, rightValue); + + expect(newValue).toStrictEqual( + expect.objectContaining(expectedMergedValue), + ); + expect(diff).toStrictEqual(expect.objectContaining(expectedDiff)); + }, + ); + }); + + describe('requiredScopes', () => { + it('incremental request an existing scope with new accounts, and 2 whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts', () => { + const initLeftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const rightValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const expectedMergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + const expectedDiff: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + const [newValue, diff] = merger(initLeftValue, rightValue); + + expect(newValue).toStrictEqual( + expect.objectContaining(expectedMergedValue), + ); + expect(diff).toStrictEqual(expect.objectContaining(expectedDiff)); + }); + }); + }); }); diff --git a/packages/multichain/src/caip25Permission.ts b/packages/multichain/src/caip25Permission.ts index 9a417f3c707..efd7880abe9 100644 --- a/packages/multichain/src/caip25Permission.ts +++ b/packages/multichain/src/caip25Permission.ts @@ -24,8 +24,10 @@ import { cloneDeep, isEqual } from 'lodash'; import { getEthAccounts } from './adapters/caip-permission-adapter-eth-accounts'; import { assertIsInternalScopesObject } from './scope/assert'; import { isSupportedScopeString } from './scope/supported'; +import { mergeInternalScopes } from './scope/transform'; import { parseScopeString, + type InternalScopeString, type ExternalScopeString, type InternalScopeObject, type InternalScopesObject, @@ -83,7 +85,9 @@ export const caip25CaveatBuilder = ({ findNetworkClientIdByChainId, listAccounts, }: Caip25EndowmentCaveatSpecificationBuilderOptions): EndowmentCaveatSpecificationConstraint & - Required> => { + Required< + Pick + > => { return { type: Caip25CaveatType, validator: ( @@ -150,6 +154,39 @@ export const caip25CaveatBuilder = ({ ); } }, + merger: ( + leftValue: Caip25CaveatValue, + rightValue: Caip25CaveatValue, + ): [Caip25CaveatValue, Caip25CaveatValue] => { + const mergedRequiredScopes = mergeInternalScopes( + leftValue.requiredScopes, + rightValue.requiredScopes, + ); + const mergedOptionalScopes = mergeInternalScopes( + leftValue.optionalScopes, + rightValue.optionalScopes, + ); + + const mergedValue: Caip25CaveatValue = { + requiredScopes: mergedRequiredScopes, + optionalScopes: mergedOptionalScopes, + isMultichainOrigin: leftValue.isMultichainOrigin, + }; + + const partialDiff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + const diff = diffScopesForCaip25CaveatValue( + partialDiff, + mergedValue, + 'optionalScopes', + ); + + return [mergedValue, diff]; + }, }; }; @@ -347,3 +384,49 @@ function removeScope( operation: CaveatMutatorOperation.RevokePermission, }; } + +/** + * Returns the differential between two provided CAIP-25 permission caveat value scopes. + * + * @param originalValue - The existing CAIP-25 permission caveat value. + * @param mergedValue - The result from merging existing and incoming CAIP-25 permission caveat values. + * @param scopeToDiff - The required or optional scopes from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. + * @returns The differential between original and merged CAIP-25 permission caveat values. + */ +function diffScopesForCaip25CaveatValue( + originalValue: Caip25CaveatValue, + mergedValue: Caip25CaveatValue, + scopeToDiff: keyof Pick< + Caip25CaveatValue, + 'optionalScopes' | 'requiredScopes' + >, +): Caip25CaveatValue { + const diff = cloneDeep(originalValue); + + for (const [scopeString, mergedScopeObject] of Object.entries( + mergedValue[scopeToDiff], + )) { + const internalScopeString = scopeString as InternalScopeString; + const originalScopeObject = diff[scopeToDiff][internalScopeString]; + + if (originalScopeObject) { + const newAccounts = mergedScopeObject.accounts.filter( + (account) => + !diff[scopeToDiff][ + scopeString as InternalScopeString + ]?.accounts.includes(account), + ); + if (newAccounts.length > 0) { + diff[scopeToDiff][internalScopeString] = { + accounts: newAccounts, + }; + continue; + } + delete diff[scopeToDiff][internalScopeString]; + } else { + diff[scopeToDiff][internalScopeString] = mergedScopeObject; + } + } + + return diff; +} diff --git a/packages/multichain/src/index.test.ts b/packages/multichain/src/index.test.ts index 8465d5a24fd..4e384e43b4f 100644 --- a/packages/multichain/src/index.test.ts +++ b/packages/multichain/src/index.test.ts @@ -28,7 +28,8 @@ describe('@metamask/multichain', () => { "parseScopeString", "normalizeScope", "mergeScopeObject", - "mergeScopes", + "mergeNormalizedScopes", + "mergeInternalScopes", "normalizeAndMergeScopes", "caip25CaveatBuilder", "Caip25CaveatType", diff --git a/packages/multichain/src/index.ts b/packages/multichain/src/index.ts index 60732796b47..76b41aec33e 100644 --- a/packages/multichain/src/index.ts +++ b/packages/multichain/src/index.ts @@ -49,7 +49,8 @@ export { parseScopeString } from './scope/types'; export { normalizeScope, mergeScopeObject, - mergeScopes, + mergeNormalizedScopes, + mergeInternalScopes, normalizeAndMergeScopes, } from './scope/transform'; diff --git a/packages/multichain/src/scope/transform.test.ts b/packages/multichain/src/scope/transform.test.ts index b5e01b5cce9..0132920488e 100644 --- a/packages/multichain/src/scope/transform.test.ts +++ b/packages/multichain/src/scope/transform.test.ts @@ -1,10 +1,15 @@ import { normalizeScope, - mergeScopes, + mergeNormalizedScopes, + mergeInternalScopes, mergeScopeObject, normalizeAndMergeScopes, } from './transform'; -import type { ExternalScopeObject, NormalizedScopeObject } from './types'; +import type { + ExternalScopeObject, + NormalizedScopeObject, + InternalScopesObject, +} from './types'; const externalScopeObject: ExternalScopeObject = { methods: [], @@ -252,10 +257,120 @@ describe('Scope Transform', () => { }); }); - describe('mergeScopes', () => { + describe('mergeInternalScopes', () => { + it.each<{ + description: string; + rightValue: InternalScopesObject; + expectedMergedValue: InternalScopesObject; + }>([ + { + description: + 'incremental request existing scope with a new account - should return merged scope with existing chain and both accounts', + rightValue: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + expectedMergedValue: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + }, + }, + { + description: + 'incremental request a whole new scope without accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with no accounts', + rightValue: { + 'eip155:10': { + accounts: [], + }, + }, + expectedMergedValue: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { + accounts: [], + }, + }, + }, + { + description: + 'incremental request a whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with new account', + rightValue: { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + expectedMergedValue: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { accounts: ['eip155:10:0xbeef'] }, + }, + }, + { + description: + 'incremental request an existing scope with new accounts, and whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chain with new accounts', + rightValue: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + expectedMergedValue: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + }, + { + description: + 'incremental request an existing scope with new accounts, and 2 whole new scope with accounts - should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts', + rightValue: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + expectedMergedValue: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + }, + ])('$description', async ({ rightValue, expectedMergedValue }) => { + const initLeftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + const mergedValue = mergeInternalScopes(initLeftValue, rightValue); + + expect(mergedValue).toStrictEqual( + expect.objectContaining(expectedMergedValue), + ); + }); + }); + + describe('mergeNormalizedScopes', () => { it('merges the scopeObjects with matching scopeString', () => { expect( - mergeScopes( + mergeNormalizedScopes( { 'eip155:1': { methods: ['a', 'b', 'c'], @@ -282,7 +397,7 @@ describe('Scope Transform', () => { it('preserves the scopeObjects with no matching scopeString', () => { expect( - mergeScopes( + mergeNormalizedScopes( { 'eip155:1': { methods: ['a', 'b', 'c'], @@ -322,12 +437,12 @@ describe('Scope Transform', () => { }); }); it('returns an empty object when no scopes are provided', () => { - expect(mergeScopes({}, {})).toStrictEqual({}); + expect(mergeNormalizedScopes({}, {})).toStrictEqual({}); }); it('returns an unchanged scope when two identical scopeObjects are provided', () => { expect( - mergeScopes( + mergeNormalizedScopes( { 'eip155:1': validScopeObject }, { 'eip155:1': validScopeObject }, ), diff --git a/packages/multichain/src/scope/transform.ts b/packages/multichain/src/scope/transform.ts index 666ff740eb5..3109b73d72a 100644 --- a/packages/multichain/src/scope/transform.ts +++ b/packages/multichain/src/scope/transform.ts @@ -4,6 +4,8 @@ import { cloneDeep } from 'lodash'; import type { ExternalScopeObject, ExternalScopesObject, + InternalScopesObject, + InternalScopeString, NormalizedScopeObject, NormalizedScopesObject, } from './types'; @@ -59,6 +61,7 @@ export const normalizeScope = ( /** * Merges two NormalizedScopeObjects + * * @param scopeObjectA - The first scope object to merge. * @param scopeObjectB - The second scope object to merge. * @returns The merged scope object. @@ -101,11 +104,12 @@ export const mergeScopeObject = ( /** * Merges two NormalizedScopeObjects - * @param scopeA - The first scope object to merge. - * @param scopeB - The second scope object to merge. - * @returns The merged scope object. + * + * @param scopeA - The first normalized scope object to merge. + * @param scopeB - The second normalized scope object to merge. + * @returns The merged normalized scope object from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. */ -export const mergeScopes = ( +export const mergeNormalizedScopes = ( scopeA: NormalizedScopesObject, scopeB: NormalizedScopesObject, ): NormalizedScopesObject => { @@ -134,8 +138,40 @@ export const mergeScopes = ( return scope; }; +/** + * Merges two InternalScopeObjects + * + * @param scopeA - The first internal scope object to merge. + * @param scopeB - The second internal scope object to merge. + * @returns The merged internal scope object from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. + */ +export const mergeInternalScopes = ( + scopeA: InternalScopesObject, + scopeB: InternalScopesObject, +): InternalScopesObject => { + const resultScope = cloneDeep(scopeA); + + Object.entries(scopeB).forEach(([scopeString, rightScopeObject]) => { + const internalScopeString = scopeString as InternalScopeString; + const leftRequiredScopeObject = resultScope[internalScopeString]; + if (!leftRequiredScopeObject) { + resultScope[internalScopeString] = rightScopeObject; + } else { + resultScope[internalScopeString] = { + accounts: getUniqueArrayItems([ + ...leftRequiredScopeObject.accounts, + ...rightScopeObject.accounts, + ]), + }; + } + }); + + return resultScope; +}; + /** * Normalizes and merges a set of ExternalScopesObjects into a NormalizedScopesObject (i.e. a set of NormalizedScopeObjects where references are flattened). + * * @param scopes - The external scopes to normalize and merge. * @returns The normalized and merged scopes. */ @@ -145,7 +181,7 @@ export const normalizeAndMergeScopes = ( let mergedScopes: NormalizedScopesObject = {}; Object.keys(scopes).forEach((scopeString) => { const normalizedScopes = normalizeScope(scopeString, scopes[scopeString]); - mergedScopes = mergeScopes(mergedScopes, normalizedScopes); + mergedScopes = mergeNormalizedScopes(mergedScopes, normalizedScopes); }); return mergedScopes; diff --git a/packages/multichain/src/scope/types.ts b/packages/multichain/src/scope/types.ts index b13b5edae75..8993eb0cabb 100644 --- a/packages/multichain/src/scope/types.ts +++ b/packages/multichain/src/scope/types.ts @@ -91,6 +91,7 @@ export type ScopedProperties = Record> & { /** * Parses a scope string into a namespace and reference. + * * @param scopeString - The scope string to parse. * @returns An object containing the namespace and reference. */