diff --git a/packages/eip-5792-middleware/CHANGELOG.md b/packages/eip-5792-middleware/CHANGELOG.md index 7a55f5c5d49..9d06c35de78 100644 --- a/packages/eip-5792-middleware/CHANGELOG.md +++ b/packages/eip-5792-middleware/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Add `auxiliaryFunds` + `requiredAssets` support defined under [ERC-7682](https://eips.ethereum.org/EIPS/eip-7682) ([#6623](https://github.com/MetaMask/core/pull/6623)) - Bump `@metamask/transaction-controller` from `^60.2.0` to `^60.4.0` ([#6561](https://github.com/MetaMask/core/pull/6561), [#6641](https://github.com/MetaMask/core/pull/6641)) - Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) diff --git a/packages/eip-5792-middleware/package.json b/packages/eip-5792-middleware/package.json index 717bdedaa2a..9fc28c59af4 100644 --- a/packages/eip-5792-middleware/package.json +++ b/packages/eip-5792-middleware/package.json @@ -51,6 +51,7 @@ "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^60.4.0", "@metamask/utils": "^11.8.0", + "lodash": "^4.17.21", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/eip-5792-middleware/src/constants.ts b/packages/eip-5792-middleware/src/constants.ts index 3ba7e6eb46c..b6b62f031a2 100644 --- a/packages/eip-5792-middleware/src/constants.ts +++ b/packages/eip-5792-middleware/src/constants.ts @@ -11,6 +11,10 @@ export enum MessageType { SendTransaction = 'eth_sendTransaction', } +export enum SupportedCapabilities { + AuxiliaryFunds = 'auxiliaryFunds', +} + // To be moved to @metamask/rpc-errors in future. export enum EIP5792ErrorCode { UnsupportedNonOptionalCapability = 5700, @@ -19,6 +23,13 @@ export enum EIP5792ErrorCode { RejectedUpgrade = 5750, } +// To be moved to @metamask/rpc-errors in future. +export enum EIP7682ErrorCode { + UnsupportedAsset = 5771, + UnsupportedChain = 5772, + MalformedRequiredAssets = 5773, +} + // wallet_getCallStatus export enum GetCallsStatusCode { PENDING = 100, diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts index 209986829d6..7e40b805d64 100644 --- a/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts @@ -44,6 +44,8 @@ describe('EIP-5792', () => { PreferencesControllerGetStateAction['handler'] > = jest.fn(); + const isAuxiliaryFundsSupportedMock: jest.Mock = jest.fn(); + let messenger: EIP5792Messenger; const getCapabilitiesHooks = { @@ -53,6 +55,7 @@ describe('EIP-5792', () => { getIsSmartTransaction: getIsSmartTransactionMock, isRelaySupported: isRelaySupportedMock, getSendBundleSupportedChains: getSendBundleSupportedChainsMock, + isAuxiliaryFundsSupported: isAuxiliaryFundsSupportedMock, }; beforeEach(() => { @@ -425,5 +428,119 @@ describe('EIP-5792', () => { }, }); }); + + it('fetches all network configurations when chainIds is undefined', async () => { + const networkConfigurationsMock = { + '0x1': { chainId: '0x1' }, + '0x89': { chainId: '0x89' }, + }; + + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + networkConfigurationsByChainId: networkConfigurationsMock, + }), + ); + + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: '0x1', + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + { + chainId: '0x89', + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + undefined, + ); + + expect(capabilities).toStrictEqual({ + '0x1': { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + '0x89': { + atomic: { + status: 'ready', + }, + }, + }); + }); + + it('includes auxiliary funds capability when supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + isAuxiliaryFundsSupportedMock.mockReturnValue(true); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + auxiliaryFunds: { + supported: true, + }, + }, + }); + }); + + it('does not include auxiliary funds capability when not supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + isAuxiliaryFundsSupportedMock.mockReturnValue(false); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + }); + }); }); }); diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts index 947c5f7becc..4059f6d953a 100644 --- a/packages/eip-5792-middleware/src/hooks/getCapabilities.ts +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts @@ -25,6 +25,8 @@ export type GetCapabilitiesHooks = { getSendBundleSupportedChains: ( chainIds: Hex[], ) => Promise>; + /** Function to validate if auxiliary funds capability is supported. */ + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; }; /** @@ -48,6 +50,7 @@ export async function getCapabilities( isAtomicBatchSupported, isRelaySupported, getSendBundleSupportedChains, + isAuxiliaryFundsSupported, } = hooks; let chainIdsNormalized = chainIds?.map( @@ -106,15 +109,22 @@ export async function getCapabilities( } const status = isSupported ? 'supported' : 'ready'; + const hexChainId = chainId as Hex; - if (acc[chainId as Hex] === undefined) { - acc[chainId as Hex] = {}; + if (acc[hexChainId] === undefined) { + acc[hexChainId] = {}; } - acc[chainId as Hex].atomic = { + acc[hexChainId].atomic = { status, }; + if (isSupportedAccount && isAuxiliaryFundsSupported(chainId)) { + acc[hexChainId].auxiliaryFunds = { + supported: true, + }; + } + return acc; }, alternateGasFeesAcc); } diff --git a/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts index 2a8a9147899..86598dfb507 100644 --- a/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts +++ b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts @@ -12,9 +12,10 @@ import type { NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; import type { TransactionController } from '@metamask/transaction-controller'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { Hex, JsonRpcRequest } from '@metamask/utils'; import { processSendCalls } from './processSendCalls'; +import { SupportedCapabilities } from '../constants'; import type { SendCallsPayload, SendCallsParams, @@ -81,6 +82,8 @@ describe('EIP-5792', () => { AccountsControllerGetStateAction['handler'] > = jest.fn(); + const isAuxiliaryFundsSupportedMock: jest.Mock = jest.fn(); + let messenger: EIP5792Messenger; const sendCallsHooks = { @@ -90,6 +93,7 @@ describe('EIP-5792', () => { getDismissSmartAccountSuggestionEnabledMock, isAtomicBatchSupported: isAtomicBatchSupportedMock, validateSecurity: validateSecurityMock, + isAuxiliaryFundsSupported: isAuxiliaryFundsSupportedMock, }; beforeEach(() => { @@ -124,6 +128,8 @@ describe('EIP-5792', () => { getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(false); + isAuxiliaryFundsSupportedMock.mockReturnValue(true); + isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: CHAIN_ID_MOCK, @@ -432,5 +438,195 @@ describe('EIP-5792', () => { `EIP-7702 upgrade not supported as account type is unknown`, ); }); + + it('validates auxiliary funds with unsupported account type', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + from: FROM_MOCK_HARDWARE, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123', + amount: '0x2', + standard: 'erc20', + }, + { + address: '0x123', + amount: '0x2', + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `Unsupported non-optional capability: ${SupportedCapabilities.AuxiliaryFunds}`, + ); + }); + + it('validates auxiliary funds with unsupported chain', async () => { + isAuxiliaryFundsSupportedMock.mockReturnValue(false); + + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123' as Hex, + amount: '0x1' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `The wallet no longer supports auxiliary funds on the requested chain: ${CHAIN_ID_MOCK}`, + ); + }); + + it('validates auxiliary funds with unsupported token standard', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123', + amount: '0x1', + standard: 'erc777', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + /The requested asset 0x123 is not available through the wallet.*s auxiliary fund system: unsupported token standard erc777/u, + ); + }); + + it('validates auxiliary funds with valid ERC-20 asset', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0x123', + amount: '0x1', + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('validates auxiliary funds with no requiredAssets', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('validates auxiliary funds with optional false and no requiredAssets', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('deduplicates auxiliary funds requiredAssets by address and standard, summing amounts', async () => { + const payload: SendCallsPayload = { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0x123' as Hex, + amount: '0x2' as Hex, + standard: 'erc20', + }, + { + address: '0x123' as Hex, + amount: '0x3' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }; + + const result = await processSendCalls( + sendCallsHooks, + messenger, + payload, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + const requiredAssets = + payload.capabilities?.auxiliaryFunds?.requiredAssets; + expect(requiredAssets).toHaveLength(1); + expect(requiredAssets?.[0]).toMatchObject({ + amount: '0x5', + address: '0x123', + standard: 'erc20', + }); + }); }); }); diff --git a/packages/eip-5792-middleware/src/hooks/processSendCalls.ts b/packages/eip-5792-middleware/src/hooks/processSendCalls.ts index 6dec738d126..6f87cb86720 100644 --- a/packages/eip-5792-middleware/src/hooks/processSendCalls.ts +++ b/packages/eip-5792-middleware/src/hooks/processSendCalls.ts @@ -9,18 +9,22 @@ import type { } from '@metamask/transaction-controller'; import { TransactionEnvelopeType } from '@metamask/transaction-controller'; import type { Hex, JsonRpcRequest } from '@metamask/utils'; -import { bytesToHex } from '@metamask/utils'; +import { add0x, bytesToHex } from '@metamask/utils'; +import { groupBy } from 'lodash'; import { parse, v4 as uuid } from 'uuid'; import { EIP5792ErrorCode, + EIP7682ErrorCode, KEYRING_TYPES_SUPPORTING_7702, MessageType, + SupportedCapabilities, VERSION, } from '../constants'; import type { EIP5792Messenger, SendCallsPayload, + SendCallsRequiredAssetsParam, SendCallsResult, } from '../types'; import { getAccountKeyringType } from '../utils'; @@ -43,6 +47,8 @@ export type ProcessSendCallsHooks = { request: ValidateSecurityRequest, chainId: Hex, ) => Promise; + /** Function to validate if auxiliary funds capability is supported. */ + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; }; /** @@ -76,6 +82,7 @@ export async function processSendCalls( getDismissSmartAccountSuggestionEnabled, isAtomicBatchSupported, validateSecurity: validateSecurityHook, + isAuxiliaryFundsSupported, } = hooks; const { calls, from: paramFrom } = params; @@ -100,12 +107,14 @@ export async function processSendCalls( addTransaction, chainId, from, + messenger, networkClientId, origin, securityAlertId, sendCalls: params, transactions, validateSecurity, + isAuxiliaryFundsSupported, }); } else { batchId = await processMultipleTransaction({ @@ -121,6 +130,7 @@ export async function processSendCalls( securityAlertId, transactions, validateSecurity, + isAuxiliaryFundsSupported, }); } @@ -134,28 +144,33 @@ export async function processSendCalls( * @param params.addTransaction - Function to add a single transaction. * @param params.chainId - The chain ID for the transaction. * @param params.from - The sender address. + * @param params.messenger - Messenger instance for controller communication. * @param params.networkClientId - The network client ID. * @param params.origin - The origin of the request (optional). * @param params.securityAlertId - The security alert ID for this transaction. * @param params.sendCalls - The original sendCalls request. * @param params.transactions - Array containing the single transaction. * @param params.validateSecurity - Function to validate security for the transaction. + * @param params.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. * @returns Promise resolving to the generated batch ID for the transaction. */ async function processSingleTransaction({ addTransaction, chainId, from, + messenger, networkClientId, origin, securityAlertId, sendCalls, transactions, validateSecurity, + isAuxiliaryFundsSupported, }: { addTransaction: TransactionController['addTransaction']; chainId: Hex; from: Hex; + messenger: EIP5792Messenger; networkClientId: string; origin?: string; securityAlertId: string; @@ -165,8 +180,16 @@ async function processSingleTransaction({ securityRequest: ValidateSecurityRequest, chainId: Hex, ) => void; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; }) { - validateSingleSendCall(sendCalls, chainId); + const keyringType = getAccountKeyringType(from, messenger); + + validateSingleSendCall( + sendCalls, + chainId, + keyringType, + isAuxiliaryFundsSupported, + ); const txParams = { from, @@ -181,6 +204,8 @@ async function processSingleTransaction({ }; validateSecurity(securityRequest, chainId); + dedupeAuxiliaryFundsRequiredAssets(sendCalls); + const batchId = generateBatchId(); await addTransaction(txParams, { @@ -208,6 +233,7 @@ async function processSingleTransaction({ * @param params.securityAlertId - The security alert ID for this batch. * @param params.transactions - Array of transactions to process. * @param params.validateSecurity - Function to validate security for the transactions. + * @param params.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. * @returns Promise resolving to the generated batch ID for the transaction batch. */ async function processMultipleTransaction({ @@ -223,6 +249,7 @@ async function processMultipleTransaction({ securityAlertId, transactions, validateSecurity, + isAuxiliaryFundsSupported, }: { addTransactionBatch: TransactionController['addTransactionBatch']; isAtomicBatchSupported: TransactionController['isAtomicBatchSupported']; @@ -239,6 +266,7 @@ async function processMultipleTransaction({ securityRequest: ValidateSecurityRequest, chainId: Hex, ) => Promise; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; }) { const batchSupport = await isAtomicBatchSupported({ address: from, @@ -258,8 +286,11 @@ async function processMultipleTransaction({ dismissSmartAccountSuggestionEnabled, chainBatchSupport, keyringType, + isAuxiliaryFundsSupported, ); + dedupeAuxiliaryFundsRequiredAssets(sendCalls); + const result = await addTransactionBatch({ from, networkClientId, @@ -287,10 +318,17 @@ function generateBatchId(): Hex { * * @param sendCalls - The sendCalls request to validate. * @param dappChainId - The chain ID that the dApp is connected to. + * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. */ -function validateSingleSendCall(sendCalls: SendCallsPayload, dappChainId: Hex) { +function validateSingleSendCall( + sendCalls: SendCallsPayload, + dappChainId: Hex, + keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, +) { validateSendCallsVersion(sendCalls); - validateCapabilities(sendCalls); + validateCapabilities(sendCalls, keyringType, isAuxiliaryFundsSupported); validateDappChainId(sendCalls, dappChainId); } @@ -302,6 +340,7 @@ function validateSingleSendCall(sendCalls: SendCallsPayload, dappChainId: Hex) { * @param dismissSmartAccountSuggestionEnabled - Whether smart account suggestions are disabled. * @param chainBatchSupport - Information about atomic batch support for the chain. * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. */ function validateSendCalls( sendCalls: SendCallsPayload, @@ -309,10 +348,11 @@ function validateSendCalls( dismissSmartAccountSuggestionEnabled: boolean, chainBatchSupport: IsAtomicBatchSupportedResultEntry | undefined, keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, ) { validateSendCallsVersion(sendCalls); validateSendCallsChainId(sendCalls, dappChainId, chainBatchSupport); - validateCapabilities(sendCalls); + validateCapabilities(sendCalls, keyringType, isAuxiliaryFundsSupported); validateUpgrade( dismissSmartAccountSuggestionEnabled, chainBatchSupport, @@ -382,18 +422,30 @@ function validateSendCallsChainId( * Validates that all required capabilities in the sendCalls request are supported. * * @param sendCalls - The sendCalls request to validate. + * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * * @throws JsonRpcError if unsupported non-optional capabilities are requested. */ -function validateCapabilities(sendCalls: SendCallsPayload) { - const { calls, capabilities } = sendCalls; +function validateCapabilities( + sendCalls: SendCallsPayload, + keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, +) { + const { calls, capabilities, chainId } = sendCalls; const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter( - (name) => capabilities?.[name].optional !== true, + (name) => + // Non optional capabilities other than `auxiliaryFunds` are not supported by the wallet + name !== SupportedCapabilities.AuxiliaryFunds.toString() && + capabilities?.[name].optional !== true, ); const requiredCallCapabilities = calls.flatMap((call) => Object.keys(call.capabilities ?? {}).filter( - (name) => call.capabilities?.[name].optional !== true, + (name) => + name !== SupportedCapabilities.AuxiliaryFunds.toString() && + call.capabilities?.[name].optional !== true, ), ); @@ -410,6 +462,79 @@ function validateCapabilities(sendCalls: SendCallsPayload) { )}`, ); } + + if (capabilities?.auxiliaryFunds) { + validateAuxFundsSupportAndRequiredAssets({ + auxiliaryFunds: capabilities.auxiliaryFunds, + chainId, + keyringType, + isAuxiliaryFundsSupported, + }); + } +} + +/** + * Validates EIP-7682 optional `requiredAssets` to see if the account and chain are supported, and that param is well-formed. + * + * docs: {@link https://eips.ethereum.org/EIPS/eip-7682#extended-usage-requiredassets-parameter} + * + * @param param - The parameter object. + * @param param.auxiliaryFunds - The auxiliaryFunds param to validate. + * @param param.auxiliaryFunds.optional - Metadata to signal for wallets that support this optional capability, while maintaining compatibility with wallets that do not. + * @param param.auxiliaryFunds.requiredAssets - Metadata that enables a wallets support for `auxiliaryFunds` capability. + * @param param.chainId - The chain ID of the incoming request. + * @param param.keyringType - The type of keyring associated with the account. + * @param param.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * @throws JsonRpcError if auxiliary funds capability is not supported. + */ +function validateAuxFundsSupportAndRequiredAssets({ + auxiliaryFunds, + chainId, + keyringType, + isAuxiliaryFundsSupported, +}: { + auxiliaryFunds: { + optional?: boolean; + requiredAssets?: SendCallsRequiredAssetsParam[]; + }; + chainId: Hex; + keyringType: KeyringTypes; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}) { + // If we can make use of that capability then we should, but otherwise we can process the request and ignore the capability + // so if the capability is signaled as optional, no validation is required, so we don't block the transaction from happening. + if (auxiliaryFunds.optional) { + return; + } + const isSupportedAccount = + KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + + if (!isSupportedAccount) { + throw new JsonRpcError( + EIP5792ErrorCode.UnsupportedNonOptionalCapability, + `Unsupported non-optional capability: ${SupportedCapabilities.AuxiliaryFunds}`, + ); + } + + if (!isAuxiliaryFundsSupported(chainId)) { + throw new JsonRpcError( + EIP7682ErrorCode.UnsupportedChain, + `The wallet no longer supports auxiliary funds on the requested chain: ${chainId}`, + ); + } + + if (!auxiliaryFunds?.requiredAssets) { + return; + } + + for (const asset of auxiliaryFunds.requiredAssets) { + if (asset.standard !== 'erc20') { + throw new JsonRpcError( + EIP7682ErrorCode.UnsupportedAsset, + `The requested asset ${asset.address} is not available through the wallet’s auxiliary fund system: unsupported token standard ${asset.standard}`, + ); + } + } } /** @@ -443,3 +568,38 @@ function validateUpgrade( ); } } + +/** + * Function to possibly deduplicate `auxiliaryFunds` capability `requiredAssets`. + * Does nothing if no `requiredAssets` exists in `auxiliaryFunds` capability. + * + * @param sendCalls - The original sendCalls request. + */ +function dedupeAuxiliaryFundsRequiredAssets(sendCalls: SendCallsPayload): void { + if (sendCalls.capabilities?.auxiliaryFunds?.requiredAssets) { + const { requiredAssets } = sendCalls.capabilities.auxiliaryFunds; + // Group assets by their address (lowercased) and standard + const grouped = groupBy( + requiredAssets, + (asset) => `${asset.address.toLowerCase()}-${asset.standard}`, + ); + + // For each group, sum the amounts and return a single asset + const deduplicatedAssets = Object.values(grouped).map((group) => { + if (group.length === 1) { + return group[0]; + } + + const totalAmount = group.reduce((sum, asset) => { + return sum + BigInt(asset.amount); + }, 0n); + + return { + ...group[0], + amount: add0x(totalAmount.toString(16)), + }; + }); + + sendCalls.capabilities.auxiliaryFunds.requiredAssets = deduplicatedAssets; + } +} diff --git a/packages/eip-5792-middleware/src/types.ts b/packages/eip-5792-middleware/src/types.ts index ae73f7d9824..bf9049a94fd 100644 --- a/packages/eip-5792-middleware/src/types.ts +++ b/packages/eip-5792-middleware/src/types.ts @@ -77,6 +77,8 @@ export type GetCapabilitiesHook = ( export type SendCallsParams = Infer; export type SendCallsPayload = SendCallsParams[0]; +export type SendCallsRequiredAssetsParam = Infer; + export type SendCallsResult = { id: Hex; capabilities?: Record; @@ -97,10 +99,17 @@ export const GetCapabilitiesStruct = tuple([ optional(array(StrictHexStruct)), ]); +const RequiredAssetStruct = type({ + address: nonempty(HexChecksumAddressStruct), + amount: nonempty(StrictHexStruct), + standard: nonempty(string()), +}); + export const CapabilitiesStruct = record( string(), type({ optional: optional(boolean()), + requiredAssets: optional(array(RequiredAssetStruct)), }), ); diff --git a/yarn.lock b/yarn.lock index 1d589b43769..fb4caeb66a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3088,6 +3088,7 @@ __metadata: deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" klona: "npm:^2.0.6" + lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0"