diff --git a/.eslintrc.js b/.eslintrc.js index 1cdc300210f8..dbe056eb5516 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -187,6 +187,8 @@ module.exports = { 'app/components/UI/Ramp/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Rewards/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Perps/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Earn/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Stake/**/*.{js,jsx,ts,tsx}', ], rules: { '@metamask/design-tokens/color-no-hex': 'error', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 38f093a99aca..6348005aafc1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -164,6 +164,7 @@ app/selectors/featureFlagController/rewards @MetaMask/rewards **/rewards/** @MetaMask/rewards # Perps Team +scripts/perps/agentic/teams/perps/ @MetaMask/perps app/components/UI/Perps/ @MetaMask/perps app/components/UI/WalletAction/*perps* @MetaMask/perps app/core/Engine/controllers/perps-controller @MetaMask/perps @@ -187,6 +188,7 @@ app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts @MetaMask/predict app/components/hooks/useIsOriginalNativeTokenSymbol @MetaMask/metamask-assets app/components/hooks/useTokenBalancesController @MetaMask/metamask-assets app/components/hooks/useTokenBalance.tsx @MetaMask/metamask-assets +app/components/hooks/useTokensData @MetaMask/metamask-assets app/components/hooks/useSafeChains.ts @MetaMask/metamask-assets app/components/UI/Assets @MetaMask/metamask-assets app/components/UI/AssetOverview @MetaMask/metamask-assets diff --git a/.github/workflows/release-pr-approval.yml b/.github/workflows/release-pr-approval.yml deleted file mode 100644 index 771b93ddbd5a..000000000000 --- a/.github/workflows/release-pr-approval.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Release PR Approval - -on: - pull_request_review: - types: [submitted] - -jobs: - release-pr-approval: - if: > - startsWith(github.event.pull_request.base.ref, 'Version-v') || - startsWith(github.event.pull_request.base.ref, 'release/') - runs-on: ubuntu-latest - steps: - - name: Require Release Team approval - uses: op5dev/require-team-approval@dfd7b8b9a88bf82a955c103f7e19642b0411aecd - with: - team: release-team - token: ${{ secrets.METAMASK_MOBILE_ORG_READ_TOKEN }} diff --git a/.gitignore b/.gitignore index 24a1b06d3488..7d4506eaf3de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # typescript incremental build cache .tsbuildinfo +# task working directory (agent-local, not shipped) +.task/ + # osx .DS_Store # don't save asdf tools-version config as nvm is prioritized. diff --git a/CHANGELOG.md b/CHANGELOG.md index c69c2b55406d..dc62e6a36e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.70.0] + +### Added + +- Add auth header to bridge getToken calls (#26191) +- Add a Contact Support button on CardHome (#27421) +- Added an mUSD bonus calculator to the Rewards tab (#27398) +- Add Monad integration on Card delegation (#27392) +- Added a Cash section on the homepage that shows aggregated mUSD balance, annualized bonus copy for stablecoin holders, and a dedicated Cash token list view with network filter (#27123) +- Homepage tokens and DeFi now always sort by balance/value; View all keeps your sort preference. Popular networks are selected when opening the Wallet so View all shows all popular networks by default (#27339) +- Include nfts on manual refresh (#27272) +- Added a remote feature flag to control default pay token preselection when users have no Perps balance. (#27289) +- Added a banner to display TRX that is ready for withdrawal on the token details view (#27075) +- Add campaign opt-in and participant status hooks/controller actions (#27121) +- Added a banner to display TRX in the 14-day unstaking lock period on the token details view (#27074) +- Add select quotes functionality (#26640) +- When users have no perps balance, the app now preselects the allowlist token with the highest balance for payment when available, and shows an "Add funds" button on the market details screen when no token can be preselected (#26281) +- Expose GET /campaigns endpoint through RewardsController with 5-minute cache (#27108) +- Always display popular networks assets on the token section of the home page (#27009) +- Added Trending tokens to the mobile Swap zero state with filter controls and improved Bridge quote/loading state handling (#26620) +- Added off-device linked accounts detection, caching, and display in Rewards Settings (#26674) +- Added a live blinking cursor to the Ramp Buy and Sell amount input screens for a more intuitive input experience (#27292) + +### Changed + +- Updated the mUSD conversion flow to redirect users to the home page if they've "Max" converted their last eligible token (#27383) +- Updated Bridge token selector balance sizing and color hierarchy (#27197) +- Improved RPC URL display in Networks Management to hide protocol and API keys, matching Network Selector (#27067) +- Update the new home page nft section redesign to always show popular networks (#27165) +- Update the new home page redesign defi section to always show popular networks (#27163) +- Updated swap price impact text coloring (#26390) +- Updated mUSD conversion copy to reflect annualized bonus and claim timeline (#27097) +- Refactored token-conversion-asset-header to stack assets vertically when text overflow is detected (#27010) +- Replaced mUSD conversion-specific network fee row with the generic transaction fee row and updated fee tooltip copy (#27091) +- Updates price impact modals content (#27256) +- Update icons when tab bar is pressed (#27082) +- Updated the error state icon on the homepage to a new no-connection illustration (#27070) +- Updated View more card styling with background color and updated Perps View more to navigate to market list (#27078) + +### Fixed + +- Fixed Ledger connect screen image being cut off on iOS after using the keyboard. (#27665) +- Fixed a bug where tapping perpetuals items on the homepage did not show the tutorial for first-time users (#27423) +- Fixed UI styling on perps, explore and predictions (#26890) +- Fixed missing mUSD icon when viewing token details from the Cash section empty state (#27442) +- Fixed a brief flash of empty content in the Tokens section while token data loads on the homepage (#27431) +- Fixed Perps reconnect recovery and error reporting for market data and position actions (#27408) +- Fixed Card Onboarding name issues (#27291) +- fix(homepage): fix session summary section tracking and visibility detection (#27402) +- Disable slide-to-dismiss behavior of swaps keypad (#26770) +- Remove thrown exceptions in migration 121 when `NetworkEnablementController` is absent or `NetworkEnablementController.nativeAssetIdentifiers` is missing (#27275) +- Fixed Android Google sign-in errors not falling back to browser-based login for unrecognized credential manager failures (#26964) +- Fixed a visual inconsistency where bridge token selector ticker text appeared thinner than other token lists. (#27357) +- fix: scanning verbiage only shows when actually scanning (#27319) +- Fixed a bug where switching to a non-EVM network caused EVM transaction details to display the wrong block explorer link (#27321) +- Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen (#27297) +- Fixed a bug where the "Change provider" link in the payment selection modal was not clickable while payment methods were loading (#27288) +- fix: increase touchable area of select quotes entry (#27267) +- Fixed OTP error messages to show the actual error from the server instead of a generic fallback (#26727) +- fix: pin seed phrase font size to prevent shrinking on large font devices (#27238) +- Fix issue related to info icon press navigating to select quotes rather than opening info modal (#27249) +- Long/Short from Asset Details now ensures Arbitrum network exists (adds it if missing) before creating the deposit transaction, fixing "Transaction creation failed" when the user has no Arbitrum network (#27213, #26756) +- Fixed a race condition causing CLIENT_NOT_INITIALIZED errors when navigating to Perps before controller initialization completes (#27178) +- Fixed a bug where hiding balances on the wallet home screen was not reflected in the account list (#27190) +- Fixed BottomSheet dismissing the wrong screen when rapidly opened and closed, which could leave mUSD conversion confirmation flows in a blocked state with unrejected pending approvals (#27026) +- Fixed layout issues in Ramp Order Details — inline info icon, centered status text, sticky footer, stripped redundant order prefix, adjusted toast offset, and centered text in processing info modal (#27025) +- Fix avoid O(n) api calls to on-ramp endpoint (#26900) +- Fixed mUSD conversion confirmation no longer gets stuck when tapping external links that briefly background the app (#27155) +- Fixed a bug in the asset picker where token and balance text could wrap incorrectly by aligning mobile layout and truncation behavior with extension (#27069) +- Fixed privacy mode not hiding financial values on Perps screens (#27128) +- Fix Ledger transaction not displayed after opening ETH app (#26322) +- Fixed issue of confirmation not rejecting when app locks (#26905) +- Fixed missing horizontal padding on NFT skeleton loading state in full view (#27077) +- Fixed a bug where the Ramp checkout provider title appeared above the WebView content (#27024) +- Hardened mUSD conversion quick convert status tracking. Auto reject pending mUSD approvals when app is foregrounded. (#26608) +- Fixed missing block explorer link on "Receive mUSD" row for Linea USDT and DAI conversions using aggregator routes (#27022) + +## [7.69.1] + +### Fixed + +- Fixed root pages scrollable behavior with SafeAreaView, standardizing safe area and header inset handling across main tab views (#27446) +- Fixed token prices in the wallet list displaying without thousand-separator commas and with too many decimal places (#27485) + ## [7.69.0] ### Added @@ -64,6 +148,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed token hiding not working on the redesigned homepage (#26649) - Fixed an issue that could cause repeated Bridge RPC balance calls and improved how quickly source balances appear after token selection (#25952) +## [7.68.3] + +### Fixed + +- Fixed seedless onboarding vault decryption crash by handling both vault formats in encryptorAdapter (#27393) +- Fixed OTA environment variable configuration to use new build flag (#26668) + ## [7.68.2] ### Fixed @@ -10917,8 +11008,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...HEAD -[7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.2...v7.69.0 +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...HEAD +[7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 +[7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 +[7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.3...v7.69.0 +[7.68.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.2...v7.68.3 [7.68.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.1...v7.68.2 [7.68.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.0...v7.68.1 [7.68.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.67.3...v7.68.0 diff --git a/app/component-library/components-temp/KeyValueRow/KeyValueLabel/KeyValueLabel.tsx b/app/component-library/components-temp/KeyValueRow/KeyValueLabel/KeyValueLabel.tsx index c69b33856f68..2b42e5700c73 100644 --- a/app/component-library/components-temp/KeyValueRow/KeyValueLabel/KeyValueLabel.tsx +++ b/app/component-library/components-temp/KeyValueRow/KeyValueLabel/KeyValueLabel.tsx @@ -34,9 +34,7 @@ const KeyValueRowLabel = ({ label, tooltip }: KeyValueRowLabelProps) => { const onNavigateToTooltipModal = () => { if (!hasTooltip) return; - openTooltipModal(tooltip.title, tooltip.content, undefined, undefined, { - bottomPadding: tooltip.bottomPadding, - }); + openTooltipModal(tooltip.title, tooltip.content, undefined, undefined); tooltip?.onPress?.(); }; diff --git a/app/component-library/components-temp/KeyValueRow/KeyValueRow.types.ts b/app/component-library/components-temp/KeyValueRow/KeyValueRow.types.ts index 590a9615b480..6c89ca433326 100644 --- a/app/component-library/components-temp/KeyValueRow/KeyValueRow.types.ts +++ b/app/component-library/components-temp/KeyValueRow/KeyValueRow.types.ts @@ -37,10 +37,6 @@ interface KeyValueRowTooltip { * Optional onPress handler */ onPress?: (...args: unknown[]) => unknown; - /** - * Optional bottom padding for the tooltip modal. - */ - bottomPadding?: number; } /** diff --git a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx index dcc34ad9e7b6..a5f62dac165a 100644 --- a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx +++ b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx @@ -308,7 +308,7 @@ const TabsBar: React.FC = ({ isDisabled={tab.isDisabled} onPress={() => handleTabPress(index)} onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)} - testID={`${testID}-tab-${index}`} + testID={tab.testID ?? `${testID}-tab-${index}`} /> ))} @@ -337,7 +337,7 @@ const TabsBar: React.FC = ({ isDisabled={tab.isDisabled} onPress={() => handleTabPress(index)} onLayout={(layoutEvent) => handleTabLayout(index, layoutEvent)} - testID={`${testID}-tab-${index}`} + testID={tab.testID ?? `${testID}-tab-${index}`} /> ))} diff --git a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.types.ts b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.types.ts index 1a698af75a17..07bad0b97a2a 100644 --- a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.types.ts +++ b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.types.ts @@ -12,6 +12,7 @@ export interface TabItem { label: string; content: React.ReactNode; isDisabled?: boolean; + testID?: string; } /** diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index 63de753e4c71..b4a1f5a85d4c 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -39,6 +39,7 @@ const TabsList = forwardRef( const props = (child as React.ReactElement).props as { tabLabel?: string; isDisabled?: boolean; + testID?: string; }; const tabLabel = props.tabLabel || `Tab ${index + 1}`; const isDisabled = props.isDisabled || false; @@ -49,6 +50,7 @@ const TabsList = forwardRef( content: child, isDisabled, isLoaded: false, + testID: props.testID, }; }), [children], diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.types.ts b/app/component-library/components-temp/Tabs/TabsList/TabsList.types.ts index 8d30083074b9..ce362f84cd18 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.types.ts +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.types.ts @@ -15,6 +15,7 @@ export interface TabItem { label: string; content: React.ReactNode; isDisabled?: boolean; + testID?: string; } /** diff --git a/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap b/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap index af1d5c403792..23c36970900b 100644 --- a/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap +++ b/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap @@ -744,6 +744,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` undefined, ] } + testID="keypad-key-1" > void; isDisabled?: boolean; boxWrapperProps?: BoxProps; + testID?: string; } const KeypadButton: React.FC = ({ diff --git a/app/components/Base/Keypad/index.tsx b/app/components/Base/Keypad/index.tsx index 9fba8b34d0f8..95f2c95bd97f 100644 --- a/app/components/Base/Keypad/index.tsx +++ b/app/components/Base/Keypad/index.tsx @@ -132,29 +132,50 @@ function KeypadComponent({ return ( - 1 - 2 - 3 + + 1 + + + 2 + + + 3 + - 4 - 5 - 6 + + 4 + + + 5 + + + 6 + - 7 - 8 - 9 + + 7 + + + 8 + + + 9 + {decimalSeparator} - 0 + + 0 + { + {__DEV__ && } diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index 1e741f005b22..1e7a5a111970 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -975,6 +975,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` undefined, ] } + testID="keypad-key-1" > ({ error: jest.fn(), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn().mockReturnValue({ - colors: { - background: { default: '#FFFFFF' }, - text: { default: '#000000' }, - }, - themeAppearance: 'light', - typography: {}, - shadows: {}, - brandColors: {}, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap index 5a48ae3fc524..fc3557c05f3d 100644 --- a/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap @@ -822,6 +822,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` undefined, ] } + testID="keypad-key-1" > { const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component @@ -92,7 +92,7 @@ describe('EarningsHistoryChart', () => { // expect bar 1 to be selected and highlighted on touch expect(chartContainer.getByText('Day 1')).toBeTruthy(); expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy(); - expect(chart.data[0].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[0].svg.fill).toBe(mockTheme.colors.success.default); // end touch bar 1 fireEvent( chartContainer.getByTestId('earnings-history-chart'), @@ -104,7 +104,7 @@ describe('EarningsHistoryChart', () => { // expect bar 1 to be selected and highlighted after touch end expect(chartContainer.getByText('Day 1')).toBeTruthy(); expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy(); - expect(chart.data[0].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[0].svg.fill).toBe(mockTheme.colors.success.default); }); it('updates chart state when bar 2 is clicked', async () => { @@ -119,7 +119,7 @@ describe('EarningsHistoryChart', () => { // expect bar 2 to be selected and highlighted on touch expect(chartContainer.getByText('Day 2')).toBeTruthy(); expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy(); - expect(chart.data[1].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[1].svg.fill).toBe(mockTheme.colors.success.default); // end touch bar 2 fireEvent( chartContainer.getByTestId('earnings-history-chart'), @@ -131,7 +131,7 @@ describe('EarningsHistoryChart', () => { // expect bar 2 to be selected and highlighted after touch end expect(chartContainer.getByText('Day 2')).toBeTruthy(); expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy(); - expect(chart.data[1].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[1].svg.fill).toBe(mockTheme.colors.success.default); }); it('updates chart state when bar 3 is clicked', async () => { @@ -145,7 +145,7 @@ describe('EarningsHistoryChart', () => { ); expect(chartContainer.getByText('Day 3')).toBeTruthy(); expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy(); - expect(chart.data[2].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[2].svg.fill).toBe(mockTheme.colors.success.default); // end touch bar 3 fireEvent( chartContainer.getByTestId('earnings-history-chart'), @@ -156,7 +156,7 @@ describe('EarningsHistoryChart', () => { ); expect(chartContainer.getByText('Day 3')).toBeTruthy(); expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy(); - expect(chart.data[2].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[2].svg.fill).toBe(mockTheme.colors.success.default); }); it('updates chart to initial state when selected bar is set unselected', async () => { @@ -177,7 +177,7 @@ describe('EarningsHistoryChart', () => { }, ); // expect bar 3 to be selected and highlighted - expect(chart.data[2].svg.fill).toBe(lightTheme.colors.success.default); + expect(chart.data[2].svg.fill).toBe(mockTheme.colors.success.default); // click again fireEvent( chartContainer.getByTestId('earnings-history-chart'), diff --git a/app/components/UI/Earn/components/MusdDeveloperOptionsSection.test.tsx b/app/components/UI/Earn/components/MusdDeveloperOptionsSection.test.tsx index 67e75889e190..097c4fb3169b 100644 --- a/app/components/UI/Earn/components/MusdDeveloperOptionsSection.test.tsx +++ b/app/components/UI/Earn/components/MusdDeveloperOptionsSection.test.tsx @@ -23,18 +23,12 @@ jest.mock('../../../../actions/user', () => ({ }, })); -jest.mock('../../../../util/theme', () => ({ - useTheme: jest.fn().mockReturnValue({ - colors: { - background: { default: '#FFFFFF' }, - text: { default: '#000000' }, - }, - themeAppearance: 'light', - typography: {}, - shadows: {}, - brandColors: {}, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); describe('MusdDeveloperOptionsSection', () => { const mockUseDispatch = jest.mocked(useDispatch); diff --git a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts index f4f65f3c1146..06cb3fee9562 100644 --- a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts @@ -96,7 +96,6 @@ describe('useMerklClaimStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Loading, hasNoTimeout: true, - iconColor: mockTheme.colors.icon.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Warning, labelOptions: [{ label: 'Claiming bonus', isBold: true }], @@ -105,7 +104,7 @@ describe('useMerklClaimStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.CheckBold, hasNoTimeout: false, - iconColor: '#00FF00', + iconColor: mockTheme.colors.success.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Success, labelOptions: [{ label: 'Your mUSD is here!', isBold: true }], @@ -114,7 +113,7 @@ describe('useMerklClaimStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.CircleX, hasNoTimeout: false, - iconColor: '#FF0000', + iconColor: mockTheme.colors.error.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Error, labelOptions: [{ label: 'Bonus claim failed', isBold: true }], diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts index 24abf6af9770..bd6a752e4802 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts @@ -124,7 +124,6 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Loading, hasNoTimeout: true, - iconColor: mockTheme.colors.icon.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Warning, labelOptions: [{ label: 'In Progress', isBold: true }], @@ -137,7 +136,7 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.CheckBold, hasNoTimeout: false, - iconColor: mockTheme.colors.icon.default, + iconColor: mockTheme.colors.success.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Success, labelOptions: [{ label: 'Success', isBold: true }], @@ -146,7 +145,7 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Danger, hasNoTimeout: false, - iconColor: mockTheme.colors.icon.default, + iconColor: mockTheme.colors.error.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Error, labelOptions: [{ label: 'Failed', isBold: true }], @@ -157,7 +156,6 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Loading, hasNoTimeout: true, - iconColor: mockTheme.colors.icon.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Warning, labelOptions: [{ label: 'Claiming bonus', isBold: true }], @@ -166,7 +164,7 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.CheckBold, hasNoTimeout: false, - iconColor: mockTheme.colors.icon.default, + iconColor: mockTheme.colors.success.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Success, labelOptions: [{ label: 'Success', isBold: true }], @@ -175,7 +173,7 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Danger, hasNoTimeout: false, - iconColor: mockTheme.colors.icon.default, + iconColor: mockTheme.colors.error.default, backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Error, labelOptions: [{ label: 'Bonus claim failed', isBold: true }], @@ -186,8 +184,8 @@ describe('useMusdConversionStatus', () => { variant: ToastVariants.Icon as const, iconName: IconName.Danger, hasNoTimeout: false, - iconColor: '#000000', - backgroundColor: '#FFFFFF', + iconColor: mockTheme.colors.error.default, + backgroundColor: mockTheme.colors.background.default, hapticsType: NotificationFeedbackType.Error, labelOptions: [{ label: 'Withdrawal failed', isBold: true }], }), diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewHeader.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewHeader.tsx index d473435a5aea..4ebe10b92377 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewHeader.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewHeader.tsx @@ -1,14 +1,11 @@ import React from 'react'; -import { Pressable } from 'react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, Text, TextVariant, - Icon, IconName, - IconSize, - IconColor, + ButtonIcon, + ButtonIconSize, BoxFlexDirection, BoxAlignItems, FontWeight, @@ -22,31 +19,25 @@ interface MarketInsightsViewHeaderProps { const MarketInsightsViewHeader: React.FC = ({ onBackPress, -}) => { - const tw = useTailwind(); - - return ( - - - - - - - {strings('market_insights.title')} - - - +}) => ( + + + + + {strings('market_insights.title')} + - ); -}; + + +); export default MarketInsightsViewHeader; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index 62fae8b6699f..500a1c5771d8 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Pressable } from 'react-native'; +import { Pressable, View } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -10,6 +10,8 @@ import { IconName, IconSize, IconColor, + ButtonIcon, + ButtonIconSize, BoxFlexDirection, BoxAlignItems, } from '@metamask/design-system-react-native'; @@ -63,11 +65,13 @@ const MarketInsightsEntryCard: React.FC = ({ {strings('market_insights.title')} - + + + diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 5fe6aa9fe429..a7c63e8d443b 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -193,6 +193,7 @@ export const PerpsAmountDisplaySelectorsIDs = { CONTAINER: 'perps-amount-display', AMOUNT_LABEL: 'perps-amount-display-amount', MAX_LABEL: 'perps-amount-display-max', + TOUCHABLE: 'perps-amount-display-touchable', }; // ======================================== @@ -261,6 +262,8 @@ export const PerpsTPSLViewSelectorsIDs = { BACK_BUTTON: 'back-button', BOTTOM_SHEET: 'perps-tpsl-bottomsheet', SET_BUTTON: 'bottomsheetfooter-button', + TAKE_PROFIT_PRICE_INPUT: 'perps-tpsl-tp-input', + STOP_LOSS_PRICE_INPUT: 'perps-tpsl-sl-input', } as const; export const getPerpsTPSLViewSelector = { @@ -374,6 +377,7 @@ export const PerpsMarketHeaderSelectorsIDs = { PRICE_TITLE_SECTION: 'perps-market-header-price-title-section', PRICE_CHANGE_TITLE_SECTION: 'perps-market-header-price-change-title-section', MORE_BUTTON: 'perps-market-header-more-button', + FAVORITE_BUTTON: 'perps-market-header-favorite-button', }; // ======================================== @@ -517,10 +521,27 @@ export const PerpsOrderViewSelectorsIDs = { FEES_INFO_ICON: 'perps-order-view-fees-info-icon', TP_SL_INFO_ICON: 'perps-order-view-tp-sl-info-icon', // Buttons present in PerpsOrderView (TouchableOpacity with testID) - TAKE_PROFIT_BUTTON: 'perps-order-view-stop-loss-button', + TAKE_PROFIT_BUTTON: 'perps-order-view-take-profit-button', STOP_LOSS_BUTTON: 'perps-order-view-stop-loss-button', PLACE_ORDER_BUTTON: 'perps-order-view-place-order-button', KEYPAD: 'perps-order-view-keypad', + // Keypad action buttons + KEYPAD_25_PCT: 'perps-order-view-keypad-25pct', + KEYPAD_50_PCT: 'perps-order-view-keypad-50pct', + KEYPAD_MAX: 'perps-order-view-keypad-max', + KEYPAD_DONE: 'perps-order-view-keypad-done', + // Row touchables that open bottom sheets + LEVERAGE_ROW: 'perps-order-view-leverage-row', + LIMIT_PRICE_ROW: 'perps-order-view-limit-price-row', +}; + +// ======================================== +// PERPS LIMIT PRICE BOTTOM SHEET SELECTORS +// ======================================== + +export const PerpsLimitPriceBottomSheetSelectorsIDs = { + PRICE_DISPLAY: 'perps-limit-price-display', + CONFIRM_BUTTON: 'perps-limit-price-confirm-button', }; // ======================================== @@ -550,6 +571,21 @@ export const PerpsClosePositionViewSelectorsIDs = { // PERPS MARKET TABS SELECTORS // ======================================== +export const PerpsOrderTypeBottomSheetSelectorsIDs = { + MARKET_OPTION: 'perps-order-type-market', + LIMIT_OPTION: 'perps-order-type-limit', +} as const; + +export const PerpsAdjustMarginActionSheetSelectorsIDs = { + ADD_MARGIN_OPTION: 'perps-adjust-margin-add-btn', + REDUCE_MARGIN_OPTION: 'perps-adjust-margin-reduce-btn', +} as const; + +export const PerpsAdjustMarginViewSelectorsIDs = { + CONFIRM_BUTTON: 'perps-adjust-margin-confirm-button', + DONE_BUTTON: 'perps-adjust-margin-done-button', +} as const; + export const PerpsMarketTabsSelectorsIDs = { // Container CONTAINER: 'perps-market-tabs-container', @@ -687,3 +723,26 @@ export const PerpsWebSocketHealthToastSelectorsIDs = { TOAST: 'perps-websocket-health-toast', RETRY_BUTTON: 'perps-websocket-health-toast-retry-button', } as const; + +// ======================================== +// PERPS ORDER DETAILS VIEW SELECTORS +// ======================================== + +export const PerpsOrderDetailsViewSelectorsIDs = { + CANCEL_BUTTON: 'perps-order-details-cancel-button', +} as const; + +// ======================================== +// PERPS COMPACT ORDER ROW SELECTORS +// ======================================== + +export const PerpsCompactOrderRowSelectorsIDs = { + FIRST_ROW: 'perps-compact-order-row-first', +} as const; + +export const PerpsTransactionsViewSelectorsIDs = { + TAB_TRADES: 'perps-transactions-tab-trades', + TAB_ORDERS: 'perps-transactions-tab-orders', + TAB_FUNDING: 'perps-transactions-tab-funding', + TAB_DEPOSITS: 'perps-transactions-tab-deposits', +} as const; diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx index 2af792fc6e95..1a0951276fe6 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx @@ -24,6 +24,7 @@ import Icon, { import ButtonIcon, { ButtonIconSizes, } from '../../../../../component-library/components/Buttons/ButtonIcon'; +import { PerpsAdjustMarginViewSelectorsIDs } from '../../Perps.testIds'; import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsAdjustMarginData } from '../../hooks/usePerpsAdjustMarginData'; @@ -422,6 +423,7 @@ const PerpsAdjustMarginView: React.FC = () => { {!isInputFocused ? ( @@ -364,10 +358,9 @@ const SmartTransactionStatus = ({ const renderSecondaryButton = () => handleSecondaryButtonPress ? ( diff --git a/app/components/hooks/DisplayName/useERC20Tokens.test.ts b/app/components/hooks/DisplayName/useERC20Tokens.test.ts index 9adfddd309c8..bcec6c2a15e8 100644 --- a/app/components/hooks/DisplayName/useERC20Tokens.test.ts +++ b/app/components/hooks/DisplayName/useERC20Tokens.test.ts @@ -6,137 +6,107 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; const TOKEN_NAME_MOCK = 'Test Token'; const TOKEN_SYMBOL_MOCK = 'TT'; const TOKEN_ICON_URL_MOCK = 'https://example.com/icon.png'; -const TOKEN_ADDRESS_MOCK = '0x0439e60F02a8900a951603950d8D4527f400C3f1'; -const UNKNOWN_ADDRESS_MOCK = '0xabc123'; - -const STATE_MOCK = { - engine: { - backgroundState: { - TokenListController: { - tokensChainsCache: { - [CHAIN_IDS.MAINNET]: { - data: { - [TOKEN_ADDRESS_MOCK.toLowerCase()]: { - name: TOKEN_NAME_MOCK, - symbol: TOKEN_SYMBOL_MOCK, - iconUrl: TOKEN_ICON_URL_MOCK, - }, - }, - }, - }, - }, - }, - }, -}; +const TOKEN_ADDRESS_MOCK = '0x0439e60f02a8900a951603950d8d4527f400c3f1'; +const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET; +const ASSET_ID_MOCK = `eip155:1/erc20:${TOKEN_ADDRESS_MOCK}`; + +jest.mock('../useTokensData/useTokensData', () => ({ + useTokensData: jest.fn(), +})); + +import { useTokensData } from '../useTokensData/useTokensData'; + +const mockUseTokensData = useTokensData as jest.Mock; + +function renderHook(requests: Parameters[0]) { + return renderHookWithProvider(() => useERC20Tokens(requests), { state: {} }); +} describe('useERC20Tokens', () => { - it('returns undefined if no token found', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - type: NameType.EthereumAddress, - value: UNKNOWN_ADDRESS_MOCK, - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.name).toBe(undefined); + beforeEach(() => { + jest.clearAllMocks(); + mockUseTokensData.mockReturnValue({ + [ASSET_ID_MOCK]: { + assetId: ASSET_ID_MOCK, + name: TOKEN_NAME_MOCK, + symbol: TOKEN_SYMBOL_MOCK, + iconUrl: TOKEN_ICON_URL_MOCK, + }, + }); }); - it('returns name if found', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - type: NameType.EthereumAddress, - value: TOKEN_ADDRESS_MOCK, - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.name).toBe(TOKEN_NAME_MOCK); + it('returns undefined if type is not EthereumAddress', () => { + const { result } = renderHook([ + { + type: 'alternateType' as NameType, + value: TOKEN_ADDRESS_MOCK, + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]).toBeUndefined(); }); - it('returns symbol if preferred', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - preferContractSymbol: true, - type: NameType.EthereumAddress, - value: TOKEN_ADDRESS_MOCK, - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.name).toBe(TOKEN_SYMBOL_MOCK); + it('returns name when token is found', () => { + const { result } = renderHook([ + { + type: NameType.EthereumAddress, + value: TOKEN_ADDRESS_MOCK, + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]?.name).toBe(TOKEN_NAME_MOCK); }); - it('returns image', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - preferContractSymbol: true, - type: NameType.EthereumAddress, - value: TOKEN_ADDRESS_MOCK, - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.image).toBe(TOKEN_ICON_URL_MOCK); + it('returns symbol when preferContractSymbol is true', () => { + const { result } = renderHook([ + { + preferContractSymbol: true, + type: NameType.EthereumAddress, + value: TOKEN_ADDRESS_MOCK, + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]?.name).toBe(TOKEN_SYMBOL_MOCK); + }); + + it('returns image when token is found', () => { + const { result } = renderHook([ + { + type: NameType.EthereumAddress, + value: TOKEN_ADDRESS_MOCK, + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]?.image).toBe(TOKEN_ICON_URL_MOCK); }); - it('returns null if type is not address', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - type: 'alternateType' as NameType, - value: TOKEN_ADDRESS_MOCK, - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.name).toBeUndefined(); + it('returns name and image as undefined when token is not found', () => { + mockUseTokensData.mockReturnValue({}); + + const { result } = renderHook([ + { + type: NameType.EthereumAddress, + value: TOKEN_ADDRESS_MOCK, + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]).toEqual({ name: undefined, image: undefined }); }); - it('normalizes addresses to lowercase', () => { - const { - result: { current }, - } = renderHookWithProvider( - () => - useERC20Tokens([ - { - type: NameType.EthereumAddress, - value: TOKEN_ADDRESS_MOCK.toUpperCase(), - variation: CHAIN_IDS.MAINNET, - }, - ]), - { state: STATE_MOCK }, - ); - - expect(current[0]?.name).toBe(TOKEN_NAME_MOCK); + it('normalizes addresses to lowercase when building the asset ID', () => { + const { result } = renderHook([ + { + type: NameType.EthereumAddress, + value: TOKEN_ADDRESS_MOCK.toUpperCase(), + variation: CHAIN_ID_MOCK, + }, + ]); + + expect(result.current[0]?.name).toBe(TOKEN_NAME_MOCK); }); }); diff --git a/app/components/hooks/DisplayName/useERC20Tokens.ts b/app/components/hooks/DisplayName/useERC20Tokens.ts index 2ff4f12fafa5..f4429853fe38 100644 --- a/app/components/hooks/DisplayName/useERC20Tokens.ts +++ b/app/components/hooks/DisplayName/useERC20Tokens.ts @@ -1,28 +1,28 @@ import { NameType } from '../../UI/Name/Name.types'; import { UseDisplayNameRequest } from './useDisplayName'; -import { selectERC20TokensByChain } from '../../../selectors/tokenListController'; -import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; +import { useTokensData } from '../useTokensData/useTokensData'; +import { buildEvmCaip19AssetId } from '../../../util/multichain/buildEvmCaip19AssetId'; export function useERC20Tokens(requests: UseDisplayNameRequest[]) { - const erc20TokensByChain = useSelector(selectERC20TokensByChain); + const assetIds = requests + .filter(({ type, value }) => type === NameType.EthereumAddress && value) + .map(({ value, variation }) => + buildEvmCaip19AssetId(value as string, variation as Hex), + ); + + const tokensByAssetId = useTokensData(assetIds); return requests.map(({ preferContractSymbol, type, value, variation }) => { if (type !== NameType.EthereumAddress || !value) { return undefined; } - const contractAddress = value.toLowerCase(); - const chainId = variation as Hex; - - const { - name: tokenName, - symbol, - iconUrl: image, - } = erc20TokensByChain[chainId]?.data?.[contractAddress] ?? {}; - - const name = preferContractSymbol && symbol ? symbol : tokenName; + const token = + tokensByAssetId[buildEvmCaip19AssetId(value as string, variation as Hex)]; + const name = + preferContractSymbol && token?.symbol ? token.symbol : token?.name; - return { name, image }; + return { name, image: token?.iconUrl }; }); } diff --git a/app/components/hooks/useTokensData/useTokensData.test.ts b/app/components/hooks/useTokensData/useTokensData.test.ts new file mode 100644 index 000000000000..9c13cddd222c --- /dev/null +++ b/app/components/hooks/useTokensData/useTokensData.test.ts @@ -0,0 +1,212 @@ +import { act, waitFor } from '@testing-library/react-native'; +import { handleFetch } from '@metamask/controller-utils'; +import { useTokensData, MAX_BATCH_SIZE } from './useTokensData'; +import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + handleFetch: jest.fn(), +})); + +const mockHandleFetch = handleFetch as jest.Mock; + +const TOKEN_NAME_MOCK = 'Test Token'; +const TOKEN_SYMBOL_MOCK = 'TT'; +const TOKEN_ICON_URL_MOCK = 'https://example.com/icon.png'; + +// Each test gets a unique asset ID to avoid module-level cache pollution. +let assetIdCounter = 0; +const makeAssetId = () => + `eip155:1/erc20:0x${(++assetIdCounter).toString().padStart(40, '0')}`; + +function makeTokenResponse(assetId: string) { + return [ + { + assetId, + name: TOKEN_NAME_MOCK, + symbol: TOKEN_SYMBOL_MOCK, + iconUrl: TOKEN_ICON_URL_MOCK, + }, + ]; +} + +function renderHook(assetIds: string[]) { + return renderHookWithProvider(() => useTokensData(assetIds), { state: {} }); +} + +describe('useTokensData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty object initially before fetch resolves', () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + const { result } = renderHook([assetId]); + + expect(result.current).toEqual({}); + }); + + it('returns token data after fetch resolves', async () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + const { result } = renderHook([assetId]); + + await waitFor(() => { + expect(result.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + }); + }); + + it('returns symbol and iconUrl after fetch resolves', async () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + const { result } = renderHook([assetId]); + + await waitFor(() => { + expect(result.current[assetId]?.symbol).toBe(TOKEN_SYMBOL_MOCK); + expect(result.current[assetId]?.iconUrl).toBe(TOKEN_ICON_URL_MOCK); + }); + }); + + it('returns empty object when fetch fails', async () => { + mockHandleFetch.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook([makeAssetId()]); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(result.current).toEqual({}); + }); + + it('returns empty object when assetIds is empty', () => { + const { result } = renderHook([]); + + expect(result.current).toEqual({}); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('calls API with correct URL including assetIds and includeIconUrl', async () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + renderHook([assetId]); + + await waitFor(() => { + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + }); + + const calledUrl = mockHandleFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('tokens.api.cx.metamask.io/v3/assets'); + expect(calledUrl).toContain(encodeURIComponent(assetId)); + expect(calledUrl).toContain('includeIconUrl=true'); + }); + + // Note: this test produces one act() warning because each renderHook call creates + // an independent React tree. When the shared in-flight promise resolves, all three + // trees update simultaneously, but only the one observed by waitFor is wrapped in act. + // This is a known RNTL limitation when testing cross-hook module-level state sharing. + it('deduplicates concurrent requests for the same asset IDs', async () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + const { result: r1 } = renderHook([assetId]); + const { result: r2 } = renderHook([assetId]); + const { result: r3 } = renderHook([assetId]); + + await waitFor(() => { + expect(r1.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + expect(r2.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + expect(r3.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + }); + + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + }); + + it('returns cached data synchronously on second mount without re-fetching', async () => { + const assetId = makeAssetId(); + mockHandleFetch.mockResolvedValue(makeTokenResponse(assetId)); + + const { result: result1 } = renderHook([assetId]); + await waitFor(() => { + expect(result1.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + }); + + mockHandleFetch.mockClear(); + const { result: result2 } = renderHook([assetId]); + + expect(result2.current[assetId]?.name).toBe(TOKEN_NAME_MOCK); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('looks up token by lowercase key when API returns checksummed assetId', async () => { + const lowercaseId = makeAssetId(); // already lowercase from makeAssetId + const checksummedId = lowercaseId.replace(/0x[0-9a-f]+/, (m) => + m.replace(/[a-f]/g, (c) => c.toUpperCase()), + ); + + // API echoes back the checksummed version of the asset ID + mockHandleFetch.mockResolvedValue([ + { + assetId: checksummedId, + name: TOKEN_NAME_MOCK, + symbol: TOKEN_SYMBOL_MOCK, + iconUrl: TOKEN_ICON_URL_MOCK, + }, + ]); + + // Hook is called with the lowercase ID (as buildAssetId produces) + const { result } = renderHook([lowercaseId]); + + await waitFor(() => { + expect(result.current[lowercaseId]?.name).toBe(TOKEN_NAME_MOCK); + }); + }); + + it(`splits requests larger than ${MAX_BATCH_SIZE} into separate batches`, async () => { + const assetIds = Array.from({ length: MAX_BATCH_SIZE + 1 }, () => + makeAssetId(), + ); + + mockHandleFetch.mockImplementation((url: string) => { + const params = new URL(url).searchParams.get('assetIds') ?? ''; + return Promise.resolve( + params.split(',').map((id) => ({ + assetId: id, + name: TOKEN_NAME_MOCK, + symbol: TOKEN_SYMBOL_MOCK, + iconUrl: TOKEN_ICON_URL_MOCK, + })), + ); + }); + + const { result } = renderHook(assetIds); + + await waitFor(() => { + expect(Object.keys(result.current)).toHaveLength(assetIds.length); + }); + + expect(mockHandleFetch).toHaveBeenCalledTimes(2); + + const firstCallCount = ( + new URL(mockHandleFetch.mock.calls[0][0] as string).searchParams + .get('assetIds') + ?.split(',') ?? [] + ).length; + const secondCallCount = ( + new URL(mockHandleFetch.mock.calls[1][0] as string).searchParams + .get('assetIds') + ?.split(',') ?? [] + ).length; + + expect(firstCallCount).toBe(MAX_BATCH_SIZE); + expect(secondCallCount).toBe(1); + }); +}); diff --git a/app/components/hooks/useTokensData/useTokensData.ts b/app/components/hooks/useTokensData/useTokensData.ts new file mode 100644 index 000000000000..1ec7412783df --- /dev/null +++ b/app/components/hooks/useTokensData/useTokensData.ts @@ -0,0 +1,124 @@ +// TODO: Once all usages of tokensChainsCache are removed from this repo, this +// fetching logic should be moved to the core package (e.g. TokenListController +// or a dedicated tokens API service), and this hook should be updated to read +// from Redux state rather than calling the API directly. +import { useState, useEffect } from 'react'; +import { handleFetch } from '@metamask/controller-utils'; + +export interface TokenAsset { + assetId: string; + iconUrl: string; + name: string; + symbol: string; +} + +const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; + +// Maximum number of asset IDs per API request. Requests with more IDs are +// split into parallel batches of this size. +export const MAX_BATCH_SIZE = 25; + +// Module-level cache and in-flight deduplication so multiple hook instances +// share a single HTTP request for the same batch of asset IDs. +const tokenCache: Record = {}; +const inFlight = new Map>(); + +function fetchTokenBatch(assetIds: string[]): Promise { + const key = assetIds.join(','); + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + if (assetIds.every((id) => tokenCache[id])) { + return Promise.resolve(assetIds.map((id) => tokenCache[id])); + } + + const params = new URLSearchParams({ + assetIds: assetIds.join(','), + includeIconUrl: 'true', + }); + + const promise = (async () => { + try { + const data: TokenAsset[] = await handleFetch( + `${TOKEN_API_V3_BASE_URL}/assets?${params}`, + ); + // Normalize keys to lowercase so they match the locally-constructed + // asset IDs (addresses are lowercased before building the CAIP-19 ID). + // The API may return EIP-55 checksummed addresses (e.g. 0xABc…) which + // would otherwise cause every cache lookup and state lookup to miss. + data.forEach((t) => { + tokenCache[t.assetId.toLowerCase()] = t; + }); + return data; + } finally { + inFlight.delete(key); + } + })(); + + inFlight.set(key, promise); + return promise; +} + +async function fetchTokenAssets(assetIds: string[]): Promise { + const batches: string[][] = []; + for (let i = 0; i < assetIds.length; i += MAX_BATCH_SIZE) { + batches.push(assetIds.slice(i, i + MAX_BATCH_SIZE)); + } + const results = await Promise.all(batches.map(fetchTokenBatch)); + return results.flat(); +} + +/** + * Fetches token metadata (name, symbol, iconUrl) for the given CAIP-19 asset IDs + * from the MetaMask tokens API. + * + * Large inputs are automatically split into parallel batches of at most + * {@link MAX_BATCH_SIZE} IDs to keep individual requests within a safe size. + * Each batch is independently cached and deduplicated so that multiple + * simultaneous hook instances for the same assets share a single HTTP request. + * + * @param assetIds - Array of CAIP-19 asset identifiers (e.g. "eip155:1/erc20:0xabc…") + * @returns A map from asset ID to {@link TokenAsset} for all resolved tokens. + */ +export function useTokensData(assetIds: string[]): Record { + const assetIdsKey = assetIds.join(','); + + const [tokensByAssetId, setTokensByAssetId] = useState< + Record + >(() => + Object.fromEntries( + assetIds.filter((id) => tokenCache[id]).map((id) => [id, tokenCache[id]]), + ), + ); + + useEffect(() => { + if (!assetIdsKey) return; + + let cancelled = false; + + (async () => { + try { + const data = await fetchTokenAssets(assetIdsKey.split(',')); + if (!cancelled) { + setTokensByAssetId((prev) => ({ + ...prev, + ...Object.fromEntries( + data.map((t) => [t.assetId.toLowerCase(), t]), + ), + })); + } + } catch { + // silently ignore fetch errors + } + })(); + + return () => { + cancelled = true; + }; + }, [assetIdsKey]); + + return tokensByAssetId; +} diff --git a/app/components/hooks/useTooltipModal.test.tsx b/app/components/hooks/useTooltipModal.test.tsx index fd09bfeecd1b..56ea8d30d2b4 100644 --- a/app/components/hooks/useTooltipModal.test.tsx +++ b/app/components/hooks/useTooltipModal.test.tsx @@ -31,7 +31,6 @@ interface TooltipModalNavigateParams { tooltip: string | React.ReactNode; footerText?: string; buttonText?: string; - bottomPadding?: number; }; } @@ -57,7 +56,6 @@ describe('useTooltipModal', () => { tooltip, footerText, buttonText, - bottomPadding: undefined, }, }); }); @@ -76,29 +74,6 @@ describe('useTooltipModal', () => { expect(navigateParams.params.tooltip).toBe(tooltip); }); - it('includes bottomPadding when provided', () => { - const { result } = renderHook(() => useTooltipModal()); - const title = 'Title'; - const tooltip = 'Tooltip text'; - const bottomPadding = 24; - - result.current.openTooltipModal(title, tooltip, undefined, undefined, { - bottomPadding, - }); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.TOOLTIP_MODAL, - params: { - title, - tooltip, - footerText: undefined, - buttonText: undefined, - bottomPadding, - }, - }); - }); - it('returns stable openTooltipModal reference across rerenders', () => { const { result, rerender } = renderHook(() => useTooltipModal()); const firstReturnValue = result.current; diff --git a/app/components/hooks/useTooltipModal.tsx b/app/components/hooks/useTooltipModal.tsx index 3760a2e5ad15..f855eed414db 100644 --- a/app/components/hooks/useTooltipModal.tsx +++ b/app/components/hooks/useTooltipModal.tsx @@ -2,10 +2,6 @@ import { useNavigation } from '@react-navigation/native'; import Routes from '../../constants/navigation/Routes'; import { ReactNode, useCallback, useMemo } from 'react'; -interface TooltipOptions { - bottomPadding?: number; -} - const useTooltipModal = () => { const { navigate } = useNavigation(); @@ -15,7 +11,6 @@ const useTooltipModal = () => { tooltip: string | ReactNode, footerText?: string, buttonText?: string, - options?: TooltipOptions, ) => navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SHEET.TOOLTIP_MODAL, @@ -24,7 +19,6 @@ const useTooltipModal = () => { tooltip, footerText, buttonText, - bottomPadding: options?.bottomPadding, }, }), [navigate], diff --git a/app/core/AgenticService/AgentStepHud.test.tsx b/app/core/AgenticService/AgentStepHud.test.tsx new file mode 100644 index 000000000000..45e3d4cad3dd --- /dev/null +++ b/app/core/AgenticService/AgentStepHud.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import AgentStepHud from './AgentStepHud'; +import { registerStepHudCallback } from './AgenticService'; + +jest.mock('./AgenticService', () => ({ + registerStepHudCallback: jest.fn(), +})); + +const mockRegister = jest.mocked(registerStepHudCallback); + +type StepCallback = (step: { id: string; description: string } | null) => void; + +function getLatestCallback(): StepCallback { + const calls = mockRegister.mock.calls; + for (let i = calls.length - 1; i >= 0; i--) { + if (typeof calls[i][0] === 'function') return calls[i][0] as StepCallback; + } + throw new Error('No callback registered'); +} + +describe('AgentStepHud', () => { + const originalDev = (globalThis as unknown as { __DEV__: boolean }).__DEV__; + + beforeEach(() => { + jest.clearAllMocks(); + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = true; + }); + + afterAll(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = originalDev; + }); + + it('renders nothing when no step is active', () => { + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('registers callback on mount and deregisters on unmount', () => { + const { unmount } = render(); + + expect(mockRegister).toHaveBeenCalledWith(expect.any(Function)); + + unmount(); + + expect(mockRegister).toHaveBeenCalledWith(null); + }); + + it('displays step id and description when callback fires', () => { + const { getByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ id: 'open-pos', description: 'Open BTC position' }); + }); + + expect(getByText('open-pos')).toBeOnTheScreen(); + expect(getByText('Open BTC position')).toBeOnTheScreen(); + }); + + it('hides overlay when callback fires with null', () => { + const { toJSON } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ id: 'step-1', description: 'test' }); + }); + + act(() => { + callback(null); + }); + + expect(toJSON()).toBeNull(); + }); +}); diff --git a/app/core/AgenticService/AgentStepHud.tsx b/app/core/AgenticService/AgentStepHud.tsx new file mode 100644 index 000000000000..d848520ba401 --- /dev/null +++ b/app/core/AgenticService/AgentStepHud.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { registerStepHudCallback } from './AgenticService'; + +interface Step { + id: string; + description: string; +} + +// Debug-only overlay — intentionally uses hardcoded colors for guaranteed +// contrast on both light and dark themes. Design tokens would defeat the purpose. +/* eslint-disable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 90, + left: 0, + right: 0, + zIndex: 9999, + backgroundColor: '#000000', + paddingVertical: 10, + paddingHorizontal: 16, + }, + stepId: { + color: '#00FF88', + fontFamily: 'Courier', + fontSize: 15, + fontWeight: '700', + marginBottom: 2, + }, + description: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '500', + }, +}); +/* eslint-enable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ + +// Inner component — hooks always called unconditionally, per rules of React. +const AgentStepHudInner = () => { + const [step, setStep] = useState(null); + + useEffect(() => { + registerStepHudCallback(setStep); + return () => { + registerStepHudCallback(null); + }; + }, []); + + if (!step) return null; + + return ( + + {step.id} + {step.description} + + ); +}; + +// Outer guard — never calls hooks, so the __DEV__ early return is fine. +const AgentStepHud = () => { + if (!__DEV__) return null; + return ; +}; + +export default AgentStepHud; diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index 9fd496d39989..72fd86e398aa 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -4,6 +4,7 @@ import AgenticService, { walkFiberRoots, tryScroll, toAccountSummary, + registerStepHudCallback, type FiberNode, type ReactDevToolsHook, } from './AgenticService'; @@ -420,6 +421,57 @@ describe('AgenticService.install', () => { expect(() => bridge().switchAccount('0xfff')).toThrow('No account found'); }); + describe('showStep / hideStep', () => { + afterEach(() => { + registerStepHudCallback(null); + }); + + it('showStep calls registered HUD callback with step data', () => { + const callback = jest.fn(); + registerStepHudCallback(callback); + + bridge().showStep({ id: 'step-1', description: 'Navigate to market' }); + + expect(callback).toHaveBeenCalledWith({ + id: 'step-1', + description: 'Navigate to market', + }); + }); + + it('hideStep calls registered HUD callback with null', () => { + const callback = jest.fn(); + registerStepHudCallback(callback); + + bridge().hideStep(); + + expect(callback).toHaveBeenCalledWith(null); + }); + + it('showStep is a no-op when no callback is registered', () => { + registerStepHudCallback(null); + expect(() => + bridge().showStep({ id: 'x', description: 'y' }), + ).not.toThrow(); + }); + }); + + describe('findFiberByTestId (bridge)', () => { + it('returns true when testID exists in fiber tree', () => { + const fiber = makeFiber({ + child: makeFiber({ testID: 'target-btn' }), + }); + installFiberHook(fiber); + + expect(bridge().findFiberByTestId('target-btn')).toBe(true); + }); + + it('returns false when testID does not exist', () => { + installFiberHook(makeFiber()); + + expect(bridge().findFiberByTestId('missing-id')).toBe(false); + }); + }); + describe('pressTestId', () => { it('presses a component found by testID', () => { const onPress = jest.fn(); diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index 568833c4aa83..7c9a5b340079 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -133,6 +133,9 @@ interface AgenticBridge { error?: string; accounts?: { address: string; name: string }[]; }>; + showStep: (step: { id: string; description: string }) => void; + hideStep: () => void; + findFiberByTestId: (testId: string) => boolean; } declare global { @@ -244,6 +247,17 @@ function tryScroll( return false; } +// ─── Step HUD callback registry ───────────────────────────────────────────── + +type StepHudCallback = + | ((step: { id: string; description: string } | null) => void) + | null; +let _stepHudCallback: StepHudCallback = null; + +export function registerStepHudCallback(fn: StepHudCallback) { + _stepHudCallback = fn; +} + // ─── AgenticService ───────────────────────────────────────────────────────── /** @@ -383,6 +397,23 @@ const AgenticService = { Engine.setSelectedAddress(target.address); return { switched: true, ...toAccountSummary(target) }; }, + showStep: (step: { id: string; description: string }) => { + _stepHudCallback?.(step); + }, + hideStep: () => { + _stepHudCallback?.(null); + }, + findFiberByTestId: (testId: string): boolean => { + let found = false; + walkFiberRoots((rootFiber) => { + if (findFiberByTestId(rootFiber, testId)) { + found = true; + return true; + } + return false; + }); + return found; + }, setupWallet: async (fixture) => { try { const { diff --git a/app/core/Engine/controllers/assets-controller/assets-controller-init.test.ts b/app/core/Engine/controllers/assets-controller/assets-controller-init.test.ts index 4d6bbd153cad..9a553e7bab9f 100644 --- a/app/core/Engine/controllers/assets-controller/assets-controller-init.test.ts +++ b/app/core/Engine/controllers/assets-controller/assets-controller-init.test.ts @@ -173,7 +173,7 @@ describe('assetsControllerInit', () => { pollInterval: 30_000, enabled: true, }, - trackMetaMetricsEvent: expect.any(Function), + trace: expect.any(Function), }), ); }); diff --git a/app/core/Engine/controllers/assets-controller/assets-controller-init.ts b/app/core/Engine/controllers/assets-controller/assets-controller-init.ts index e7415a78f3ca..322fb8aab58d 100644 --- a/app/core/Engine/controllers/assets-controller/assets-controller-init.ts +++ b/app/core/Engine/controllers/assets-controller/assets-controller-init.ts @@ -1,7 +1,6 @@ import { createApiPlatformClient } from '@metamask/core-backend'; import { AssetsController, - AssetsControllerFirstInitFetchMetaMetricsPayload, type AssetsControllerOptions, } from '@metamask/assets-controller'; import { @@ -16,8 +15,7 @@ import { } from '../../messengers/assets-controller'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; import { store } from '../../../../store'; -import { MetaMetricsEvents } from '../../../Analytics'; -import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { trace } from '../../../../util/trace'; type QueryApiClient = AssetsControllerOptions['queryApiClient']; @@ -127,28 +125,6 @@ export const assetsControllerInit: ControllerInitFunction< } }; - // Track first init fetch duration and per-data-source latency when AssetsController - // completes initial load after unlock. Uses AnalyticsController:trackEvent (same pattern - // as SmartTransactionsController and other controller inits). - const trackMetaMetricsEvent = ( - payload: AssetsControllerFirstInitFetchMetaMetricsPayload, - ): void => { - try { - const event = AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.ASSETS_FIRST_INIT_FETCH_COMPLETED, - ) - .addProperties({ - duration_ms: payload.durationMs, - chain_ids: payload.chainIds, - duration_by_data_source: payload.durationByDataSource, - }) - .build(); - initMessenger.call('AnalyticsController:trackEvent', event); - } catch { - // AnalyticsController may not be available (e.g. init order); skip tracking. - } - }; - // Create the controller - it now creates all data sources internally const controller = new AssetsController({ messenger: controllerMessenger, @@ -173,7 +149,8 @@ export const assetsControllerInit: ControllerInitFunction< pollInterval: 30_000, enabled: true, }, - trackMetaMetricsEvent, + // @ts-expect-error: Type of `TraceRequest` is different. + trace, }); return { controller }; diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index e96ce515ecbc..89918d752516 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -207,6 +207,40 @@ describe('ramps controller init', () => { }); }); + it('calls init when remote flags were off at startup then V2 enables on RemoteFeatureFlagController:stateChange', async () => { + let remoteEnabled = false; + const subscribeMock = jest.fn(); + const initMessenger = { + call: jest.fn(() => ({ + remoteFeatureFlags: { + rampsUnifiedBuyV2: remoteEnabled + ? { enabled: true, minimumVersion: '1.0.0' } + : { enabled: false }, + }, + })), + subscribe: subscribeMock, + } as unknown as RampsControllerInitMessenger; + + initRequestMock.initMessenger = initMessenger; + + rampsControllerInit(initRequestMock); + + expect(mockInit).not.toHaveBeenCalled(); + + const stateChangeHandler = subscribeMock.mock.calls.find( + (call) => call[0] === 'RemoteFeatureFlagController:stateChange', + )?.[1] as () => void; + + expect(stateChangeHandler).toBeDefined(); + + remoteEnabled = true; + stateChangeHandler(); + + await waitFor(() => { + expect(mockInit).toHaveBeenCalledTimes(1); + }); + }); + it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ enabled: true, @@ -253,6 +287,7 @@ describe('ramps controller init', () => { call: jest.fn().mockImplementation(() => { throw new Error('Controller not ready'); }), + subscribe: jest.fn(), } as unknown as RampsControllerInitMessenger; rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index f7a02ca56052..09ebbe7a9f18 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -11,8 +11,7 @@ import { handleOrderStatusChangedForNotifications } from './event-handlers/notif import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; /** - * Determines whether the ramps unified buy V2 feature is enabled - * by reading the remote feature flag state. + * Whether Unified Buy V2 is enabled per RemoteFeatureFlagController state. * * @param initMessenger - The init messenger to read RemoteFeatureFlagController state. * @returns Whether V2 is enabled. @@ -54,21 +53,28 @@ export const rampsControllerInit: ControllerInitFunction< state: rampsControllerState, }); - const isV2Enabled = getIsRampsUnifiedBuyV2Enabled(initMessenger); + let orderSubscriptionsRegistered = false; - if (isV2Enabled) { + const registerUnifiedBuyV2OrderSubscriptions = (): void => { + if (orderSubscriptionsRegistered) { + return; + } + orderSubscriptionsRegistered = true; initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForNotifications, ); - initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForMetrics, ); + }; - // Start init immediately so tokens (and providers) load on app start. - // init() is async and does not block controller creation. + const startUnifiedBuyV2IfEnabled = (): void => { + if (!getIsRampsUnifiedBuyV2Enabled(initMessenger)) { + return; + } + registerUnifiedBuyV2OrderSubscriptions(); controller .init() .then(() => { @@ -77,7 +83,20 @@ export const rampsControllerInit: ControllerInitFunction< .catch(() => { // Initialization failed - error state will be available via selectors }); - } + }; + + startUnifiedBuyV2IfEnabled(); + + // Remote flags can be empty on first Engine init and fill in once the + // controller has fetched; re-check so RampsController.init() runs then. + // + // This event fires for any RemoteFeatureFlagController state update — not + // only rampsUnifiedBuyV2. When V2 is off, startUnifiedBuyV2IfEnabled returns + // immediately. When V2 is on, order subscriptions register once; init() and + // startOrderPolling() are idempotent, so repeat invocations are safe. + initMessenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + startUnifiedBuyV2IfEnabled(); + }); return { controller, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 39f42c5e39ba..93367ce70032 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -18655,27 +18655,10 @@ describe('RewardsController', () => { ); }); - it('returns empty array when campaigns feature flag is disabled', async () => { - const disabledController = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isCampaignsEnabled: () => false, - }); - - const result = await disabledController.getCampaigns(mockSubscriptionId); - - expect(result).toEqual([]); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getCampaigns', - expect.anything(), - ); - }); - - it('fetches campaigns when campaigns feature flag is enabled', async () => { + it('fetches campaigns when rewards is enabled', async () => { controller = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), - isCampaignsEnabled: () => true, }); const mockCampaigns = [createTestCampaign({ id: 'campaign-flag-test' })]; @@ -18694,7 +18677,6 @@ describe('RewardsController', () => { controller = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), - isCampaignsEnabled: () => true, }); const mockCampaigns = [ @@ -18735,7 +18717,6 @@ describe('RewardsController', () => { }, }, }, - isCampaignsEnabled: () => true, }); const result = await controller.getCampaigns(mockSubscriptionId); @@ -18760,7 +18741,6 @@ describe('RewardsController', () => { }, }, }, - isCampaignsEnabled: () => true, }); mockMessenger.call.mockResolvedValue(freshCampaigns); @@ -18778,7 +18758,6 @@ describe('RewardsController', () => { controller = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), - isCampaignsEnabled: () => true, }); mockMessenger.call.mockResolvedValue([]); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 468afdc3b519..f9f5d7dcb624 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -3390,9 +3390,6 @@ export class RewardsController extends BaseController< if (!rewardsEnabled) { return []; } - if (!this.#isCampaignsEnabled()) { - return []; - } const result = await wrapWithCache({ key: subscriptionId, ttl: CAMPAIGNS_CACHE_THRESHOLD_MS, diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 387367d8123c..33d853ed79c4 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -432,6 +432,7 @@ import { ComplianceServiceActions, ComplianceServiceEvents, } from '@metamask/compliance-controller'; +import { captureException } from '@sentry/react-native'; /** * Controllers that area always instantiated @@ -654,6 +655,7 @@ export type RootExtendedMessenger = ExtendedMessenger< export const getRootExtendedMessenger = (): RootExtendedMessenger => new ExtendedMessenger<'Root', GlobalActions, GlobalEvents>({ namespace: 'Root', + captureException, }); /** @@ -665,6 +667,7 @@ export type RootMessenger = Messenger<'Root', GlobalActions, GlobalEvents>; export const getRootMessenger = (): RootMessenger => new Messenger<'Root', GlobalActions, GlobalEvents>({ namespace: 'Root', + captureException, }); /** diff --git a/app/util/multichain/buildEvmCaip19AssetId.test.ts b/app/util/multichain/buildEvmCaip19AssetId.test.ts new file mode 100644 index 000000000000..6f42bf333ba6 --- /dev/null +++ b/app/util/multichain/buildEvmCaip19AssetId.test.ts @@ -0,0 +1,24 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { buildEvmCaip19AssetId } from './buildEvmCaip19AssetId'; + +const ADDRESS_MOCK = '0x0439e60f02a8900a951603950d8d4527f400c3f1'; + +describe('buildEvmCaip19AssetId', () => { + it('builds a CAIP-19 asset ID for Mainnet', () => { + expect(buildEvmCaip19AssetId(ADDRESS_MOCK, CHAIN_IDS.MAINNET)).toBe( + `eip155:1/erc20:${ADDRESS_MOCK}`, + ); + }); + + it('builds a CAIP-19 asset ID for a non-Mainnet chain', () => { + expect(buildEvmCaip19AssetId(ADDRESS_MOCK, CHAIN_IDS.POLYGON)).toBe( + `eip155:137/erc20:${ADDRESS_MOCK}`, + ); + }); + + it('normalizes the address to lowercase', () => { + expect( + buildEvmCaip19AssetId(ADDRESS_MOCK.toUpperCase(), CHAIN_IDS.MAINNET), + ).toBe(`eip155:1/erc20:${ADDRESS_MOCK}`); + }); +}); diff --git a/app/util/multichain/buildEvmCaip19AssetId.ts b/app/util/multichain/buildEvmCaip19AssetId.ts new file mode 100644 index 000000000000..d3bc1c3f0de8 --- /dev/null +++ b/app/util/multichain/buildEvmCaip19AssetId.ts @@ -0,0 +1,16 @@ +import { Hex } from '@metamask/utils'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; + +/** + * Builds a CAIP-19 asset ID for an ERC-20 token. + * + * The address is normalized to lowercase so that lookups are consistent + * regardless of whether the caller passes an EIP-55 checksummed address. + * + * @param address - The ERC-20 contract address (hex string). + * @param chainId - The EVM chain ID in hex format (e.g. "0x1" for Mainnet). + * @returns A CAIP-19 asset ID string, e.g. "eip155:1/erc20:0xabc…". + */ +export function buildEvmCaip19AssetId(address: string, chainId: Hex): string { + return `${toEvmCaipChainId(chainId)}/erc20:${address.toLowerCase()}`; +} diff --git a/docs/confirmation-refactoring/approve/README.md b/docs/confirmation-refactoring/approve/README.md deleted file mode 100644 index 9d6334f2f00f..000000000000 --- a/docs/confirmation-refactoring/approve/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Approve - -This confirmation page allows user to give approval to their assets: - -1. Giving approval to token: - - -2. Giving approval to collectibles: - - -The component responsible for rendering this view is: [/Views/ApproveView/Approve](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/Views/ApproveView/Approve) - -### Refactoring `/Views/ApproveView/Approve` - -This is a well defined component. The components also includes 3 small child components: - [ShowBlockExplorer](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/ApproveTransactionReview/ShowBlockExplorer) - [AddNickName](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/ApproveTransactionReview/AddNickname) - [ApproveTransactionReview](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/ApproveTransactionReview/index.js) -Following are the improveement areas: - -1. The file path can be improved to `/Views/Approve` as it is the only component in `/Views/ApproveView` folder. -2. The component has few methods are are quite generic and should be fully or partially converted to reusable functions in utils or hooks: - - For gas polling its proposed to create re-usable hook [here](https://github.com/MetaMask/metamask-mobile/pull/6003/files#diff-7c74af67b37335b69af34b0dc466c46bc3a08e37832414f7eba12984bcbf5abfR119), the hook can be used in this component also. - - Function [validateGas](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L326) - - Function [prepareTransaction](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L350) -3. Nested ternary conditions like used [here](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L625) are avoidable. -4. AddNickName, ApproveTransactionReview components also has logic to show block explorer which looks redundant [here](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx#L150) and [here](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L949). - -### Refactoring `/UI/ApproveTransactionReview` - -`/UI/ApproveTransactionReview` is another very huge component used on this page that needs code cleanup. - -1. The function is responsible for rendering many sections of the page, it can be simplified by breaking it down into more well tested sub-components: - - [renderGasTooltip](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L564) - - [renderEditPermission](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L596) - - [renderDetails](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L630) - - [renderTransactionReview](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L847) - - [renderVerifyContractDetails](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L885) - - [renderQRDetails](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L996) -2. Functions like [goToFaucet](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L986), [buyEth](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L962), [fetchEstimatedL1Fee](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L271) can be utility method / hook. -3. The code in [componentDidMount](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L293) can be converted extracted into utility function or hook. -4. Nested ternary like [here](https://github.com/MetaMask/metamask-mobile/blob/f5d3bb82924bce231fee76ef29d7ba077886bc17/app/components/UI/ApproveTransactionReview/index.js#L1032) is avoidable. - -### Other Code Improvements: - -1. Creating re-usable Modal component: In a lot of confirmation pages Modal component is initialised [example](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L606). Many of the props passed are similar. We can create a re-usable components to avoid duplicating the props at all places. -2. At a lot of places we display gas selection modals like [this](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L690). We should be able to create a single component which takes transaction and depending on the type legacy or EIP-1559 display gas options. -3. All react native components should be converted to functional typescript components with unit test coverage. -4. Child components which are possibly re-usable should be placed in folder [/components/UI](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI) and not nested in sub-folders. diff --git a/docs/confirmation-refactoring/approve/approve_collectible.png b/docs/confirmation-refactoring/approve/approve_collectible.png deleted file mode 100644 index 4dcd420f147d..000000000000 Binary files a/docs/confirmation-refactoring/approve/approve_collectible.png and /dev/null differ diff --git a/docs/confirmation-refactoring/approve/approve_token.png b/docs/confirmation-refactoring/approve/approve_token.png deleted file mode 100644 index 0cf853fc96de..000000000000 Binary files a/docs/confirmation-refactoring/approve/approve_token.png and /dev/null differ diff --git a/docs/confirmation-refactoring/dapp-transaction/README.md b/docs/confirmation-refactoring/dapp-transaction/README.md deleted file mode 100644 index 0a8450b199a3..000000000000 --- a/docs/confirmation-refactoring/dapp-transaction/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# DAPP Transactions - -For all transactions coming from the DAPP (except approve) this view is shown, these transactions include: - -1. Transfer ETH or other token -2. Create Token -3. Deploy Contract -4. Deploy Collectible, etc - - - - - -The component responsible for rendering this view is: [/Views/Approval](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/Views/Approval) - -### Refactoring `/Views/Approval` - -1. Function [isTXStatusCancellable](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L150) can be converted to a utility function. - -2. Function [handleAppStateChange](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L163) is re-used at many places in codebase. It will be useful to extract this into a re-usable hook. - -3. Function [showWalletConnectNotification](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L280) is partially duplicated at many places in the app and should be extracted into a re-usable utility function. - -4. Functions [prepareTransaction](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L406) and [prepareAssetTransaction](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L438) can be make re-usable utility functions. These can be made re-usable across confirmation components. - -### Refactoring `/UI/TransactionEditor` - -1. Function [handleDataGeneration](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/UI/TransactionEditor/index.js#L477), [validateTotal](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/UI/TransactionEditor/index.js#L541), [computeGasExtimates](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/UI/TransactionEditor/index.js#L147), [validateToAddress](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/UI/TransactionEditor/index.js#L575) should be utility functions. -2. State variable names [here](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/UI/TransactionEditor/index.js#L134) like `ready`, `over` are very confusing. - -### Refactoring `/UI/ApproveTransactionReview` - -This is covered already in document for refactoring Approve flow [here](https://github.com/MetaMask/metamask-mobile/pull/6024). - -### Other Code Improvements: - -Most of these points are covered in previous documents, but addding these here also as these are application to DAPP transaction related components also: - -1. Code to start / stop gas polling is duplicated at couple of places and should be moved to a re-usable hook. -2. As detailed in refactoring send flow document, a lot of components have logic to `updateNavBar` as [here](https://github.com/MetaMask/metamask-mobile/blob/e3f89a49a672b7c74419b4c6c9fc34a3ae9be023/app/components/Views/Approval/index.js#L121). This can be refactored out into a reusable hook. -3. Component [AnimatedTransactionModal](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/AnimatedTransactionModal/index.js) needs to be converted into a functional component using TypeScript. -4. Component [WatchAssetRequest](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/WatchAssetRequest/index.js) to be refactored into functional component using TypeScript. -5. At a lot of places we display gas selection modals like [this](https://github.com/MetaMask/metamask-mobile/blob/a803bec1d941f92062349f1edb619f447819f932/app/components/Views/ApproveView/Approve/index.js#L690). We should be able to create a single component which takes transaction and depending on the type legacy or EIP-1559 display gas options. -6. All react native components should be converted to functional typescript components with unit test coverage. -7. Components which are possibly re-usable should be placed in folder [/components/UI](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI) and not nested in sub-folders. -8. Transaction related utils are placed at a couple of places as [here](https://github.com/MetaMask/metamask-mobile/tree/main/app/util) like [/util/transactions](https://github.com/MetaMask/metamask-mobile/tree/main/app/util/transactions), [/util/dappTransaction](https://github.com/MetaMask/metamask-mobile/tree/main/app/util/dappTransactions). These can be moved together into same folder. diff --git a/docs/confirmation-refactoring/dapp-transaction/create-token.png b/docs/confirmation-refactoring/dapp-transaction/create-token.png deleted file mode 100644 index 760e87969a55..000000000000 Binary files a/docs/confirmation-refactoring/dapp-transaction/create-token.png and /dev/null differ diff --git a/docs/confirmation-refactoring/dapp-transaction/transfer-token.png b/docs/confirmation-refactoring/dapp-transaction/transfer-token.png deleted file mode 100644 index 0f6597d86cc9..000000000000 Binary files a/docs/confirmation-refactoring/dapp-transaction/transfer-token.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/README.md b/docs/confirmation-refactoring/signature-requests/README.md deleted file mode 100644 index 551bd27edeb2..000000000000 --- a/docs/confirmation-refactoring/signature-requests/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Signature Request Pages - -#### MetaMask mobile support for 5 different signature requests: - -1. ETH Sign: ETH sign is disabled by default. It can be enabled from advanced settings. - - - -2. Personal Sign - - - -3. Sign Typed Data V1 - - - -4. Sign Typed Data V3 - - - -5. Sign Typed Data V4 - - - -### Current flow of Signature Request: - - - -1. RPC request coming from DAPP is handled by [Message Managers](https://github.com/MetaMask/core/tree/main/packages/message-manager) defined in [@metamask/core](https://github.com/MetaMask/core) - - - ETH Signature requests are handled by [MessageManager](https://github.com/MetaMask/core/blob/main/packages/message-manager/src/MessageManager.ts) - - Personal Message Signature requests are handled by [PersonalMessageManager](https://github.com/MetaMask/core/blob/main/packages/message-manager/src/PersonalMessageManager.ts) - - Typed Message Signature requests are handled by [TypedMessageManager](https://github.com/MetaMask/core/blob/main/packages/message-manager/src/TypedMessageManager.ts) - -2. [RootRPCMethodsUI](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/Nav/Main/RootRPCMethodsUI.js) has event listeners defined to handle new un-approved message. It triggers the signature modal and renders one of the following components: - - - [MessageSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/MessageSign) - - [PersonalSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/PersonalSign) - - [TypedSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/TypedSign) - -3. All the 3 Signature Request components use [SignatureRequest](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/SignatureRequest) child component to render the page. - -### Proposed Refactoring / Code Cleanup: - -1. [RootRPCMethodsUI](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/Nav/Main/RootRPCMethodsUI.js): All Signature request related code from here should all be moved to a component `/app/components/Nav/UI/SignatureRequestBase`. This component will have events listeners for `un-approved messages` and render modal appropriately. `SignatureRequestBase` can be imported in `RootRPCMethodsUI`. -2. Signature Request Components - [MessageSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/MessageSign), [PersonalSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/PersonalSign), [TypedSign](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/TypedSign). The 3 components have a lot of code duplication: - - `showWalletConnectNotification` this function is duplicated in 3 components. It can be moved to [SignatureRequest](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/SignatureRequest) - Additionally this is partially duplicated at many places in the app and should be moved to a re-usable utility function. - - Methods `signMessage`, `rejectMessage`, `cancelSignature`, `confirmSignature` are also duplicated at 3 places. These should be moved to [SignatureRequest](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/SignatureRequest) - - The logic to conditionally show expanded message [here](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/MessageSign/index.js#L224) can also be moved to [SignatureRequest](https://github.com/MetaMask/metamask-mobile/tree/main/app/components/UI/SignatureRequest) - -Creating functional react components using TypeScript and adding unit test coverage is a requirement or all of this component refactoring. diff --git a/docs/confirmation-refactoring/signature-requests/eth_sign.png b/docs/confirmation-refactoring/signature-requests/eth_sign.png deleted file mode 100644 index cb5f35607907..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/eth_sign.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/personal_sign.png b/docs/confirmation-refactoring/signature-requests/personal_sign.png deleted file mode 100644 index 73be02271fe2..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/personal_sign.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v1.png b/docs/confirmation-refactoring/signature-requests/sign_typed_data_v1.png deleted file mode 100644 index 5a62dc0c4963..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v1.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v3.png b/docs/confirmation-refactoring/signature-requests/sign_typed_data_v3.png deleted file mode 100644 index 7c248c17ddee..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v3.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v4.png b/docs/confirmation-refactoring/signature-requests/sign_typed_data_v4.png deleted file mode 100644 index 2f4d69a0e06b..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/sign_typed_data_v4.png and /dev/null differ diff --git a/docs/confirmation-refactoring/signature-requests/signature_request_flow.png.png b/docs/confirmation-refactoring/signature-requests/signature_request_flow.png.png deleted file mode 100644 index 04c337f5f6c8..000000000000 Binary files a/docs/confirmation-refactoring/signature-requests/signature_request_flow.png.png and /dev/null differ diff --git a/docs/confirmations.md b/docs/confirmations.md deleted file mode 100644 index f32265c4bceb..000000000000 --- a/docs/confirmations.md +++ /dev/null @@ -1,189 +0,0 @@ -# Adding New Confirmations - -## Overview - -Given the security focused nature of self-custody, confirmations and approvals form a pivotal aspect of MetaMask Mobile. - -Confirmations can be triggered by dApps and the UI itself, and are used to approve a variety of operations such as: - -- Connecting to dApps -- Giving permissions to dApps -- Sending Eth -- Transfering tokens -- Signing data -- Interacting with Snaps -- Adding Ethereum networks - -It is vital any new confirmations are implemented using best practices and consistent patterns, to avoid adding complexity to the code, and to minimise the maintenance cost of many alternate confirmations. - -## Steps - -### 1. Create Approval Request - -Call the `add` method on the `ApprovalController` to create an approval request. - -This returns a `Promise` which will resolve if the confirmation is approved, and reject if the confirmation is denied or cancelled. - -Use an `async` function to send the message so the logic can `await` the confirmation and code execution can continue once approved. This enables the logic ran after approval to be kept in the same flow and therefore the logic to remain readable and encapsulated. - -Ensure suitable error handling is in place to handle the confirmation being cancelled or denied and therefore the `Promise` being rejected. - -The available arguments are: - -| Name | Description | Example Value | -| -- | -- | -- | -| opts.id | The ID of the approval request.
Assigned to a random value if not provided. | `"f81f5c8a-33bb-4f31-a4e2-52f8b94c393b"` | -| opts.origin | The origin of the request.
Either the dApp host or "metamask" if internal. | `"metamask.github.io"` | -| opts.type | A string identifying the type of confirmation. | `"eth_signTypedData"` | -| opts.requestData | Additional fixed data for the request.
Must be a JSON compatible object.| `{ transactionId: '123' }` | -| opts.requestState | Additional mutable data for the request.
Must be a JSON compatible object.
Can be updated using the `updateRequestState` method. | `{ status: 'pending' }` | - -#### Example - -```javascript -const approvalData = await Engine.context.ApprovalController.add({ - origin: hostname, - type: ApprovalTypes.EXAMPLE, - requestData: { value: 'Example Value' } -}); -``` - -### 2. Update Approval Request (Optional) - -If you wish to provide additional state to the confirmation while it is visible, call the `updateRequestState` method on the `ApprovalController`. - -This requires you to have provided the `id` when creating the approval request, so it can be provided in the arguments. - -The available arguments are: - -| Name | Description | Example Value | -| -- | -- | -- | -| opts.id | The ID of the approval request to update. | `"f81f5c8a-33bb-4f31-a4e2-52f8b94c393b"` | -| opts.requestState | The updated mutable data for the request.
Must be a JSON compatible object. | `{ status: 'pending' }` | - -#### Example - -```javascript -await Engine.context.ApprovalController.updateRequestState({ - id, - requestState: { counter }, -}); -``` - -### 3. Define Approval Type - -Update the `ApprovalTypes` enum in [app/core/RPCMethods/RPCMethodMiddleware.ts](../app/core/RPCMethods/RPCMethodMiddleware.ts) to include a new entry to identify the new confirmations. - -#### Example - -```typescript -export enum ApprovalTypes { - ... - EXAMPLE = 'EXAMPLE' -} -``` - -This must match the `opts.type` provided in the `add` request. - -### 4. Handle Approval Type - -Update the `handlePendingApprovals` method in [app/components/Nav/Main/RootRPCMethodsUI.js](../app/components/Nav/Main/RootRPCMethodsUI.js) with a new case for the new approval type. - -This case needs to update the local React state using any relevant data from the approval request and set the pending approval state using the `showPendingApprovalModal` method. - -#### Example - -```javascript -case ApprovalTypes.EXAMPLE: - setExampleRequest({ data: requestData, id: request.id }); - showPendingApprovalModal({ - type: ApprovalTypes.EXAMPLE, - origin: request.origin, - }); - break; -``` - -### 5. Create Approval Component - -Create a component in `app/components/UI` to define the content of the modal. - -Use `Approval` as a suffix so similar components can be identified, such as `ExampleApproval`. - -Include `onCancel` and `onConfirm` properties to decouple the component from the side effects of rejecting or approving the confirmation. - -For an example, see the [ExampleApproval](https://github.com/MetaMask/metamask-mobile/blob/example/confirmation/app/components/UI/ExampleApproval/index.js) in the [example branch](https://github.com/MetaMask/metamask-mobile/compare/main...example/confirmation). - -### 6. Create Handler Functions - -Create handler functions for approval and rejection of the confirmation in the [RootRPCMethodsUI](../app/components/Nav/Main/RootRPCMethodsUI.js) component. - -Use the `acceptPendingApproval` and `rejectPendingApproval` local methods to trigger the `accept` and `reject` methods of the `ApprovalController` to resolve the confirmation promise. - -Ensure `setShowPendingApproval` is called to clear the local pending approval state. - -Ensure any related local state is cleared. - -#### Example - -```javascript -const onExampleConfirm = () => { - acceptPendingApproval(exampleRequest.id, exampleRequest.requestData); - setShowPendingApproval(false); - setExampleRequest(undefined); -}; - -const onExampleReject = () => { - rejectPendingApproval(exampleRequest.id, exampleRequest.requestData); - setShowPendingApproval(false); - setExampleRequest(undefined); -}; -``` - -### 7. Render Modal - -Update the [RootRPCMethodsUI](../app/components/Nav/Main/RootRPCMethodsUI.js) component to render a modal for the new approval type. - -Use a new method to encapsulate the code for the approval type. - -Include the new approval component as a child of the `Modal` component. - -Reference the new handlers in the `Modal` properties `onSwipeComplete` and `onBackdropPress`, and in the appropriate properties of the new approval component. - -#### Example - -```javascript -const renderExampleModal = () => ( - - - - ); -``` - -## Example Branch - -See [this branch](https://github.com/MetaMask/metamask-mobile/compare/main...example/confirmation) as an example of the full code needed to add a confirmation. - -The confirmation can be tested using the [E2E Test dApp](https://metamask.github.io/test-dapp/) and selecting `Connect`. - -## Screenshots - -### Confirmation Modal - -[](assets/confirmation.png) diff --git a/docs/perps/perps-agentic-feedback-loop.md b/docs/perps/perps-agentic-feedback-loop.md index 98b25994901f..b4541207a873 100644 --- a/docs/perps/perps-agentic-feedback-loop.md +++ b/docs/perps/perps-agentic-feedback-loop.md @@ -1,464 +1,403 @@ -# Perps Agentic Feedback Loop +# Perps Agentic Toolkit -> **Status: WIP** — This document is under active development. The toolkit scripts and workflow are functional but the guide may evolve as we validate across more feature areas and gather feedback from the team. -> -> **Location note:** The agentic toolkit currently lives under `scripts/perps/agentic/` while being validated as part of the perps workflow. Once the pattern is proven and adopted by other feature teams, the intent is to promote it to `scripts/agentic/` as general-purpose infrastructure. +The agentic toolkit lets AI agents interact with a running MetaMask Mobile app via CDP (Chrome DevTools Protocol). Agents execute parameterized flows — JSON test sequences that navigate screens, press buttons, type values, and assert state — to verify their own code changes without human intervention. - - -How AI agents use the agentic toolkit to verify their own code changes against a running MetaMask Mobile app. - -## Prerequisites - -- App running on **iOS Simulator** or **Android Emulator/device** -- Metro bundler active (`scripts/perps/agentic/start-metro.sh`) -- The `__AGENTIC__` bridge is auto-installed on `globalThis` by `app/core/NavigationService/NavigationService.ts` in `__DEV__` mode -- The Redux store is exposed on `globalThis.store` for state queries (set up alongside `__AGENTIC__` in dev mode) -- The `Engine` singleton is exposed on `globalThis.Engine` for direct controller access (see Section 4: Engine & Controller Access) -- **iOS**: Xcode command-line tools (`xcrun simctl`) -- **Android**: Android SDK with `adb` on PATH +The toolkit lives at `scripts/perps/agentic/`. It works on both iOS Simulator and Android Emulator. --- -## 1. Environment Setup +## Architecture -### Start Metro - -```bash -scripts/perps/agentic/start-metro.sh ``` - -Reuses an existing Metro process or starts a new one. Logs go to `.agent/metro.log`. Port is controlled by `WATCHER_PORT` in `.js.env` (default `8081`). - -### Verify the app is reachable - -```bash -scripts/perps/agentic/app-state.sh route +Agent (Claude Code / Cursor / etc.) + | + v +validate-recipe.sh # Orchestrates flow execution + | + +-- cdp-bridge.js # CDP engine (WebSocket -> Metro -> Hermes) + | +-- lib/ws-client.js # WebSocket connection + | +-- lib/target-discovery.js # Find the right CDP target + | +-- lib/cdp-eval.js # Eval sync/async via CDP + | +-- lib/config.js # Port + env resolution + | +-- lib/assert.js # Assertion operators + | +-- lib/registry.js # Pre-condition registry + | + +-- teams/perps/ + +-- flows/ # 12 parameterized flow JSONs + +-- evals/ # Hierarchical eval ref collections + +-- evals.json # Built-in eval refs + +-- pre-conditions.js # Named pre-condition checks ``` -Should return the current route name. If it fails, check: device booted (`xcrun simctl list devices booted` / `adb devices`), Metro running (`lsof -i :${WATCHER_PORT:-8081}`), app installed (take a screenshot). - -### Android-specific setup +--- -Android devices need extra steps to reach Metro: +## Quick Start ```bash -# 1. Port-forward so the device can reach Metro on localhost -adb -s reverse tcp:${WATCHER_PORT:-8081} tcp:${WATCHER_PORT:-8081} +# 1. Check app + Metro + CDP are connected +yarn a:status -# 2. Launch the app -adb -s shell am start -n io.metamask/.MainActivity +# 2. Run a built-in eval ref (single CDP eval) +bash scripts/perps/agentic/app-state.sh eval-ref perps/positions -# 3. If the Expo dev launcher appears, tap the localhost:${WATCHER_PORT:-8081} entry -adb -s shell input tap 540 600 +# 3. Run a flow (multi-step UI sequence) +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/flows/market-discovery.json --skip-manual -# 4. Wait ~15s for JS bundle, then verify CDP targets -curl -s http://localhost:${WATCHER_PORT:-8081}/json/list | python3 -c \ - "import json,sys; [print(t['deviceName'],t['title']) for t in json.load(sys.stdin)]" -``` +# 4. Dry-run a flow (prints steps without executing) +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/flows/trade-open-market.json --dry-run -The installed APK must be a debug build connected to Metro for CDP to work. +# 5. Run all flows (dry-run) +for f in scripts/perps/agentic/teams/perps/flows/*.json; do + bash scripts/perps/agentic/validate-recipe.sh "$f" --dry-run --skip-manual +done +``` --- -## 2. Toolkit Reference +## Flows -All tools work on **both iOS and Android**. Platform is auto-detected (see Section 7 for overrides). +A flow is a parameterized JSON file that `validate-recipe.sh` executes step-by-step against the live app. Each flow declares its parameters in an `inputs` block, its required app state in `pre_conditions`, and a sequence of `steps`. -``` -scripts/perps/agentic/app-navigate.sh [params-json] # navigate + auto-screenshot -scripts/perps/agentic/app-state.sh status # route + selected account snapshot -scripts/perps/agentic/app-state.sh route # current route + params -scripts/perps/agentic/app-state.sh state # Redux state at path -scripts/perps/agentic/app-state.sh eval "" # run JS in app context (sync) -scripts/perps/agentic/app-state.sh eval-async "" # run JS returning a Promise (async) -scripts/perps/agentic/app-state.sh nav # full navigation tree -scripts/perps/agentic/app-state.sh can-go-back # check if can go back -scripts/perps/agentic/app-state.sh go-back # navigate back -scripts/perps/agentic/app-state.sh accounts # list all accounts -scripts/perps/agentic/app-state.sh account # get selected account -scripts/perps/agentic/app-state.sh switch-account # switch to account by address -scripts/perps/agentic/app-state.sh press # press component by testID -scripts/perps/agentic/app-state.sh scroll [--test-id ] [--offset ] # scroll a view -scripts/perps/agentic/app-state.sh sentry-debug [enable|disable] # patch Sentry to log to console -scripts/perps/agentic/app-state.sh unlock # unlock wallet via fiber tree -scripts/perps/agentic/app-state.sh recipe # run a recipe (e.g. perps/positions) -scripts/perps/agentic/app-state.sh recipe --list # list all available recipes -scripts/perps/agentic/screenshot.sh [label] # take screenshot, returns path -scripts/perps/agentic/start-metro.sh # ensure Metro is running -scripts/perps/agentic/stop-metro.sh # stop Metro background process -scripts/perps/agentic/reload-metro.sh # trigger hot-reload on all connected apps -``` - -**yarn shortcuts** (human-friendly aliases): - -```bash -yarn a:start # start/attach Metro (no app launch) -yarn a:stop # stop Metro -yarn a:status # current route + account snapshot -yarn a:reload # hot-reload all connected apps -yarn a:navigate # navigate to a screen -yarn a:ios # boot sim → Metro → launch app → wallet setup → CDP ready -yarn a:android # boot device → Metro → launch app → wallet setup → CDP ready -yarn a:setup:ios # clean build: yarn setup → build → install → Metro → wallet -yarn a:setup:android # clean build: same for Android -``` +### Parameter Templating -**Fast relaunch** (skip wallet import, ~10-15s faster — `yarn` aliases coming soon): +Flows use `{{param}}` tokens in titles, expressions, test_ids, and params. Defaults come from the `inputs` block: -```bash -scripts/perps/agentic/preflight.sh --platform ios # boot sim → Metro → launch → CDP ready -scripts/perps/agentic/preflight.sh --platform android # boot device → Metro → launch → CDP ready +```json +{ + "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}", + "inputs": { + "side": { "type": "string", "default": "long" }, + "symbol": { "type": "string", "default": "BTC" }, + "usdAmount": { "type": "string", "default": "10" } + } +} ``` -> **Which command?** First time → `yarn a:setup:ios`. Daily restart → `preflight.sh --platform ios` (no wallet). Wallet corrupted → `yarn a:ios`. +When run standalone, `inputs` defaults are applied. When called via `flow_ref`, the parent provides values that override defaults. Params without a default are required. -**Metro log**: `.agent/metro.log` — grep for errors after changes. +### Pre-Conditions -**Architecture**: +Pre-conditions gate flow execution. If any check fails, the runner aborts with a clear error and hint. -``` -scripts/perps/agentic/ -├── cdp-bridge.js # CDP engine: WebSocket client, target discovery, eval, navigate -├── app-navigate.sh # Navigate wrapper (calls cdp-bridge + auto-screenshot) -├── app-state.sh # State/route/eval/accounts/recipe wrapper (calls cdp-bridge) -├── screenshot.sh # Cross-platform screenshot (iOS simctl / Android adb) -├── start-metro.sh # Start Metro (or attach to existing) -├── stop-metro.sh # Stop Metro background process -├── reload-metro.sh # Trigger hot-reload on all connected apps -├── preflight.sh # Full env setup: build → Metro → CDP → wallet seed -├── setup-wallet.sh # Seed wallet from .agent/wallet-fixture.json via CDP -├── unlock-wallet.sh # Unlock wallet on lock screen -├── interactive-start.sh # Interactive guided setup -├── validate-recipe.sh # Run a recipe folder against the live app -├── validate-myx.sh # MYX-specific validation -└── recipes/ # Per-team recipe files (see recipes/README.md) - ├── perps.json # Perps team recipes (positions, auth, balances, markets, trade-flow, etc.) - └── README.md # How to add recipes for your team +```json +"pre_conditions": [ + "wallet.unlocked", + "perps.ready_to_trade", + { "name": "perps.open_position", "symbol": "{{symbol}}" } +] ``` -The `__AGENTIC__` bridge on `globalThis` exposes: `navigate()`, `getRoute()`, `getState()`, `canGoBack()`, `goBack()`, `listAccounts()`, `getSelectedAccount()`, `switchAccount()`. These work identically on both platforms via Metro's Hermes CDP. - -> **Platform targeting**: CDP-based commands (navigate, state, eval, go-back) are platform-agnostic — they go through Metro's WebSocket and reach whichever app is connected. Screenshots require direct device access (`xcrun simctl` or `adb`), so `screenshot.sh` auto-detects the platform. When both iOS and Android devices are connected, set `PLATFORM=android` or `PLATFORM=ios` to disambiguate. Since `app-navigate.sh` takes a verification screenshot, pass `PLATFORM` when needed: -> -> ```bash -> PLATFORM=android scripts/perps/agentic/app-navigate.sh PerpsMarketListView -> ``` +String form for simple checks, object form for parameterized checks. Shorthand `"perps.open_position(symbol={{symbol}})"` is also supported. + +**Available pre-conditions** (from `teams/perps/pre-conditions.js`): + +| Name | Description | +| ---------------------------------- | ------------------------------------------------- | +| `wallet.unlocked` | Wallet is unlocked and navigable | +| `perps.feature_enabled` | PerpsController is available | +| `perps.trading_flag` | Perps trading remote flag is on | +| `perps.ready_to_trade` | Provider is authenticated | +| `perps.sufficient_balance` | Account has non-zero balance | +| `perps.open_position` | Open position exists (optionally by symbol) | +| `perps.open_position_tpsl` | Position with TP/SL exists (optionally by symbol) | +| `perps.open_limit_order` | Open limit order exists (optionally by symbol) | +| `perps.not_in_watchlist` | Symbol is not in watchlist | +| `ui.homepage_redesign_v1_enabled` | Homepage redesign V1 flag is on | +| `ui.homepage_redesign_v1_disabled` | Homepage redesign V1 flag is off | + +### Authoring Rules + +Enforced by `node scripts/perps/agentic/validate-flow-schema.js`: + +1. **Eval steps must assert.** Every `eval_sync`, `eval_async`, `eval_ref` step needs an `"assert"` block. Use `{"operator":"not_null"}` at minimum. +2. **Terminal step must assert.** The last step must be an asserting eval or a `log_watch`. Never end on `wait`, `navigate`, or `press`. +3. **No unknown actions.** Only recognized action types are allowed. +4. **Inputs must match params.** Every `{{param}}` in steps must have a matching key in `inputs`. + +Full schema: `scripts/perps/agentic/schemas/flow.schema.json` + +### Available Flows + +| Flow | Inputs (defaults) | Pre-conditions | +| ---------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `activity-view` | `tab` ("trades") | wallet.unlocked, perps.feature_enabled | +| `market-discovery` | `symbol` ("BTC") | wallet.unlocked, perps.feature_enabled | +| `market-watchlist` | `symbol` ("BTC") | wallet.unlocked, perps.feature_enabled, perps.not_in_watchlist | +| `order-limit-cancel` | `symbol` (required) | wallet.unlocked, perps.open_limit_order | +| `order-limit-place` | `side` ("long"), `symbol` ("BTC"), `usdAmount` ("10"), `limitPrice` ("60000") | wallet.unlocked, perps.ready_to_trade | +| `position-add-margin` | `symbol` (required), `marginAmount` (required) | wallet.unlocked, perps.open_position | +| `setup-account` | `address` (required) | wallet.unlocked | +| `setup-testnet` | _(none)_ | wallet.unlocked, perps.feature_enabled | +| `tpsl-create` | `symbol` (required), `tpPreset` ("25"), `slPreset` ("-10") | wallet.unlocked, perps.open_position | +| `tpsl-edit` | `symbol` (required), `tpPreset` ("50"), `slPreset` ("-25") | wallet.unlocked, perps.open_position_tpsl | +| `trade-close-position` | `symbol` (required) | wallet.unlocked, perps.open_position | +| `trade-open-market` | `side` ("long"), `symbol` ("BTC"), `usdAmount` ("10") | wallet.unlocked, perps.ready_to_trade, perps.sufficient_balance | --- -## 3. Feedback Loop - -After code changes, Metro hot-reloads automatically. Then: +## Eval Refs -1. **Recover navigation** — hot-reload may reset to home: - ```bash - scripts/perps/agentic/app-navigate.sh WalletTabHome - scripts/perps/agentic/app-navigate.sh '' - ``` -2. **Verify route**: `scripts/perps/agentic/app-state.sh route` -3. **Inspect state**: `scripts/perps/agentic/app-state.sh state engine.backgroundState.PerpsController` -4. **Screenshot**: `scripts/perps/agentic/screenshot.sh after-fix` -5. **Check Metro logs**: `grep -iE 'ERROR|error' .agent/metro.log | tail -20` -6. **Iterate** — if something is wrong, fix code, wait for hot-reload, repeat 1-5. - ---- - -## 4. Advanced Patterns - -**Console.log to Metro** — logs appear in `.agent/metro.log`: +Eval refs are named CDP eval expressions in `teams/perps/evals.json` and `teams/perps/evals/*.json`. Unlike flows (multi-step UI sequences), eval refs are single eval calls. ```bash -scripts/perps/agentic/app-state.sh eval "console.log('AGENTIC: checkpoint reached'); 'logged'" -``` - -**Custom `__DEV__` helpers** — for interactions beyond navigation/Redux: - -```javascript -if (__DEV__) { - globalThis.__AGENTIC_CUSTOM__ = { - triggerTrade: () => { - /* call internal handlers */ - }, - setAmount: (val) => { - /* set input state */ - }, - }; -} -``` - -Then: `scripts/perps/agentic/app-state.sh eval "globalThis.__AGENTIC_CUSTOM__?.triggerTrade()"` - -**Chaining nav + verify**: +# List all eval refs +bash scripts/perps/agentic/app-state.sh eval-ref --list -```bash -scripts/perps/agentic/app-navigate.sh PerpsMarketDetails '{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}' -scripts/perps/agentic/app-state.sh route +# Run an eval ref +bash scripts/perps/agentic/app-state.sh eval-ref perps/positions +bash scripts/perps/agentic/app-state.sh eval-ref perps/core/watchlist +bash scripts/perps/agentic/app-state.sh eval-ref perps/setup/testnet-mode ``` -### Engine & Controller Access - -In `__DEV__` mode, `NavigationService.ts` exposes the `Engine` singleton on `globalThis.Engine` (alongside `__AGENTIC__` and `store`). This gives CDP-based agents direct access to every controller registered on the Engine. - -**Sync vs async evaluation:** +**Built-in eval refs** (`perps/`): positions, auth, balances, markets, orders, state, providers, pre-trade, post-trade, place-order -```bash -# Sync (non-promise) — use eval -scripts/perps/agentic/app-state.sh eval "Engine.context.PerpsController.state" - -# Async (returns promise) — use eval-async -scripts/perps/agentic/app-state.sh eval-async \ - "Engine.context.PerpsController.getPositions().then(function(r) { return JSON.stringify(r); })" -``` +**Extended eval refs** (`perps/core/`): pump-market, tpsl-orders, positions-by-symbol, leverage-config, watchlist -> `eval-async` works by storing the promise result on a temporary `globalThis` key and polling until it resolves. The default timeout is 30 seconds. The `.then(function(r) { return JSON.stringify(r); })` pattern is needed for complex return values — CDP's `returnByValue` can only serialize primitives and plain objects. +**Setup eval refs** (`perps/setup/`): testnet-mode, current-provider -**Useful PerpsController methods:** +### eval_ref in Flows -| Method | Returns | Description | -| --------------------------- | --------------------------- | ------------------------------ | -| `getPositions()` | `Promise` | Open positions | -| `getAccountState()` | `Promise` | Balances, margin, withdrawable | -| `getMarketDataWithPrices()` | `Promise` | All markets with live prices | -| `validateOrder(params)` | `Promise` | Pre-flight order check | -| `placeOrder(params)` | `Promise` | Submit an order | -| `closePosition({symbol})` | `Promise` | Close a position by symbol | -| `getOpenOrders()` | `Promise` | Active limit/stop orders | -| `getTradeConfiguration()` | `Promise` | Leverage limits, fee tiers | +Use `eval_ref` inside a flow to run a built-in eval ref and assert on its result: -**Order params shape:** - -```javascript +```json { - symbol: 'BTC', // market symbol - isBuy: true, // true = long, false = short - orderType: 'market', // 'market' | 'limit' - size: '0.0001', // position size in base asset - leverage: 2, // leverage multiplier - usdAmount: '10', // notional USD value - priceAtCalculation: 65000, // current price (for slippage calc) - maxSlippageBps: 500, // max slippage in basis points + "id": "check-pos", + "action": "eval_ref", + "ref": "positions", + "assert": { "operator": "length_gt", "field": "positions", "value": 0 } } ``` -### Trade Flow Validation - -The trade flow validation pattern uses three recipes in `recipes/perps.json` to capture pre/post state around an order. This replaces a shell script with composable `eval-async` calls that an agent can orchestrate directly. - -**Recipes:** +--- -| Recipe | Description | -| ------------------- | -------------------------------------------------- | -| `perps/pre-trade` | Position count + balance snapshot before the trade | -| `perps/place-order` | **TEMPLATE** — market buy BTC $10 at 2x leverage | -| `perps/post-trade` | Same snapshot shape as pre-trade for comparison | +## CDP Commands -**Orchestration pattern:** +All CDP commands go through `cdp-bridge.js` or `app-state.sh` wrappers: ```bash -# 1. Capture baseline -scripts/perps/agentic/app-state.sh recipe perps/pre-trade - -# 2. Place order (template — edit the expression or use eval-async for custom params) -scripts/perps/agentic/app-state.sh recipe perps/place-order - -# 3. Wait for WebSocket updates -sleep 5 - -# 4. Capture post-trade state -scripts/perps/agentic/app-state.sh recipe perps/post-trade +CDP="node scripts/perps/agentic/cdp-bridge.js" +AS="bash scripts/perps/agentic/app-state.sh" + +$CDP status # Route + account snapshot +$CDP get-route # Current route name +$CDP eval "" # Sync JS eval (ES5 only) +$CDP eval-async "" # Async eval (Promise, use .then()) +$CDP eval-ref perps/positions # Run a named eval ref +$CDP check-pre-conditions '' # Validate pre-conditions +$CDP press-test-id # Press by testID +$CDP scroll-view --test-id # Scroll a view +$CDP set-input "val" # Type into input ``` -**Custom order via `eval-async`** (substitute your own params): +**ES5 only.** No arrow functions, no `const`/`let`, no template literals, no top-level `await`. ```bash -scripts/perps/agentic/app-state.sh eval-async \ - "Engine.context.PerpsController.placeOrder({symbol:'ETH',isBuy:false,orderType:'market',size:'0.01',leverage:3,usdAmount:'20',maxSlippageBps:500}).then(function(r){return JSON.stringify(r)})" -``` +# Good: +$CDP eval "var x = Engine.context.PerpsController.state; JSON.stringify(x)" +$CDP eval-async "Engine.context.PerpsController.getPositions().then(function(r){ return JSON.stringify(r) })" -> **Important:** `perps/place-order` places a real order. It is labeled as a template/example. Always verify auth state (`perps/auth`) and balances (`perps/balances`) before running. +# Bad: +$CDP eval "const x = () => Engine.context" # arrow + const +$CDP eval-async "await Engine.context.getPos()" # top-level await +``` -### Metro Log Debugging +--- -Perps code uses a `[PERPS_DEBUG]` prefix convention for structured debug logs in Metro. These logs are written to `.agent/metro.log` and are invaluable for diagnosing WebSocket, state, and connection issues. +## Shell Commands -**Useful grep patterns:** +| Command | Purpose | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `app-state.sh status\|route\|eval\|eval-async\|eval-ref\|accounts\|press\|scroll\|set-input` | State queries and UI interaction | +| `app-navigate.sh [params-json]` | Navigate + auto-screenshot. `--list` discovers all live routes | +| `screenshot.sh [label]` | Cross-platform screenshot (iOS simctl / Android adb) | +| `validate-recipe.sh [--dry-run] [--skip-manual] [--step ]` | Execute a flow/recipe against the live app | +| `validate-flow-schema.js` | Validate all flows against authoring rules | +| `validate-pre-conditions.js` | Verify pre-condition expressions and assertions | +| `start-metro.sh --platform ios\|android` | Start or attach to Metro | +| `setup-wallet.sh` | Seed wallet from `.agent/wallet-fixture.json` | -```bash -# All perps debug logs (recent) -grep PERPS_DEBUG .agent/metro.log | tail -30 - -# Position WS updates — confirms data flowing from Hyperliquid -grep 'PositionStreamChannel: WS callback' .agent/metro.log | tail -10 +--- -# Price stream initialization — confirms market data arriving -grep 'FIRST WS data' .agent/metro.log +## Assertions -# Connection state changes — ensureReady() and reconnection logic -grep 'ensureReady' .agent/metro.log | tail -20 +Every asserting step includes `"assert": { "operator": "", "field": "", "value": }`. -# Order lifecycle — placement, fills, errors -grep 'PERPS_DEBUG.*order' .agent/metro.log | tail -20 +| Operator | Passes when | +| -------------- | --------------------------------------------- | +| `not_null` | `actual != null` | +| `eq` | `actual === expected` | +| `gt` | `actual > expected` (number) | +| `length_eq` | `actual.length === expected` | +| `length_gt` | `actual.length > expected` | +| `contains` | `actual.includes(expected)` (string or array) | +| `not_contains` | `!actual.includes(expected)` | -# Cache invalidation — stale data clearing -grep 'PERPS_DEBUG.*cache' .agent/metro.log | tail -20 -``` +`field` is a dot-path into the result JSON (e.g. `"route"`, `"positions.0.symbol"`). Omit or set to `null` to assert on the entire result. Double-encoded JSON strings are automatically unwrapped. -> When debugging WS issues, the key signal is whether `PositionStreamChannel: WS callback` appears after placing an order. If it doesn't, the WebSocket subscription may be stale — check `ensureReady` logs for connection state. +--- -### Account Management +## UI Interactions -The `__AGENTIC__` bridge exposes account methods at the root level. These are available via `cdp-bridge.js` commands or `app-state.sh` wrappers. +The toolkit interacts with React components by `testID` — no coordinates needed. Under the hood, it walks the React fiber tree via `__REACT_DEVTOOLS_GLOBAL_HOOK__`. ```bash -# List all accounts (id, address, name) -scripts/perps/agentic/app-state.sh accounts +bash app-state.sh press # tap a button +bash app-state.sh scroll --test-id --offset 300 # scroll down +bash app-state.sh set-input "0.5" # type into input +``` -# Get the currently selected account -scripts/perps/agentic/app-state.sh account +In flows, use `press`, `scroll`, `set_input`, `type_keypad`, `clear_keypad`, and `wait_for` actions. -# Switch to a different account by address -scripts/perps/agentic/app-state.sh switch-account 0x1234...abcd -``` +**Keypad pattern:** Always clear before typing — use `clear_keypad` (count: 8) before `type_keypad` to wipe any pre-filled value. Assert the displayed amount matches before submitting. -Useful for auth scoping validation — switch accounts and verify that controller state (e.g. perps auth) updates correctly. Combine with `recipe perps/auth` to check auth state after switching. +--- -### Recipes +## Gherkin to Flow Translation -Recipes are per-team JSON files in `scripts/perps/agentic/recipes/` that define reusable CDP expressions. This keeps domain-specific helpers in the scripts layer rather than the app source — any controller method accessible via `Engine.context` can be a recipe. +Gherkin maps naturally to flow JSON: -```bash -# Run a recipe -scripts/perps/agentic/app-state.sh recipe perps/positions -scripts/perps/agentic/app-state.sh recipe perps/auth -scripts/perps/agentic/app-state.sh recipe perps/markets +| Gherkin | Flow equivalent | +| --------------------------- | ----------------------------------------------------------------- | +| **Given** (preconditions) | `pre_conditions` array | +| **When** (user actions) | `navigate`, `press`, `set_input`, `type_keypad`, `wait_for` steps | +| **Then** (expected outcome) | `eval_sync`/`eval_async` steps with `assert` | + +**Example:** -# List all available recipes -scripts/perps/agentic/app-state.sh recipe --list +```gherkin +Given the wallet is unlocked + And BTC has an open position +When the user navigates to BTC market detail + And presses the Close Position button +Then the close position screen is shown ``` -**Adding recipes for your team:** Create `recipes/.json` — see `recipes/README.md` for the format. Each recipe has a description, a JS expression, and an `async` flag. +```json +{ + "title": "Close BTC position", + "inputs": { + "symbol": { "type": "string", "description": "Market symbol" } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { "name": "perps.open_position", "symbol": "{{symbol}}" } + ], + "steps": [ + { + "id": "nav", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-market", + "action": "wait_for", + "route": "PerpsMarketDetails" + }, + { + "id": "press-close", + "action": "press", + "test_id": "perps-market-details-close-button" + }, + { + "id": "wait-close-screen", + "action": "wait_for", + "route": "PerpsClosePosition" + } + ] + } + } +} +``` --- -## 5. State Paths & Routes - -### Common perps state paths +## Recipes -| Path | Contents | -| ------------------------------------------------------------------- | --------------------------- | -| `engine.backgroundState.PerpsController` | Positions, orders, balances | -| `engine.backgroundState.RemoteFeatureFlagController` | Feature flags | -| `engine.backgroundState.NetworkController.selectedNetworkClientId` | Active network | -| `engine.backgroundState.AccountTrackerController.accountsByChainId` | Account balances by chain | +Recipes compose multiple flows via `flow_ref` for integration-level validation. They live in `scripts/perps/agentic/teams//recipes/` and prove that end-to-end scenarios work across flow boundaries. -### Perps routes +See `teams/perps/recipes/full-trade-lifecycle.json` for an example that chains: wallet home → mainnet → perps → testnet → clear position → open market → TP/SL (presets) → close — all via `flow_ref`. -All routes are in `app/constants/navigation/Routes.ts`. Nested routes are handled automatically by `cdp-bridge.js`. - -> Perps routes are nested under the `Perps` parent navigator. The mapping is defined in `cdp-bridge.js` (`NESTED_ROUTE_PARENTS`). When adding new Perps routes, add them to this map. +```bash +# Run a recipe +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json -> **Note**: Route strings don't always match component names. `PerpsMarketListView` is the **home** screen route (renders PerpsHomeView). The actual market list component is at route `PerpsTrendingView`. +# Dry-run +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json --dry-run +``` -| Route | Description | Params | -| ------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | | -| `PerpsTrendingView` | Market list (all markets, full view) | | -| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` | -| `PerpsTradingView` | Redirect: navigates to wallet home and selects perps tab | | -| `PerpsPositions` | Open positions | | -| `PerpsActivity` | Activity history | | -| `PerpsWithdraw` | Withdraw funds | | -| `PerpsTutorial` | Onboarding tutorial | | -| `PerpsClosePosition` | Close a position | | -| `PerpsTPSL` | Take-profit / stop-loss | | -| `PerpsAdjustMargin` | Adjust position margin | | -| `PerpsSelectModifyAction` | Select modify action sheet | | -| `PerpsSelectAdjustMarginAction` | Select adjust margin action | | -| `PerpsSelectOrderType` | Select order type | | -| `PerpsOrderDetailsView` | Order detail view | | -| `PerpsOrderBook` | Full order book depth view | | -| `PerpsPnlHeroCard` | PnL hero card | | -| `PerpsHIP3Debug` | HIP3 debug view | | +--- -All perps route constants are in `app/constants/navigation/Routes.ts` under `Routes.PERPS`. For the full list of navigable routes, check `NESTED_ROUTE_PARENTS` in `cdp-bridge.js`. +## Error Recovery -Other useful routes: `WalletTabHome`, `SettingsView`, `DeveloperOptions`, `BrowserTabHome`. +| Symptom | Fix | +| ------------------------ | --------------------------------------------------------------------------------- | +| Metro crash / no output | `bash start-metro.sh --platform

` | +| CDP "not connected" | Check Metro running + device booted. Poll for `__AGENTIC__` (5-120s after unlock) | +| Hot reload resets app | `app-navigate.sh WalletTabHome` then target screen | +| App crash / white screen | `bash preflight.sh --platform

` | +| eval returns undefined | Use `eval-async` with `.then(function(r){ return JSON.stringify(r) })` | +| "SyntaxError" in eval | ES5 violation — check for arrow functions, const/let, template literals | +| Eval ref assertion fails | Check `eval-ref --list` for correct name; re-read the eval ref JSON | +| adb reverse lost | `adb reverse tcp:PORT tcp:PORT` | +| Route not found | Check route name in the table below; cdp-bridge handles nested routing | --- -## 6. Error Recovery +## Routes and State Paths -| Problem | Solution | -| ---------------------------- | ----------------------------------------------------------------------------------------------------- | -| Metro crash / stale PID | `scripts/perps/agentic/stop-metro.sh` then `scripts/perps/agentic/start-metro.sh` | -| CDP connection failure | Check Metro running + device booted (iOS: `xcrun simctl list devices booted`, Android: `adb devices`) | -| Hot-reload resets app | `app-navigate.sh WalletTabHome` then target screen | -| App crash | Rebuild: `yarn start:ios` or `yarn start:android`, then navigate | -| Device not running (iOS) | `xcrun simctl boot ` | -| Device not running (Android) | Start emulator from Android Studio or `emulator -avd ` | -| adb issues | Ensure `platform-tools` on PATH; try `adb kill-server && adb start-server` | -| Route not found | Check route name in Section 5; `cdp-bridge.js` handles nested routing automatically | +### Perps Routes ---- +| Route | Description | Params | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | | +| `PerpsTrendingView` | Market list (all markets) | | +| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` | +| `PerpsActivity` | Activity history | `{"redirectToPerpsTransactions":true}` | +| `PerpsClosePosition` | Close a position | | +| `PerpsTPSL` | Take-profit / stop-loss | | +| `PerpsAdjustMargin` | Adjust position margin | | +| `PerpsOrderDetailsView` | Order detail view | | +| `PerpsOrderBook` | Order book depth | | +| `PerpsWithdraw` | Withdraw funds | | +| `PerpsTutorial` | Onboarding tutorial | | -## 7. Multi-Device Support +Other useful routes: `WalletTabHome`, `SettingsView`, `DeveloperOptions`, `BrowserTabHome`. -### Platform override +### Engine Controller Paths ```bash -PLATFORM=android scripts/perps/agentic/screenshot.sh my-label -PLATFORM=ios scripts/perps/agentic/screenshot.sh my-label +Engine.context.PerpsController.state # Positions, orders, balances, config +Engine.context.NetworkController.state # Network selection +Engine.context.AccountsController.state # Accounts, selected account +Engine.context.RemoteFeatureFlagController.state # Feature flags +Engine.context.PreferencesController.state # User preferences ``` -Without `PLATFORM`, auto-detection priority: booted iOS simulator → connected Android device → default iOS. - -> `PLATFORM` only affects `screenshot.sh` (and `app-navigate.sh`'s verification screenshot). CDP-based commands (`app-state.sh`, `cdp-bridge.js`, `reload-metro.sh`) are platform-agnostic and ignore `PLATFORM`. - -### Targeting specific devices - -| Env var | Purpose | Example | -| ---------------- | ---------------------------------------------------------- | ------------------------ | -| `PLATFORM` | Force platform for screenshot + navigate (screenshot step) | `android`, `ios` | -| `IOS_SIMULATOR` | CDP target filtering by simulator name | `iPhone16Pro-Alpha` | -| `ANDROID_DEVICE` | CDP target filtering by device name | `Pixel 6a - 16 - API 36` | -| `ADB_SERIAL` | adb serial for Android screenshots | `emulator-5554` | -| `WATCHER_PORT` | Metro port (default `8081`) | `8082` | - -Set these in `.js.env` or pass as env vars. The CDP bridge filters Metro's `/json/list` targets by `deviceName` when `IOS_SIMULATOR` or `ANDROID_DEVICE` is set, ensuring commands reach the correct app instance. +### Common PerpsController Methods + +| Method | Returns | Description | +| --------------------------- | ----------------------- | ------------------------ | +| `getPositions()` | `Promise` | Open positions | +| `getAccountState()` | `Promise` | Balances, margin | +| `getMarketDataWithPrices()` | `Promise` | Markets with live prices | +| `getOpenOrders()` | `Promise` | Active limit/stop orders | +| `getTradeConfiguration()` | `Promise` | Leverage limits, fees | +| `placeOrder(params)` | `Promise` | Submit an order | +| `closePosition({symbol})` | `Promise` | Close by symbol | diff --git a/docs/perps/agentic-scripts-quickref.md b/docs/perps/perps-agentic-scripts-quickref.md similarity index 65% rename from docs/perps/agentic-scripts-quickref.md rename to docs/perps/perps-agentic-scripts-quickref.md index d9c3d7f34b0b..2e59a61cbec2 100644 --- a/docs/perps/agentic-scripts-quickref.md +++ b/docs/perps/perps-agentic-scripts-quickref.md @@ -25,6 +25,36 @@ 1. `.js.env` must have `WATCHER_PORT`, `IOS_SIMULATOR`, `SIM_UDID` (iOS) or `ANDROID_DEVICE` (Android) 2. `.agent/wallet-fixture.json` must exist (copy from `scripts/perps/agentic/wallet-fixture.example.json`) +## Flows + +Flows are parameterized JSON test sequences in `scripts/perps/agentic/teams//flows/`. + +```bash +# List all flows +ls scripts/perps/agentic/teams/perps/flows/*.json + +# Run a flow +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/flows/market-discovery.json --skip-manual + +# Dry-run (prints steps, no execution) +bash scripts/perps/agentic/validate-recipe.sh \ + scripts/perps/agentic/teams/perps/flows/trade-open-market.json --dry-run + +# Run all flows (dry-run) +for f in scripts/perps/agentic/teams/perps/flows/*.json; do + bash scripts/perps/agentic/validate-recipe.sh "$f" --dry-run --skip-manual +done +``` + +### Parameter Passing + +Flows use `{{param}}` tokens. Defaults are declared in the flow's `inputs` block. Override via `flow_ref` params or by editing the JSON. + +### Pre-Conditions + +Flows can declare `pre_conditions` — named checks that must pass before steps run. If a check fails, the runner aborts with a hint. Available pre-conditions are registered in `teams/perps/pre-conditions.js`. + ## CDP Bridge Commands ```bash @@ -41,8 +71,9 @@ $CDP press-test-id # Press component by testID $CDP scroll-view --test-id # Scroll a ScrollView/FlatList $CDP list-accounts # All accounts $CDP switch-account

# Switch active account -$CDP recipe perps/positions # Run a named recipe -$CDP recipe --list # List all recipes +$CDP eval-ref perps/positions # Run a named eval ref +$CDP eval-ref --list # List all eval refs +$CDP check-pre-conditions '' # Validate pre-conditions ``` ## Other Scripts @@ -54,6 +85,8 @@ scripts/perps/agentic/screenshot.sh # Capture simulator screensh scripts/perps/agentic/setup-wallet.sh # Seed wallet via CDP scripts/perps/agentic/unlock-wallet.sh # Unlock via CDP scripts/perps/agentic/validate-recipe.sh # Run PR recipe folder +scripts/perps/agentic/validate-flow-schema.js # Validate flow authoring rules +scripts/perps/agentic/validate-pre-conditions.js # Validate pre-condition registry ``` ## Architecture @@ -68,11 +101,6 @@ CDP Bridge (cdp-bridge.js) --> reads globalThis.__AGENTIC__.* ``` -## Worktree Mapping +## Worktree / Multi-Device Mapping -| Worktree | Simulator | Port | -| -------- | ----------------- | ---- | -| alpha | iPhone16Pro-Alpha | 8085 | -| beta | iPhone16Pro-Beta | 8084 | -| gamma | iPhone16Pro-Gamma | 8083 | -| delta | iPhone16Pro-Delta | 8082 | +Ports are set per-slot via `.js.env` `WATCHER_PORT`. When both iOS and Android devices are connected, set `PLATFORM=android` or `PLATFORM=ios` to disambiguate screenshot targets. CDP commands are platform-agnostic. diff --git a/docs/perps/perps-agentic-system-design.md b/docs/perps/perps-agentic-system-design.md new file mode 100644 index 000000000000..e1b56c531d60 --- /dev/null +++ b/docs/perps/perps-agentic-system-design.md @@ -0,0 +1,295 @@ +# Agentic System Design + +The agentic toolkit is a system that lets AI agents write code, verify it against a running +app, and iterate — all without human intervention. It provides a fast, local feedback loop: +the agent gets signal in seconds from a live app instead of waiting for heavyweight test +frameworks. It complements E2E tests (Detox) and CI — it doesn't replace them. It's built +on three pillars. + +The toolkit was built by the perps team but designed for any team in MetaMask Mobile. The +infrastructure (`scripts/perps/agentic/teams/`) auto-discovers team directories — any team +can add flows, recipes, and pre-conditions without modifying shared code. + +--- + +## The Three Pillars + +1. **Wallet Fixtures & Preflight** — Get to a known state in seconds, not minutes +2. **Recipe & Flow System** — Parameterized, composable, deterministic test sequences +3. **CDP Instrumentation** — Direct app access via Chrome DevTools Protocol, no vision model needed + +These aren't independent tools. They form a flywheel: + +``` +Wallet Fixtures ──→ Known State ──→ Recipes execute deterministically + ↑ │ + └──── Clean state for next iteration ←────┘ + ↑ + CDP: text-based assertions + (no screenshots, no vision tokens) +``` + +--- + +## Pillar 1: Wallet Fixtures & Preflight + +### The problem + +A fresh MetaMask wallet requires ~15 manual steps to reach a usable state: create wallet, +back up seed phrase, dismiss onboarding modals, import trading accounts, enable feature +flags, suppress consent screens, navigate to the target feature. An E2E test takes 2-5 +minutes for this. An agent doing it via UI automation burns tokens on every step and hits +flaky modal dismissals along the way. + +### The solution + +`wallet-fixture.json` defines the desired wallet state declaratively — password, accounts +(mnemonic or private key), and settings that suppress friction: + +```json +{ + "password": "...", + "accounts": [ + { "type": "mnemonic", "value": "twelve word seed ..." }, + { "type": "privateKey", "value": "0xabc...", "name": "Trading" } + ], + "settings": { + "metametrics": false, + "skipGtmModals": true, + "skipPerpsTutorial": true, + "autoLockNever": true + } +} +``` + +`setup-wallet.sh` reads this fixture and calls `__AGENTIC__.setupWallet(fixture)` — a single +CDP eval that restores the wallet, imports accounts, dispatches all onboarding flags, +suppresses modals, and navigates to wallet home. Pure JS execution, no UI navigation, no +modal handling, no screenshot verification. + +`preflight.sh` orchestrates the full environment pipeline: + +| Scenario | What runs | Time | +| ---------------------------------- | ----------------------------------- | ------- | +| Cold start (first time) | build + boot + Metro + CDP + wallet | ~150s | +| Warm start (Metro running) | boot device + CDP + wallet | ~10-20s | +| Hot iteration (everything running) | wallet restore if needed | ~2-5s | + +**Key insight: isolation.** Each agent run starts from a known wallet state. No leaking +state between iterations. The fixture is the contract — deterministic input produces +deterministic starting point. + +--- + +## Pillar 2: Recipe & Flow System + +### The problem + +E2E tests (Detox) take 90-300 seconds per test, run serially, and produce failures that +require screenshots to diagnose. CI on GitHub can take up to 20 minutes per push. These +tools remain essential for final validation, but an agent iterating on a fix needs faster +signal during development. + +### The solution + +JSON-based recipes and flows executed via `validate-recipe.sh`, organized by team under +`scripts/perps/agentic/teams/`. Each team directory follows the same structure: + +- `teams//flows/` — flow JSONs validated by `validate-flow-schema.js` +- `teams//evals.json` — quick eval refs (e.g. `perps/positions`, `swap/quote-status`) +- `teams//evals/` — named eval ref collections +- `teams//pre-conditions.js` — namespaced checks (e.g. `perps.ready_to_trade`, `swap.has_valid_quote`) + +`lib/registry.js` auto-discovers all team directories and merges their pre-conditions at load +time. Duplicate keys across teams cause a load-time error — namespace enforcement by convention. +A new team adds a directory and immediately gets access to all shared infrastructure. + +**Recipes** are single CDP eval expressions — state snapshots that run in <1 second. +The path `/` is the team boundary — `eval-ref perps/positions` is a perps team +eval ref, `eval-ref swap/quote-status` would be a swap team eval ref. + +**Flows** are multi-step UI sequences — navigate, press, type, wait, assert. They run in +10-30 seconds. Parameterized with `{{symbol}}`, composable via `flow_ref` and `eval_ref`. + +| Dimension | E2E (Detox) | Recipes/Flows | +| ------------- | --------------------------- | ----------------------------------------- | +| Speed | 90-300s/test | 1-30s/flow | +| Flakiness | High (animations, timing) | Low (explicit waits, direct fiber access) | +| Output | Screenshots (vision tokens) | JSON text (cheap) | +| Composability | Copy entire test files | `flow_ref` + `eval_ref` + params | + +Flows declare their requirements via pre-conditions. If the wallet isn't unlocked or no +position exists, the runner aborts with a clear error before wasting time on doomed steps. + +### Recipes are the agent's eyes + +Instead of "take a screenshot and look at it" (thousands of vision tokens), the agent runs +`recipe perps/positions` and gets structured JSON back. The assertion system (`eq`, `gt`, +`length_gt`, `contains`) lets the agent verify state without seeing the screen. One recipe +call costs one tool invocation. One screenshot costs a vision model call plus the tokens +to describe what's in the image. + +### Recipes are proof + +When an agent fixes a bug, it writes a recipe that reproduces the bug (assertion fails), +applies the fix, re-runs the recipe (assertion passes). The recipe IS the proof. It goes +into the PR as `recipe.json` — reviewers can re-run it to verify. The same recipe becomes +a regression check for future changes. + +--- + +## Pillar 3: CDP Instrumentation + +### The problem + +Traditional mobile test automation requires either a native framework (Detox, Appium) with +heavy setup, or coordinate-based tapping that breaks on layout changes. + +### The solution + +The `__AGENTIC__` bridge on `globalThis`, installed by `AgenticService.ts` in `__DEV__` +mode when NavigationService sets its ref. It exposes: + +- **Navigation**: `navigate()`, `getRoute()`, `canGoBack()`, `goBack()` +- **Accounts**: `listAccounts()`, `getSelectedAccount()`, `switchAccount()` +- **UI interaction**: `pressTestId()`, `scrollView()`, `setInput()` +- **Setup**: `setupWallet()` (the 11-step initialization from Pillar 1) + +`pressTestId` walks the React fiber tree via `__REACT_DEVTOOLS_GLOBAL_HOOK__` to find the +component with a matching `testID` prop and calls its `onPress` handler directly. No +coordinates, no image recognition, no screenshots. Same for `setInput` (calls +`onChangeText`) and `scrollView` (calls `scrollTo` on the nearest scrollable ancestor). + +`cdp-bridge.js` connects via Metro's Hermes WebSocket — same protocol on iOS and Android. +Everything returns structured JSON. + +**Key insight: the bridge turns the running app into an API.** Instead of "look at the +screen, find the button, tap at coordinates", the agent says +`press perps-market-details-long-button`. Instead of "take a screenshot to check what +screen we're on", the agent evaluates `getRoute().name` and gets `"PerpsMarketDetails"` +as a string. + +--- + +## The Flywheel: How It All Connects + +### Agent development cycle + +1. Agent gets a task (bug fix, new feature, PR review) +2. Preflight restores wallet to known state (~2-5s warm) +3. Agent reads code, understands the problem +4. Agent writes a recipe that reproduces the bug (assertion fails) +5. Agent fixes the code +6. Metro hot-reloads (~2s) +7. Agent re-runs the recipe (assertion passes) — **sub-minute verification** +8. Agent commits fix + recipe as PR evidence + +**Without the toolkit:** the agent's fastest feedback is Detox (90-300s per test) or pushing +to CI (up to 20 minutes). Screenshots require vision models (expensive, fragile). + +**With the toolkit:** the agent verifies locally against a running app. Metro auto-reloads +on save (HMR for React changes is instantaneous), and feedback comes back as text. + +### Feedback channels — cheapest to most expensive + +The toolkit provides multiple feedback layers. In practice, ~95% of verification uses the +cheapest one: + +1. **DevLogger + grep (primary)** — Drop a tagged log line in any render path or hook, save + the file, Metro hot-reloads instantly, grep the Metro log for your tag. One log line + + one grep = instant signal about what the UI is actually doing. Works for state bugs, race + conditions, render order, data flow — anything where you need to know _what happened_, not + _what it looks like_. Zero vision tokens, near-zero cost. +2. **CDP eval / recipes** — Query app state directly via `__AGENTIC__` bridge. Returns + structured JSON. Use when you need to assert on controller state, position data, or + any value the UI consumes. Cheap but each call is a tool invocation. +3. **Screenshots** — Capture the screen for visual feedback. Use when implementing from a + design reference and comparing against designer mockups. Triggers a vision model call — + reserve for cases where visual appearance is what you're verifying. +4. **System logs (logcat / Console.app)** — For native module work (Objective-C, Java/Kotlin). + Rare on MetaMask Mobile since most code is JS/TS in the React Native layer. + +**Rule of thumb:** if you can verify with a log line, don't take a screenshot. If you can +verify with a recipe, don't write custom CDP eval. Always start at level 1. + +### HUD overlay — making videos reviewable + +Agents produce video recordings as PR evidence, but raw video of an app being tapped by +an invisible hand is hard for human reviewers to follow. The **Agent Step HUD** +(`AgentStepHud.tsx`) solves this by rendering a persistent on-screen overlay during recipe +execution that shows the current step ID, description, and action type. + +The HUD is enabled by default. Use `--no-hud` to disable it. Before each step executes, +the runner sends the step metadata to the app via CDP eval, and `AgentStepHud` renders it +as an overlay banner. The HUD propagates through `flow_ref` sub-invocations +automatically, so nested flow steps are annotated too. + +This turns an opaque screen recording into a narrated walkthrough: reviewers see exactly +what the agent is testing at each moment, which assertion is running, and what the +expected outcome is — without needing to cross-reference the recipe JSON. The result is a +tighter feedback loop between autonomous agents and human reviewers: the video itself +communicates intent. + +### The compounding effect + +- Wallet fixtures make recipes deterministic (known starting state) +- Recipes make bug fixes provable (assertion = proof) +- CDP instrumentation makes recipes cheap (text, not vision) +- Pre-conditions catch stale state early (fail fast with hints) +- `flow_ref` lets agents compose complex scenarios from simple building blocks +- Each recipe written for one PR becomes reusable regression for future PRs + +### Beyond single agents + +The toolkit is designed to be consumed by autonomous orchestration systems. The orchestrator +dispatches tasks using **workflow templates** (bug fix, PR review, feature dev) that are +project-scoped, not team-scoped. An outer orchestrator can: + +1. **Dispatch tasks** — assign a Jira ticket to an agent with a worker template +2. **Prepare the environment** — run `preflight.sh` to get the slot ready +3. **Monitor progress** — poll the task file for status transitions +4. **Validate results** — re-run the agent's recipe to confirm the fix independently +5. **Scale horizontally** — run multiple agents in parallel worktrees, each with its own + `WATCHER_PORT`, device, and wallet fixture + +The worker template injects team-specific context (which flows to run, which pre-conditions +to check) via template variables — different teams have different flow libraries but share +the same preflight, CDP bridge, recipe runner, and assertion engine. + +This works because the toolkit's contracts are stable: fixtures produce known state, recipes +produce JSON assertions, CDP returns structured data. An orchestrator just prepares the +environment and lets the agent use the toolkit's primitives. + +--- + +## Practical Example: Bug Fix Workflow + +Here's a concrete example from the perps team — the first adopter. The same pattern +applies to any team's flows. + +An agent is assigned: "TP/SL values don't persist after edit." + +1. **Preflight** — wallet restored with funds on testnet (~5s) +2. **`flow_ref: trade-open-market`** — opens a BTC long position ($10) +3. **`flow_ref: tpsl-create`** — sets initial TP/SL using percentage presets (TP +25%, SL -10%) +4. **Recipe: read TP/SL** — `recipe perps/core/tpsl-orders` → assert TP/SL orders exist (PASS) +5. **`flow_ref: tpsl-edit`** — changes TP/SL presets (TP +50%, SL -25%) +6. **Recipe: read TP/SL** — assert updated TP/SL values (FAIL — bug confirmed, still shows old values) +7. **Agent reads code** — finds stale cache in the edit handler, fixes it +8. **Hot-reload** — Metro picks up changes (~2s) +9. **Re-run steps 5-6** — assert updated TP/SL values (PASS — fix verified) +10. **Recipe goes into PR** as `recipe.json` — reviewer runs `validate-recipe.sh` to verify + +Total time from bug confirmation to verified fix: under 3 minutes of agent wall time. +The recipe.json is the test, the reproduction, and the proof — all in one file. + +--- + +## Cross-Reference + +- `docs/perps/perps-agentic-feedback-loop.md` — full reference for all commands, actions, + routes, and pre-conditions +- `docs/perps/agentic-scripts-quickref.md` — cheat sheet for daily use +- `scripts/perps/agentic/schemas/flow.schema.json` — formal flow specification +- `scripts/perps/agentic/teams/README.md` — contribution guide for adding a new team +- `app/core/AgenticService/AgenticService.ts` — bridge implementation diff --git a/package.json b/package.json index 364ea1b7849c..a7c82bb1bb8b 100644 --- a/package.json +++ b/package.json @@ -207,11 +207,11 @@ "@metamask/account-tree-controller": "^5.0.0", "@metamask/accounts-controller": "^37.0.0", "@metamask/address-book-controller": "^7.1.0", - "@metamask/ai-controllers": "^0.4.0", + "@metamask/ai-controllers": "^0.5.0", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", - "@metamask/assets-controller": "^2.3.0", + "@metamask/assets-controller": "^3.0.0", "@metamask/assets-controllers": "^101.0.1", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", @@ -221,7 +221,7 @@ "@metamask/compliance-controller": "^1.0.1", "@metamask/connectivity-controller": "^0.1.0", "@metamask/controller-utils": "^11.18.0", - "@metamask/core-backend": "^5.0.0", + "@metamask/core-backend": "^6.2.0", "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^0.15.0", "@metamask/design-system-react-native": "^0.10.0", diff --git a/scripts/perps/agentic/README.md b/scripts/perps/agentic/README.md new file mode 100644 index 000000000000..9a3293e0c982 --- /dev/null +++ b/scripts/perps/agentic/README.md @@ -0,0 +1,43 @@ +# Agentic Scripts + +This directory contains the agentic automation toolkit: CDP bridge, recipe runner, +flow validator, and per-team data (flows, recipes, snippets, pre-conditions). + +## Scope — perps-first, org-wide intent + +The tooling currently lives under `scripts/perps/` and code ownership sits with +`@MetaMask/perps`. This is intentional: the perps team is building and validating +the pattern in a single team context to allow fast iteration without requiring +cross-team code ownership review on every change. + +Once the toolkit is proven — stable CLI API, documented conventions, validator +suite passing — the intent is to promote it to a shared location (e.g. +`scripts/agentic/`) owned by the broader MetaMask mobile organisation, with each +product team owning their slice under `teams//`. + +**Do not let the current path mislead you.** The infrastructure in `lib/`, +`validate-flow-schema.js`, `validate-pre-conditions.js`, `cdp-bridge.js`, and the +`teams/` layout is deliberately generic and team-agnostic. + +## Directory layout + +``` +agentic/ + cdp-bridge.js — CDP client: eval, navigate, eval-ref, press, scroll, … + validate-recipe.sh — Run a flow JSON against the live app + validate-flow-schema.js — Offline: enforce flow authoring rules + validate-pre-conditions.js — Offline: verify pre-condition assertion logic + lib/ + assert.js — Shared assertion evaluator + registry.js — Auto-discovers and merges all team pre-conditions + teams/ + perps/ — Perps team flows, evals, pre-conditions + mobile-platform/ — Mobile platform team pre-conditions (placeholder) + / — Add your team here (see teams/README.md) + app-state.sh — Convenience wrapper: status, eval, press, eval-ref, … + app-navigate.sh — Navigate to any registered screen + screenshot.sh — Capture simulator/device screenshot + start-metro.sh — Start Metro bundler for a given platform/slot +``` + +See `teams/README.md` for how to add a new team. diff --git a/scripts/perps/agentic/app-state.sh b/scripts/perps/agentic/app-state.sh index 92d81e2f727e..69dc5c33aca1 100755 --- a/scripts/perps/agentic/app-state.sh +++ b/scripts/perps/agentic/app-state.sh @@ -17,8 +17,8 @@ # scripts/perps/agentic/app-state.sh press # Press component by testID # scripts/perps/agentic/app-state.sh scroll [--test-id ] [--offset ] # Scroll # scripts/perps/agentic/app-state.sh set-input # Set text input value -# scripts/perps/agentic/app-state.sh recipe perps/positions # Run a recipe -# scripts/perps/agentic/app-state.sh recipe --list # List recipes +# scripts/perps/agentic/app-state.sh eval-ref perps/positions # Run an eval ref +# scripts/perps/agentic/app-state.sh eval-ref --list # List eval refs set -euo pipefail @@ -76,8 +76,8 @@ case "$COMMAND" in unlock) node scripts/perps/agentic/cdp-bridge.js unlock "$@" ;; - recipe) - node scripts/perps/agentic/cdp-bridge.js recipe "$@" + eval-ref) + node scripts/perps/agentic/cdp-bridge.js eval-ref "$@" ;; *) echo "Usage: app-state.sh [args...]" @@ -100,8 +100,8 @@ case "$COMMAND" in echo " set-input Set text input value by testID" echo " sentry-debug [enable|disable] Patch Sentry to log errors to console" echo " unlock Unlock wallet via fiber tree" - echo " recipe Run a recipe (e.g. perps/positions)" - echo " recipe --list List all available recipes" + echo " eval-ref Run an eval ref (e.g. perps/positions)" + echo " eval-ref --list List all available eval refs" exit 1 ;; esac diff --git a/scripts/perps/agentic/cdp-bridge.js b/scripts/perps/agentic/cdp-bridge.js index 7b96f90480ea..c9966d4e47f3 100644 --- a/scripts/perps/agentic/cdp-bridge.js +++ b/scripts/perps/agentic/cdp-bridge.js @@ -16,375 +16,23 @@ 'use strict'; -const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Read a value from .js.env */ -function loadEnvValue(key) { - try { - const envPath = path.resolve(__dirname, '../../../.js.env'); - const content = fs.readFileSync(envPath, 'utf8'); - // .js.env uses `export KEY="value"` (shell-sourceable format), - // so we handle the optional `export` prefix and strip surrounding quotes. - const match = content.match(new RegExp(`^(?:export\\s+)?${key}=(.+)$`, 'm')); - if (match) return match[1].trim().replace(/^["']|["']$/g, ''); - } catch { - // .js.env may not exist — fall through to undefined - } - return undefined; -} - -/** Read WATCHER_PORT from .js.env or env (default: 8081) */ -function loadPort() { - return Number.parseInt(process.env.WATCHER_PORT || loadEnvValue('WATCHER_PORT') || '8081', 10); -} - -/** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */ -function loadSimulatorName() { - return process.env.IOS_SIMULATOR || loadEnvValue('IOS_SIMULATOR') || ''; -} - -/** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */ -function loadAndroidDevice() { - return process.env.ANDROID_DEVICE || loadEnvValue('ANDROID_DEVICE') || ''; -} - -/** Fetch JSON from a URL (http only, no external deps) */ -function fetchJSON(url) { - return new Promise((resolve, reject) => { - const req = http.get(url, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch (e) { - reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`)); - } - }); - }); - req.on('error', reject); - req.setTimeout(5000, () => { - req.destroy(); - reject(new Error(`Timeout fetching ${url}`)); - }); - }); -} - -/** - * Quick probe: connect to a CDP target, evaluate `__DEV__`, disconnect. - * Returns true if __DEV__ is true, false otherwise. - */ -async function probeTarget(wsUrl) { - try { - const client = await createWSClient(wsUrl, 3000); - try { - const result = await client.send('Runtime.evaluate', { - expression: '(function(){ return typeof globalThis.__AGENTIC__; })()', - returnByValue: true, - awaitPromise: false, - }); - return result?.result?.value === 'object'; - } finally { - client.close(); - } - } catch { - // Connection failed — target is not the right one - return false; - } -} - -/** - * Discover the Hermes CDP WebSocket URL from Metro's /json/list endpoint. - * - * Multi-simulator support: - * - When IOS_SIMULATOR is set, filters targets by deviceName - * - With Hermes bridgeless mode, there are multiple pages per device: - * page 1 = native C++ runtime, page 2+ = JS runtime (where __AGENTIC__ lives) - * - We probe candidates to find the one with __AGENTIC__ installed - */ -async function discoverTarget(port) { - const listUrl = `http://localhost:${port}/json/list`; - let targets; - try { - targets = await fetchJSON(listUrl); - } catch (e) { - throw new Error( - `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`, - ); - } - - if (!Array.isArray(targets) || targets.length === 0) { - throw new Error(`No debug targets found at ${listUrl}`); - } - - // Filter to React Native / Hermes targets with a WebSocket URL - let candidates = targets.filter( - (t) => - t.webSocketDebuggerUrl && - t.title && - (/react/i.test(t.title) || /hermes/i.test(t.title)), - ); - - // Filter by device name if IOS_SIMULATOR is set - const simName = loadSimulatorName(); - if (simName && candidates.length > 1) { - const deviceFiltered = candidates.filter( - (t) => t.deviceName === simName, - ); - if (deviceFiltered.length > 0) { - candidates = deviceFiltered; - } - } - - // Filter by device name if ANDROID_DEVICE is set - const androidDevice = loadAndroidDevice(); - if (androidDevice && candidates.length > 1) { - const deviceFiltered = candidates.filter( - (t) => t.deviceName === androidDevice, - ); - if (deviceFiltered.length > 0) { - candidates = deviceFiltered; - } - } - - if (candidates.length === 0) { - candidates = targets.filter((t) => t.webSocketDebuggerUrl); - } - - if (candidates.length === 0) { - throw new Error( - `No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`, - ); - } - - // Sort by page number descending (JS runtime has higher page number than C++ native) - candidates.sort((a, b) => { - const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10); - const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10); - return bPage - aPage; - }); - - // Probe candidates to find the one with __AGENTIC__ installed (the JS runtime) - for (const candidate of candidates) { - const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl); - if (hasAgentic) { - return { wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' }; - } - } - - // Fallback: return highest page number (most likely the JS runtime) - return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' }; -} - -/** - * Minimal CDP client using the built-in ws-like interface over raw WebSocket. - * Node 22+ has a built-in WebSocket; for older versions we use the ws package - * that ships with React Native / Metro dev dependencies. - */ -function createWSClient(wsUrl, timeout) { - return new Promise((resolve, reject) => { - let WebSocketImpl; - // Node 22+ has globalThis.WebSocket - if (typeof globalThis.WebSocket === 'function') { - WebSocketImpl = globalThis.WebSocket; - } else { - try { - // Dynamic require avoids depcheck static analysis — ws is an optional - // fallback for Node < 22 which has no built-in WebSocket. - const wsModule = 'ws'; - WebSocketImpl = require(wsModule); - } catch { - throw new Error( - 'WebSocket not available. Install "ws" package or use Node >= 22.', - ); - } - } - - const ws = new WebSocketImpl(wsUrl); - let msgId = 0; - const pending = new Map(); - - const timer = setTimeout(() => { - ws.close(); - reject(new Error(`CDP connection timeout after ${timeout}ms`)); - }, timeout); - - ws.onopen = () => { - clearTimeout(timer); - resolve({ - /** Send a CDP command and wait for the response */ - send(method, params = {}, msgTimeout = timeout) { - return new Promise((res, rej) => { - const id = ++msgId; - const timer = setTimeout(() => { - pending.delete(id); - rej( - new Error( - `CDP message timeout after ${msgTimeout}ms for ${method}`, - ), - ); - }, msgTimeout); - pending.set(id, { - resolve: (v) => { - clearTimeout(timer); - res(v); - }, - reject: (e) => { - clearTimeout(timer); - rej(e); - }, - }); - const msg = JSON.stringify({ id, method, params }); - ws.send(msg); - }); - }, - close() { - ws.close(); - }, - }); - }; - - ws.onmessage = (evt) => { - const data = typeof evt.data === 'string' ? evt.data : evt.data.toString(); - let msg; - try { - msg = JSON.parse(data); - } catch { - // Non-JSON frame — ignore - return; - } - if (msg.id && pending.has(msg.id)) { - const { resolve: res, reject: rej } = pending.get(msg.id); - pending.delete(msg.id); - if (msg.error) { - rej(new Error(`CDP error: ${JSON.stringify(msg.error)}`)); - } else { - res(msg.result); - } - } - }; - - ws.onerror = (err) => { - clearTimeout(timer); - reject(new Error(`WebSocket error: ${err.message || err}`)); - }; - - ws.onclose = () => { - clearTimeout(timer); - for (const [, { reject: rej }] of pending) { - rej(new Error('WebSocket closed')); - } - pending.clear(); - }; - }); -} - -/** - * Evaluate a JS expression in the app's Hermes runtime via CDP Runtime.evaluate. - * Returns the evaluated value (primitives and JSON-serialisable objects). - */ -async function cdpEval(client, expression) { - // Hermes doesn't support async in Runtime.evaluate, use a plain IIFE - const wrapped = `(function() { return (${expression}); })()`; - const result = await client.send('Runtime.evaluate', { - expression: wrapped, - returnByValue: true, - awaitPromise: false, - generatePreview: false, - }); - - if (result.exceptionDetails) { - const desc = - result.exceptionDetails.exception?.description || - result.exceptionDetails.text || - JSON.stringify(result.exceptionDetails); - throw new Error(`Evaluation error: ${desc}`); - } - - return result.result?.value; -} - -/** - * Evaluate a JS expression that returns a Promise. - * Hermes CDP doesn't support awaitPromise, so we store the result on - * globalThis.__cdp_async__ and poll for it. - */ -async function cdpEvalAsync(client, expression, timeoutMs = 30000) { - // Unique key per call to avoid collisions - const key = `__cdp_async_${Date.now()}_${Math.random().toString(36).slice(2)}__`; - - // Kick off the promise, store result when done. - // The try/catch guards against synchronous throws during argument evaluation - // of Promise.resolve() — without it, a sync error escapes the - // IIFE and globalThis[key] stays 'pending' forever. - const kickoff = `(function() { - globalThis['${key}'] = { status: 'pending' }; - try { - Promise.resolve(${expression}) - .then(function(v) { globalThis['${key}'] = { status: 'resolved', value: v }; }) - .catch(function(e) { globalThis['${key}'] = { status: 'rejected', error: String(e) }; }); - } catch(e) { - globalThis['${key}'] = { status: 'rejected', error: String(e) }; - } - return 'started'; - })()`; - - const kickoffResult = await client.send('Runtime.evaluate', { - expression: kickoff, - returnByValue: true, - awaitPromise: false, - }, timeoutMs); - - // If the IIFE itself failed to evaluate (syntax error, etc.), bail early - if (kickoffResult.exceptionDetails) { - const desc = - kickoffResult.exceptionDetails.exception?.description || - kickoffResult.exceptionDetails.text || - JSON.stringify(kickoffResult.exceptionDetails); - throw new Error(`Async evaluation error: ${desc}`); - } - - // Best-effort cleanup — swallow errors so a disconnected WebSocket - // doesn't obscure the actual result or diagnostic error. - const cleanup = () => - client - .send('Runtime.evaluate', { - expression: `delete globalThis['${key}']`, - returnByValue: true, - awaitPromise: false, - }) - // eslint-disable-next-line no-empty-function - .catch(() => {}); - - // Poll for completion - const pollInterval = 200; - const deadline = Date.now() + timeoutMs; - try { - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, pollInterval)); - const check = await client.send('Runtime.evaluate', { - expression: `(function() { return globalThis['${key}']; })()`, - returnByValue: true, - awaitPromise: false, - }, timeoutMs); - const val = check.result?.value; - if (val && val.status === 'resolved') { - return val.value; - } - if (val && val.status === 'rejected') { - throw new Error(`Async evaluation error: ${val.error}`); - } - } - throw new Error(`Async evaluation timed out after ${timeoutMs}ms`); - } finally { - await cleanup(); - } +const PRE_CONDITIONS = require('./lib/registry'); +const { loadPort } = require('./lib/config'); +const { discoverTarget } = require('./lib/target-discovery'); +const { createWSClient } = require('./lib/ws-client'); +const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval'); +const { checkAssert } = require('./lib/assert'); + +async function evalSpec(client, entry, params) { + const expr = typeof entry.expression === 'function' ? entry.expression(params) : entry.expression; + let raw = entry.async + ? await cdpEvalAsync(client, expr) + : await cdpEval(client, expr); + if (raw === undefined || raw === null) raw = 'null'; + if (typeof raw !== 'string') raw = JSON.stringify(raw); + return raw; } // --------------------------------------------------------------------------- @@ -542,7 +190,7 @@ const COMMANDS = { return { route: route, account: account }; })()`; const snapshot = await cdpEval(client, expr); - return Object.assign({}, snapshot, { deviceName: deviceName || '', platform: platform || '' }); + return { ...snapshot, deviceName: deviceName || '', platform: platform || '' }; }, async 'list-accounts'(client) { @@ -818,7 +466,7 @@ const COMMANDS = { })()`; const injectResult = await cdpEval(client, injectExpr); - if (!injectResult || !injectResult.ok) { + if (!injectResult?.ok) { return { ok: false, error: injectResult?.error || 'Password injection failed', deviceName }; } @@ -847,74 +495,156 @@ const COMMANDS = { const pressResult = await cdpEval(client, pressExpr); return { - ok: (pressResult && pressResult.ok) || false, + ok: pressResult?.ok ?? false, injected: true, - pressed: (pressResult && pressResult.ok) || false, + pressed: pressResult?.ok ?? false, error: pressResult?.error, deviceName, }; }, - async recipe(client, args) { + async 'check-pre-conditions'(client, args) { + const specsJson = args[0]; + if (!specsJson) throw new Error('Usage: check-pre-conditions '); + + let specs; + try { + specs = JSON.parse(specsJson); + } catch (e) { + throw new Error(`Invalid specs JSON: ${e.message}`); + } + if (!Array.isArray(specs) || specs.length === 0) return { ok: true, checked: 0 }; + + const failures = []; + + for (const spec of specs) { + const name = typeof spec === 'string' ? spec : spec.name; + const params = typeof spec === 'object' ? spec : {}; + const entry = PRE_CONDITIONS[name]; + + if (!entry) { + failures.push({ name, error: `Unknown pre-condition "${name}". Check pre-conditions.js for valid names.` }); + continue; + } + + let raw; + try { + raw = await evalSpec(client, entry, params); + } catch (e) { + failures.push({ name, description: entry.description, error: `Eval failed: ${e.message}`, hint: entry.hint }); + continue; + } + + const passed = checkAssert(raw, entry.assert); + if (!passed) { + failures.push({ name, description: entry.description, got: raw, hint: entry.hint }); + } + } + + const ok = failures.length === 0; + return { ok, checked: specs.length, failures: ok ? [] : failures }; + }, + + async 'show-step'(client, args) { + const stepId = args[0] || ''; + const description = args.slice(1).join(' '); + const payload = JSON.stringify({ id: stepId, description }); + await cdpEval(client, `globalThis.__AGENTIC__?.showStep && globalThis.__AGENTIC__.showStep(${payload})`); + return { ok: true }; + }, + + async 'hide-step'(client) { + await cdpEval(client, `globalThis.__AGENTIC__?.hideStep && globalThis.__AGENTIC__.hideStep()`); + return { ok: true }; + }, + + async 'eval-ref'(client, args) { const arg = args[0]; if (!arg || arg === '--help') { - console.error('Usage: recipe | recipe --list'); + console.error('Usage: eval-ref | eval-ref --list'); process.exit(1); } - const recipesDir = path.resolve(__dirname, 'recipes'); + const teamsDir = path.resolve(__dirname, 'teams'); if (arg === '--list') { - // List all available recipes from recipes/*.json - const files = fs.readdirSync(recipesDir).filter((f) => f.endsWith('.json')); - const all = {}; - for (const file of files) { - const team = path.basename(file, '.json'); - const data = JSON.parse(fs.readFileSync(path.join(recipesDir, file), 'utf8')); - all[team] = Object.fromEntries( - Object.entries(data).map(([name, r]) => [name, r.description || '']) - ); - } - return all; + return listEvalRefs(teamsDir); } - // Parse "team/name" + // Parse "team/name" (2-part) or "team/subfile/name" (3-part) const parts = arg.split('/'); - if (parts.length !== 2) { - throw new Error('Recipe must be in "team/name" format (e.g. perps/positions)'); + if (parts.length < 2 || parts.length > 3) { + throw new Error('Eval ref must be "team/name" or "team/subfile/name" (e.g. perps/positions or perps/core/pump-market)'); + } + let evalFile, evalName; + if (parts.length === 3) { + const [team, subfile, name] = parts; + evalFile = path.join(teamsDir, team, 'evals', `${subfile}.json`); + evalName = name; + } else { + const [team, name] = parts; + evalFile = path.join(teamsDir, team, 'evals.json'); + evalName = name; } - const [team, name] = parts; - const recipeFile = path.join(recipesDir, `${team}.json`); - if (!fs.existsSync(recipeFile)) { - throw new Error(`No recipe file found: recipes/${team}.json`); + if (!fs.existsSync(evalFile)) { + throw new Error(`No eval file found: ${path.relative(path.dirname(teamsDir), evalFile)}`); } - const recipes = JSON.parse(fs.readFileSync(recipeFile, 'utf8')); - const recipe = recipes[name]; - if (!recipe) { - const available = Object.keys(recipes).join(', '); - throw new Error(`Recipe "${name}" not found in ${team}. Available: ${available}`); + const evals = JSON.parse(fs.readFileSync(evalFile, 'utf8')); + const entry = evals[evalName]; + if (!entry) { + const available = Object.keys(evals).join(', '); + throw new Error(`Eval ref "${evalName}" not found. Available: ${available}`); } - let raw; - if (recipe.async) { - raw = await cdpEvalAsync(client, recipe.expression); - } else { - raw = await cdpEval(client, recipe.expression); - } - // Recipe expressions typically JSON.stringify their result. + const raw = entry.async + ? await cdpEvalAsync(client, entry.expression) + : await cdpEval(client, entry.expression); + // Eval expressions typically JSON.stringify their result. // Parse it so main()'s JSON.stringify produces clean output // instead of double-encoded strings. if (typeof raw === 'string') { - try { - return JSON.parse(raw); - } catch { - // Not valid JSON — return as-is - } + try { return JSON.parse(raw); } catch { /* not JSON — return as-is */ } } return raw; }, }; +// --------------------------------------------------------------------------- +// Eval-ref helpers +// --------------------------------------------------------------------------- + +/** List all eval refs from teams//evals.json and teams//evals/*.json */ +function listEvalRefs(teamsDir) { + const all = {}; + const teamDirs = fs.readdirSync(teamsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + // Top-level evals: teams//evals.json → keyed as + for (const team of teamDirs) { + const f = path.join(teamsDir, team, 'evals.json'); + if (fs.existsSync(f)) { + const data = JSON.parse(fs.readFileSync(f, 'utf8')); + all[team] = Object.fromEntries( + Object.entries(data).map(([name, r]) => [name, r.description || '']) + ); + } + } + // Sub-collections: teams//evals/.json → keyed as / + for (const team of teamDirs) { + const evalsDir = path.join(teamsDir, team, 'evals'); + if (!fs.existsSync(evalsDir)) continue; + const subFiles = fs.readdirSync(evalsDir).filter((f) => f.endsWith('.json')); + for (const file of subFiles) { + const key = `${team}/${path.basename(file, '.json')}`; + const data = JSON.parse(fs.readFileSync(path.join(evalsDir, file), 'utf8')); + all[key] = Object.fromEntries( + Object.entries(data).map(([name, r]) => [name, r.description || '']) + ); + } + } + return all; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -947,8 +677,8 @@ Commands: set-input Set text input value by testID (calls onChangeText) sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix unlock Unlock wallet (inject password + press login button via fiber tree) - recipe Run a recipe (e.g. perps/positions) - recipe --list List all available recipes + eval-ref Run an eval ref (e.g. perps/positions) + eval-ref --list List all available eval refs Environment: WATCHER_PORT Metro port (default: 8081) @@ -965,8 +695,8 @@ Environment: process.exit(1); } - // `recipe --list` only reads local JSON files — skip CDP connection entirely. - if (command === 'recipe' && args[1] === '--list') { + // `eval-ref --list` only reads local JSON files — skip CDP connection entirely. + if (command === 'eval-ref' && args[1] === '--list') { const result = await handler(null, args.slice(1), {}); console.log(JSON.stringify(result, null, 2)); return; diff --git a/scripts/perps/agentic/lib/assert.js b/scripts/perps/agentic/lib/assert.js new file mode 100644 index 000000000000..6fb49b3d0a99 --- /dev/null +++ b/scripts/perps/agentic/lib/assert.js @@ -0,0 +1,38 @@ +'use strict'; +/** + * checkAssert — evaluate a field path and apply an assertion operator. + * Shared by cdp-bridge.js, validate-pre-conditions.js, and validate-recipe.sh. + * + * @param {string} raw Raw JSON string (or plain value) returned by an eval. + * @param {{ operator: string, field?: string, value?: unknown }} assertSpec + * @returns {boolean} + */ +function checkAssert(raw, assertSpec) { + if (!assertSpec) return true; + let parsed; + try { parsed = JSON.parse(raw); } catch (_) { parsed = raw; } + // cdp-bridge.js eval prints output JSON-encoded (strings get outer quotes). + // Unwrap one extra level so field traversal works against the real value. + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { /* keep as string */ } + } + let actual = parsed; + if (assertSpec.field) { + for (const part of assertSpec.field.split('.')) { + if (actual == null) { actual = undefined; break; } + actual = actual[part]; + } + } + const op = assertSpec.operator; + const expected = assertSpec.value; + if (op === 'not_null') return actual != null; + if (op === 'eq') return actual === expected; + if (op === 'neq') return actual !== expected; + if (op === 'gt') return typeof actual === 'number' && actual > expected; + if (op === 'length_eq') return Array.isArray(actual) ? actual.length === expected : (actual != null && actual.length === expected); + if (op === 'length_gt') return Array.isArray(actual) ? actual.length > expected : (actual != null && actual.length > expected); + if (op === 'contains') return Array.isArray(actual) ? actual.includes(expected) : (typeof actual === 'string' && actual.includes(expected)); + if (op === 'not_contains') return Array.isArray(actual) ? !actual.includes(expected) : (typeof actual !== 'string' || !actual.includes(expected)); + throw new Error('Unknown operator: ' + op); +} +module.exports = { checkAssert }; diff --git a/scripts/perps/agentic/lib/cdp-eval.js b/scripts/perps/agentic/lib/cdp-eval.js new file mode 100644 index 000000000000..480cc670040f --- /dev/null +++ b/scripts/perps/agentic/lib/cdp-eval.js @@ -0,0 +1,105 @@ +'use strict'; + +/** + * Evaluate a JS expression in the app's Hermes runtime via CDP Runtime.evaluate. + * Returns the evaluated value (primitives and JSON-serialisable objects). + */ +async function cdpEval(client, expression) { + // Hermes doesn't support async in Runtime.evaluate, use a plain IIFE + const wrapped = `(function() { return (${expression}); })()`; + const result = await client.send('Runtime.evaluate', { + expression: wrapped, + returnByValue: true, + awaitPromise: false, + generatePreview: false, + }); + + if (result.exceptionDetails) { + const desc = + result.exceptionDetails.exception?.description || + result.exceptionDetails.text || + JSON.stringify(result.exceptionDetails); + throw new Error(`Evaluation error: ${desc}`); + } + + return result.result?.value; +} + +/** + * Evaluate a JS expression that returns a Promise. + * Hermes CDP doesn't support awaitPromise, so we store the result on + * globalThis.__cdp_async__ and poll for it. + */ +async function cdpEvalAsync(client, expression, timeoutMs = 30000) { + // Unique key per call to avoid collisions + const key = `__cdp_async_${Date.now()}_${Math.random().toString(36).slice(2)}__`; + + // Kick off the promise, store result when done. + // The try/catch guards against synchronous throws during argument evaluation + // of Promise.resolve() — without it, a sync error escapes the + // IIFE and globalThis[key] stays 'pending' forever. + const kickoff = `(function() { + globalThis['${key}'] = { status: 'pending' }; + try { + Promise.resolve(${expression}) + .then(function(v) { globalThis['${key}'] = { status: 'resolved', value: v }; }) + .catch(function(e) { globalThis['${key}'] = { status: 'rejected', error: String(e) }; }); + } catch(e) { + globalThis['${key}'] = { status: 'rejected', error: String(e) }; + } + return 'started'; + })()`; + + const kickoffResult = await client.send('Runtime.evaluate', { + expression: kickoff, + returnByValue: true, + awaitPromise: false, + }, timeoutMs); + + // If the IIFE itself failed to evaluate (syntax error, etc.), bail early + if (kickoffResult.exceptionDetails) { + const desc = + kickoffResult.exceptionDetails.exception?.description || + kickoffResult.exceptionDetails.text || + JSON.stringify(kickoffResult.exceptionDetails); + throw new Error(`Async evaluation error: ${desc}`); + } + + // Best-effort cleanup — swallow errors so a disconnected WebSocket + // doesn't obscure the actual result or diagnostic error. + const cleanup = () => + client + .send('Runtime.evaluate', { + expression: `delete globalThis['${key}']`, + returnByValue: true, + awaitPromise: false, + }) + // eslint-disable-next-line no-empty-function + .catch(() => {}); + + // Poll for completion + const pollInterval = 200; + const deadline = Date.now() + timeoutMs; + try { + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollInterval)); + const check = await client.send('Runtime.evaluate', { + expression: `(function() { return globalThis['${key}']; })()`, + returnByValue: true, + awaitPromise: false, + }, timeoutMs); + const val = check.result?.value; + if (val?.status === 'resolved') { + return val.value; + } + if (val?.status === 'rejected') { + throw new Error(`Async evaluation error: ${val.error}`); + } + } + throw new Error(`Async evaluation timed out after ${timeoutMs}ms`); + } finally { + await cleanup(); + } +} + +module.exports = { cdpEval, cdpEvalAsync }; diff --git a/scripts/perps/agentic/lib/config.js b/scripts/perps/agentic/lib/config.js new file mode 100644 index 000000000000..832bebeb8a1a --- /dev/null +++ b/scripts/perps/agentic/lib/config.js @@ -0,0 +1,36 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +/** Read a value from .js.env */ +function loadEnvValue(key) { + try { + const envPath = path.resolve(__dirname, '../../../../.js.env'); + const content = fs.readFileSync(envPath, 'utf8'); + // .js.env uses `export KEY="value"` (shell-sourceable format), + // so we handle the optional `export` prefix and strip surrounding quotes. + const match = content.match(new RegExp(String.raw`^(?:export\s+)?${key}=(.+)$`, 'm')); + if (match) return match[1].trim().replace(/^["']/, '').replace(/["']$/, ''); + } catch { + // .js.env may not exist — fall through to undefined + } + return undefined; +} + +/** Read WATCHER_PORT from .js.env or env (default: 8081) */ +function loadPort() { + return Number.parseInt(process.env.WATCHER_PORT || loadEnvValue('WATCHER_PORT') || '8081', 10); +} + +/** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */ +function loadSimulatorName() { + return process.env.IOS_SIMULATOR || loadEnvValue('IOS_SIMULATOR') || ''; +} + +/** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */ +function loadAndroidDevice() { + return process.env.ANDROID_DEVICE || loadEnvValue('ANDROID_DEVICE') || ''; +} + +module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice }; diff --git a/scripts/perps/agentic/lib/registry.js b/scripts/perps/agentic/lib/registry.js new file mode 100644 index 000000000000..44a4154df983 --- /dev/null +++ b/scripts/perps/agentic/lib/registry.js @@ -0,0 +1,32 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const TEAMS_DIR = path.join(__dirname, '..', 'teams'); + +/** + * Load and merge pre-condition registries from all team directories. + * Each teams//pre-conditions.js must export a Record. + * Keys must use the team's dot-notation namespace prefix (e.g. "perps.*", "mobile-platform.*"). + * Duplicate keys across teams throw an error at load time to prevent silent overrides. + * + * @returns {Record} + */ +function loadRegistry() { + const merged = {}; + const teamDirs = fs.readdirSync(TEAMS_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + for (const team of teamDirs) { + const file = path.join(TEAMS_DIR, team, 'pre-conditions.js'); + if (!fs.existsSync(file)) continue; + const entries = require(file); + for (const key of Object.keys(entries)) { + if (merged[key]) throw new Error(`Duplicate pre-condition key "${key}" from team "${team}"`); + merged[key] = entries[key]; + } + } + return merged; +} + +module.exports = loadRegistry(); diff --git a/scripts/perps/agentic/lib/target-discovery.js b/scripts/perps/agentic/lib/target-discovery.js new file mode 100644 index 000000000000..9592a3c873fb --- /dev/null +++ b/scripts/perps/agentic/lib/target-discovery.js @@ -0,0 +1,135 @@ +'use strict'; + +const http = require('node:http'); +const { loadSimulatorName, loadAndroidDevice } = require('./config'); +const { createWSClient } = require('./ws-client'); + +/** Fetch JSON from a URL (http only, no external deps) */ +function fetchJSON(url) { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`)); + } + }); + }); + req.on('error', reject); + req.setTimeout(5000, () => { + req.destroy(); + reject(new Error(`Timeout fetching ${url}`)); + }); + }); +} + +/** + * Quick probe: connect to a CDP target, evaluate `__DEV__`, disconnect. + * Returns true if __AGENTIC__ is installed, false otherwise. + */ +async function probeTarget(wsUrl) { + try { + const client = await createWSClient(wsUrl, 3000); + try { + const result = await client.send('Runtime.evaluate', { + expression: '(function(){ return typeof globalThis.__AGENTIC__; })()', + returnByValue: true, + awaitPromise: false, + }); + return result?.result?.value === 'object'; + } finally { + client.close(); + } + } catch { + // Connection failed — target is not the right one + return false; + } +} + +/** + * Discover the Hermes CDP WebSocket URL from Metro's /json/list endpoint. + * + * Multi-simulator support: + * - When IOS_SIMULATOR is set, filters targets by deviceName + * - With Hermes bridgeless mode, there are multiple pages per device: + * page 1 = native C++ runtime, page 2+ = JS runtime (where __AGENTIC__ lives) + * - We probe candidates to find the one with __AGENTIC__ installed + */ +async function discoverTarget(port) { + const listUrl = `http://localhost:${port}/json/list`; + let targets; + try { + targets = await fetchJSON(listUrl); + } catch (e) { + throw new Error( + `Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`, + ); + } + + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error(`No debug targets found at ${listUrl}`); + } + + // Filter to React Native / Hermes targets with a WebSocket URL + let candidates = targets.filter( + (t) => + t.webSocketDebuggerUrl && + t.title && + (/react/i.test(t.title) || /hermes/i.test(t.title)), + ); + + // Filter by device name if IOS_SIMULATOR is set + const simName = loadSimulatorName(); + if (simName && candidates.length > 1) { + const deviceFiltered = candidates.filter( + (t) => t.deviceName === simName, + ); + if (deviceFiltered.length > 0) { + candidates = deviceFiltered; + } + } + + // Filter by device name if ANDROID_DEVICE is set + const androidDevice = loadAndroidDevice(); + if (androidDevice && candidates.length > 1) { + const deviceFiltered = candidates.filter( + (t) => t.deviceName === androidDevice, + ); + if (deviceFiltered.length > 0) { + candidates = deviceFiltered; + } + } + + if (candidates.length === 0) { + candidates = targets.filter((t) => t.webSocketDebuggerUrl); + } + + if (candidates.length === 0) { + throw new Error( + `No suitable debug target found. Targets:\n${JSON.stringify(targets, null, 2)}`, + ); + } + + // Sort by page number descending (JS runtime has higher page number than C++ native) + candidates.sort((a, b) => { + const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10); + const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10); + return bPage - aPage; + }); + + // Probe candidates to find the one with __AGENTIC__ installed (the JS runtime) + for (const candidate of candidates) { + const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl); + if (hasAgentic) { + return { wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' }; + } + } + + // Fallback: return highest page number (most likely the JS runtime) + return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' }; +} + +module.exports = { discoverTarget }; diff --git a/scripts/perps/agentic/lib/ws-client.js b/scripts/perps/agentic/lib/ws-client.js new file mode 100644 index 000000000000..09c87d845a19 --- /dev/null +++ b/scripts/perps/agentic/lib/ws-client.js @@ -0,0 +1,108 @@ +'use strict'; + +/* global globalThis */ + +/** + * Minimal CDP client using the built-in ws-like interface over raw WebSocket. + * Node 22+ has a built-in WebSocket; for older versions we use the ws package + * that ships with React Native / Metro dev dependencies. + */ +function createWSClient(wsUrl, timeout) { + return new Promise((resolve, reject) => { + let WebSocketImpl; + // Node 22+ has globalThis.WebSocket + if (typeof globalThis.WebSocket === 'function') { + WebSocketImpl = globalThis.WebSocket; + } else { + try { + // Dynamic require avoids depcheck static analysis — ws is an optional + // fallback for Node < 22 which has no built-in WebSocket. + const wsModule = 'ws'; + WebSocketImpl = require(wsModule); + } catch { + throw new Error( + 'WebSocket not available. Install "ws" package or use Node >= 22.', + ); + } + } + + const ws = new WebSocketImpl(wsUrl); + let msgId = 0; + const pending = new Map(); + + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`CDP connection timeout after ${timeout}ms`)); + }, timeout); + + ws.onopen = () => { + clearTimeout(timer); + resolve({ + /** Send a CDP command and wait for the response */ + send(method, params = {}, msgTimeout = timeout) { + return new Promise((res, rej) => { + const id = ++msgId; + const timer = setTimeout(() => { + pending.delete(id); + rej( + new Error( + `CDP message timeout after ${msgTimeout}ms for ${method}`, + ), + ); + }, msgTimeout); + pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + res(v); + }, + reject: (e) => { + clearTimeout(timer); + rej(e); + }, + }); + const msg = JSON.stringify({ id, method, params }); + ws.send(msg); + }); + }, + close() { + ws.close(); + }, + }); + }; + + ws.onmessage = (evt) => { + const data = typeof evt.data === 'string' ? evt.data : evt.data.toString(); + let msg; + try { + msg = JSON.parse(data); + } catch { + // Non-JSON frame — ignore + return; + } + if (msg.id && pending.has(msg.id)) { + const { resolve: res, reject: rej } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) { + rej(new Error(`CDP error: ${JSON.stringify(msg.error)}`)); + } else { + res(msg.result); + } + } + }; + + ws.onerror = (err) => { + clearTimeout(timer); + reject(new Error(`WebSocket error: ${err.message || err}`)); + }; + + ws.onclose = () => { + clearTimeout(timer); + for (const [, { reject: rej }] of pending) { + rej(new Error('WebSocket closed')); + } + pending.clear(); + }; + }); +} + +module.exports = { createWSClient }; diff --git a/scripts/perps/agentic/recipes/README.md b/scripts/perps/agentic/recipes/README.md deleted file mode 100644 index 347e803850c5..000000000000 --- a/scripts/perps/agentic/recipes/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agentic Recipes - -Per-team recipe files for domain-specific CDP helpers. Each file is a JSON map of recipe name to expression. - -## Adding recipes - -Create `.json` in this directory: - -```json -{ - "recipe-name": { - "description": "What it does", - "expression": "Engine.context.SomeController.someMethod().then(function(r){return JSON.stringify(r)})", - "async": true - } -} -``` - -Fields: -- **description** — shown in `recipe --list` -- **expression** — JS expression evaluated in the app's Hermes runtime via CDP -- **async** — `true` if the expression returns a Promise, `false` for sync - -## Usage - -```bash -# Run a recipe -scripts/perps/agentic/app-state.sh recipe / - -# List all recipes -scripts/perps/agentic/app-state.sh recipe --list -``` - -## Tips - -- Use `.then(function(r){return JSON.stringify(r)})` for complex return values — CDP can only serialize primitives and plain objects -- `Engine.context` gives access to all controllers registered on the Engine singleton -- Recipes run in `__DEV__` mode only (the `Engine` global is exposed by NavigationService.ts) -- Keep expressions self-contained — no external imports, no multi-statement blocks unless wrapped in an IIFE diff --git a/scripts/perps/agentic/schemas/flow.schema.json b/scripts/perps/agentic/schemas/flow.schema.json new file mode 100644 index 000000000000..429fcc49ce0e --- /dev/null +++ b/scripts/perps/agentic/schemas/flow.schema.json @@ -0,0 +1,318 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flow.schema.json", + "title": "Agentic Flow", + "description": "A parameterized test sequence that agents execute against a running MetaMask Mobile app via CDP. Flows combine navigation, UI interaction, and state assertions into a reproducible validation sequence.", + "type": "object", + "required": ["title", "validate"], + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "Human-readable title. May contain {{param}} template tokens that resolve at runtime." + }, + "pr": { + "type": ["string", "number"], + "description": "Associated PR number (informational)." + }, + "inputs": { + "type": "object", + "description": "Declared parameters. Every {{param}} used in steps MUST have a matching key here. Params without a default are required.", + "additionalProperties": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean"], + "description": "Informational type hint (no runtime type checking)." + }, + "default": { + "description": "Default value. If present, the param is optional. Single source of truth — prefer this over inline {{param|default}} syntax." + }, + "description": { + "type": "string", + "description": "What this parameter controls." + } + }, + "additionalProperties": false + } + }, + "initial_conditions": { + "type": "object", + "description": "App state to establish before running steps.", + "properties": { + "account": { "type": "string", "description": "Ethereum address to switch to." }, + "testnet": { "type": "boolean", "description": "Enable/disable testnet mode." }, + "provider": { "type": "string", "description": "Active provider (hyperliquid, myx, aggregated)." } + }, + "additionalProperties": false + }, + "validate": { + "type": "object", + "required": ["runtime"], + "additionalProperties": false, + "properties": { + "runtime": { + "type": "object", + "required": ["steps"], + "additionalProperties": false, + "properties": { + "pre_conditions": { + "type": "array", + "description": "Checks that must pass before steps run. String refs or parameterized objects.", + "items": { + "oneOf": [ + { + "type": "string", + "description": "Named pre-condition (e.g. 'wallet.unlocked'). Shorthand 'name(k=v)' is also supported." + }, + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Pre-condition registry key." } + }, + "additionalProperties": { "type": "string" }, + "description": "Parameterized pre-condition (e.g. { name: 'perps.open_position', symbol: 'BTC' })." + } + ] + } + }, + "initial_conditions": { + "type": "object", + "description": "Same as top-level initial_conditions (nested form for backward compat).", + "properties": { + "account": { "type": "string" }, + "testnet": { "type": "boolean" }, + "provider": { "type": "string" } + }, + "additionalProperties": false + }, + "steps": { + "type": "array", + "minItems": 1, + "description": "Ordered sequence of actions. Terminal step MUST assert.", + "items": { "$ref": "#/$defs/step" } + } + } + } + } + } + }, + "$defs": { + "assert": { + "type": "object", + "required": ["operator"], + "additionalProperties": false, + "description": "Assertion applied to step result.", + "properties": { + "operator": { + "type": "string", + "enum": ["not_null", "eq", "neq", "gt", "length_eq", "length_gt", "contains", "not_contains"], + "description": "Comparison operator." + }, + "field": { + "type": ["string", "null"], + "description": "Dot-path into the result JSON to extract the actual value (e.g. 'route', 'positions.0.symbol'). Null or omitted = use entire result." + }, + "value": { + "description": "Expected value for eq/gt/length_eq/length_gt/contains/not_contains. Not used for not_null." + } + } + }, + "step": { + "type": "object", + "required": ["id", "action"], + "properties": { + "id": { + "type": "string", + "description": "Unique step identifier. Use descriptive kebab-case (e.g. 'assert-route', 'press-close')." + }, + "description": { + "type": "string", + "description": "Optional human note. Only add when the id + action aren't self-explanatory." + }, + "action": { + "type": "string", + "enum": [ + "navigate", "eval_sync", "eval_async", "eval_ref", + "press", "scroll", "set_input", "type_keypad", "clear_keypad", + "flow_ref", "log_watch", "wait", "wait_for", "screenshot", "manual", + "select_account", "toggle_testnet", "switch_provider" + ] + }, + "assert": { "$ref": "#/$defs/assert" } + }, + "allOf": [ + { + "if": { "properties": { "action": { "const": "navigate" } } }, + "then": { + "properties": { + "target": { "type": "string", "description": "Route name (e.g. 'PerpsMarketDetails')." }, + "params": { "type": "object", "description": "Navigation params (route-specific)." } + }, + "required": ["target"] + } + }, + { + "if": { "properties": { "action": { "const": "eval_sync" } } }, + "then": { + "properties": { + "expression": { "type": "string", "description": "ES5 JS expression evaluated synchronously via CDP." } + }, + "required": ["expression", "assert"] + } + }, + { + "if": { "properties": { "action": { "const": "eval_async" } } }, + "then": { + "properties": { + "expression": { "type": "string", "description": "ES5 JS expression returning a Promise. Use .then() chains, NOT await." } + }, + "required": ["expression", "assert"] + } + }, + { + "if": { "properties": { "action": { "const": "eval_ref" } } }, + "then": { + "properties": { + "ref": { "type": "string", "description": "Eval ref name (e.g. 'positions', 'core/tpsl-orders')." } + }, + "required": ["ref", "assert"] + } + }, + { + "if": { "properties": { "action": { "const": "press" } } }, + "then": { + "properties": { + "test_id": { "type": "string", "description": "testID prop of the React component to press." } + }, + "required": ["test_id"] + } + }, + { + "if": { "properties": { "action": { "const": "scroll" } } }, + "then": { + "properties": { + "test_id": { "type": "string", "description": "testID of the scrollable container." }, + "offset": { "type": "number", "default": 300, "description": "Scroll offset in pixels." }, + "animated": { "type": "boolean", "default": false } + } + } + }, + { + "if": { "properties": { "action": { "const": "set_input" } } }, + "then": { + "properties": { + "test_id": { "type": "string", "description": "testID of the TextInput." }, + "value": { "type": "string", "description": "Text to type into the input." } + }, + "required": ["test_id", "value"] + } + }, + { + "if": { "properties": { "action": { "const": "type_keypad" } } }, + "then": { + "properties": { + "value": { "type": "string", "description": "Digits/dot to type via keypad buttons (e.g. '10.5')." } + }, + "required": ["value"] + } + }, + { + "if": { "properties": { "action": { "const": "clear_keypad" } } }, + "then": { + "properties": { + "count": { "type": "number", "default": 8, "description": "Number of delete presses." } + } + } + }, + { + "if": { "properties": { "action": { "const": "flow_ref" } } }, + "then": { + "properties": { + "ref": { "type": "string", "description": "Flow path: 'team/name' or just 'name' (defaults to perps team)." }, + "params": { "type": "object", "description": "Values for the referenced flow's {{param}} tokens." } + }, + "required": ["ref"] + } + }, + { + "if": { "properties": { "action": { "const": "log_watch" } } }, + "then": { + "properties": { + "window_seconds": { "type": "number", "default": 10 }, + "must_not_appear": { "type": "array", "items": { "type": "string" }, "description": "Strings that must NOT appear in recent logs (case-insensitive)." }, + "watch_for": { "type": "array", "items": { "type": "string" }, "description": "Strings to count in recent logs (informational)." } + } + } + }, + { + "if": { "properties": { "action": { "const": "wait" } } }, + "then": { + "properties": { + "ms": { "type": "number", "default": 1000, "description": "Milliseconds to pause." } + } + } + }, + { + "if": { "properties": { "action": { "const": "screenshot" } } }, + "then": { + "properties": { + "filename": { "type": "string", "description": "Screenshot label." } + } + } + }, + { + "if": { "properties": { "action": { "const": "manual" } } }, + "then": { + "properties": { + "note": { "type": "string", "description": "Instructions for human intervention." } + } + } + }, + { + "if": { "properties": { "action": { "const": "select_account" } } }, + "then": { + "properties": { + "address": { "type": "string", "description": "Ethereum address to switch to." } + }, + "required": ["address"] + } + }, + { + "if": { "properties": { "action": { "const": "toggle_testnet" } } }, + "then": { + "properties": { + "enabled": { "type": "boolean", "default": true, "description": "Desired testnet state." } + } + } + }, + { + "if": { "properties": { "action": { "const": "switch_provider" } } }, + "then": { + "properties": { + "provider": { "type": "string", "description": "Provider ID (hyperliquid, myx, aggregated)." } + }, + "required": ["provider"] + } + }, + { + "if": { "properties": { "action": { "const": "wait_for" } } }, + "then": { + "properties": { + "expression": { "type": "string", "description": "ES5 JS expression to poll (sync or async with .then()). Used when route/test_id sugar is not sufficient." }, + "route": { "type": "string", "description": "Shorthand: poll until current route name equals this value. Expands to a getRoute() expression + eq assert." }, + "not_route": { "type": "string", "description": "Shorthand: poll until current route name does NOT equal this value (e.g. wait to leave a screen)." }, + "test_id": { "type": "string", "description": "Shorthand: poll until a component with this testID exists (or disappears if visible=false) in the React fiber tree." }, + "visible": { "type": "boolean", "default": true, "description": "Used with test_id: true = wait for element to appear, false = wait for element to disappear." }, + "timeout_ms": { "type": "number", "default": 10000, "description": "Max wait in milliseconds before failing." }, + "poll_ms": { "type": "number", "default": 500, "description": "Polling interval in milliseconds." } + } + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/README.md b/scripts/perps/agentic/teams/README.md new file mode 100644 index 000000000000..8765527bf0b8 --- /dev/null +++ b/scripts/perps/agentic/teams/README.md @@ -0,0 +1,105 @@ +# Agentic Teams — Contribution Guide + +Each team owns its own directory under `teams//`. +The registry auto-discovers and merges all team pre-conditions at load time. + +## Directory structure + +``` +teams/ + perps/ + flows/ ← flow JSON files (validated by validate-flow-schema.js) + recipes/ ← integration-level recipes that compose flows via flow_ref + evals/ ← named eval collections (core.json, setup.json, ...) + evals.json ← quick CDP eval refs (positions, auth, balances, ...) + pre-conditions.js ← perps.* checks + mobile-platform/ + pre-conditions.js ← mobile-platform.* checks + / + flows/ ← optional: flow JSON files + recipes/ ← optional: integration-level recipes + evals/ ← optional: named eval collections + evals.json ← optional: quick CDP eval refs + pre-conditions.js ← .* checks +``` + +## Adding a new team + +1. Create `teams//pre-conditions.js` exporting a `Record`. +2. Key naming convention: `.` — e.g. `swap.has_quote`, `nft.owns_token`. +3. Duplicate keys across teams cause a load-time error, so namespacing is enforced by convention. +4. Optionally add `flows/`, `evals/`, and `evals.json` for team-specific automation. + +## Pre-condition shape + +```js +'use strict'; +const REGISTRY = { + 'myteam.some_check': { + description: 'Human-readable description shown on failure.', + async: false, + // Plain string for fixed checks; function(params) => string for parameterised ones. + expression: 'JSON.stringify({ ok: true })', + assert: { operator: 'eq', field: 'ok', value: true }, + hint: 'What the user should do when this check fails.', + }, +}; +module.exports = REGISTRY; +``` + +## Flows + +Flow JSON files live in `teams//flows/`. They are automatically discovered by `validate-flow-schema.js`. + +```bash +# Validate all flows +node scripts/perps/agentic/validate-flow-schema.js + +# Validate a single flow +node scripts/perps/agentic/validate-flow-schema.js teams/perps/flows/trade-open-market.json +``` + +## Evals + +Named eval collections live in `teams//evals/.json`. +Run them via: `node cdp-bridge.js eval-ref //` + +Example: `node cdp-bridge.js eval-ref perps/core/pump-market` + +Quick CDP eval refs live in `teams//evals.json`. +Run them via: `node cdp-bridge.js eval-ref /` + +Example: `node cdp-bridge.js eval-ref perps/positions` + +List all available eval refs: + +```bash +node scripts/perps/agentic/cdp-bridge.js eval-ref --list +``` + +## Recipes + +Recipes live in `teams//recipes/`. They compose multiple flows via `flow_ref` for integration-level validation — proving that end-to-end scenarios work across flow boundaries. + +```bash +# Run a recipe against the live app +bash scripts/perps/agentic/validate-recipe.sh teams/perps/recipes/full-trade-lifecycle.json + +# Dry-run (prints steps without executing) +bash scripts/perps/agentic/validate-recipe.sh teams/perps/recipes/full-trade-lifecycle.json --dry-run +``` + +See `teams/perps/recipes/full-trade-lifecycle.json` for an example that chains wallet home → mainnet → perps → testnet → open position → TP/SL → close. + +## Validators + +```bash +# Check assertion correctness for all pre-conditions (no live app needed) +node scripts/perps/agentic/validate-pre-conditions.js + +# Validate all flow JSON files against schema rules +node scripts/perps/agentic/validate-flow-schema.js + +# Run a recipe against the live app +bash scripts/perps/agentic/validate-recipe.sh +``` diff --git a/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js b/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js new file mode 100644 index 000000000000..051618a3e5e6 --- /dev/null +++ b/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js @@ -0,0 +1,7 @@ +'use strict'; +/** @type {Record} */ +const REGISTRY = { + // mobile-platform.* pre-conditions go here. + // Key naming convention: mobile-platform. +}; +module.exports = REGISTRY; diff --git a/scripts/perps/agentic/recipes/perps.json b/scripts/perps/agentic/teams/perps/evals.json similarity index 100% rename from scripts/perps/agentic/recipes/perps.json rename to scripts/perps/agentic/teams/perps/evals.json diff --git a/scripts/perps/agentic/teams/perps/evals/core.json b/scripts/perps/agentic/teams/perps/evals/core.json new file mode 100644 index 000000000000..37228f227fb8 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/evals/core.json @@ -0,0 +1,27 @@ +{ + "pump-market": { + "description": "PUMP market data with live price", + "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify(ms.find(function(m){return m.symbol==='PUMP'}))})", + "async": true + }, + "tpsl-orders": { + "description": "Open TP/SL trigger orders", + "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){return JSON.stringify(os.filter(function(o){return o.isTrigger}))})", + "async": true + }, + "positions-by-symbol": { + "description": "Find open position by symbol (TEMPLATE — substitute symbol via eval-async for custom scenarios)", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){return JSON.stringify(ps.find(function(p){return p.symbol==='BTC'}))})", + "async": true + }, + "leverage-config": { + "description": "Trade configurations (leverage, fee tiers) from controller state", + "expression": "JSON.stringify(Engine.context.PerpsController.state.tradeConfigurations)", + "async": false + }, + "watchlist": { + "description": "Watchlist markets from controller state", + "expression": "JSON.stringify(Engine.context.PerpsController.state.watchlistMarkets)", + "async": false + } +} diff --git a/scripts/perps/agentic/teams/perps/evals/setup.json b/scripts/perps/agentic/teams/perps/evals/setup.json new file mode 100644 index 000000000000..2f9e276111f7 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/evals/setup.json @@ -0,0 +1,17 @@ +{ + "testnet-mode": { + "description": "Current testnet mode (isTestnet boolean)", + "expression": "Engine.context.PerpsController.state.isTestnet", + "async": false + }, + "current-provider": { + "description": "Currently active provider", + "expression": "Engine.context.PerpsController.state.activeProvider", + "async": false + }, + "account-balance": { + "description": "Perps account balance", + "expression": "Engine.context.PerpsController.getBalance().then(function(r){return JSON.stringify(r)})", + "async": true + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/activity-view.json b/scripts/perps/agentic/teams/perps/flows/activity-view.json new file mode 100644 index 000000000000..3888b64f56d7 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/activity-view.json @@ -0,0 +1,45 @@ +{ + "title": "Activity — view recent trades and funding history", + "inputs": { + "tab": { + "type": "string", + "default": "trades", + "description": "Activity tab to select (trades/orders/funding/deposits)" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.feature_enabled" + ], + "steps": [ + { + "id": "nav-activity", + "description": "Navigate to perps activity screen (perps transactions tab)", + "action": "navigate", + "target": "PerpsActivity", + "params": { + "redirectToPerpsTransactions": true + } + }, + { + "id": "wait-render", + "action": "wait_for", + "route": "PerpsActivity" + }, + { + "id": "press-tab", + "description": "Select the requested tab", + "action": "press", + "test_id": "perps-transactions-tab-{{tab}}" + }, + { + "id": "wait-filter", + "action": "wait_for", + "route": "PerpsActivity" + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/market-discovery.json b/scripts/perps/agentic/teams/perps/flows/market-discovery.json new file mode 100644 index 000000000000..9a8959c1e5bf --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/market-discovery.json @@ -0,0 +1,101 @@ +{ + "title": "Market Discovery — find {{symbol}} in list and verify price loads", + "inputs": { + "symbol": { + "type": "string", + "default": "BTC", + "description": "Market symbol to look up" + }, + "category": { + "type": "string", + "default": "", + "description": "Optional category filter: crypto, stocks, commodities, forex. If empty, no chip is pressed (shows all)." + }, + "search": { + "type": "string", + "default": "", + "description": "Optional search query to type into the search bar. If empty, search is skipped." + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.feature_enabled" + ], + "steps": [ + { + "id": "nav-market-list", + "action": "navigate", + "target": "PerpsTrendingView" + }, + { + "id": "wait-market-data", + "action": "wait_for", + "route": "PerpsTrendingView" + }, + { + "id": "select-category", + "action": "eval_sync", + "expression": "(function(){var cat='{{category}}';if(!cat)return JSON.stringify({skipped:true,category:'all'});var r=globalThis.__AGENTIC__.pressTestId('perps-market-list-sort-filters-categories-'+cat);return JSON.stringify({selected:true,category:cat})})()", + "assert": { + "operator": "not_null" + } + }, + { + "id": "search-symbol", + "action": "eval_sync", + "expression": "(function(){var q='{{search}}';if(!q)return JSON.stringify({skipped:true});globalThis.__AGENTIC__.setInput('perps-market-list-search-bar',q);return JSON.stringify({searched:true,query:q})})()", + "assert": { + "operator": "not_null" + } + }, + { + "id": "wait-filter", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})", + "assert": { + "operator": "gt", + "field": "count", + "value": 0 + } + }, + { + "id": "assert-symbol-in-list", + "action": "eval_async", + "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,symbol:m?m.symbol:null})})", + "assert": { + "operator": "eq", + "field": "found", + "value": true + } + }, + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-price-load", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({price:m?m.price:'0'})})", + "assert": { + "operator": "not_null", + "field": "price" + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/market-watchlist.json b/scripts/perps/agentic/teams/perps/flows/market-watchlist.json new file mode 100644 index 000000000000..4824f2cc524b --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/market-watchlist.json @@ -0,0 +1,87 @@ +{ + "title": "Market Watchlist — toggle {{symbol}} on/off watchlist", + "inputs": { + "symbol": { + "type": "string", + "default": "BTC", + "description": "Market symbol to toggle on/off watchlist" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.feature_enabled", + { + "name": "perps.not_in_watchlist", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "ensure-not-in-watchlist", + "action": "eval_sync", + "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})", + "assert": { + "operator": "eq", + "field": "inWatchlist", + "value": false + } + }, + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "perps-market-header-favorite-button" + }, + { + "id": "press-favorite-add", + "description": "Press star button to add to watchlist", + "action": "press", + "test_id": "perps-market-header-favorite-button" + }, + { + "id": "wait-state-update", + "action": "wait_for", + "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})", + "assert": { + "operator": "eq", + "field": "inWatchlist", + "value": true + } + }, + { + "id": "press-favorite-remove", + "description": "Press star button again to remove from watchlist", + "action": "press", + "test_id": "perps-market-header-favorite-button" + }, + { + "id": "wait-state-update-2", + "action": "wait_for", + "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})", + "assert": { + "operator": "eq", + "field": "inWatchlist", + "value": false + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json b/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json new file mode 100644 index 000000000000..552601d40162 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json @@ -0,0 +1,79 @@ +{ + "title": "Order — cancel first open limit order for {{symbol}}", + "inputs": { + "symbol": { + "type": "string", + "description": "Market symbol of limit order to cancel" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { + "name": "perps.open_limit_order", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "assert-order-exists", + "action": "eval_async", + "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var filtered=os.filter(function(o){return o.symbol==='{{symbol}}'&&!o.isTrigger});return JSON.stringify({count:filtered.length})})", + "assert": { + "operator": "gt", + "field": "count", + "value": 0 + } + }, + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "perps-compact-order-row-first" + }, + { + "id": "press-first-order", + "description": "Tap first compact order row to navigate to order details", + "action": "press", + "test_id": "perps-compact-order-row-first" + }, + { + "id": "wait-order-details", + "action": "wait_for", + "test_id": "perps-order-details-cancel-button" + }, + { + "id": "press-cancel-button", + "action": "press", + "test_id": "perps-order-details-cancel-button" + }, + { + "id": "wait-cancel", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var filtered=os.filter(function(o){return o.symbol==='{{symbol}}'&&!o.isTrigger});return JSON.stringify({count:filtered.length})})", + "assert": { + "operator": "eq", + "field": "count", + "value": 0 + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/order-limit-place.json b/scripts/perps/agentic/teams/perps/flows/order-limit-place.json new file mode 100644 index 000000000000..33e43cf1a0e2 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/order-limit-place.json @@ -0,0 +1,166 @@ +{ + "title": "Order — limit {{side}} {{symbol}} ${{usdAmount}} at ${{limitPrice}}", + "inputs": { + "side": { + "type": "string", + "default": "long", + "description": "Trade side (long/short)" + }, + "symbol": { + "type": "string", + "default": "BTC", + "description": "Market symbol" + }, + "usdAmount": { + "type": "string", + "default": "10", + "description": "USD notional amount" + }, + "limitPrice": { + "type": "string", + "default": "60000", + "description": "Limit price in USD" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.ready_to_trade" + ], + "steps": [ + { + "id": "nav", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "perps-market-details-{{side}}-button" + }, + { + "id": "press-side", + "action": "press", + "test_id": "perps-market-details-{{side}}-button" + }, + { + "id": "wait-form", + "action": "wait_for", + "test_id": "perps-order-header-order-type-button" + }, + { + "id": "press-order-type", + "action": "press", + "test_id": "perps-order-header-order-type-button" + }, + { + "id": "wait-type-sheet", + "action": "wait_for", + "test_id": "perps-order-type-limit" + }, + { + "id": "press-limit", + "action": "press", + "test_id": "perps-order-type-limit" + }, + { + "id": "wait-limit-form", + "action": "wait_for", + "test_id": "perps-order-view-limit-price-row" + }, + { + "id": "press-limit-price-row", + "action": "press", + "test_id": "perps-order-view-limit-price-row" + }, + { + "id": "wait-price-sheet", + "action": "wait_for", + "test_id": "keypad-delete-button" + }, + { + "id": "clear-limit-keypad", + "action": "clear_keypad", + "count": 12 + }, + { + "id": "type-limit-price", + "action": "type_keypad", + "value": "{{limitPrice}}" + }, + { + "id": "press-set", + "action": "press", + "test_id": "perps-limit-price-confirm-button" + }, + { + "id": "wait-price-set", + "action": "wait_for", + "test_id": "perps-amount-display-touchable" + }, + { + "id": "press-amount", + "action": "press", + "test_id": "perps-amount-display-touchable" + }, + { + "id": "wait-keypad", + "action": "wait_for", + "test_id": "keypad-delete-button" + }, + { + "id": "clear-keypad", + "action": "clear_keypad", + "count": 8 + }, + { + "id": "type-amount", + "action": "type_keypad", + "value": "{{usdAmount}}" + }, + { + "id": "assert-amount", + "action": "eval_sync", + "expression": "JSON.stringify((function(){ var hook=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; if(!hook)return{v:null}; var found=null; function walk(f){if(!f)return; if(f.memoizedProps&&f.memoizedProps.testID==='perps-amount-display-amount'){found=f;return;} walk(f.child);if(!found)walk(f.sibling);} for(var [id] of hook.renderers){var roots=hook.getFiberRoots?hook.getFiberRoots(id):null;if(roots)roots.forEach(function(r){if(!found)walk(r.current);});} return {v: found&&found.memoizedProps?String(found.memoizedProps.children||''):null}; })())", + "assert": { + "operator": "contains", + "field": "v", + "value": "{{usdAmount}}" + } + }, + { + "id": "press-done", + "action": "press", + "test_id": "perps-order-view-keypad-done" + }, + { + "id": "wait-summary", + "action": "wait_for", + "test_id": "perps-order-view-place-order-button" + }, + { + "id": "place-order", + "action": "press", + "test_id": "perps-order-view-place-order-button" + }, + { + "id": "wait-confirm", + "action": "wait_for", + "not_route": "RedesignedConfirmations" + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/position-add-margin.json b/scripts/perps/agentic/teams/perps/flows/position-add-margin.json new file mode 100644 index 000000000000..934f3c928360 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/position-add-margin.json @@ -0,0 +1,120 @@ +{ + "title": "Position — add ${{marginAmount}} margin to {{symbol}}", + "inputs": { + "symbol": { + "type": "string", + "description": "Market symbol of position to add margin to" + }, + "marginAmount": { + "type": "string", + "description": "USD margin amount to add" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { + "name": "perps.open_position", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "assert-position-exists", + "action": "eval_async", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({margin:p?p.marginUsed:null})})", + "assert": { + "operator": "not_null", + "field": "margin" + } + }, + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "position-card-margin-chevron" + }, + { + "id": "press-margin-chevron", + "action": "press", + "test_id": "position-card-margin-chevron" + }, + { + "id": "wait-action-sheet", + "action": "wait_for", + "test_id": "perps-adjust-margin-add-btn" + }, + { + "id": "press-add-margin-option", + "action": "press", + "test_id": "perps-adjust-margin-add-btn" + }, + { + "id": "wait-margin-screen", + "action": "wait_for", + "route": "PerpsAdjustMargin" + }, + { + "id": "press-amount-display", + "action": "press", + "test_id": "perps-amount-display-touchable" + }, + { + "id": "wait-keypad", + "action": "wait_for", + "test_id": "keypad-delete-button" + }, + { + "id": "clear-keypad", + "action": "clear_keypad", + "count": 8 + }, + { + "id": "type-amount", + "action": "type_keypad", + "value": "{{marginAmount}}" + }, + { + "id": "press-done", + "action": "press", + "test_id": "perps-adjust-margin-done-button" + }, + { + "id": "wait-done", + "action": "wait_for", + "test_id": "perps-adjust-margin-confirm-button" + }, + { + "id": "press-confirm", + "action": "press", + "test_id": "perps-adjust-margin-confirm-button" + }, + { + "id": "wait-confirm", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({margin:p?p.marginUsed:null})})", + "assert": { + "operator": "not_null", + "field": "margin" + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/select-account.json b/scripts/perps/agentic/teams/perps/flows/select-account.json new file mode 100644 index 000000000000..96b297a662b9 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/select-account.json @@ -0,0 +1,33 @@ +{ + "title": "Select — switch to account {{address}} and verify", + "inputs": { + "address": { + "type": "string", + "description": "Ethereum address to switch to" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked" + ], + "steps": [ + { + "id": "switch-account", + "action": "select_account", + "address": "{{address}}" + }, + { + "id": "wait-switch", + "action": "wait_for", + "expression": "JSON.stringify({address:Engine.context.AccountsController.state.internalAccounts.accounts[Engine.context.AccountsController.state.internalAccounts.selectedAccount].address})", + "assert": { + "operator": "eq", + "field": "address", + "value": "{{address}}" + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/setup-testnet.json b/scripts/perps/agentic/teams/perps/flows/setup-testnet.json new file mode 100644 index 000000000000..57e6e37df8b7 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/setup-testnet.json @@ -0,0 +1,38 @@ +{ + "title": "Setup — enable testnet and verify market data loads", + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.feature_enabled" + ], + "steps": [ + { + "id": "enable-testnet", + "action": "toggle_testnet", + "enabled": true + }, + { + "id": "wait-reload", + "action": "wait_for", + "expression": "JSON.stringify({isTestnet:Engine.context.PerpsController.state.isTestnet})", + "assert": { + "operator": "eq", + "field": "isTestnet", + "value": true + } + }, + { + "id": "verify-markets", + "action": "eval_ref", + "ref": "markets", + "assert": { + "operator": "not_null", + "field": null, + "value": null + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/tpsl-create.json b/scripts/perps/agentic/teams/perps/flows/tpsl-create.json new file mode 100644 index 000000000000..3cc9de5601e3 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/tpsl-create.json @@ -0,0 +1,108 @@ +{ + "title": "TP/SL — create +{{tpPreset}}% TP / {{slPreset}}% SL for {{symbol}}", + "inputs": { + "symbol": { + "type": "string", + "description": "Market symbol with open position" + }, + "tpPreset": { + "type": "string", + "default": "25", + "description": "Take-profit RoE preset to press (10, 25, 50, 100)" + }, + "slPreset": { + "type": "string", + "default": "-10", + "description": "Stop-loss RoE preset to press (-5, -10, -25, -50)" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { + "name": "perps.open_position", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-market-render", + "action": "wait_for", + "test_id": "position-card-auto-close-toggle" + }, + { + "id": "press-auto-close", + "action": "press", + "test_id": "position-card-auto-close-toggle" + }, + { + "id": "wait-tpsl-route", + "action": "wait_for", + "route": "PerpsTPSL" + }, + { + "id": "wait-tp-button", + "action": "wait_for", + "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}" + }, + { + "id": "press-tp-preset", + "action": "press", + "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}" + }, + { + "id": "wait-sl-button", + "action": "wait_for", + "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}" + }, + { + "id": "press-sl-preset", + "action": "press", + "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}" + }, + { + "id": "assert-tpsl-screen-intact", + "action": "eval_sync", + "expression": "JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})", + "assert": { + "operator": "eq", + "field": "route", + "value": "PerpsTPSL" + } + }, + { + "id": "press-set-tpsl", + "action": "press", + "test_id": "perps-tpsl-bottomsheet" + }, + { + "id": "wait-tpsl-created", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({hasTp:!!(p&&p.takeProfitPrice),hasSl:!!(p&&p.stopLossPrice)})})", + "assert": { + "operator": "eq", + "field": "hasTp", + "value": true + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json b/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json new file mode 100644 index 000000000000..b033b328a83c --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json @@ -0,0 +1,108 @@ +{ + "title": "TP/SL — edit to +{{tpPreset}}% TP / {{slPreset}}% SL for {{symbol}}", + "inputs": { + "symbol": { + "type": "string", + "description": "Market symbol with open position and existing TP/SL" + }, + "tpPreset": { + "type": "string", + "default": "50", + "description": "Take-profit RoE preset to press (10, 25, 50, 100)" + }, + "slPreset": { + "type": "string", + "default": "-25", + "description": "Stop-loss RoE preset to press (-5, -10, -25, -50)" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { + "name": "perps.open_position_tpsl", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "position-card-auto-close-toggle" + }, + { + "id": "press-modify-tpsl", + "action": "press", + "test_id": "position-card-auto-close-toggle" + }, + { + "id": "wait-tpsl-route", + "action": "wait_for", + "route": "PerpsTPSL" + }, + { + "id": "wait-tp-button", + "action": "wait_for", + "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}" + }, + { + "id": "press-tp-preset", + "action": "press", + "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}" + }, + { + "id": "wait-sl-button", + "action": "wait_for", + "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}" + }, + { + "id": "press-sl-preset", + "action": "press", + "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}" + }, + { + "id": "assert-tpsl-screen-intact", + "action": "eval_sync", + "expression": "JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})", + "assert": { + "operator": "eq", + "field": "route", + "value": "PerpsTPSL" + } + }, + { + "id": "press-set-tpsl", + "action": "press", + "test_id": "perps-tpsl-bottomsheet" + }, + { + "id": "wait-tpsl-updated", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({hasTp:!!(p&&p.takeProfitPrice),hasSl:!!(p&&p.stopLossPrice)})})", + "assert": { + "operator": "eq", + "field": "hasTp", + "value": true + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/trade-close-position.json b/scripts/perps/agentic/teams/perps/flows/trade-close-position.json new file mode 100644 index 000000000000..20df2e2314c0 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/trade-close-position.json @@ -0,0 +1,83 @@ +{ + "title": "Trade — close position for {{symbol}}", + "inputs": { + "symbol": { + "type": "string", + "description": "Market symbol of position to close" + }, + "closePercent": { + "type": "string", + "default": "100", + "description": "Percentage of position to close (reserved for future partial close)" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + { + "name": "perps.open_position", + "symbol": "{{symbol}}" + } + ], + "steps": [ + { + "id": "assert-position-exists", + "action": "eval_async", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!p})})", + "assert": { + "operator": "eq", + "field": "found", + "value": true + } + }, + { + "id": "nav-market-detail", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "perps-market-details-close-button" + }, + { + "id": "press-close", + "action": "press", + "test_id": "perps-market-details-close-button" + }, + { + "id": "wait-close-screen", + "action": "wait_for", + "route": "PerpsClosePosition" + }, + { + "id": "press-confirm-close", + "action": "press", + "test_id": "close-position-confirm-button" + }, + { + "id": "wait-position-closed", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!p})})", + "assert": { + "operator": "eq", + "field": "found", + "value": false + } + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/flows/trade-open-market.json b/scripts/perps/agentic/teams/perps/flows/trade-open-market.json new file mode 100644 index 000000000000..83c05b0ec9a6 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/flows/trade-open-market.json @@ -0,0 +1,117 @@ +{ + "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}", + "inputs": { + "side": { + "type": "string", + "default": "long", + "description": "Trade side (long/short)" + }, + "symbol": { + "type": "string", + "default": "BTC", + "description": "Market symbol" + }, + "usdAmount": { + "type": "string", + "default": "10", + "description": "USD notional amount" + }, + "leverage": { + "type": "string", + "default": "2", + "description": "Leverage multiplier (reserved, not wired yet)" + } + }, + "validate": { + "runtime": { + "pre_conditions": [ + "wallet.unlocked", + "perps.ready_to_trade", + "perps.sufficient_balance" + ], + "steps": [ + { + "id": "nav", + "action": "navigate", + "target": "PerpsMarketDetails", + "params": { + "market": { + "symbol": "{{symbol}}", + "name": "{{symbol}}", + "price": "0", + "change24h": "0", + "change24hPercent": "0", + "volume": "0", + "maxLeverage": "100" + } + } + }, + { + "id": "wait-render", + "action": "wait_for", + "test_id": "perps-market-details-{{side}}-button" + }, + { + "id": "press-side", + "action": "press", + "test_id": "perps-market-details-{{side}}-button" + }, + { + "id": "wait-form", + "action": "wait_for", + "route": "RedesignedConfirmations" + }, + { + "id": "press-amount", + "action": "press", + "test_id": "perps-amount-display-touchable" + }, + { + "id": "wait-keypad", + "action": "wait_for", + "test_id": "keypad-delete-button" + }, + { + "id": "clear-keypad", + "action": "clear_keypad", + "count": 8 + }, + { + "id": "type-amount", + "action": "type_keypad", + "value": "{{usdAmount}}" + }, + { + "id": "assert-amount", + "action": "eval_sync", + "expression": "JSON.stringify((function(){ var hook=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; if(!hook)return{v:null}; var found=null; function walk(f){if(!f)return; if(f.memoizedProps&&f.memoizedProps.testID==='perps-amount-display-amount'){found=f;return;} walk(f.child);if(!found)walk(f.sibling);} for(var [id] of hook.renderers){var roots=hook.getFiberRoots?hook.getFiberRoots(id):null;if(roots)roots.forEach(function(r){if(!found)walk(r.current);});} return {v: found&&found.memoizedProps?String(found.memoizedProps.children||''):null}; })())", + "assert": { + "operator": "contains", + "field": "v", + "value": "{{usdAmount}}" + } + }, + { + "id": "press-done", + "action": "press", + "test_id": "perps-order-view-keypad-done" + }, + { + "id": "wait-summary", + "action": "wait_for", + "test_id": "perps-order-view-place-order-button" + }, + { + "id": "place-order", + "action": "press", + "test_id": "perps-order-view-place-order-button" + }, + { + "id": "wait-order-placed", + "action": "wait_for", + "not_route": "RedesignedConfirmations" + } + ] + } + } +} diff --git a/scripts/perps/agentic/teams/perps/pre-conditions.js b/scripts/perps/agentic/teams/perps/pre-conditions.js new file mode 100644 index 000000000000..676cc3738630 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/pre-conditions.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * pre-conditions.js — Registry of named, executable pre-condition checks. + * + * Each entry defines a CDP eval expression (sync or async) and an assertion + * that must pass before a flow is allowed to run. If a check fails the runner + * aborts immediately with a clear message and actionable hint instead of + * letting the flow limp forward and die on step 4 with a cryptic error. + * + * Usage in flow JSON: + * "pre_conditions": [ + * "wallet.unlocked", + * "perps.ready_to_trade", + * { "name": "perps.open_position", "symbol": "BTC" } + * ] + * + * String entries are looked up by name with no params. + * Object entries pass remaining fields as params to the expression builder. + * + * Keys use dot-notation namespaces (wallet.*, perps.*, ui.*) to signal + * ownership and avoid collisions across teams. + * + * Adding a new check: + * 1. Add an entry to REGISTRY below. + * 2. If the expression needs params (e.g. symbol), make `expression` a + * function(params) => string instead of a plain string. + * 3. Set `async: true` if the expression returns a Promise. + * 4. Update your flow's pre_conditions array. + * 5. Run `node validate-flow-schema.js` — it validates names against this file. + */ + +'use strict'; + +/** + * @typedef {Object} PreCondition + * @property {string} description Human-readable description shown on failure. + * @property {boolean} async true if expression returns a Promise. + * @property {string | ((params: object) => string)} expression + * CDP JS expression (ES5 only). String for fixed checks, function for + * parameterised checks (receives the spec object minus "name"). + * @property {{ operator: string, field?: string, value?: unknown }} assert + * Same operators as recipe step assertions. + * @property {string} hint What to do when the check fails. + */ + +/** @type {Record} */ +const REGISTRY = { + 'wallet.unlocked': { + description: 'Wallet is unlocked and app is navigable', + async: false, + expression: '(function(){ var r=globalThis.__AGENTIC__.getRoute().name; return JSON.stringify({route:r,unlocked:r!=="Login"&&r!=="LoginView"&&r!=="Onboarding"}); })()', + assert: { operator: 'eq', field: 'unlocked', value: true }, + hint: 'Unlock the wallet first:\n bash scripts/perps/agentic/app-state.sh unlock ', + }, + + 'perps.feature_enabled': { + description: 'PerpsController is available on Engine.context', + async: false, + expression: 'JSON.stringify({enabled: !!(Engine.context && Engine.context.PerpsController)})', + assert: { operator: 'eq', field: 'enabled', value: true }, + hint: 'Enable the Perps feature flag for this account/environment.', + }, + + 'perps.ready_to_trade': { + description: 'Perps provider is ready to trade', + async: true, + expression: '(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers.get(id); if(!p) return Promise.resolve(JSON.stringify({isAuthenticated:false,error:"no active provider"})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({isAuthenticated:r.ready}); }); })()', + assert: { operator: 'eq', field: 'isAuthenticated', value: true }, + hint: 'Complete Perps authentication/onboarding before running this flow.', + }, + + 'perps.sufficient_balance': { + description: 'Perps account has a non-zero available balance', + async: true, + expression: 'Engine.context.PerpsController.getAccountState().then(function(r){ return JSON.stringify({balance: parseFloat(r.availableBalance||"0")}); })', + assert: { operator: 'gt', field: 'balance', value: 0 }, + hint: 'Deposit funds into your Perps account before placing orders.', + }, + + 'perps.open_position': { + description: 'At least one open position exists (optionally filtered by symbol)', + async: true, + expression: (params) => { + const filter = params && params.symbol + ? `function(x){ return x.symbol === '${params.symbol}'; }` + : `function(){ return true; }`; + return `Engine.context.PerpsController.getPositions().then(function(ps){ var filtered=ps.filter(${filter}); return JSON.stringify({count:filtered.length}); })`; + }, + assert: { operator: 'gt', field: 'count', value: 0 }, + hint: 'Open a position first using the trade-open-market flow.', + }, + + 'perps.open_position_tpsl': { + description: 'At least one open position with TP or SL set (optionally filtered by symbol)', + async: true, + expression: (params) => { + const symbolClause = params && params.symbol + ? `x.symbol === '${params.symbol}' && ` + : ''; + return `Engine.context.PerpsController.getPositions().then(function(ps){ var filtered=ps.filter(function(x){ return ${symbolClause}!!(x.takeProfitPrice||x.stopLossPrice); }); return JSON.stringify({count:filtered.length}); })`; + }, + assert: { operator: 'gt', field: 'count', value: 0 }, + hint: 'Create a TP/SL first using the tpsl-create flow.', + }, + + 'perps.open_limit_order': { + description: 'At least one open limit order exists (optionally filtered by symbol)', + async: true, + expression: (params) => { + const filter = params && params.symbol + ? `function(x){ return x.symbol === '${params.symbol}'; }` + : `function(){ return true; }`; + return `Engine.context.PerpsController.getOpenOrders().then(function(orders){ var filtered=orders.filter(${filter}); return JSON.stringify({count:filtered.length}); })`; + }, + assert: { operator: 'gt', field: 'count', value: 0 }, + hint: 'Place a limit order first using the order-limit-place flow.', + }, + + 'perps.not_in_watchlist': { + description: 'Symbol is not already in the watchlist', + async: false, + expression: (params) => { + const symbol = (params && params.symbol) || ''; + return `(function(){ var s=Engine.context.PerpsController.state; var markets=s.isTestnet?s.watchlistMarkets.testnet:s.watchlistMarkets.mainnet; var inList=(markets||[]).some(function(m){ return (m.symbol||m)==='${symbol}'; }); return JSON.stringify({inWatchlist:inList}); })()`; + }, + assert: { operator: 'eq', field: 'inWatchlist', value: false }, + hint: 'Remove the symbol from the watchlist first, or use a symbol not already in it.', + }, + + 'perps.trading_flag': { + description: 'Perps trading remote feature flag is enabled', + async: false, + expression: '(function(){ var f=Engine.context.RemoteFeatureFlagController.state.remoteFeatureFlags.perpsPerpTradingEnabled; var enabled=f===true||(f&&f.enabled===true); return JSON.stringify({enabled:!!enabled}); })()', + assert: { operator: 'eq', field: 'enabled', value: true }, + hint: 'Enable the perps trading flag: Settings → Experimental → Feature Flags → perpsPerpTradingEnabled.', + }, + + 'ui.homepage_redesign_v1_enabled': { + description: 'Homepage redesign V1 feature flag is ON', + async: false, + expression: '(function(){ var f=Engine.context.RemoteFeatureFlagController.state.remoteFeatureFlags.homepageRedesignV1; var enabled=f===true||(f&&f.enabled===true); return JSON.stringify({enabled:!!enabled}); })()', + assert: { operator: 'eq', field: 'enabled', value: true }, + hint: 'Enable homepageRedesignV1: Settings → Experimental → Feature Flags → homepageRedesignV1.', + }, + + 'ui.homepage_redesign_v1_disabled': { + description: 'Homepage redesign V1 feature flag is OFF (classic PerpsTabView layout)', + async: false, + expression: '(function(){ var f=Engine.context.RemoteFeatureFlagController.state.remoteFeatureFlags.homepageRedesignV1; var enabled=f===true||(f&&f.enabled===true); return JSON.stringify({enabled:!!enabled}); })()', + assert: { operator: 'eq', field: 'enabled', value: false }, + hint: 'Disable homepageRedesignV1: Settings → Experimental → Feature Flags → homepageRedesignV1.', + }, +}; + +module.exports = REGISTRY; diff --git a/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json b/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json new file mode 100644 index 000000000000..d61405922002 --- /dev/null +++ b/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json @@ -0,0 +1,124 @@ +{ + "title": "Full BTC trade lifecycle — mainnet start, testnet switch, open, TP/SL, close", + "description": "Starts from wallet home on mainnet, navigates to Perps, switches to testnet, then chains 4 flow_refs: market open, TP/SL creation, position close. Proves full end-to-end composability.", + "validate": { + "runtime": { + "steps": [ + { + "id": "nav-wallet-home", + "action": "navigate", + "target": "WalletView" + }, + { + "id": "switch-mainnet", + "action": "toggle_testnet", + "enabled": false + }, + { + "id": "wait-mainnet", + "action": "wait_for", + "expression": "JSON.stringify({isTestnet:Engine.context.PerpsController.state.isTestnet})", + "assert": { + "operator": "eq", + "field": "isTestnet", + "value": false + } + }, + { + "id": "nav-perps-home", + "action": "navigate", + "target": "PerpsHomeView" + }, + { + "id": "wait-perps-home", + "action": "wait_for", + "route": "PerpsMarketListView" + }, + { + "id": "ensure-testnet", + "action": "flow_ref", + "ref": "setup-testnet" + }, + { + "id": "verify-provider", + "action": "eval_ref", + "ref": "providers", + "assert": { + "operator": "contains", + "value": "hyperliquid" + } + }, + { + "id": "clear-btc-position", + "action": "eval_async", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'BTC'}).then(function(){return JSON.stringify({cleared:true})})})", + "assert": { + "operator": "eq", + "field": "cleared", + "value": true + } + }, + { + "id": "wait-no-btc", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})", + "assert": { + "operator": "eq", + "field": "found", + "value": false + }, + "timeout_ms": 10000 + }, + { + "id": "open-long-btc", + "action": "flow_ref", + "ref": "trade-open-market", + "params": { + "symbol": "BTC", + "side": "long", + "usdAmount": "10", + "leverage": "2" + } + }, + { + "id": "wait-position", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p,side:p?p.side:null})})", + "assert": { + "operator": "eq", + "field": "found", + "value": true + }, + "timeout_ms": 10000 + }, + { + "id": "create-tpsl", + "action": "flow_ref", + "ref": "tpsl-create", + "params": { + "symbol": "BTC" + } + }, + { + "id": "close-position", + "action": "flow_ref", + "ref": "trade-close-position", + "params": { + "symbol": "BTC" + } + }, + { + "id": "wait-closed", + "action": "wait_for", + "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})", + "assert": { + "operator": "eq", + "field": "found", + "value": false + }, + "timeout_ms": 10000 + } + ] + } + } +} diff --git a/scripts/perps/agentic/validate-flow-schema.js b/scripts/perps/agentic/validate-flow-schema.js new file mode 100644 index 000000000000..2ab7db47c9fe --- /dev/null +++ b/scripts/perps/agentic/validate-flow-schema.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * validate-flow-schema.js — Enforce flow authoring rules across all recipe/flow JSON files. + * + * Rules enforced: + * 1. Every eval_sync / eval_async step MUST have an "assert" block. + * (Even snapshot steps — at minimum use {"operator":"not_null"} to prove data arrived.) + * 2. Every flow MUST end with an asserting step: an eval with assert, or a log_watch. + * Flows that end on "wait" or "navigate" silently pass even when the feature is broken. + * 3. No unknown action types — catches typos early. + * 4. Every {{param}} in steps must have a matching key in `inputs`. + * + * Usage: + * node scripts/perps/agentic/validate-flow-schema.js # all flows + * node scripts/perps/agentic/validate-flow-schema.js path/to/flow.json + * + * Exit code: 0 = all pass, 1 = violations found. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const PRE_CONDITIONS = require('./lib/registry'); + +// --- Helpers ---------------------------------------------------------------- + +/** Parse pre-condition shorthand: "name(k=v, ...)" -> { name, k: v, ... } */ +function parsePrecondSpec(spec) { + if (typeof spec !== 'string') return spec; + const m = spec.match(/^([^(]+)\((.+)\)$/); + if (!m) return spec; + const result = { name: m[1] }; + m[2].split(',').forEach((pair) => { + const eq = pair.indexOf('='); + if (eq > 0) result[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + }); + return result; +} + +// --- Rules ------------------------------------------------------------------ + +/** Actions that produce a meaningful result and MUST have an assert. */ +const MUST_ASSERT = new Set(['eval_sync', 'eval_async', 'eval_ref']); + +/** Actions that are structural bookkeeping — assert is optional. */ +const STRUCTURAL = new Set([ + 'navigate', 'wait', 'manual', 'screenshot', + 'press', 'scroll', 'set_input', 'flow_ref', + 'select_account', 'toggle_testnet', 'switch_provider', 'type_keypad', + 'clear_keypad', +]); + +/** log_watch uses must_not_appear as its assertion — counts as asserting. */ +const SELF_ASSERTING = new Set(['log_watch', 'wait_for']); + +const ALL_KNOWN = new Set([...MUST_ASSERT, ...STRUCTURAL, ...SELF_ASSERTING]); + +// --- Validation ------------------------------------------------------------- + +function validateFlow(filePath) { + const issues = []; + let data; + try { + data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + return [` parse error: ${e.message}`]; + } + + // Rule 0: pre_conditions must use registered names + const preConds = data.validate?.runtime?.pre_conditions ?? []; + preConds.forEach((rawSpec) => { + const spec = parsePrecondSpec(rawSpec); + const name = typeof spec === 'string' ? spec : spec?.name; + if (!name) { + issues.push(` pre_condition entry has no name: ${JSON.stringify(rawSpec)}`); + } else if (!PRE_CONDITIONS[name]) { + issues.push( + ` pre_condition "${name}" is not in the registry — add it to pre-conditions.js or fix the typo`, + ); + } + }); + + const steps = data.validate?.runtime?.steps ?? []; + if (steps.length === 0) { + return [' no steps defined']; + } + + steps.forEach((step) => { + const { id = '?', action = '' } = step; + + // Rule 3: unknown action + if (!ALL_KNOWN.has(action)) { + issues.push(` [${id}] unknown action "${action}"`); + return; + } + + // Rule 1: eval steps must assert + if (MUST_ASSERT.has(action) && !('assert' in step)) { + issues.push( + ` [${id}] action="${action}" has no assert — add at minimum {"operator":"not_null"}`, + ); + } + + // Rule 1b: wait_for with expression (no route/not_route/test_id sugar) must have assert + if (action === 'wait_for' && !('assert' in step) && !('route' in step) && !('not_route' in step) && !('test_id' in step)) { + issues.push( + ` [${id}] wait_for with expression requires an assert block (or use route/not_route/test_id sugar)`, + ); + } + }); + + // Rule 2: terminal step must be asserting + const last = steps[steps.length - 1]; + const terminalOk = + ('assert' in last && last.assert) || SELF_ASSERTING.has(last.action); + if (!terminalOk) { + issues.push( + ` terminal step [${last.id}] action="${last.action}" has no assert — ` + + `flows must end with a state assertion, not a "${last.action}"`, + ); + } + + // Rule 4: inputs <-> {{param}} cross-check + const inputs = data.inputs || {}; + const inputKeys = new Set(Object.keys(inputs)); + const usedParams = new Set(); + + const paramRegex = /\{\{([^|}]+)(?:\|[^}]*)?\}\}/g; + function scanStrings(obj) { + if (typeof obj === 'string') { + let m; + while ((m = paramRegex.exec(obj)) !== null) { + usedParams.add(m[1]); + } + paramRegex.lastIndex = 0; + } else if (Array.isArray(obj)) { + obj.forEach(scanStrings); + } else if (obj && typeof obj === 'object') { + Object.values(obj).forEach(scanStrings); + } + } + + scanStrings(data.title || ''); + scanStrings(preConds); + steps.forEach(scanStrings); + + for (const param of usedParams) { + if (!inputKeys.has(param)) { + issues.push( + ` [inputs] param "{{${param}}}" used in steps but missing from inputs — add it to the "inputs" block`, + ); + } + } + + for (const key of inputKeys) { + if (!usedParams.has(key)) { + // Warn only — does not cause failure + console.warn( + ` \u26a0\ufe0f [${path.basename(filePath)}] "${key}" declared in inputs but never referenced in steps`, + ); + } + } + + return issues; +} + +// --- Main ------------------------------------------------------------------- + +function collectFiles(args) { + if (args.length > 0) return args; + const teamsDir = path.resolve(__dirname, 'teams'); + const files = []; + function walk(dir) { + fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.json')) files.push(full); + }); + } + fs.readdirSync(teamsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .forEach((d) => { + const flowsDir = path.join(teamsDir, d.name, 'flows'); + if (fs.existsSync(flowsDir)) walk(flowsDir); + }); + return files; +} + +const args = process.argv.slice(2); +const files = collectFiles(args); + +let totalViolations = 0; + +files.forEach((file) => { + const rel = path.relative(process.cwd(), file); + const issues = validateFlow(file); + if (issues.length === 0) { + console.log(`\u2705 ${rel}`); + } else { + console.log(`\u274c ${rel}`); + issues.forEach((i) => console.log(i)); + totalViolations += issues.length; + } +}); + +console.log(''); +if (totalViolations === 0) { + console.log(`All ${files.length} flow(s) pass schema validation.`); + process.exit(0); +} else { + console.log(`${totalViolations} violation(s) across ${files.length} flow(s).`); + process.exit(1); +} diff --git a/scripts/perps/agentic/validate-pre-conditions.js b/scripts/perps/agentic/validate-pre-conditions.js new file mode 100644 index 000000000000..bc1b463058fd --- /dev/null +++ b/scripts/perps/agentic/validate-pre-conditions.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * validate-pre-conditions.js — Offline assertion correctness test. + * + * For every entry in the pre-conditions REGISTRY, verifies that: + * - checkAssert(passFixture, entry.assert) returns true + * - checkAssert(failFixture, entry.assert) returns false + * + * No live app or CDP connection required. + * + * Usage: + * node scripts/perps/agentic/validate-pre-conditions.js + */ + +'use strict'; + +const REGISTRY = require('./lib/registry'); +const { checkAssert } = require('./lib/assert'); + +// --------------------------------------------------------------------------- +// Fixtures — pass/fail JSON strings per REGISTRY key +// --------------------------------------------------------------------------- +const FIXTURES = { + 'wallet.unlocked': { + pass: '{"route":"WalletView","unlocked":true}', + fail: '{"route":"Login","unlocked":false}', + }, + 'perps.feature_enabled': { + pass: '{"enabled":true}', + fail: '{"enabled":false}', + }, + 'perps.ready_to_trade': { + pass: '{"isAuthenticated":true}', + fail: '{"isAuthenticated":false}', + }, + 'perps.sufficient_balance': { + pass: '{"balance":100}', + fail: '{"balance":0}', + }, + 'perps.open_position': { + pass: '{"count":2}', + fail: '{"count":0}', + }, + 'perps.open_position_tpsl': { + pass: '{"count":1}', + fail: '{"count":0}', + }, + 'perps.open_limit_order': { + pass: '{"count":1}', + fail: '{"count":0}', + }, + 'perps.not_in_watchlist': { + pass: '{"inWatchlist":false}', + fail: '{"inWatchlist":true}', + }, + 'perps.trading_flag': { + pass: '{"enabled":true}', + fail: '{"enabled":false}', + }, + 'ui.homepage_redesign_v1_enabled': { + pass: '{"enabled":true}', + fail: '{"enabled":false}', + }, + 'ui.homepage_redesign_v1_disabled': { + pass: '{"enabled":false}', + fail: '{"enabled":true}', + }, +}; + +// --------------------------------------------------------------------------- +// Run checks +// --------------------------------------------------------------------------- +const failures = []; +const keys = Object.keys(REGISTRY); + +keys.forEach((name) => { + const entry = REGISTRY[name]; + const fixture = FIXTURES[name]; + + if (!fixture) { + failures.push(' ' + name + ': no fixture defined — add pass/fail JSON strings to FIXTURES'); + return; + } + + const passResult = checkAssert(fixture.pass, entry.assert); + if (!passResult) { + failures.push(' ' + name + ': pass-fixture did not satisfy assert\n' + + ' fixture : ' + fixture.pass + '\n' + + ' assert : ' + JSON.stringify(entry.assert)); + } + + const failResult = checkAssert(fixture.fail, entry.assert); + if (failResult) { + failures.push(' ' + name + ': fail-fixture unexpectedly satisfied assert\n' + + ' fixture : ' + fixture.fail + '\n' + + ' assert : ' + JSON.stringify(entry.assert)); + } +}); + +if (failures.length > 0) { + console.error('Pre-condition assertion check FAILED:\n' + failures.join('\n')); + process.exit(1); +} else { + console.log('All ' + keys.length + ' pre-condition(s) pass assertion correctness check.'); +} diff --git a/scripts/perps/agentic/validate-recipe.sh b/scripts/perps/agentic/validate-recipe.sh index 550f8d038862..c159c9e43f29 100755 --- a/scripts/perps/agentic/validate-recipe.sh +++ b/scripts/perps/agentic/validate-recipe.sh @@ -50,18 +50,21 @@ export WATCHER_PORT="${WATCHER_PORT:-8081}" SD="scripts/perps/agentic" # ── Args ────────────────────────────────────────────────────────────── -RECIPE="" DRY=false SKIP_MANUAL=false SINGLE="" +RECIPE="" DRY=false SKIP_MANUAL=false SINGLE="" OVERRIDE_ACCOUNT="" OVERRIDE_TESTNET=false HUD_ENABLED=true while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY=true; shift ;; --skip-manual) SKIP_MANUAL=true; shift ;; + --no-hud) HUD_ENABLED=false; shift ;; --step) SINGLE="$2"; shift 2 ;; + --account) OVERRIDE_ACCOUNT="$2"; shift 2 ;; + --testnet) OVERRIDE_TESTNET=true; shift ;; -*) echo "Unknown flag: $1"; exit 1 ;; *) RECIPE="$1"; shift ;; esac done -[ -z "$RECIPE" ] && { echo "Usage: validate-recipe.sh [--dry-run] [--step ] [--skip-manual]"; exit 1; } +[ -z "$RECIPE" ] && { echo "Usage: validate-recipe.sh [--dry-run] [--step ] [--skip-manual] [--account ] [--testnet]"; exit 1; } # Resolve folder → recipe.json; track RECIPE_DIR for artifact output RECIPE_DIR="" @@ -85,6 +88,27 @@ jfile() { node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8') # Read a field from a JSON string passed as arg jstr() { node -p "JSON.parse(process.argv[1])[process.argv[2]]||''" "$1" "$2"; } +# ── Apply {{param|default}} substitution on the recipe itself ───────── +# Pass 1: apply defaults declared in the "inputs" block (single source of truth). +# Pass 2: fallback — replace remaining {{key|default}} with inline defaults (backward compat). +_RECIPE_SUBST=$(mktemp /tmp/perps-recipe-XXXXXXXXXXXX).json +_FLOW_TEMPS=() +trap 'rm -f "$_RECIPE_SUBST" "${_FLOW_TEMPS[@]}"' EXIT +node -e " + var fs=require('fs'); + var src=fs.readFileSync(process.argv[1],'utf8'); + var doc; try{doc=JSON.parse(src);}catch(e){doc={};} + var inputs=doc.inputs||{}; + for(var k in inputs){ + if(inputs[k].default!=null){ + src=src.replace(new RegExp('\\\\{\\\\{'+k+'(?:\\\\|[^}]*)?\\\\}\\\\}','g'),String(inputs[k].default)); + } + } + src=src.replace(/\{\{[^|}]+\|([^}]+)\}\}/g,'\$1'); + fs.writeFileSync(process.argv[2],src); +" "$RECIPE" "$_RECIPE_SUBST" +RECIPE="$_RECIPE_SUBST" + # ── Recipe metadata ─────────────────────────────────────────────────── TITLE=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).title||'Untitled'" "$RECIPE") PR=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).pr||'?'" "$RECIPE") @@ -94,6 +118,74 @@ echo "Running recipe: $TITLE (PR #$PR)" echo "Pre-conditions: $PRECOND" echo "" +# ── Initial conditions ──────────────────────────────────────────────── +IC_ACCOUNT=$(node -p "const c=(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).initial_conditions||{});c.account||''" "$RECIPE") +IC_TESTNET=$(node -p "const c=(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).initial_conditions||{});c.testnet!==undefined?String(c.testnet):''" "$RECIPE") +IC_PROVIDER=$(node -p "const c=(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).initial_conditions||{});c.provider||''" "$RECIPE") + +# CLI overrides +[ -n "$OVERRIDE_ACCOUNT" ] && IC_ACCOUNT="$OVERRIDE_ACCOUNT" +[ "$OVERRIDE_TESTNET" = true ] && IC_TESTNET="true" + +if [ "$DRY" = false ]; then + if [ -n "$IC_ACCOUNT" ]; then + echo "[setup] switch-account $IC_ACCOUNT" + bash "$SD/app-state.sh" switch-account "$IC_ACCOUNT" >/dev/null 2>&1 + fi + if [ -n "$IC_TESTNET" ]; then + CURR_TESTNET=$(node "$SD/cdp-bridge.js" eval "Engine.context.PerpsController.state.isTestnet" 2>/dev/null) + if [ "$CURR_TESTNET" != "$IC_TESTNET" ]; then + echo "[setup] toggle_testnet (current: $CURR_TESTNET → desired: $IC_TESTNET)" + node "$SD/cdp-bridge.js" eval-async "Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})" >/dev/null 2>&1 + fi + fi + if [ -n "$IC_PROVIDER" ]; then + echo "[setup] switch_provider $IC_PROVIDER" + node "$SD/cdp-bridge.js" eval-async "Engine.context.PerpsController.switchProvider('$IC_PROVIDER').then(function(r){return JSON.stringify(r)})" >/dev/null 2>&1 + fi +fi + +# ── Pre-condition checks ────────────────────────────────────────────── +# Expand pre-condition shorthand: "name(k=v)" → { name, k: v } +PC_JSON=$(node -p " + var d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')); + var pcs=((d.validate||{}).runtime||{}).pre_conditions||[]; + var expanded=pcs.map(function(spec){ + if(typeof spec!=='string')return spec; + var m=spec.match(/^([^(]+)\((.+)\)$/); + if(!m)return spec; + var r={name:m[1]}; + m[2].split(',').forEach(function(pair){ + var eq=pair.indexOf('='); + if(eq>0)r[pair.slice(0,eq).trim()]=pair.slice(eq+1).trim(); + }); + return r; + }); + JSON.stringify(expanded); +" "$RECIPE") +if [ "$PC_JSON" != "[]" ] && [ "$DRY" = false ]; then + echo "[pre-conditions] Checking: $PC_JSON" + PC_RESULT=$(node "$SD/cdp-bridge.js" check-pre-conditions "$PC_JSON" 2>&1) + PC_OK=$(node -p "JSON.parse(process.argv[1]).ok" "$PC_RESULT" 2>/dev/null || echo "false") + if [ "$PC_OK" != "true" ]; then + echo "" + echo "PRE-CONDITIONS FAILED ❌" + node -e " + var r=JSON.parse(process.argv[1]); + (r.failures||[]).forEach(function(f){ + console.log(' • '+f.name+(f.description?' — '+f.description:'')); + if(f.error) console.log(' error: '+f.error); + if(f.got) console.log(' got: '+f.got); + if(f.hint) console.log(' hint: '+f.hint); + }); + " "$PC_RESULT" + echo "" + exit 1 + fi + echo "[pre-conditions] ✅ All passed" + echo "" +fi + # ── Counters ────────────────────────────────────────────────────────── TOTAL=0 PASSED=0 FAILED=0 SKIPPED=0 @@ -102,32 +194,21 @@ fail_recipe() { echo "" echo "────────────────────────────────────────" echo "Results: $PASSED/$TOTAL passed, $FAILED failed" + if [ "$HUD_ENABLED" = true ]; then + node "$SD/cdp-bridge.js" hide-step 2>/dev/null || true + fi echo "Recipe: FAIL ❌" exit 1 } # ── Assertion evaluator ─────────────────────────────────────────────── # Usage: check_assert -# Output: "PASS" or "FAIL: " +# Output: "PASS" or "FAIL" check_assert() { node -e " -const raw=process.argv[1],spec=JSON.parse(process.argv[2]); -let val;try{val=JSON.parse(raw);}catch(e){val=raw;} -if(typeof val==='string'){try{val=JSON.parse(val);}catch(e){}} -const f=spec.field||''; -if(f){for(const p of f.split('.')){if(val&&typeof val==='object')val=Array.isArray(val)&&/^\d+$/.test(p)?val[+p]:val[p];else{val=undefined;break;}}} -const op=spec.operator||'',exp=spec.value; -let pass=false,reason=''; -if(op==='not_null'){pass=val!=null;reason='expected not null, got null';} -else if(op==='eq'){pass=val===exp;reason='expected '+JSON.stringify(exp)+', got '+JSON.stringify(val);} -else if(op==='gt'){pass=val>exp;reason='expected > '+exp+', got '+val;} -else if(op==='length_eq'){const n=val!=null?val.length:null;pass=n===exp;reason='expected length=='+exp+', got '+n;} -else if(op==='length_gt'){const n=val!=null?val.length:null;pass=n!=null&&n>exp;reason='expected length>'+exp+', got '+n;} -else if(op==='contains'){const t=String(exp);pass=typeof val==='string'?val.includes(t):Array.isArray(val)&&val.map(String).includes(t);reason=JSON.stringify(val)+' does not contain '+t;} -else if(op==='not_contains'){const t=String(exp);pass=typeof val==='string'?!val.includes(t):Array.isArray(val)&&!val.map(String).includes(t);reason=JSON.stringify(val)+' contains '+t;} -else{reason='unknown operator: '+op;} -console.log(pass?'PASS':'FAIL: '+reason); -" "$1" "$2" +const { checkAssert } = require(require('path').resolve(process.argv[3], 'lib/assert')); +console.log(checkAssert(process.argv[1], JSON.parse(process.argv[2])) ? 'PASS' : 'FAIL'); +" "$1" "$2" "$SD" } # ── Log scanner ─────────────────────────────────────────────────────── @@ -148,7 +229,22 @@ console.log(JSON.stringify(r)); # ── Main loop ───────────────────────────────────────────────────────── while IFS= read -r sj; do SID=$(node -p "JSON.parse(process.argv[1]).id||'?'" "$sj") - SDESC=$(node -p "JSON.parse(process.argv[1]).description||''" "$sj") + SDESC=$(node -p " + var s = JSON.parse(process.argv[1]); + s.description || (function() { + var a = s.action || ''; + if (a === 'press') return 'press ' + s.test_id; + if (a === 'wait_for') return 'wait for ' + (s.test_id || s.route || s.not_route || 'condition'); + if (a === 'navigate') return 'navigate to ' + s.target; + if (a === 'set_input') return 'set ' + s.test_id + '=' + s.value; + if (a === 'flow_ref') return 'flow: ' + s.ref; + if (a === 'eval_ref') return 'eval ref: ' + s.ref; + if (a === 'eval_sync' || a === 'eval_async') return a; + if (a === 'type_keypad') return 'type ' + s.value; + if (a === 'toggle_testnet') return 'toggle testnet=' + (s.enabled !== undefined ? s.enabled : 'true'); + return a; + }()) + " "$sj") ACT=$(node -p "JSON.parse(process.argv[1]).action||''" "$sj") HAS_A=$(node -p "'assert' in JSON.parse(process.argv[1])" "$sj") A_JSON=$(node -p "JSON.stringify(JSON.parse(process.argv[1]).assert||{})" "$sj") @@ -157,6 +253,10 @@ while IFS= read -r sj; do TOTAL=$((TOTAL + 1)) echo "[$SID] $SDESC" + if [ "$HUD_ENABLED" = true ] && [ "$DRY" = false ]; then + node "$SD/cdp-bridge.js" show-step "$SID" "$TITLE — $SDESC" 2>/dev/null || true + fi + RESULT="" case "$ACT" in navigate) @@ -180,10 +280,10 @@ while IFS= read -r sj; do echo " -> eval-async \"${EXPR:0:80}\"..." [ "$DRY" = false ] && RESULT=$(node "$SD/cdp-bridge.js" eval-async "$EXPR" 2>/dev/null) ;; - recipe_ref) + eval_ref) REF=$(node -p "JSON.parse(process.argv[1]).ref||''" "$sj") - echo " -> recipe perps/$REF" - [ "$DRY" = false ] && RESULT=$(node "$SD/cdp-bridge.js" recipe "perps/$REF" 2>/dev/null) + echo " -> eval-ref perps/$REF" + [ "$DRY" = false ] && RESULT=$(node "$SD/cdp-bridge.js" eval-ref "perps/$REF" 2>/dev/null) ;; log_watch) WS=$(node -p "JSON.parse(process.argv[1]).window_seconds||10" "$sj") @@ -274,6 +374,170 @@ while IFS= read -r sj; do fi fi ;; + flow_ref) + FL_REF=$(node -p "JSON.parse(process.argv[1]).ref||''" "$sj") + FL_PARAMS=$(node -p "JSON.stringify(JSON.parse(process.argv[1]).params||{})" "$sj") + # Resolve flow path: "team/name" → teams/team/flows/name.json + if [[ "$FL_REF" == */* ]]; then + FL_TEAM="${FL_REF%%/*}" + FL_NAME="${FL_REF#*/}" + FLOW_FILE="$SD/teams/${FL_TEAM}/flows/${FL_NAME}.json" + else + FLOW_FILE="$SD/teams/perps/flows/${FL_REF}.json" + fi + if [ ! -f "$FLOW_FILE" ]; then + echo " ❌ FAIL: flow not found: ${FLOW_FILE#$SD/}"; fail_recipe + fi + echo " -> flow: $FL_REF (params: ${FL_PARAMS:0:80})" + SUBST_FLOW=$(mktemp /tmp/perps-flow-XXXXXXXXXXXX).json + _FLOW_TEMPS+=("$SUBST_FLOW") + node -e " + var fs=require('fs'); + var src=fs.readFileSync(process.argv[1],'utf8'); + var doc; try{doc=JSON.parse(src);}catch(e){doc={};} + var p=JSON.parse(process.argv[2]); + var inputs=doc.inputs||{}; + // Pass 1: replace {{key}} and {{key|default}} with provided param values + for(var k in p){src=src.replace(new RegExp('\\\\{\\\\{'+k+'(?:\\\\|[^}]*)?\\\\}\\\\}','g'),String(p[k]));} + // Pass 2: apply defaults from the referenced flow's inputs block + for(var ik in inputs){ + if(inputs[ik].default!=null && !p.hasOwnProperty(ik)){ + src=src.replace(new RegExp('\\\\{\\\\{'+ik+'(?:\\\\|[^}]*)?\\\\}\\\\}','g'),String(inputs[ik].default)); + } + } + // Pass 3: fallback — replace remaining {{key|default}} with inline defaults + src=src.replace(/\{\{[^|}]+\|([^}]+)\}\}/g,'\$1'); + fs.writeFileSync(process.argv[3],src); + " "$FLOW_FILE" "$FL_PARAMS" "$SUBST_FLOW" + FLOW_FLAGS=() + [ "$DRY" = true ] && FLOW_FLAGS+=(--dry-run) + [ "$SKIP_MANUAL" = true ] && FLOW_FLAGS+=(--skip-manual) + [ "$HUD_ENABLED" = false ] && FLOW_FLAGS+=(--no-hud) + if bash "$SD/validate-recipe.sh" "$SUBST_FLOW" "${FLOW_FLAGS[@]}"; then + RESULT='{"ok":true}' + else + echo " ❌ FAIL: flow failed: $FL_REF"; fail_recipe + fi + ;; + select_account) + ADDR=$(node -p "JSON.parse(process.argv[1]).address||''" "$sj") + echo " -> switch-account $ADDR" + [ "$DRY" = false ] && RESULT=$(bash "$SD/app-state.sh" switch-account "$ADDR" 2>&1) + ;; + toggle_testnet) + DESIRED=$(node -p "var s=JSON.parse(process.argv[1]);s.enabled!==undefined?String(s.enabled):'true'" "$sj") + echo " -> toggle_testnet (desired: $DESIRED)" + if [ "$DRY" = false ]; then + CURR=$(node "$SD/cdp-bridge.js" eval "Engine.context.PerpsController.state.isTestnet" 2>/dev/null) + if [ "$CURR" != "$DESIRED" ]; then + RESULT=$(node "$SD/cdp-bridge.js" eval-async "Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})" 2>/dev/null) + else + RESULT='{"ok":true,"already":true}' + fi + fi + ;; + switch_provider) + PROV=$(node -p "JSON.parse(process.argv[1]).provider||''" "$sj") + echo " -> switch_provider $PROV" + [ "$DRY" = false ] && RESULT=$(node "$SD/cdp-bridge.js" eval-async "Engine.context.PerpsController.switchProvider('$PROV').then(function(r){return JSON.stringify(r)})" 2>/dev/null) + ;; + type_keypad) + TK_VAL=$(node -p "JSON.parse(process.argv[1]).value||''" "$sj") + echo " -> type_keypad \"$TK_VAL\"" + if [ "$DRY" = false ]; then + KEYS=$(node -p " + var v=String(process.argv[1]),keys=[]; + for(var i=0;i='0'&&c<='9') keys.push('keypad-key-'+c); + else if(c==='.') keys.push('keypad-key-dot'); + } + keys.join('\n') + " "$TK_VAL") + while IFS= read -r key; do + [ -n "$key" ] && node "$SD/cdp-bridge.js" press-test-id "$key" 2>/dev/null || true + done <<< "$KEYS" + RESULT="{\"ok\":true,\"value\":\"$TK_VAL\"}" + fi + ;; + clear_keypad) + CK_COUNT=$(node -p "JSON.parse(process.argv[1]).count||8" "$sj") + echo " -> clear_keypad x${CK_COUNT}" + if [ "$DRY" = false ]; then + for ((i=0; i/dev/null || true + done + RESULT="{\"ok\":true,\"deleted\":$CK_COUNT}" + fi + ;; + wait_for) + WF_EXPR=$(node -p "JSON.parse(process.argv[1]).expression||''" "$sj") + WF_ROUTE=$(node -p "JSON.parse(process.argv[1]).route||''" "$sj") + WF_NROUTE=$(node -p "JSON.parse(process.argv[1]).not_route||''" "$sj") + WF_TID=$(node -p "JSON.parse(process.argv[1]).test_id||''" "$sj") + WF_TIMEOUT=$(node -p "JSON.parse(process.argv[1]).timeout_ms||10000" "$sj") + WF_POLL=$(node -p "JSON.parse(process.argv[1]).poll_ms||500" "$sj") + + # Resolve expression + assertion from sugar (priority: route > not_route > test_id > expression) + if [ -n "$WF_ROUTE" ]; then + WF_EXPR="JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})" + A_JSON=$(printf '{"operator":"eq","field":"route","value":"%s"}' "$WF_ROUTE") + HAS_A=true + echo " -> wait_for route=$WF_ROUTE (timeout=${WF_TIMEOUT}ms)" + elif [ -n "$WF_NROUTE" ]; then + WF_EXPR="JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})" + A_JSON=$(printf '{"operator":"neq","field":"route","value":"%s"}' "$WF_NROUTE") + HAS_A=true + echo " -> wait_for not_route=$WF_NROUTE (timeout=${WF_TIMEOUT}ms)" + elif [ -n "$WF_TID" ]; then + WF_VISIBLE=$(node -p "JSON.parse(process.argv[1]).visible!==false" "$sj") + WF_EXPR="JSON.stringify({visible:globalThis.__AGENTIC__.findFiberByTestId('$WF_TID')})" + if [ "$WF_VISIBLE" = "true" ]; then + A_JSON='{"operator":"eq","field":"visible","value":true}' + echo " -> wait_for testID=$WF_TID visible=true (timeout=${WF_TIMEOUT}ms)" + else + A_JSON='{"operator":"eq","field":"visible","value":false}' + echo " -> wait_for testID=$WF_TID visible=false (timeout=${WF_TIMEOUT}ms)" + fi + HAS_A=true + else + echo " -> wait_for expression (timeout=${WF_TIMEOUT}ms, poll=${WF_POLL}ms)" + fi + + if [ "$DRY" = true ]; then + SKIPPED=$((SKIPPED + 1)); echo " [DRY RUN - not executed]"; echo ""; continue + fi + + # Determine eval mode: async if expression contains .then( + WF_EVAL_MODE="eval" + case "$WF_EXPR" in *".then("*) WF_EVAL_MODE="eval-async" ;; esac + + # Poll loop (use perl for ms timestamps — avoids spawning node per iteration) + _ms_now() { perl -MTime::HiRes=time -e 'printf "%d\n", time*1000'; } + WF_DEADLINE=$(( $(_ms_now) + WF_TIMEOUT )) + WF_SLEEP=$(awk "BEGIN{printf \"%.2f\", $WF_POLL/1000}") + WF_PASSED=false + while true; do + RESULT=$(node "$SD/cdp-bridge.js" "$WF_EVAL_MODE" "$WF_EXPR" 2>/dev/null) || RESULT="" + if [ -n "$RESULT" ] && [ "$HAS_A" = "true" ]; then + AR=$(check_assert "$RESULT" "$A_JSON") + if [[ "$AR" == PASS* ]]; then + WF_PASSED=true + break + fi + fi + [ "$(_ms_now)" -ge "$WF_DEADLINE" ] && break + sleep "$WF_SLEEP" + done + + [ -n "$RESULT" ] && echo " -> Result: ${RESULT:0:200}" + if [ "$WF_PASSED" = true ]; then + echo " ✅ PASS"; PASSED=$((PASSED + 1)) + else + echo " ❌ FAIL: wait_for timed out after ${WF_TIMEOUT}ms"; fail_recipe + fi + echo ""; continue + ;; *) echo " ❌ FAIL: unknown action '$ACT'" FAILED=$((FAILED + 1)); echo ""; continue @@ -311,6 +575,9 @@ if [ "$DRY" = true ]; then echo "Recipe: DRY RUN" else echo "Results: $PASSED/$TOTAL passed" + if [ "$HUD_ENABLED" = true ]; then + node "$SD/cdp-bridge.js" hide-step 2>/dev/null || true + fi [ "$FAILED" -gt 0 ] && { echo "Recipe: FAIL ❌"; exit 1; } echo "Recipe: PASS ✅" fi diff --git a/scripts/perps/agentic/wallet-fixture.example.json b/scripts/perps/agentic/wallet-fixture.example.json index 4af12a56c433..de4ebdbaebea 100644 --- a/scripts/perps/agentic/wallet-fixture.example.json +++ b/scripts/perps/agentic/wallet-fixture.example.json @@ -6,6 +6,9 @@ ], "settings": { "metametrics": false, - "skipGtmModals": true + "skipGtmModals": true, + "skipPerpsTutorial": true, + "autoLockNever": true, + "deviceAuthEnabled": false } } diff --git a/tests/api-mocking/mock-responses/defaults/index.ts b/tests/api-mocking/mock-responses/defaults/index.ts index a09e8c29294f..b28d8602cde4 100644 --- a/tests/api-mocking/mock-responses/defaults/index.ts +++ b/tests/api-mocking/mock-responses/defaults/index.ts @@ -28,6 +28,7 @@ import { ACL_EXECUTION_MOCKS } from './acl-execution.ts'; import { CONTENTFUL_BANNERS_MOCKS } from './contentful-banners.ts'; import { PERPS_HYPERLIQUID_MOCKS } from './perps-hyperliquid.ts'; import { TRENDING_API_MOCKS } from '../trending-api-mocks.ts'; +import { TX_SENTINEL_NETWORKS_MAP } from '../tx-sentinel-networks-map.ts'; // Get auth mocks const authMocks = getAuthMocks(); @@ -77,195 +78,7 @@ export const DEFAULT_MOCKS = { urlEndpoint: 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/networks', responseCode: 200, - response: { - '1': { - name: 'Mainnet', - group: 'ethereum', - chainID: 1, - nativeCurrency: { - name: 'ETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'ethereum-mainnet', - explorer: 'https://etherscan.io', - confirmations: true, - smartTransactions: true, - relayTransactions: true, - hidden: false, - sendBundle: true, - }, - '10': { - name: 'Optimism Mainnet', - group: 'optimism', - chainID: 10, - nativeCurrency: { - name: 'ETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'optimism-mainnet', - explorer: 'https://optimistic.etherscan.io', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '11155111': { - name: 'Sepolia', - group: 'ethereum', - chainID: 11155111, - nativeCurrency: { - name: 'SepoliaETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'ethereum-sepolia', - explorer: 'https://sepolia.etherscan.io', - confirmations: true, - smartTransactions: true, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '1329': { - name: 'Sei Mainnet', - group: 'sei', - chainID: 1329, - nativeCurrency: { - name: 'SEI', - symbol: 'SEI', - decimals: 18, - }, - network: 'sei-mainnet', - explorer: 'https://seitrace.com', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '137': { - name: 'Polygon Mainnet', - group: 'polygon', - chainID: 137, - nativeCurrency: { - name: 'MATIC', - symbol: 'MATIC', - decimals: 18, - }, - network: 'polygon-mainnet', - explorer: 'https://polygonscan.com/', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '143': { - name: 'Monad Mainnet', - group: 'monad', - chainID: 143, - nativeCurrency: { - name: 'MON', - symbol: 'MON', - decimals: 18, - }, - network: 'monad-mainnet', - explorer: 'https://monadscan.com/', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '42161': { - name: 'Arbitrum Mainnet', - group: 'arbitrum', - chainID: 42161, - nativeCurrency: { - name: 'ETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'arbitrum-mainnet', - explorer: 'https://arbiscan.io/', - confirmations: true, - smartTransactions: true, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '43114': { - name: 'Avalanche Mainnet', - group: 'avalanche', - chainID: 43114, - nativeCurrency: { - name: 'AVAX', - symbol: 'AVAX', - decimals: 18, - }, - network: 'avalanche-mainnet', - explorer: 'https://avascan.info/', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '56': { - name: 'BNB Smart Chain', - group: 'bnb', - chainID: 56, - nativeCurrency: { - name: 'BNB', - symbol: 'BNB', - decimals: 18, - }, - network: 'bsc-mainnet', - explorer: 'https://bscscan.com/', - confirmations: true, - smartTransactions: true, - relayTransactions: true, - hidden: false, - sendBundle: true, - }, - '59144': { - name: 'Linea Mainnet', - group: 'linea', - chainID: 59144, - nativeCurrency: { - name: 'ETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'linea-mainnet', - explorer: 'https://lineascan.build', - confirmations: true, - smartTransactions: false, - relayTransactions: false, - hidden: false, - sendBundle: false, - }, - '8453': { - name: 'Base Mainnet', - group: 'base', - chainID: 8453, - nativeCurrency: { - name: 'ETH', - symbol: 'ETH', - decimals: 18, - }, - network: 'base-mainnet', - explorer: 'https://basescan.org', - confirmations: true, - smartTransactions: true, - relayTransactions: true, - hidden: false, - sendBundle: false, - }, - }, + response: TX_SENTINEL_NETWORKS_MAP, }, // TX Sentinel single network endpoint (for chainId-specific requests) { diff --git a/tests/api-mocking/mock-responses/defaults/token-apis.ts b/tests/api-mocking/mock-responses/defaults/token-apis.ts index d79880661837..754492a72ab1 100644 --- a/tests/api-mocking/mock-responses/defaults/token-apis.ts +++ b/tests/api-mocking/mock-responses/defaults/token-apis.ts @@ -12,6 +12,11 @@ const tokenListRegex = const tokenAssetsRegex = /^https:\/\/token\.api\.cx\.metamask\.io\/assets\?assetIds=.*&includeTokenSecurityData=true$/; +// Matches the v3 assets endpoint used by useERC20Tokens to fetch token metadata +// e.g. https://tokens.api.cx.metamask.io/v3/assets?assetIds=eip155:1/erc20:0x...&includeIconUrl=true +const tokenV3AssetsRegex = + /^https:\/\/tokens\.api\.cx\.metamask\.io\/v3\/assets\?.*$/; + export const TOKEN_API_MOCKS: MockEventsObject = { GET: [ { @@ -24,5 +29,10 @@ export const TOKEN_API_MOCKS: MockEventsObject = { responseCode: 200, response: [], }, + { + urlEndpoint: tokenV3AssetsRegex, + responseCode: 200, + response: [], + }, ], }; diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts index 01fc1e185ece..0319455fd024 100644 --- a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts +++ b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts @@ -55,6 +55,7 @@ import { import { createTransactionSentinelResponse } from './polymarket-transaction-sentinel-response.ts'; import { GEO_BLOCKED_COUNTRIES } from '../../../../app/components/UI/Predict/constants/geoblock.ts'; import { POLYMARKET_GEOBLOCK_ELIGIBLE } from '../defaults/polymarket-apis.ts'; +import { TX_SENTINEL_NETWORKS_MAP } from '../tx-sentinel-networks-map.ts'; /** * Mock for Polymarket API returning 500 error @@ -824,6 +825,23 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( currentUSDCBalance = customBalance; } + // Token API single-token metadata (Polygon bridged USDC). Activity and other flows + // call GET .../token/137?address=0x2791...&includeRwaData=true — must be mocked for + // live-request validation in E2E. + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /^https:\/\/token\.api\.cx\.metamask\.io\/token\/137\?.*address=0x2791bca1f2de4661ed88a30c99a7a9449aa84174/i, + responseCode: 200, + response: { + address: USDC_CONTRACT_ADDRESS.toLowerCase(), + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/137/0x2791bca1f2de4661ed88a30c99a7a9449aa84174.png', + }, + }); + // The app makes balance calls through the proxy, not direct Infura calls // Our existing proxy mock below will handle these calls @@ -959,10 +977,22 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( // Return a reasonable gas estimate result = '0xa49f3'; // ~675,683 gas } else if (body?.method === 'eth_getTransactionReceipt') { - // Return a mock transaction receipt indicating the transaction is confirmed - // This is critical for TransactionController to mark transactions as confirmed - // TransactionController polls for receipts to determine transaction status - result = MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT; + // Return a mock receipt so submitted txs confirm. Use the requested hash so it + // matches eth_sendRawTransaction / eth_sendTransaction responses. + const requestedHash = + body?.params?.[0] ?? + MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT.transactionHash; + result = { + ...MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT, + transactionHash: requestedHash, + }; + } else if ( + body?.method === 'eth_sendRawTransaction' || + body?.method === 'eth_sendTransaction' + ) { + // A valid 32-byte tx hash is required; returning `0x` breaks submission and the + // activity list never shows Predict withdraw (or any) transactions. + result = MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT.transactionHash; } else if (body?.method === 'eth_getBlockByNumber') { // Return block details to enable EIP-1559 transactions result = { @@ -1068,11 +1098,71 @@ export const POLYMARKET_MARKET_FEEDS_MOCKS = async (mockServer: Mockttp) => { })); }; +/** UUID returned for `eth_sendRelayTransaction` on Polygon sentinel (EIP-7702 predict withdraw). */ +const POLYGON_RELAY_TX_E2E_UUID = 'predict-e2e-withdraw-relay-uuid'; + +/** + * Overrides TX Sentinel `/networks` so Polygon (137) advertises transaction relay. + * Required for Delegation7702PublishHook: default mocks set relayTransactions=false for 137, + * so submitRelayTransaction cannot run and predict withdraw confirmation fails. + * + * Keep sendBundle false: if sendBundle is true while smart transactions are enabled, + * publishHook skips Delegation7702 and uses submitSmartTransactionHook instead + * (see transaction-controller-init.ts), so eth_sendRelayTransaction mocks would never run. + */ +export const POLYMARKET_POLYGON_RELAY_NETWORK_FLAGS_MOCKS = async ( + mockServer: Mockttp, +) => { + const withPolygonRelay = { + ...TX_SENTINEL_NETWORKS_MAP, + '137': { + ...TX_SENTINEL_NETWORKS_MAP['137'], + relayTransactions: true, + sendBundle: false, + }, + }; + + await mockServer + .forGet('https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/networks') + .asPriority(PRIORITY.HOMEPAGE_POSITIONS_OVERRIDE) + .thenCallback(() => ({ + statusCode: 200, + json: withPolygonRelay, + })); +}; + /** - * Mocks transaction sentinel for Polygon transactions - * Mocks the infura_simulateTransactions method for transaction simulation + * GET `smart-transactions/{uuid}` while waitForRelayResult polls after EIP-7702 relay submit. + * Use only with predict withdraw (together with POLYMARKET_POLYGON_RELAY_NETWORK_FLAGS_MOCKS). + */ +export const POLYMARKET_POLYGON_RELAY_POLLING_MOCKS = async ( + mockServer: Mockttp, +) => { + await mockServer + .forGet( + `https://tx-sentinel-polygon-mainnet.api.cx.metamask.io/smart-transactions/${POLYGON_RELAY_TX_E2E_UUID}`, + ) + .asPriority(PRIORITY.BASE) + .thenCallback(() => ({ + statusCode: 200, + json: { + transactions: [ + { + hash: MOCK_RPC_RESPONSES.TRANSACTION_RECEIPT_RESULT.transactionHash, + // RelayStatus.Success — must match app/util/transactions/transaction-relay.ts + status: 'VALIDATED', + }, + ], + }, + })); +}; + +/** + * Mocks Polygon TX Sentinel JSON-RPC POST (simulation + optional relay submit). + * Relay `eth_sendRelayTransaction` branch is only exercised when the app uses Delegation7702 + * relay on Polygon; other flows only hit the simulation response. + * * @param mockServer - The mockttp server instance - * @param fromAddress - Optional address to use in the response (defaults to USER_WALLET_ADDRESS) */ export const POLYMARKET_TRANSACTION_SENTINEL_MOCKS = async ( mockServer: Mockttp, @@ -1084,6 +1174,18 @@ export const POLYMARKET_TRANSACTION_SENTINEL_MOCKS = async ( try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : {}; + + if (body?.method === 'eth_sendRelayTransaction') { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: body.id ?? 1, + result: { uuid: POLYGON_RELAY_TX_E2E_UUID }, + }, + }; + } + const transactions = body?.params?.[0]?.transactions || []; const firstTx = transactions[0] || {}; const fromAddress = diff --git a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts new file mode 100644 index 000000000000..0a28e014b130 --- /dev/null +++ b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts @@ -0,0 +1,193 @@ +/** + * TX Sentinel `/networks` response body (chainId string keys). + * Shared by default mocks and Polygon relay E2E overrides (e.g. POLYMARKET_POLYGON_RELAY_NETWORK_FLAGS_MOCKS). + */ +export const TX_SENTINEL_NETWORKS_MAP = { + '1': { + name: 'Mainnet', + group: 'ethereum', + chainID: 1, + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'ethereum-mainnet', + explorer: 'https://etherscan.io', + confirmations: true, + smartTransactions: true, + relayTransactions: true, + hidden: false, + sendBundle: true, + }, + '10': { + name: 'Optimism Mainnet', + group: 'optimism', + chainID: 10, + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'optimism-mainnet', + explorer: 'https://optimistic.etherscan.io', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '11155111': { + name: 'Sepolia', + group: 'ethereum', + chainID: 11155111, + nativeCurrency: { + name: 'SepoliaETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'ethereum-sepolia', + explorer: 'https://sepolia.etherscan.io', + confirmations: true, + smartTransactions: true, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '1329': { + name: 'Sei Mainnet', + group: 'sei', + chainID: 1329, + nativeCurrency: { + name: 'SEI', + symbol: 'SEI', + decimals: 18, + }, + network: 'sei-mainnet', + explorer: 'https://seitrace.com', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '137': { + name: 'Polygon Mainnet', + group: 'polygon', + chainID: 137, + nativeCurrency: { + name: 'MATIC', + symbol: 'MATIC', + decimals: 18, + }, + network: 'polygon-mainnet', + explorer: 'https://polygonscan.com/', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '143': { + name: 'Monad Mainnet', + group: 'monad', + chainID: 143, + nativeCurrency: { + name: 'MON', + symbol: 'MON', + decimals: 18, + }, + network: 'monad-mainnet', + explorer: 'https://monadscan.com/', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '42161': { + name: 'Arbitrum Mainnet', + group: 'arbitrum', + chainID: 42161, + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'arbitrum-mainnet', + explorer: 'https://arbiscan.io/', + confirmations: true, + smartTransactions: true, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '43114': { + name: 'Avalanche Mainnet', + group: 'avalanche', + chainID: 43114, + nativeCurrency: { + name: 'AVAX', + symbol: 'AVAX', + decimals: 18, + }, + network: 'avalanche-mainnet', + explorer: 'https://avascan.info/', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '56': { + name: 'BNB Smart Chain', + group: 'bnb', + chainID: 56, + nativeCurrency: { + name: 'BNB', + symbol: 'BNB', + decimals: 18, + }, + network: 'bsc-mainnet', + explorer: 'https://bscscan.com/', + confirmations: true, + smartTransactions: true, + relayTransactions: true, + hidden: false, + sendBundle: true, + }, + '59144': { + name: 'Linea Mainnet', + group: 'linea', + chainID: 59144, + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'linea-mainnet', + explorer: 'https://lineascan.build', + confirmations: true, + smartTransactions: false, + relayTransactions: false, + hidden: false, + sendBundle: false, + }, + '8453': { + name: 'Base Mainnet', + group: 'base', + chainID: 8453, + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + network: 'base-mainnet', + explorer: 'https://basescan.org', + confirmations: true, + smartTransactions: true, + relayTransactions: true, + hidden: false, + sendBundle: false, + }, +}; diff --git a/tests/page-objects/Predict/PredictBalance.ts b/tests/page-objects/Predict/PredictBalance.ts new file mode 100644 index 000000000000..484658077258 --- /dev/null +++ b/tests/page-objects/Predict/PredictBalance.ts @@ -0,0 +1,29 @@ +import { Matchers, Gestures, Assertions } from '../../framework'; +import { + PredictBalanceSelectorsIDs, + PredictBalanceSelectorsText, +} from '../../../app/components/UI/Predict/Predict.testIds'; + +class PredictBalance { + get balanceCard(): DetoxElement { + return Matchers.getElementByID(PredictBalanceSelectorsIDs.BALANCE_CARD); + } + + get withdrawButton(): DetoxElement { + return Matchers.getElementByText(PredictBalanceSelectorsText.WITHDRAW); + } + + async tapWithdraw(): Promise { + await Gestures.waitAndTap(this.withdrawButton, { + elemDescription: 'Predict Withdraw button', + }); + } + + async expectBalanceCardVisible(): Promise { + await Assertions.expectElementToBeVisible(this.balanceCard, { + description: 'Predict balance card should be visible', + }); + } +} + +export default new PredictBalance(); diff --git a/tests/page-objects/Transactions/ActivitiesView.ts b/tests/page-objects/Transactions/ActivitiesView.ts index ce9de95907ff..593c59d9792e 100644 --- a/tests/page-objects/Transactions/ActivitiesView.ts +++ b/tests/page-objects/Transactions/ActivitiesView.ts @@ -68,6 +68,12 @@ class ActivitiesView { ); } + get predictWithdraw(): DetoxElement { + return Matchers.getElementByText( + ActivitiesViewSelectorsText.PREDICT_WITHDRAW, + ); + } + transactionStatus(row: number): DetoxElement { return Matchers.getElementByID(`transaction-status-${row}`); } diff --git a/tests/smoke/predict/predict-withdraw.spec.ts b/tests/smoke/predict/predict-withdraw.spec.ts index 757bd84c102b..4efa3a1bc807 100644 --- a/tests/smoke/predict/predict-withdraw.spec.ts +++ b/tests/smoke/predict/predict-withdraw.spec.ts @@ -1,24 +1,34 @@ import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { SmokeTrade } from '../../tags'; +import { SmokePredictions } from '../../tags'; import { loginToApp } from '../../flows/wallet.flow'; - +import { Assertions } from '../../framework'; import { remoteFeatureFlagHomepageSectionsV1Enabled, remoteFeatureFlagPredictEnabled, confirmationFeatureFlags, } from '../../api-mocking/mock-responses/feature-flags-mocks'; import { + POLYMARKET_POLYGON_RELAY_NETWORK_FLAGS_MOCKS, + POLYMARKET_POLYGON_RELAY_POLLING_MOCKS, POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS, POLYMARKET_TRANSACTION_SENTINEL_MOCKS, POLYMARKET_USDC_BALANCE_MOCKS, + POLYMARKET_WITHDRAW_BALANCE_LOAD_MOCKS, } from '../../api-mocking/mock-responses/polymarket/polymarket-mocks'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../page-objects/wallet/WalletActionsBottomSheet'; +import PredictBalance from '../../page-objects/Predict/PredictBalance'; +import PredictMarketList from '../../page-objects/Predict/PredictMarketList'; +import TransactionPayConfirmation from '../../page-objects/Confirmation/TransactionPayConfirmation'; +import FooterActions from '../../page-objects/Browser/Confirmations/FooterActions'; const PredictionMarketFeature = async (mockServer: Mockttp) => { + // Polygon predict withdraw publishes via EIP-7702 transaction relay, not Infura eth_sendRawTransaction. + await POLYMARKET_POLYGON_RELAY_NETWORK_FLAGS_MOCKS(mockServer); + await POLYMARKET_POLYGON_RELAY_POLLING_MOCKS(mockServer); await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagPredictEnabled(true), ...remoteFeatureFlagHomepageSectionsV1Enabled(), @@ -28,21 +38,42 @@ const PredictionMarketFeature = async (mockServer: Mockttp) => { await POLYMARKET_USDC_BALANCE_MOCKS(mockServer); // Sets up all RPC mocks needed for withdraw flow await POLYMARKET_TRANSACTION_SENTINEL_MOCKS(mockServer); // needed to load the withdraw/deposit/claim screen await POLYMARKET_POSITIONS_WITH_WINNINGS_MOCKS(mockServer, false); // do not include winnings for claim flow + await POLYMARKET_WITHDRAW_BALANCE_LOAD_MOCKS(mockServer); }; -describe(SmokeTrade('Predictions'), () => { - it('should withdraw positions', async () => { +describe(SmokePredictions('Predictions Withdraw'), () => { + it('withdraws from Predict balance', async () => { await withFixtures( { - fixture: new FixtureBuilder().withPolygon().build(), + fixture: new FixtureBuilder() + .withPolygon() + // STX + sendBundle on Polygon would skip Delegation7702PublishHook and use the + // smart-transaction publish path (not covered by POLYMARKET_TRANSACTION_SENTINEL_MOCKS). + .withDisabledSmartTransactions() + .build(), restartDevice: true, testSpecificMock: PredictionMarketFeature, }, async () => { await loginToApp(); + await TabBarComponent.tapActions(); await WalletActionsBottomSheet.tapPredictButton(); - // TODO: Add withdraw flow + + await Assertions.expectElementToBeVisible(PredictMarketList.container, { + description: 'Predict market list container should be visible', + }); + await PredictBalance.expectBalanceCardVisible(); + await PredictBalance.tapWithdraw(); + + await TransactionPayConfirmation.tapKeyboardAmount('5'); + await TransactionPayConfirmation.tapKeyboardContinueButton(); + await FooterActions.tapConfirmButton(); + + await Assertions.expectElementToBeVisible(PredictMarketList.container, { + description: + 'Predict market list should be visible after withdraw confirmation', + }); }, ); }); diff --git a/yarn.lock b/yarn.lock index 5eefd3347f36..a1fe3f093ef3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7629,15 +7629,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ai-controllers@npm:^0.4.0": - version: 0.4.0 - resolution: "@metamask/ai-controllers@npm:0.4.0" +"@metamask/ai-controllers@npm:^0.5.0": + version: 0.5.0 + resolution: "@metamask/ai-controllers@npm:0.5.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/b51e15821dd62c6b15dc2c1efe6da56f4467b81ee8e7ff7df5f38c429d8b47162ee918e94a5713fe55902390f75a4cb6672813f4e103e8b348fca624dd27c467 + checksum: 10/a4806d4de47913e86117d825a395deb30bad8fccce1128592763b79bae245889796331de4bfba2dcf8d7aba334bf9b8b87c31d2255615492b34e26a09feb009d languageName: node linkType: hard @@ -7695,7 +7695,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^2.3.0, @metamask/assets-controller@npm:^2.4.0": +"@metamask/assets-controller@npm:^2.4.0": version: 2.4.0 resolution: "@metamask/assets-controller@npm:2.4.0" dependencies: @@ -7730,6 +7730,41 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/assets-controller@npm:3.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/assets-controllers": "npm:^101.0.1" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/client-controller": "npm:^1.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/core-backend": "npm:^6.2.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/keyring-internal-api": "npm:^10.0.0" + "@metamask/keyring-snap-client": "npm:^8.2.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" + lodash: "npm:^4.17.21" + p-limit: "npm:^3.1.0" + checksum: 10/8f5984c11b3efa899871a79d5017475c4622a9dd23a1a8983122dc6c75b46089d71b40808d7a7b29cb8acfa3c476bbfa8c49abecfbd64e11c7f82bfc790ba0d2 + languageName: node + linkType: hard + "@metamask/assets-controllers@npm:^101.0.0, @metamask/assets-controllers@npm:^101.0.1": version: 101.0.1 resolution: "@metamask/assets-controllers@npm:101.0.1" @@ -35470,11 +35505,11 @@ __metadata: "@metamask/account-tree-controller": "npm:^5.0.0" "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/address-book-controller": "npm:^7.1.0" - "@metamask/ai-controllers": "npm:^0.4.0" + "@metamask/ai-controllers": "npm:^0.5.0" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" - "@metamask/assets-controller": "npm:^2.3.0" + "@metamask/assets-controller": "npm:^3.0.0" "@metamask/assets-controllers": "npm:^101.0.1" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" @@ -35488,7 +35523,7 @@ __metadata: "@metamask/compliance-controller": "npm:^1.0.1" "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.18.0" - "@metamask/core-backend": "npm:^5.0.0" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^0.15.0" "@metamask/design-system-react-native": "npm:^0.10.0"