diff --git a/app/scripts/controller-init/claims/claims-controller-init.test.ts b/app/scripts/controller-init/claims/claims-controller-init.test.ts new file mode 100644 index 000000000000..183f909c1cdf --- /dev/null +++ b/app/scripts/controller-init/claims/claims-controller-init.test.ts @@ -0,0 +1,51 @@ +import { + ClaimsController, + ClaimsControllerMessenger, +} from '@metamask/claims-controller'; +import { ControllerInitRequest } from '../types'; +import { + ClaimsControllerInitMessenger, + getClaimsControllerInitMessenger, + getClaimsControllerMessenger, +} from '../messengers/claims/claims-controller-messenger'; +import { getRootMessenger } from '../../lib/messenger'; +import { buildControllerInitRequestMock } from '../test/utils'; +import { ClaimsControllerInit } from './claims-controller-init'; + +jest.mock('@metamask/claims-controller'); + +function buildInitRequestMock(): jest.Mocked< + ControllerInitRequest< + ClaimsControllerMessenger, + ClaimsControllerInitMessenger + > +> { + const baseControllerMessenger = getRootMessenger(); + + return { + ...buildControllerInitRequestMock(), + controllerMessenger: getClaimsControllerMessenger(baseControllerMessenger), + initMessenger: getClaimsControllerInitMessenger(baseControllerMessenger), + }; +} + +describe('ClaimsControllerInit', () => { + it('should initialize the controller', () => { + const request = buildInitRequestMock(); + const controllerInitResult = ClaimsControllerInit(request); + expect(controllerInitResult).toBeDefined(); + expect(controllerInitResult.controller).toBeInstanceOf(ClaimsController); + }); + + it('should initialize with correct messenger and state', () => { + const ClaimsControllerClassMock = jest.mocked(ClaimsController); + + const requestMock = buildInitRequestMock(); + ClaimsControllerInit(requestMock); + + expect(ClaimsControllerClassMock).toHaveBeenCalledWith({ + messenger: requestMock.controllerMessenger, + state: requestMock.persistedState.ClaimsController, + }); + }); +}); diff --git a/app/scripts/controller-init/claims/claims-controller-init.ts b/app/scripts/controller-init/claims/claims-controller-init.ts new file mode 100644 index 000000000000..849fae586832 --- /dev/null +++ b/app/scripts/controller-init/claims/claims-controller-init.ts @@ -0,0 +1,21 @@ +import { + ClaimsController, + ClaimsControllerMessenger, +} from '@metamask/claims-controller'; +import { ControllerInitFunction } from '../types'; +import { ClaimsControllerInitMessenger } from '../messengers/claims/claims-controller-messenger'; + +export const ClaimsControllerInit: ControllerInitFunction< + ClaimsController, + ClaimsControllerMessenger, + ClaimsControllerInitMessenger +> = (request) => { + const { controllerMessenger, persistedState } = request; + const controller = new ClaimsController({ + messenger: controllerMessenger, + state: persistedState.ClaimsController, + }); + return { + controller, + }; +}; diff --git a/app/scripts/controller-init/claims/claims-service-init.test.ts b/app/scripts/controller-init/claims/claims-service-init.test.ts new file mode 100644 index 000000000000..3cd90d6ef9eb --- /dev/null +++ b/app/scripts/controller-init/claims/claims-service-init.test.ts @@ -0,0 +1,34 @@ +import { + ClaimsService, + ClaimsServiceMessenger, +} from '@metamask/claims-controller'; +import { getRootMessenger } from '../../lib/messenger'; +import { getClaimsServiceMessenger } from '../messengers/claims/claims-service-messenger'; +import { buildControllerInitRequestMock } from '../test/utils'; +import { ControllerInitRequest } from '../types'; +import { ClaimsServiceInit } from './claims-service-init'; + +jest.mock('@metamask/claims-controller'); + +function buildInitRequestMock() { + const baseControllerMessenger = getRootMessenger(); + + return { + ...buildControllerInitRequestMock(), + controllerMessenger: getClaimsServiceMessenger(baseControllerMessenger), + initMessenger: undefined, + } as unknown as jest.Mocked>; +} + +describe('ClaimsServiceInit', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return Service instance', () => { + const requestMock = buildInitRequestMock(); + expect(ClaimsServiceInit(requestMock).controller).toBeInstanceOf( + ClaimsService, + ); + }); +}); diff --git a/app/scripts/controller-init/claims/claims-service-init.ts b/app/scripts/controller-init/claims/claims-service-init.ts new file mode 100644 index 000000000000..629a062d2a70 --- /dev/null +++ b/app/scripts/controller-init/claims/claims-service-init.ts @@ -0,0 +1,25 @@ +import { ClaimsService } from '@metamask/claims-controller'; +import { ControllerInitFunction } from '../types'; +import { ClaimsServiceMessengerType } from '../messengers/claims/claims-service-messenger'; +import { loadShieldConfig } from '../../../../shared/modules/shield/config'; + +export const ClaimsServiceInit: ControllerInitFunction< + ClaimsService, + ClaimsServiceMessengerType +> = (request) => { + const { controllerMessenger } = request; + + const { claimsEnv } = loadShieldConfig(); + + const service = new ClaimsService({ + messenger: controllerMessenger, + env: claimsEnv, + fetchFunction: fetch.bind(globalThis), + }); + + return { + controller: service, + memStateKey: null, + persistedStateKey: null, + }; +}; diff --git a/app/scripts/controller-init/claims/index.ts b/app/scripts/controller-init/claims/index.ts new file mode 100644 index 000000000000..cc2b13d63395 --- /dev/null +++ b/app/scripts/controller-init/claims/index.ts @@ -0,0 +1,2 @@ +export { ClaimsControllerInit } from './claims-controller-init'; +export { ClaimsServiceInit } from './claims-service-init'; diff --git a/app/scripts/controller-init/controller-list.ts b/app/scripts/controller-init/controller-list.ts index 5ab5f7c5fed3..11a7a6596520 100644 --- a/app/scripts/controller-init/controller-list.ts +++ b/app/scripts/controller-init/controller-list.ts @@ -81,6 +81,7 @@ import { AccountActivityService, BackendWebSocketService, } from '@metamask/core-backend'; +import { ClaimsController, ClaimsService } from '@metamask/claims-controller'; import OnboardingController from '../controllers/onboarding'; import { PreferencesController } from '../controllers/preferences-controller'; import SwapsController from '../controllers/swaps'; @@ -117,6 +118,7 @@ export type Controller = | AuthenticationController | BridgeController | BridgeStatusController + | ClaimsController | CronjobController | CurrencyRateController | DecryptMessageController @@ -192,7 +194,8 @@ export type Controller = | BackendWebSocketService | AccountActivityService | MultichainAccountService - | NetworkEnablementController; + | NetworkEnablementController + | ClaimsService; /** * Flat state object for all controllers supporting or required by modular initialization. @@ -210,6 +213,7 @@ export type ControllerFlatState = AccountOrderController['state'] & AuthenticationController['state'] & BridgeController['state'] & BridgeStatusController['state'] & + ClaimsController['state'] & CronjobController['state'] & CurrencyRateController['state'] & DeFiPositionsController['state'] & diff --git a/app/scripts/controller-init/messengers/claims/claims-controller-messenger.ts b/app/scripts/controller-init/messengers/claims/claims-controller-messenger.ts new file mode 100644 index 000000000000..0c075abd24e9 --- /dev/null +++ b/app/scripts/controller-init/messengers/claims/claims-controller-messenger.ts @@ -0,0 +1,80 @@ +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { + ClaimsControllerMessenger, + ClaimsServiceGenerateMessageForClaimSignatureAction, + ClaimsServiceGetClaimsAction, + ClaimsServiceGetClaimsApiUrlAction, + ClaimsServiceGetRequestHeadersAction, +} from '@metamask/claims-controller'; +import { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import { RootMessenger } from '../../../lib/messenger'; + +type AllowedActions = + | MessengerActions + | ClaimsServiceGetRequestHeadersAction + | ClaimsServiceGetClaimsApiUrlAction + | ClaimsServiceGenerateMessageForClaimSignatureAction + | ClaimsServiceGetClaimsAction + | KeyringControllerSignPersonalMessageAction; +type AllowedEvents = MessengerEvents; + +export type ClaimsControllerMessengerType = ReturnType< + typeof getClaimsControllerMessenger +>; + +export function getClaimsControllerMessenger( + messenger: RootMessenger, +): ClaimsControllerMessenger { + const controllerMessenger = new Messenger< + 'ClaimsController', + AllowedActions, + AllowedEvents, + typeof messenger + >({ + namespace: 'ClaimsController', + parent: messenger, + }); + messenger.delegate({ + messenger: controllerMessenger, + actions: [ + 'ClaimsService:getClaimsApiUrl', + 'ClaimsService:getRequestHeaders', + 'ClaimsService:generateMessageForClaimSignature', + 'ClaimsService:getClaims', + 'KeyringController:signPersonalMessage', + ], + events: [], + }); + return controllerMessenger; +} + +type InitActions = never; +type InitEvents = never; + +export type ClaimsControllerInitMessenger = ReturnType< + typeof getClaimsControllerInitMessenger +>; + +export function getClaimsControllerInitMessenger( + messenger: RootMessenger, +) { + const controllerInitMessenger = new Messenger< + 'ClaimsControllerInit', + InitActions, + InitEvents, + typeof messenger + >({ + namespace: 'ClaimsControllerInit', + parent: messenger, + }); + messenger.delegate({ + messenger: controllerInitMessenger, + events: [], + actions: [], + }); + return controllerInitMessenger; +} diff --git a/app/scripts/controller-init/messengers/claims/claims-service-messenger.ts b/app/scripts/controller-init/messengers/claims/claims-service-messenger.ts new file mode 100644 index 000000000000..9ce149d8eab2 --- /dev/null +++ b/app/scripts/controller-init/messengers/claims/claims-service-messenger.ts @@ -0,0 +1,53 @@ +import { Messenger, MessengerActions } from '@metamask/messenger'; +import { ClaimsServiceMessenger } from '@metamask/claims-controller'; +import { AuthenticationControllerGetBearerToken } from '@metamask/profile-sync-controller/auth'; +import { RootMessenger } from '../../../lib/messenger'; + +type AllowedActions = + | MessengerActions + | AuthenticationControllerGetBearerToken; + +export type ClaimsServiceMessengerType = ReturnType< + typeof getClaimsServiceMessenger +>; + +export function getClaimsServiceMessenger( + messenger: RootMessenger, +) { + const serviceMessenger = new Messenger< + 'ClaimsService', + AllowedActions, + never, + typeof messenger + >({ + namespace: 'ClaimsService', + parent: messenger, + }); + messenger.delegate({ + messenger: serviceMessenger, + actions: ['AuthenticationController:getBearerToken'], + events: [], + }); + return serviceMessenger; +} + +type InitActions = never; +type InitEvents = never; + +export type ClaimsServiceInitMessenger = ReturnType< + typeof getClaimsServiceInitMessenger +>; + +export function getClaimsServiceInitMessenger( + messenger: RootMessenger, +) { + return new Messenger< + 'ClaimsServiceInit', + InitActions, + InitEvents, + typeof messenger + >({ + namespace: 'ClaimsServiceInit', + parent: messenger, + }); +} diff --git a/app/scripts/controller-init/messengers/index.ts b/app/scripts/controller-init/messengers/index.ts index 74891b69b3de..71ab936fcdfb 100644 --- a/app/scripts/controller-init/messengers/index.ts +++ b/app/scripts/controller-init/messengers/index.ts @@ -195,6 +195,11 @@ import { getUserOperationControllerMessenger, } from './user-operation-controller-messenger'; import { getRewardsDataServiceMessenger } from './reward-data-service-messenger'; +import { + getClaimsControllerInitMessenger, + getClaimsControllerMessenger, +} from './claims/claims-controller-messenger'; +import { getClaimsServiceMessenger } from './claims/claims-service-messenger'; export type { AccountOrderControllerMessenger } from './account-order-controller-messenger'; export { getAccountOrderControllerMessenger } from './account-order-controller-messenger'; @@ -445,6 +450,14 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getBridgeStatusControllerMessenger, getInitMessenger: noop, }, + ClaimsController: { + getMessenger: getClaimsControllerMessenger, + getInitMessenger: getClaimsControllerInitMessenger, + }, + ClaimsService: { + getMessenger: getClaimsServiceMessenger, + getInitMessenger: noop, + }, CronjobController: { getMessenger: getCronjobControllerMessenger, getInitMessenger: noop, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index fb878eb0f604..1a650bb24f1e 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -414,6 +414,10 @@ import { UserOperationControllerInit } from './controller-init/confirmations/use import { RewardsDataServiceInit } from './controller-init/rewards-data-service-init'; import { RewardsControllerInit } from './controller-init/rewards-controller-init'; import { getRootMessenger } from './lib/messenger'; +import { + ClaimsControllerInit, + ClaimsServiceInit, +} from './controller-init/claims'; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) @@ -634,6 +638,8 @@ export default class MetamaskController extends EventEmitter { SubscriptionService: SubscriptionServiceInit, NetworkOrderController: NetworkOrderControllerInit, ShieldController: ShieldControllerInit, + ClaimsController: ClaimsControllerInit, + ClaimsService: ClaimsServiceInit, GatorPermissionsController: GatorPermissionsControllerInit, SnapsNameProvider: SnapsNameProviderInit, EnsController: EnsControllerInit, @@ -753,7 +759,8 @@ export default class MetamaskController extends EventEmitter { this.announcementController = controllersByName.AnnouncementController; this.accountOrderController = controllersByName.AccountOrderController; this.rewardsController = controllersByName.RewardsController; - + this.claimsController = controllersByName.ClaimsController; + this.claimsService = controllersByName.ClaimsService; this.backup = new Backup({ preferencesController: this.preferencesController, addressBookController: this.addressBookController, @@ -1210,6 +1217,8 @@ export default class MetamaskController extends EventEmitter { DeFiPositionsController: this.deFiPositionsController, PhishingController: this.phishingController, ShieldController: this.shieldController, + ClaimsController: this.claimsController, + ClaimsService: this.claimsService, ...resetOnRestartStore, ...controllerMemState, }, @@ -2547,9 +2556,6 @@ export default class MetamaskController extends EventEmitter { getRewardsSeasonMetadata: this.rewardsController.getSeasonMetadata.bind( this.rewardsController, ), - getRewardsSeasonStatus: this.rewardsController.getSeasonStatus.bind( - this.rewardsController, - ), getRewardsHasAccountOptedIn: this.rewardsController.getHasAccountOptedIn.bind( this.rewardsController, @@ -2558,6 +2564,15 @@ export default class MetamaskController extends EventEmitter { this.rewardsController, ), + // claims + getSubmitClaimConfig: this.claimsController.getSubmitClaimConfig.bind( + this.claimsController, + ), + generateClaimSignature: this.claimsController.generateClaimSignature.bind( + this.claimsController, + ), + getClaims: this.claimsController.getClaims.bind(this.claimsController), + // hardware wallets connectHardware: this.connectHardware.bind(this), forgetDevice: this.forgetDevice.bind(this), diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index d4af795fc69b..83b49da7e21a 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -948,6 +948,13 @@ "lodash": true } }, + "@metamask/claims-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/controller-utils": true, + "@metamask/utils": true + } + }, "@metamask/controller-utils": { "globals": { "URL": true, diff --git a/lavamoat/browserify/experimental/policy.json b/lavamoat/browserify/experimental/policy.json index d4af795fc69b..83b49da7e21a 100644 --- a/lavamoat/browserify/experimental/policy.json +++ b/lavamoat/browserify/experimental/policy.json @@ -948,6 +948,13 @@ "lodash": true } }, + "@metamask/claims-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/controller-utils": true, + "@metamask/utils": true + } + }, "@metamask/controller-utils": { "globals": { "URL": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index d4af795fc69b..83b49da7e21a 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -948,6 +948,13 @@ "lodash": true } }, + "@metamask/claims-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/controller-utils": true, + "@metamask/utils": true + } + }, "@metamask/controller-utils": { "globals": { "URL": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index d4af795fc69b..83b49da7e21a 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -948,6 +948,13 @@ "lodash": true } }, + "@metamask/claims-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/controller-utils": true, + "@metamask/utils": true + } + }, "@metamask/controller-utils": { "globals": { "URL": true, diff --git a/lavamoat/webpack/policy.json b/lavamoat/webpack/policy.json index b6252c5bd384..513d3e47f20f 100644 --- a/lavamoat/webpack/policy.json +++ b/lavamoat/webpack/policy.json @@ -986,6 +986,13 @@ "lodash": true } }, + "@metamask/claims-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/controller-utils": true, + "@metamask/utils": true + } + }, "@metamask/controller-utils": { "globals": { "Buffer.from": true, diff --git a/package.json b/package.json index 08cb2bf25d7f..70c28f901079 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "@metamask/bridge-status-controller": "^56.0.0", "@metamask/browser-passworder": "^4.3.0", "@metamask/chain-agnostic-permission": "^1.2.2", + "@metamask/claims-controller": "^0.1.0", "@metamask/contract-metadata": "^2.5.0", "@metamask/controller-utils": "^11.15.0", "@metamask/core-backend": "^4.0.0", diff --git a/shared/modules/shield/config.ts b/shared/modules/shield/config.ts index a4924827f656..488c44b61c74 100644 --- a/shared/modules/shield/config.ts +++ b/shared/modules/shield/config.ts @@ -1,4 +1,5 @@ import { Env as SubscriptionEnv } from '@metamask/subscription-controller'; +import { Env as ClaimsEnv } from '@metamask/claims-controller'; import { ENVIRONMENT } from '../../../development/build/constants'; import { ShieldEnvConfig } from './type'; @@ -40,36 +41,42 @@ export type BuildType = (typeof BUILD_TYPE)[keyof typeof BUILD_TYPE]; export const ShieldConfigMap: Record = { [BUILD_TYPE.main]: { subscriptionEnv: SubscriptionEnv.PRD, + claimsEnv: ClaimsEnv.PRD, gatewayUrl: SHIELD_GATEWAY_URL[ENV.prd], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.prd], claimUrl: SHIELD_CLAIMS_API_URL[ENV.prd], }, [BUILD_TYPE.flask]: { subscriptionEnv: SubscriptionEnv.PRD, + claimsEnv: ClaimsEnv.PRD, gatewayUrl: SHIELD_GATEWAY_URL[ENV.prd], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.prd], claimUrl: SHIELD_CLAIMS_API_URL[ENV.prd], }, [BUILD_TYPE.beta]: { subscriptionEnv: SubscriptionEnv.UAT, + claimsEnv: ClaimsEnv.UAT, gatewayUrl: SHIELD_GATEWAY_URL[ENV.uat], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.uat], claimUrl: SHIELD_CLAIMS_API_URL[ENV.uat], }, [BUILD_TYPE.experimental]: { subscriptionEnv: SubscriptionEnv.PRD, + claimsEnv: ClaimsEnv.PRD, gatewayUrl: SHIELD_GATEWAY_URL[ENV.prd], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.prd], claimUrl: SHIELD_CLAIMS_API_URL[ENV.prd], }, [BUILD_TYPE.dev]: { subscriptionEnv: SubscriptionEnv.DEV, + claimsEnv: ClaimsEnv.DEV, gatewayUrl: SHIELD_GATEWAY_URL[ENV.dev], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.dev], claimUrl: SHIELD_CLAIMS_API_URL[ENV.dev], }, [BUILD_TYPE.uat]: { subscriptionEnv: SubscriptionEnv.UAT, + claimsEnv: ClaimsEnv.UAT, gatewayUrl: SHIELD_GATEWAY_URL[ENV.uat], ruleEngineUrl: SHIELD_RULE_ENGINE_URL[ENV.uat], claimUrl: SHIELD_CLAIMS_API_URL[ENV.uat], diff --git a/shared/modules/shield/type.ts b/shared/modules/shield/type.ts index b43fae0e14d2..00f0c94fff7c 100644 --- a/shared/modules/shield/type.ts +++ b/shared/modules/shield/type.ts @@ -1,7 +1,9 @@ import { Env } from '@metamask/subscription-controller'; +import { Env as ClaimsEnv } from '@metamask/claims-controller'; export type ShieldEnvConfig = { subscriptionEnv: Env; + claimsEnv: ClaimsEnv; gatewayUrl: string; ruleEngineUrl: string; claimUrl: string; diff --git a/shared/types/background.ts b/shared/types/background.ts index 0555fe4abe1c..8dfac3f17bc4 100644 --- a/shared/types/background.ts +++ b/shared/types/background.ts @@ -58,6 +58,7 @@ import type { } from '@metamask/notification-services-controller'; import type { SmartTransactionsControllerState } from '@metamask/smart-transactions-controller'; +import type { ClaimsControllerState } from '@metamask/claims-controller'; import type { NetworkOrderControllerState } from '../../app/scripts/controllers/network-order'; import type { AccountOrderControllerState } from '../../app/scripts/controllers/account-order'; import type { PreferencesControllerState } from '../../app/scripts/controllers/preferences-controller'; @@ -319,6 +320,7 @@ export type ControllerStatePropertiesEnumerated = { rewardsSeasons: RewardsControllerState['rewardsSeasons']; rewardsSeasonStatuses: RewardsControllerState['rewardsSeasonStatuses']; rewardsSubscriptionTokens: RewardsControllerState['rewardsSubscriptionTokens']; + claims: ClaimsControllerState['claims']; }; type ControllerStateTypesMerged = AccountsControllerState & @@ -333,6 +335,7 @@ type ControllerStateTypesMerged = AccountsControllerState & AppStateControllerState & BridgeControllerState & BridgeStatusControllerState & + ClaimsControllerState & CronjobControllerState & CurrencyRateState & DecryptMessageControllerState & diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index c638b8d18eed..36a7a3e98d4a 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -101,6 +101,8 @@ "minimumBalanceForRentExemptionInLamports": "string" }, "BridgeStatusController": { "txHistory": "object" }, + "ClaimsController": "object", + "ClaimsService": "undefined", "CronjobController": { "events": "object" }, "CurrencyController": { "currentCurrency": "usd", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 9bf2636e4a3b..93fbebf14608 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -114,6 +114,7 @@ "shouldShowAggregatedBalancePopover": "boolean" }, "firstTimeFlowType": "import", + "claims": "object", "completedOnboarding": true, "knownMethodData": "object", "use4ByteResolution": true, diff --git a/ui/contexts/claims/claims.tsx b/ui/contexts/claims/claims.tsx index 4b08ae356c99..8d423c333bd7 100644 --- a/ui/contexts/claims/claims.tsx +++ b/ui/contexts/claims/claims.tsx @@ -8,17 +8,13 @@ import React, { useCallback, } from 'react'; import { numberToHex } from '@metamask/utils'; -import { - ShieldClaim, - CLAIM_STATUS, - ClaimStatus, -} from '../../pages/settings/transaction-shield-tab/types'; +import { Claim, ClaimStatusEnum } from '@metamask/claims-controller'; import { getShieldClaims } from '../../store/actions'; type ClaimsContextType = { - claims: ShieldClaim[]; - pendingClaims: ShieldClaim[]; - historyClaims: ShieldClaim[]; + claims: Claim[]; + pendingClaims: Claim[]; + historyClaims: Claim[]; isLoading: boolean; error: Error | null; refetchClaims: () => Promise; @@ -31,14 +27,14 @@ type ClaimsProviderProps = { }; const PENDING_CLAIM_STATUSES = [ - CLAIM_STATUS.CREATED, - CLAIM_STATUS.SUBMITTED, - CLAIM_STATUS.IN_PROGRESS, - CLAIM_STATUS.WAITING_FOR_CUSTOMER, -] as ClaimStatus[]; + ClaimStatusEnum.CREATED, + ClaimStatusEnum.SUBMITTED, + ClaimStatusEnum.IN_PROGRESS, + ClaimStatusEnum.WAITING_FOR_CUSTOMER, +] as ClaimStatusEnum[]; export const ClaimsProvider: React.FC = ({ children }) => { - const [claims, setClaims] = useState([]); + const [claims, setClaims] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -49,12 +45,12 @@ export const ClaimsProvider: React.FC = ({ children }) => { const claimsData = await getShieldClaims(); // sort claims by createdAt descending const sortedClaims = claimsData - .sort((a: ShieldClaim, b: ShieldClaim) => { + .sort((a: Claim, b: Claim) => { const dateA = new Date(a.createdAt).getTime(); const dateB = new Date(b.createdAt).getTime(); return dateB - dateA; }) - .map((claim: ShieldClaim, index: number) => { + .map((claim: Claim, index: number) => { const numberChain = Number(claim.chainId); const chainId = isNaN(numberChain) ? '' : numberToHex(numberChain); return { diff --git a/ui/hooks/claims/useClaimState.ts b/ui/hooks/claims/useClaimState.ts index 8cc53fcfaedc..ed1e5d576f3e 100644 --- a/ui/hooks/claims/useClaimState.ts +++ b/ui/hooks/claims/useClaimState.ts @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { ShieldClaimAttachment } from '../../pages/settings/transaction-shield-tab/types'; +import { Attachment as ClaimAttachment } from '@metamask/claims-controller'; import { useClaims } from '../../contexts/claims/claims'; +import { generateClaimSignature } from '../../store/actions'; export const useClaimState = (isView: boolean = false) => { const { pathname } = useLocation(); @@ -16,12 +17,25 @@ export const useClaimState = (isView: boolean = false) => { useState(''); const [caseDescription, setCaseDescription] = useState(''); const [files, setFiles] = useState(); - const [uploadedFiles, setUploadedFiles] = useState( - [], - ); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [claimSignature, setClaimSignature] = useState(''); const claimId = pathname.split('/').pop(); + useEffect(() => { + if (isView || !chainId || !impactedWalletAddress) { + return; + } + + (async () => { + const signature = await generateClaimSignature( + chainId, + impactedWalletAddress, + ); + setClaimSignature(signature); + })(); + }, [isView, chainId, impactedWalletAddress]); + useEffect(() => { if (isView && claimId) { const claimDetails = claims.find((claim) => claim.id === claimId); @@ -32,7 +46,7 @@ export const useClaimState = (isView: boolean = false) => { setImpactedTransactionHash(claimDetails.impactedTxHash); setReimbursementWalletAddress(claimDetails.reimbursementWalletAddress); setCaseDescription(claimDetails.description); - setUploadedFiles(claimDetails.attachments); + setUploadedFiles(claimDetails.attachments || []); } } }, [isView, claimId, claims]); @@ -53,6 +67,7 @@ export const useClaimState = (isView: boolean = false) => { files, setFiles, uploadedFiles, + claimSignature, clear: () => { setChainId(''); setEmail(''); diff --git a/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx b/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx index 013ab4eb3235..a1ab94caf57a 100644 --- a/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx +++ b/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; import { isValidHexAddress } from '@metamask/controller-utils'; -import { isStrictHexString } from '@metamask/utils'; import { Box, BoxAlignItems, @@ -60,75 +59,19 @@ import { FileUploader } from '../../../../components/component-library/file-uplo import { SUBMIT_CLAIM_ERROR_CODES, SUBMIT_CLAIM_FIELDS, - SubmitClaimErrorCode, SubmitClaimField, } from '../types'; import { SubmitClaimError } from '../claim-error'; import AccountSelector from '../account-selector'; import NetworkSelector from '../network-selector'; - -const VALID_SUBMISSION_WINDOW_DAYS = 21; -const MAX_FILE_SIZE_MB = 5; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; - -// Error codes for codes on the root level of the error response -const ERROR_MESSAGE_MAP: Partial< - Record< - SubmitClaimErrorCode, - { - messageKey: string; - params?: (string | number)[]; - field?: SubmitClaimField; - } - > -> = { - [SUBMIT_CLAIM_ERROR_CODES.TRANSACTION_NOT_ELIGIBLE]: { - messageKey: 'shieldClaimImpactedTxHashNotEligible', - field: SUBMIT_CLAIM_FIELDS.IMPACTED_TRANSACTION_HASH, - }, - [SUBMIT_CLAIM_ERROR_CODES.SUBMISSION_WINDOW_EXPIRED]: { - messageKey: 'shieldClaimSubmissionWindowExpired', - params: [VALID_SUBMISSION_WINDOW_DAYS.toString()], - }, - [SUBMIT_CLAIM_ERROR_CODES.MAX_CLAIMS_LIMIT_EXCEEDED]: { - messageKey: 'shieldClaimMaxClaimsLimitExceeded', - }, - [SUBMIT_CLAIM_ERROR_CODES.DUPLICATE_CLAIM_EXISTS]: { - messageKey: 'shieldClaimDuplicateClaimExists', - }, - [SUBMIT_CLAIM_ERROR_CODES.INVALID_WALLET_ADDRESSES]: { - messageKey: 'shieldClaimSameWalletAddressesError', - field: SUBMIT_CLAIM_FIELDS.REIMBURSEMENT_WALLET_ADDRESS, - }, - [SUBMIT_CLAIM_ERROR_CODES.FILES_SIZE_EXCEEDED]: { - messageKey: 'shieldClaimFileErrorSizeExceeded', - }, - [SUBMIT_CLAIM_ERROR_CODES.FILES_COUNT_EXCEEDED]: { - messageKey: 'shieldClaimFileErrorCountExceeded', - }, - [SUBMIT_CLAIM_ERROR_CODES.INVALID_FILES_TYPE]: { - messageKey: 'shieldClaimFileErrorInvalidType', - }, - [SUBMIT_CLAIM_ERROR_CODES.FIELD_REQUIRED]: { - messageKey: 'shieldClaimInvalidRequired', - }, -}; - -// Error codes for fields in the error response -const FIELD_ERROR_MESSAGE_KEY_MAP: Partial> = { - [SUBMIT_CLAIM_FIELDS.CHAIN_ID]: 'shieldClaimInvalidChainId', - [SUBMIT_CLAIM_FIELDS.EMAIL]: 'shieldClaimInvalidEmail', - [SUBMIT_CLAIM_FIELDS.IMPACTED_WALLET_ADDRESS]: - 'shieldClaimInvalidWalletAddress', - [SUBMIT_CLAIM_FIELDS.IMPACTED_TRANSACTION_HASH]: 'shieldClaimInvalidTxHash', - [SUBMIT_CLAIM_FIELDS.REIMBURSEMENT_WALLET_ADDRESS]: - 'shieldClaimInvalidWalletAddress', -}; - -function isValidTransactionHash(hash: string): boolean { - // Check if it's exactly 66 characters (0x + 64 hex chars) - return hash.length === 66 && isStrictHexString(hash); -} +import { + ERROR_MESSAGE_MAP, + FIELD_ERROR_MESSAGE_KEY_MAP, + MAX_FILE_SIZE_BYTES, + MAX_FILE_SIZE_MB, + VALID_SUBMISSION_WINDOW_DAYS, +} from './constants'; +import { isValidTransactionHash } from './utils'; const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { const t = useI18nContext(); @@ -153,6 +96,7 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { files, setFiles, uploadedFiles, + claimSignature, } = useClaimState(isView); const [errors, setErrors] = useState< @@ -375,6 +319,7 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { reimbursementWalletAddress, caseDescription, files, + signature: claimSignature, }); dispatch(setShowClaimSubmitToast(ClaimSubmitToastType.Success)); // update claims @@ -397,6 +342,7 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { dispatch, navigate, refetchClaims, + claimSignature, handleSubmitClaimError, ]); @@ -652,7 +598,7 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { {uploadedFiles.map((file, index) => ( { > +> = { + [SUBMIT_CLAIM_ERROR_CODES.TRANSACTION_NOT_ELIGIBLE]: { + messageKey: 'shieldClaimImpactedTxHashNotEligible', + field: SUBMIT_CLAIM_FIELDS.IMPACTED_TRANSACTION_HASH, + }, + [SUBMIT_CLAIM_ERROR_CODES.SUBMISSION_WINDOW_EXPIRED]: { + messageKey: 'shieldClaimSubmissionWindowExpired', + params: [VALID_SUBMISSION_WINDOW_DAYS.toString()], + }, + [SUBMIT_CLAIM_ERROR_CODES.MAX_CLAIMS_LIMIT_EXCEEDED]: { + messageKey: 'shieldClaimMaxClaimsLimitExceeded', + }, + [SUBMIT_CLAIM_ERROR_CODES.DUPLICATE_CLAIM_EXISTS]: { + messageKey: 'shieldClaimDuplicateClaimExists', + }, + [SUBMIT_CLAIM_ERROR_CODES.INVALID_WALLET_ADDRESSES]: { + messageKey: 'shieldClaimSameWalletAddressesError', + field: SUBMIT_CLAIM_FIELDS.REIMBURSEMENT_WALLET_ADDRESS, + }, + [SUBMIT_CLAIM_ERROR_CODES.FILES_SIZE_EXCEEDED]: { + messageKey: 'shieldClaimFileErrorSizeExceeded', + }, + [SUBMIT_CLAIM_ERROR_CODES.FILES_COUNT_EXCEEDED]: { + messageKey: 'shieldClaimFileErrorCountExceeded', + }, + [SUBMIT_CLAIM_ERROR_CODES.INVALID_FILES_TYPE]: { + messageKey: 'shieldClaimFileErrorInvalidType', + }, + [SUBMIT_CLAIM_ERROR_CODES.FIELD_REQUIRED]: { + messageKey: 'shieldClaimInvalidRequired', + }, +}; + +// Error codes for fields in the error response +export const FIELD_ERROR_MESSAGE_KEY_MAP: Partial< + Record +> = { + [SUBMIT_CLAIM_FIELDS.CHAIN_ID]: 'shieldClaimInvalidChainId', + [SUBMIT_CLAIM_FIELDS.EMAIL]: 'shieldClaimInvalidEmail', + [SUBMIT_CLAIM_FIELDS.IMPACTED_WALLET_ADDRESS]: + 'shieldClaimInvalidWalletAddress', + [SUBMIT_CLAIM_FIELDS.IMPACTED_TRANSACTION_HASH]: 'shieldClaimInvalidTxHash', + [SUBMIT_CLAIM_FIELDS.REIMBURSEMENT_WALLET_ADDRESS]: + 'shieldClaimInvalidWalletAddress', +}; diff --git a/ui/pages/settings/transaction-shield-tab/claims-form/utils.ts b/ui/pages/settings/transaction-shield-tab/claims-form/utils.ts new file mode 100644 index 000000000000..8791fcc6e984 --- /dev/null +++ b/ui/pages/settings/transaction-shield-tab/claims-form/utils.ts @@ -0,0 +1,6 @@ +import { isStrictHexString } from '@metamask/utils'; + +export function isValidTransactionHash(hash: string): boolean { + // Check if it's exactly 66 characters (0x + 64 hex chars) + return hash.length === 66 && isStrictHexString(hash); +} diff --git a/ui/pages/settings/transaction-shield-tab/claims-list/claims-list.tsx b/ui/pages/settings/transaction-shield-tab/claims-list/claims-list.tsx index 2fd19aad2f18..77de9a65da95 100644 --- a/ui/pages/settings/transaction-shield-tab/claims-list/claims-list.tsx +++ b/ui/pages/settings/transaction-shield-tab/claims-list/claims-list.tsx @@ -12,6 +12,7 @@ import { TextAlign, } from '@metamask/design-system-react'; import { useNavigate } from 'react-router-dom-v5-compat'; +import { Claim, ClaimStatusEnum } from '@metamask/claims-controller'; import LoadingScreen from '../../../../components/ui/loading-screen'; import { Tag } from '../../../../components/component-library'; import { @@ -22,43 +23,47 @@ import { } from '../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useClaims } from '../../../../contexts/claims/claims'; -import { CLAIM_STATUS, ClaimStatus, ShieldClaim } from '../types'; import { TRANSACTION_SHIELD_CLAIM_ROUTES } from '../../../../helpers/constants/routes'; const CLAIM_STATUS_MAP: Record< - ClaimStatus, + ClaimStatusEnum, { label: string; backgroundColor: BackgroundColor; textColor: TextColor } > = { - [CLAIM_STATUS.CREATED]: { + [ClaimStatusEnum.CREATED]: { label: 'shieldClaimStatusCreated', backgroundColor: BackgroundColor.warningMuted, textColor: TextColor.warningDefault, }, - [CLAIM_STATUS.SUBMITTED]: { + [ClaimStatusEnum.SUBMITTED]: { label: 'shieldClaimStatusSubmitted', backgroundColor: BackgroundColor.warningMuted, textColor: TextColor.warningDefault, }, - [CLAIM_STATUS.IN_PROGRESS]: { + [ClaimStatusEnum.IN_PROGRESS]: { label: 'shieldClaimStatusInProgress', backgroundColor: BackgroundColor.warningMuted, textColor: TextColor.warningDefault, }, - [CLAIM_STATUS.WAITING_FOR_CUSTOMER]: { + [ClaimStatusEnum.WAITING_FOR_CUSTOMER]: { label: 'shieldClaimStatusWaitingForCustomer', backgroundColor: BackgroundColor.warningMuted, textColor: TextColor.warningDefault, }, - [CLAIM_STATUS.APPROVED]: { + [ClaimStatusEnum.APPROVED]: { label: 'shieldClaimStatusApproved', backgroundColor: BackgroundColor.successMuted, textColor: TextColor.successDefault, }, - [CLAIM_STATUS.REJECTED]: { + [ClaimStatusEnum.REJECTED]: { label: 'shieldClaimStatusRejected', backgroundColor: BackgroundColor.errorMuted, textColor: TextColor.errorDefault, }, + [ClaimStatusEnum.UNKNOWN]: { + label: 'shieldClaimStatusUnknown', + backgroundColor: BackgroundColor.warningMuted, + textColor: TextColor.warningDefault, + }, }; const ClaimsList = () => { @@ -67,9 +72,9 @@ const ClaimsList = () => { const { pendingClaims, historyClaims, isLoading } = useClaims(); const claimItem = useCallback( - (claim: ShieldClaim) => { + (claim: Claim) => { // add leading zero to claim number if it is less than 1000 - const claimNumber = claim.claimNumber.toString().padStart(3, '0'); + const claimNumber = claim.shortId.toString().padStart(3, '0'); return ( ( + 'getSubmitClaimConfig', + [params], + ); + const { headers, method, url } = submitClaimConfig; - const claimsUrl = `${baseUrl}/claims`; const formData = new FormData(); formData.append('chainId', params.chainId); formData.append('email', params.email); @@ -7734,11 +7737,7 @@ export async function submitShieldClaim(params: { params.reimbursementWalletAddress, ); formData.append('description', params.caseDescription); - // TODO: temporary value for signature, update to correct signature after implement signature verification - formData.append( - 'signature', - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12', - ); + formData.append('signature', params.signature); formData.append('timestamp', Date.now().toString()); // add files to form data @@ -7748,15 +7747,14 @@ export async function submitShieldClaim(params: { }); } - const accessToken = await submitRequestToBackground('getBearerToken'); - try { // we do the request here instead of background controllers because files are not serializable - const response = await fetch(claimsUrl, { - method: 'POST', + const response = await fetch(url, { + method, body: formData, + // FIXME: remove `Content-Type: multipart/form-data` from the controller headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: headers.Authorization, }, }); @@ -7770,32 +7768,39 @@ export async function submitShieldClaim(params: { return ClaimSubmitToastType.Success; } catch (error) { - console.error(error); + log.error('[submitShieldClaim] Failed to submit shield claim:', error); throw new SubmitClaimError(ClaimSubmitToastType.Errored); } } +/** + * Fetches all shield claims, relates to the current user profile ID. + * + * @returns The shield claims. + */ export async function getShieldClaims() { - const baseUrl = shieldConfig.claimUrl; - - const claimsUrl = `${baseUrl}/claims`; - const accessToken = await submitRequestToBackground('getBearerToken'); - try { - const response = await fetch(claimsUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - const errorBody = await response.json(); - throw new Error(errorBody.error || 'Failed to get shield claims'); - } - return await response.json(); + const claims = await submitRequestToBackground('getClaims'); + return claims; } catch (error) { - console.error('Failed to get shield claims:', error); + log.error('[getShieldClaims] Failed to get shield claims:', error); throw error; } } + +/** + * Generates a signature for a claim. + * + * @param chainId - The chain ID. + * @param walletAddress - The wallet address. + * @returns The signature. + */ +export async function generateClaimSignature( + chainId: string, + walletAddress: string, +) { + return await submitRequestToBackground('generateClaimSignature', [ + chainId, + walletAddress, + ]); +} diff --git a/yarn.lock b/yarn.lock index 913adc5c0d1b..7f2d5d7928f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5787,6 +5787,18 @@ __metadata: languageName: node linkType: hard +"@metamask/claims-controller@npm:^0.1.0": + version: 0.1.0 + resolution: "@metamask/claims-controller@npm:0.1.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.15.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + checksum: 10/dcc541d951d30ddd59745d8e65c468670925ac27574b534178b5a804e3122ca369d467b94d31d43a804fa5cf4bc69a788878995313b36bc8f4a4889823810384 + languageName: node + linkType: hard + "@metamask/contract-metadata@npm:^2.4.0, @metamask/contract-metadata@npm:^2.5.0": version: 2.5.0 resolution: "@metamask/contract-metadata@npm:2.5.0" @@ -31985,6 +31997,7 @@ __metadata: "@metamask/browser-passworder": "npm:^4.3.0" "@metamask/build-utils": "npm:^3.0.0" "@metamask/chain-agnostic-permission": "npm:^1.2.2" + "@metamask/claims-controller": "npm:^0.1.0" "@metamask/contract-metadata": "npm:^2.5.0" "@metamask/controller-utils": "npm:^11.15.0" "@metamask/core-backend": "npm:^4.0.0"