From 6d2ff1b31a2346f3234433c501cbcefc8273e17f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 1 Jun 2026 09:42:52 +0200 Subject: [PATCH 01/24] fix: Improve URL detection in `SitesSearchFooter` (#30801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Ensure valid URLs are treated as valid by URL detection in `SitesSearchFooter`. Additionally move out the existing regex to not have to redeclare/recompile it for every check. ## **Changelog** CHANGELOG entry: Fixed an issue where certain URLs would not be consider valid in the in-app browser --- > [!NOTE] > **Low Risk** > Small UI-only change to how search footer classifies input; no auth, payments, or data-layer impact. > > **Overview** > **Sites search footer** now treats more inputs as URLs when deciding whether to show the direct “open URL” action alongside the search-engine link. > > `looksLikeUrl` combines the existing **`is-url`** check with the prior domain-style regex (hoisted to a module-level **`URL_REGEX`** so it isn’t recompiled on every call). That fixes cases like **`http://localhost:8000`** that the regex alone missed. A unit test covers localhost URLs with a scheme. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ae6c8550dbb3a3ba21037147fc9eb65a711a22c4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../SitesSearchFooter/SitesSearchFooter.test.tsx | 8 ++++++++ .../components/SitesSearchFooter/SitesSearchFooter.tsx | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx index 91f9a861fa6f..219dbc895fc3 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx @@ -95,6 +95,14 @@ describe('SitesSearchFooter', () => { expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); }); + it('detects localhost URLs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen(); + }); + it('detects URLs with path', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx index fcf3fce7f545..724c8ac18cd3 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx @@ -15,6 +15,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { selectSearchEngine } from '../../../../../reducers/browser/selectors'; import { SEARCH_ENGINE_URLS, SearchEngine } from '../../../../../util/browser'; import AppConstants from '../../../../../core/AppConstants'; +import isUrlFn from 'is-url'; // TODO: @MetaMask/design-system-engineers // Use the concrete Box component props here instead of BoxProps. @@ -39,11 +40,14 @@ export interface SitesSearchFooterProps { containerStyle?: BoxComponentProps['style']; } +// Note: This regex intentionally does not require a fully valid URL +const URL_REGEX = /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/; + /** * Checks if a string looks like a URL */ function looksLikeUrl(str: string): boolean { - return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str); + return isUrlFn(str) || URL_REGEX.test(str); } export const useSearchFooterBrowserNavigation = () => { From 321590fba1aefc5d9a5d93fb4a5d1714dd287dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:16:45 +0200 Subject: [PATCH 02/24] refactor: enhance UI spacing in various components for improved layout (#30729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Homepage sections that render row-based content (token list, perps positions, predict positions) have ~12 px of natural vertical padding built into each row item (py-3, height: 64, or paddingVertical: 12). Sections that render horizontal carousels or pill rails had no equivalent padding, causing the inter-section gap to look visually inconsistent — row sections appeared to have a larger breathing room than carousel/pill sections. The fix adds py-3 to the contentContainerStyle of each horizontal carousel and pill rail on the homepage, so every section contributes the same amount of vertical space regardless of its content type. Additionally, ScamWarningModal and RemoveTokenBottomSheet in TokensSection are moved outside the gap-applying container to prevent the modal library's hidden wrapper from consuming an extra 12 px gap below the Tokens section. ## **Changelog** CHANGELOG entry: Fixes spacing inconsistencies on the homepage ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-816 ## **Manual testing steps** ```gherkin Feature: Homepage section spacing Scenario: user views the homepage with all sections visible Given the app is open on the homepage And the user has tokens, perps positions, predict positions, and top traders visible When user scrolls through the homepage Then the vertical gap between every section header and the section below it looks visually equal And the gap between sections is consistent regardless of whether the section shows rows or a horizontal carousel/pill rail Scenario: user views homepage with Perps showing pills empty state Given the Perps section is showing the pills rail (no open positions) When user views the spacing above and below the pills rail Then the gap matches the spacing around row-based sections like Tokens Scenario: user views homepage with Perps showing trending carousel Given the Perps section is showing the trending market tile carousel When user views the spacing above and below the carousel Then the gap matches the spacing around row-based sections like Tokens ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-05-28 at 12 18 42 ### **After** Screenshot 2026-05-28 at 11 07 12 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI-only layout and container restructuring on the wallet homepage with no auth, data, or payment logic changes. > > **Overview** > Homepage sections now use **consistent vertical breathing room** between headers and content. Horizontal **carousels and pill rails** (Perps trending, Perps pills, Predict trending, Top Traders) get **`py-3`** on their scroll content so they match row-based sections that already pick up ~12px from list rows. > > **Tokens** wraps only the header and list in the **`sectionGap`** container and moves **`ScamWarningModal`** and **`RemoveTokenBottomSheet`** outside it so hidden modal wrappers do not add an extra gap under the section. > > **Perps** keeps positions and orders inside a single **`SectionRow`**, showing the skeleton in that same row while loading or trending is pending—so loading and filled states share the same vertical padding as other row sections. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3f90becc4506e7e901315bf4aeee37fb40ce51f2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Sections/Perpetuals/PerpsSectionMain.tsx | 44 +++++------ .../Perpetuals/components/PerpsPillsRail.tsx | 2 +- .../components/PerpsTrendingCarousel.tsx | 2 +- .../HomepagePredictTrendingCarousel.tsx | 2 +- .../Sections/Tokens/TokensSection.tsx | 76 ++++++++++--------- .../Sections/TopTraders/TopTradersSection.tsx | 2 +- 6 files changed, 65 insertions(+), 63 deletions(-) diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx index 0de689d05ad4..a2eba4299dd0 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSectionMain.tsx @@ -376,29 +376,29 @@ const PerpsSectionMain = forwardRef( /> )} - {showSkeleton || pendingTrending ? ( + {showSkeleton || pendingTrending || hasItems ? ( - - - ) : hasItems ? ( - - - {displayPositions.map((position) => ( - handlePositionPress(position)} - testID={`perps-position-row-${position.symbol}`} - /> - ))} - {displayOrders.map((order) => ( - - ))} - + {showSkeleton || pendingTrending ? ( + + ) : ( + + {displayPositions.map((position) => ( + handlePositionPress(position)} + testID={`perps-position-row-${position.symbol}`} + /> + ))} + {displayOrders.map((order) => ( + + ))} + + )} ) : shouldShowPillsEmptyState ? ( data={data} isLoading={isLoading} - wrapperTwClassName="bg-transparent" + wrapperTwClassName="bg-transparent py-3" renderItem={(item) => ( )} diff --git a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx index 9dd8f94728be..f41cc3ca1afc 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/components/PerpsTrendingCarousel.tsx @@ -30,7 +30,7 @@ const PerpsTrendingCarousel = ({ {markets.map((market) => ( diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx index 527fe15a3dcf..0acc051ce24b 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingCarousel.tsx @@ -53,7 +53,7 @@ const HomepagePredictTrendingCarousel = ({ {isLoadingMarkets ? ( CAROUSEL_SKELETON_KEYS.map((key) => ( diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx index 1f50976e7621..0149066bb1ed 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx @@ -239,45 +239,47 @@ const TokensSectionMain = forwardRef( } return ( - - - {showTokensError ? ( - + + - ) : isZeroBalanceAccount ? ( - - - - ) : ( - - {displayTokenKeys.length === 0 && sortedTokenKeys.length === 0 ? ( - - ) : ( - displayTokenKeys.map((tokenKey, index) => ( - - )) - )} - - )} + ) : isZeroBalanceAccount ? ( + + + + ) : ( + + {displayTokenKeys.length === 0 && sortedTokenKeys.length === 0 ? ( + + ) : ( + displayTokenKeys.map((tokenKey, index) => ( + + )) + )} + + )} + {showSkeletons From da9ed3920422fce97a802477dfaa372778cd8294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Mon, 1 Jun 2026 09:33:55 +0100 Subject: [PATCH 03/24] chore: handle optional RelatedAsset fields after ai-controllers update (#30799) ## **Description** Bump `@metamask/ai-controllers` to `^0.7.0` so market overview no longer fails when `relatedAsset.name` or `sourceAssetId` is missing. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Localized homepage/Perps presentation and dependency bump; no auth, payments, or transaction logic changes. > > **Overview** > Upgrades **`@metamask/ai-controllers`** to **^0.7.0** so market overview can return **`RelatedAsset`** entries without **`name`** or **`sourceAssetId`**. > > The Whats Happening UI now **falls back to `symbol`** for avatars, labels, and Perps trade navigation when **`name`** is missing, and uses **`symbol` + index** for list keys instead of **`sourceAssetId`**. New unit tests cover sparse assets, empty trends, and confirming an empty successful load does **not** show the error UI. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 977027e76c3f5facf9667e47314957db3645af8e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../WhatsHappeningSection.test.tsx | 14 +++++++ .../components/WhatsHappeningAssetPill.tsx | 6 ++- .../WhatsHappeningAssetSlider.test.tsx | 17 ++++++++ .../components/WhatsHappeningAssetSlider.tsx | 4 +- .../hooks/useWhatsHappening.test.ts | 42 +++++++++++++++++++ .../components/AssetRow.tsx | 2 +- .../components/WhatsHappeningExpandedCard.tsx | 4 +- .../hooks/useTradeNavigation.ts | 4 +- package.json | 2 +- yarn.lock | 14 +++---- 10 files changed, 93 insertions(+), 16 deletions(-) diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx index 590ad35fb825..774b93d96287 100644 --- a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx +++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx @@ -95,6 +95,20 @@ describe('WhatsHappeningSection', () => { ).toBeNull(); }); + it('renders null (not ErrorState) when items are empty but there is no error', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: null, + refresh: jest.fn(), + }); + renderWithProvider(); + expect(screen.queryByText(/unable to load/i)).toBeNull(); + expect( + screen.queryByTestId(WhatsHappeningSelectorsIDs.CAROUSEL), + ).toBeNull(); + }); + it('renders skeleton cards while loading', () => { mockUseWhatsHappening.mockReturnValue({ items: [], diff --git a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx index 5caf37d85833..38fcd0fcb938 100644 --- a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx +++ b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetPill.tsx @@ -94,7 +94,11 @@ const WhatsHappeningAssetPill: React.FC = ({ paddingVertical={1} twClassName="rounded-full" > - + { expect(screen.getByText('+1.23%')).toBeOnTheScreen(); }); + it('renders pills for perps assets that have no sourceAssetId', () => { + const assetWithoutSourceId = { + symbol: 'SOL', + name: 'Solana', + caip19: [], + hlPerpsMarket: ['SOL'], + // sourceAssetId intentionally absent + }; + renderWithProvider( + , + ); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + }); + it('shows negative change in red and hides change text when undefined', () => { mockUseWhatsHappeningAssetPrices.mockReturnValue({ perpsPriceBySymbol: { diff --git a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx index e7389835f679..94b6088612b2 100644 --- a/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx +++ b/app/components/UI/WhatsHappening/components/WhatsHappeningAssetSlider.tsx @@ -40,9 +40,9 @@ const WhatsHappeningAssetSlider: React.FC = ({ contentContainerStyle={tw.style('flex-row gap-2 mt-2')} nestedScrollEnabled > - {perpsAssets.map((asset) => ( + {perpsAssets.map((asset, index) => ( { await waitFor(() => expect(result.current.items).toHaveLength(2)); }); + + it('returns items and no error when an asset is missing sourceAssetId and name', async () => { + const assetWithoutOptionalFields = { + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + // sourceAssetId intentionally absent + // name intentionally absent + }; + mockFetchMarketOverview.mockResolvedValue({ + ...mockOverview, + trends: [ + { + ...mockTrend, + relatedAssets: [assetWithoutOptionalFields], + }, + ], + }); + + const { result } = renderHook(() => useWhatsHappening()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.items).toHaveLength(1); + expect(result.current.error).toBeNull(); + expect(result.current.items[0].relatedAssets[0].symbol).toBe('ETH'); + expect( + result.current.items[0].relatedAssets[0].sourceAssetId, + ).toBeUndefined(); + expect(result.current.items[0].relatedAssets[0].name).toBeUndefined(); + }); + + it('returns empty items and no error when API returns overview with empty trends', async () => { + mockFetchMarketOverview.mockResolvedValue({ + ...mockOverview, + trends: [], + }); + + const { result } = renderHook(() => useWhatsHappening()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.items).toHaveLength(0); + expect(result.current.error).toBeNull(); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx index d655e652eafe..c41fdf3fc929 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx @@ -51,7 +51,7 @@ const AssetRow: React.FC = ({ gap={3} twClassName="py-3" > - + = ({ {strings('homepage.sections.related_assets')} - {item.relatedAssets.map((asset) => ( + {item.relatedAssets.map((asset, index) => ( { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { - market: { symbol: hlPerpsMarket, name: asset.name }, + market: { symbol: hlPerpsMarket, name: asset.name || asset.symbol }, source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, }, }); - }, [navigation, hlPerpsMarket, asset.name]); + }, [navigation, hlPerpsMarket, asset.name, asset.symbol]); return { handleTrade, canTrade: Boolean(hlPerpsMarket) }; }; diff --git a/package.json b/package.json index 081792342ed9..1753c4f5d481 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,7 @@ "@metamask/account-tree-controller": "^7.2.0", "@metamask/accounts-controller": "^38.0.0", "@metamask/address-book-controller": "^7.1.2", - "@metamask/ai-controllers": "0.6.3", + "@metamask/ai-controllers": "^0.7.0", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 7d1c3a53775e..045f013aadd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7826,15 +7826,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ai-controllers@npm:0.6.3": - version: 0.6.3 - resolution: "@metamask/ai-controllers@npm:0.6.3" +"@metamask/ai-controllers@npm:^0.7.0": + version: 0.7.0 + resolution: "@metamask/ai-controllers@npm:0.7.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/678f024889a2fe691633df7b3cce529c8a87c0533096df9b238308736ce232f4a71f1a2f9a74cff16ef6cd635b551141517e8c53dedfc0a38a45d2771850b6b6 + checksum: 10/129d02a26595f16362c6bb1905ef3885b369529eb0a174da46ed7add5fd1d6ffa4d00ba29e09f7583ef70a299dc99f33c82efc45eeea08ab7e5e712c4a3dc11d languageName: node linkType: hard @@ -35415,7 +35415,7 @@ __metadata: "@metamask/account-tree-controller": "npm:^7.2.0" "@metamask/accounts-controller": "npm:^38.0.0" "@metamask/address-book-controller": "npm:^7.1.2" - "@metamask/ai-controllers": "npm:0.6.3" + "@metamask/ai-controllers": "npm:^0.7.0" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" From df1afd8f0330d3c2167339d4c645a313555ffdad Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Mon, 1 Jun 2026 10:05:53 +0100 Subject: [PATCH 04/24] chore: bump assets controller v8.1.0 (#30836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: bump assets controller v8.1.0 ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches wallet/asset and keyring-related controller packages transitively (including keyring-controller 26.x), so regression risk is in balances, tokens/NFTs, and account flows rather than in local code edits. > > **Overview** > Bumps the direct dependency **`@metamask/assets-controller`** from **8.0.1** to **8.1.0** in `package.json`, with **`yarn.lock`** refreshed so the resolved tree picks up that release and its transitive updates (notably **`@metamask/assets-controllers` 108.2.0**, **`@metamask/account-tree-controller` 7.5.0**, **`@metamask/keyring-controller` 26.0.0**, and related controller/backend/profile-sync/snap-account-service pins). > > There are **no application source changes** in this diff—behavior shifts, if any, come only from the upgraded packages at runtime. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 880012e9fc9de592c2310fa6d576f85183092d7e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- yarn.lock | 127 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 1753c4f5d481..b9be50b6acfa 100644 --- a/package.json +++ b/package.json @@ -247,7 +247,7 @@ "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", - "@metamask/assets-controller": "^8.0.1", + "@metamask/assets-controller": "^8.1.0", "@metamask/assets-controllers": "^108.1.0", "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", diff --git a/yarn.lock b/yarn.lock index 045f013aadd6..50d1a5d0ab77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7762,17 +7762,17 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.4.0": - version: 7.4.0 - resolution: "@metamask/account-tree-controller@npm:7.4.0" +"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.4.0, @metamask/account-tree-controller@npm:^7.5.0": + version: 7.5.0 + resolution: "@metamask/account-tree-controller@npm:7.5.0" dependencies: - "@metamask/accounts-controller": "npm:^38.1.1" + "@metamask/accounts-controller": "npm:^38.1.2" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/multichain-account-service": "npm:^10.0.0" - "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/multichain-account-service": "npm:^10.0.1" + "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" @@ -7783,7 +7783,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/8c9967775e4ef3bcb718efb18feb2f94f077c540510c94eb9cc9e814dc91f19b1d4e374e694cbfb6697e162d9c88922acafa1723e727b8912bf4ef3620a8e784 + checksum: 10/b7d6f1505d3ae07304649c3d922e5275270a83bbb97391dd47d776656ad3b22b6926d75f3925e22b28dde7c6e713f23fd6a4de7f20c2d02a9eece27cd1b10f9f languageName: node linkType: hard @@ -7916,22 +7916,22 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^8.0.0, @metamask/assets-controller@npm:^8.0.1": - version: 8.0.1 - resolution: "@metamask/assets-controller@npm:8.0.1" +"@metamask/assets-controller@npm:^8.0.0, @metamask/assets-controller@npm:^8.0.1, @metamask/assets-controller@npm:^8.1.0": + version: 8.1.0 + resolution: "@metamask/assets-controller@npm:8.1.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:^7.4.0" - "@metamask/accounts-controller": "npm:^38.1.1" - "@metamask/assets-controllers": "npm:^108.1.0" + "@metamask/account-tree-controller": "npm:^7.5.0" + "@metamask/accounts-controller": "npm:^38.1.2" + "@metamask/assets-controllers": "npm:^108.2.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/core-backend": "npm:^6.3.0" + "@metamask/core-backend": "npm:^6.3.1" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/keyring-snap-client": "npm:^9.0.2" "@metamask/messenger": "npm:^1.2.0" @@ -7949,13 +7949,13 @@ __metadata: bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/056627318de4985ea2780f3009f8f803cba7eb4ba45f605b29dc4783146db9a8743e83e9d91ea949ad401c2018105531eef66793f09bf7822d955807446016f1 + checksum: 10/d24f095789bcd2f08d78aca09b54c065edae8590855954a2d305cffde431c444442124c8f26605b0ee31ce44f317a2ff84ca87c20c5c0564b874f601038f88f7 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^108.0.0, @metamask/assets-controllers@npm:^108.1.0": - version: 108.1.0 - resolution: "@metamask/assets-controllers@npm:108.1.0" +"@metamask/assets-controllers@npm:^108.0.0, @metamask/assets-controllers@npm:^108.1.0, @metamask/assets-controllers@npm:^108.2.0": + version: 108.2.0 + resolution: "@metamask/assets-controllers@npm:108.2.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7970,7 +7970,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/core-backend": "npm:^6.2.2" + "@metamask/core-backend": "npm:^6.3.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^23.1.0" "@metamask/keyring-controller": "npm:^25.5.0" @@ -7978,10 +7978,10 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^10.0.0" "@metamask/network-controller": "npm:^32.0.0" - "@metamask/network-enablement-controller": "npm:^5.1.1" + "@metamask/network-enablement-controller": "npm:^5.2.0" "@metamask/permission-controller": "npm:^13.1.1" - "@metamask/phishing-controller": "npm:^17.1.2" - "@metamask/polling-controller": "npm:^16.0.5" + "@metamask/phishing-controller": "npm:^17.2.0" + "@metamask/polling-controller": "npm:^16.0.6" "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/profile-sync-controller": "npm:^28.1.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -7989,7 +7989,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^65.3.0" + "@metamask/transaction-controller": "npm:^66.0.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" "@types/bn.js": "npm:^5.1.5" @@ -8006,7 +8006,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/b30574058978f594505b6585dff7501b05a1b36591dfe97ea58c915b85f4e534374d8d3fc72420d58f0a44df9d29aa62c526ec5e903482a64ed9384ff4f09f44 + checksum: 10/732e4cdab79a6dc31c7094149a1d684555cfc7b9a434f435e79873b00f860c8ad540425ed80d700a22a07c73eefb9cd69c2bcd8985a266d622fd82012b7904d4 languageName: node linkType: hard @@ -8364,20 +8364,20 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^6.2.2, @metamask/core-backend@npm:^6.3.0": - version: 6.3.0 - resolution: "@metamask/core-backend@npm:6.3.0" +"@metamask/core-backend@npm:^6.2.2, @metamask/core-backend@npm:^6.3.0, @metamask/core-backend@npm:^6.3.1": + version: 6.3.1 + resolution: "@metamask/core-backend@npm:6.3.1" dependencies: - "@metamask/accounts-controller": "npm:^38.1.1" + "@metamask/accounts-controller": "npm:^38.1.2" "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" - "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" async-mutex: "npm:^0.5.0" uuid: "npm:^8.3.2" - checksum: 10/4823dc2d9ac77e405190eb7aca87c14ddc1cc2d30092872c48a2dbf45672f64859a36bc298fa2673cfcd14165525216dad4f3d9242c7f2ec743a71c175045928 + checksum: 10/f267e17202738f6190da8edc7eeeb49941b76ba893d33c8e07fb2010bf0f9c44f360556199b2e5bc08db4e66296efec377b8d06cb7fabba2be99bf7c2a6d21c9 languageName: node linkType: hard @@ -9064,6 +9064,29 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-controller@npm:^26.0.0": + version: 26.0.0 + resolution: "@metamask/keyring-controller@npm:26.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/eth-hd-keyring": "npm:^14.1.1" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/eth-simple-keyring": "npm:^12.0.2" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-internal-api": "npm:^11.0.1" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.9.0" + async-mutex: "npm:^0.5.0" + ethereumjs-wallet: "npm:^1.0.1" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + ulid: "npm:^2.3.0" + checksum: 10/3ca8a86b97fb9568debdc7b574d42cf98a6c3ff76695e72679980169738ff9e4b9626c9ee59a91f3aa57e3e89003ecccfd5f2e9fa7c2775ff1b545e12a47933f + languageName: node + linkType: hard + "@metamask/keyring-internal-api@npm:^11.0.1": version: 11.0.1 resolution: "@metamask/keyring-internal-api@npm:11.0.1" @@ -9332,22 +9355,22 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/multichain-account-service@npm:10.0.0" +"@metamask/multichain-account-service@npm:^10.0.0, @metamask/multichain-account-service@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/multichain-account-service@npm:10.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@metamask/accounts-controller": "npm:^38.1.1" + "@metamask/accounts-controller": "npm:^38.1.2" "@metamask/base-controller": "npm:^9.1.0" "@metamask/eth-snap-keyring": "npm:^22.0.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/keyring-internal-api": "npm:^11.0.1" "@metamask/keyring-snap-client": "npm:^9.0.2" "@metamask/keyring-utils": "npm:^3.2.1" "@metamask/messenger": "npm:^1.2.0" - "@metamask/snap-account-service": "npm:^0.1.0" + "@metamask/snap-account-service": "npm:^0.2.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" @@ -9359,7 +9382,7 @@ __metadata: "@metamask/account-api": ^1.0.4 "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/07b7e0dc1d4b50bfbd4846fa82598d9a1244a37645a122d00502d710bacb05abcc863a2f569d1c6a33ea656c7f25361b8e1b4de7895be028718195aa2673384b + checksum: 10/e522b621aa939f2cd3e8733574c137951275d2b209369e6765e005d26cc9a3b63842ce66bab8c705c78ee19f74cfcc14f59d1b3970e5bda1278efdbf2fc12f18 languageName: node linkType: hard @@ -9704,13 +9727,13 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.0.2, @metamask/profile-sync-controller@npm:^28.1.0": - version: 28.1.0 - resolution: "@metamask/profile-sync-controller@npm:28.1.0" +"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.0.2, @metamask/profile-sync-controller@npm:^28.1.0, @metamask/profile-sync-controller@npm:^28.1.1": + version: 28.1.1 + resolution: "@metamask/profile-sync-controller@npm:28.1.1" dependencies: "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" @@ -9724,7 +9747,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/33c318fe005e55b38b4d0d603806c9303b8deaddf8f98fc04f89ecb49cf26da2189ceea20ac6a4b1be5da49060a928d6788cb465b4451aa199c8e51172366213 + checksum: 10/0fd175ee7f3327eaaf9d019ca1044aa10d98df1025c8f2d1fbd9c24f8ee0d5183a695aed32a9a6ff18fe94247edd18967ad7d9c2872bc9999d70c39a5794b523 languageName: node linkType: hard @@ -10106,20 +10129,20 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-account-service@npm:^0.1.0": - version: 0.1.0 - resolution: "@metamask/snap-account-service@npm:0.1.0" +"@metamask/snap-account-service@npm:^0.2.1": + version: 0.2.1 + resolution: "@metamask/snap-account-service@npm:0.2.1" dependencies: "@metamask/account-api": "npm:^1.0.4" - "@metamask/account-tree-controller": "npm:^7.4.0" + "@metamask/account-tree-controller": "npm:^7.5.0" "@metamask/eth-snap-keyring": "npm:^22.0.1" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/utils": "npm:^11.9.0" lodash: "npm:^4.17.21" - checksum: 10/75f07b042cc6223943f663a6ef5cf76553fe7bf5a1132e4b95d2621e749e8c7ec88c47e881f7e914e7017ae810d2380f95825a0aff7583f9ad3c9868116ef08c + checksum: 10/ab6b412b1bda09d2299ff97e24dacbf58ae6faafdc13ead7f4fe0b875472f9495811d59a48195259515d8057911900da184d37e5f285ad1732b189a359bb32d7 languageName: node linkType: hard @@ -35419,7 +35442,7 @@ __metadata: "@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:^8.0.1" + "@metamask/assets-controller": "npm:^8.1.0" "@metamask/assets-controllers": "npm:^108.1.0" "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^5.3.0" From d1b6346b227b75225afb7d98d1c38be758c093ed Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:15:56 +0800 Subject: [PATCH 05/24] fix(agentic): fixture account setup (#30750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Hardens the agentic fixture wallet setup so named fixture accounts are created reliably and the flow tolerates legacy vaults. 1. **What is the reason for the change?** The fixture account setup was fragile: account-group naming was loosely typed, vault unlock used a bare `submitPassword` instead of the real post-login path, and legacy/empty vaults could throw during init or account-tree refresh. 2. **What is the improvement/solution?** `AgenticService` now types `queryUiTarget` visibility and `setAccountGroupName` (`AccountGroupId`), unlocks existing vaults via `Authentication.unlockWallet`, imports each private key gracefully, and falls back to skipping group rename for legacy vaults instead of throwing. `setup-wallet.sh` validates the expected HD account total, resolves an absolute `SCRIPT_DIR`, guards the `--fixture` arg, and shares a `safe-env-parser.sh` helper. Scope is agentic dev tooling only — no app/runtime user-facing behavior. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Agentic fixture wallet setup Scenario: Seed a fresh wallet from a fixture with named accounts Given a wallet-fixture.json defining HD and private-key accounts When I run scripts/perps/agentic/setup-wallet.sh Then the expected number of HD accounts is created And each account group is renamed to its fixture name And the script exits successfully Scenario: Re-run against an existing legacy vault Given a wallet was already seeded by a legacy run When I re-run setup-wallet.sh Then the unlock uses the real post-login flow And group rename is skipped without throwing And the run completes successfully ``` ## **Screenshots/Recordings** ### **Before** N/A — no UI surface. ### **After** N/A — no UI surface. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes unlock/import paths and account-tree naming in __DEV__-only agentic code; legacy-vault fallbacks add complexity but do not affect production bundles. > > **Overview** > **Agentic fixture wallet setup** is reworked so Farmslot recipes can seed named HD/private-key accounts reliably on fresh and legacy vaults. `setupWallet` and new **`applyWalletFixture`** share **`materializeFixtureAccounts`**: multi-SRP import via `importNewSecretRecoveryPhrase` / `addNewHdAccount`, `count`/`names` helpers, legacy vault fallbacks, **`Authentication.unlockWallet`** instead of bare password submit, **`EngineClass.disableAutomaticVaultBackup`**, and skipping account-tree group rename when no group exists. Failures can return a **`step`** label; the bridge adds **`queryUiTarget`**, **`refreshPerpsStreams`**, and typed **`AccountGroupId`** usage. > > **Agent Step HUD** now shows a status/progress badge (pass/fail coloring), parses multi-line descriptions (intent + `subflow:`/`error:` lines), and respects safe-area insets. > > **Agentic scripts/docs** narrow Mobile to the CDP bridge + preflight/`setup-wallet`/`safe-env-parser.sh`; in-repo recipe runner pieces (`eval-ref`, pre-conditions registry, `validate-recipe` libs, team recipes) and long perps validation docs are removed or pointed at the external Recipe v1 runner. **`yarn a:*`** preflight scripts gain explicit **`--mode fast|clean`**. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 80da06a35401e1cc478d23a5312f655ad5b9c3ea. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/core/AgenticService/AgentStepHud.test.tsx | 54 +- app/core/AgenticService/AgentStepHud.tsx | 118 +- .../AgenticService/AgenticService.test.ts | 226 +- app/core/AgenticService/AgenticService.ts | 779 ++++++- docs/perps/myx-validation-report.md | 103 - ...ccount-abstraction-and-balance-contract.md | 396 +--- docs/perps/perps-agentic-feedback-loop.md | 418 +--- docs/perps/perps-agentic-scripts-quickref.md | 106 - docs/perps/perps-agentic-system-design.md | 295 --- package.json | 8 +- .../perps/agentic/CDP-capabilities-mobile.md | 136 +- scripts/perps/agentic/CDP-summary-mobile.md | 63 +- scripts/perps/agentic/GETTING_STARTED.md | 101 - scripts/perps/agentic/README.md | 325 +-- scripts/perps/agentic/app-state.sh | 7 - scripts/perps/agentic/cdp-bridge.js | 153 +- scripts/perps/agentic/e2e-recipe-benchmark.md | 69 - scripts/perps/agentic/lib/assert.js | 147 -- scripts/perps/agentic/lib/catalog.js | 301 --- scripts/perps/agentic/lib/cdp-eval.js | 7 +- .../{recipe-issues.js => issue-capture.js} | 6 +- scripts/perps/agentic/lib/registry.js | 8 - scripts/perps/agentic/lib/safe-env-parser.sh | 27 + scripts/perps/agentic/lib/workflow.js | 435 ---- scripts/perps/agentic/run-timing-benchmark.sh | 345 --- .../perps/agentic/schemas/flow.schema.json | 328 --- scripts/perps/agentic/setup-wallet.sh | 177 +- scripts/perps/agentic/teams/README.md | 105 - .../teams/mobile-platform/pre-conditions.js | 7 - scripts/perps/agentic/teams/perps/evals.json | 57 - .../perps/agentic/teams/perps/evals/core.json | 32 - .../agentic/teams/perps/evals/setup.json | 17 - .../teams/perps/flows/activity-view.json | 50 - .../perps/flows/candle-rapid-switch.json | 191 -- .../flows/hl-balance-contract-check.json | 57 - .../perps/flows/hl-balance-math-check.json | 61 - .../perps/flows/hl-balance-validation.json | 237 -- .../perps/flows/hl-provision-fixture.json | 128 -- .../teams/perps/flows/market-discovery.json | 106 - .../teams/perps/flows/market-visit.json | 44 - .../teams/perps/flows/market-watchlist.json | 92 - .../teams/perps/flows/order-limit-cancel.json | 84 - .../flows/order-limit-place-controller.json | 64 - .../teams/perps/flows/order-limit-place.json | 171 -- .../perps/flows/position-add-margin.json | 125 -- .../teams/perps/flows/select-account.json | 38 - .../teams/perps/flows/setup-testnet.json | 43 - .../teams/perps/flows/tpsl-create.json | 113 - .../agentic/teams/perps/flows/tpsl-edit.json | 113 - .../perps/flows/trade-close-position.json | 88 - .../flows/trade-open-market-controller.json | 65 - .../teams/perps/flows/trade-open-market.json | 116 - .../agentic/teams/perps/pre-conditions.js | 178 -- .../teams/perps/recipes/app-lifecycle.json | 181 -- .../recipes/benchmark/perf-add-funds.json | 75 - .../benchmark/perf-position-management.json | 78 - .../recipes/benchmark/perps-add-funds.json | 81 - .../benchmark/perps-limit-long-fill.json | 94 - .../benchmark/perps-no-funds-tutorial.json | 131 -- .../benchmark/perps-position-liquidation.json | 79 - .../benchmark/perps-position-stop-loss.json | 78 - .../recipes/benchmark/perps-position.json | 91 - .../performance-metrics-smoke.json | 83 - .../capabilities/profiler-trace-smoke.json | 77 - .../capabilities/recipe-issues-smoke.json | 40 - .../perps/recipes/full-trade-lifecycle.json | 129 -- .../perps/recipes/hl-balance-contract.json | 375 ---- .../teams/perps/recipes/provider-smoke.json | 59 - .../reference-decimal-key-screens.json | 638 ------ scripts/perps/agentic/timing-benchmark.md | 43 - scripts/perps/agentic/validate-flow-schema.js | 420 ---- scripts/perps/agentic/validate-myx.sh | 576 ----- .../perps/agentic/validate-pre-conditions.js | 65 - scripts/perps/agentic/validate-recipe.js | 1929 ----------------- scripts/perps/agentic/validate-recipe.sh | 24 - .../perps/agentic/wallet-fixture.example.json | 37 +- 76 files changed, 1367 insertions(+), 11536 deletions(-) delete mode 100644 docs/perps/myx-validation-report.md delete mode 100644 docs/perps/perps-agentic-scripts-quickref.md delete mode 100644 docs/perps/perps-agentic-system-design.md delete mode 100644 scripts/perps/agentic/GETTING_STARTED.md delete mode 100644 scripts/perps/agentic/e2e-recipe-benchmark.md delete mode 100644 scripts/perps/agentic/lib/assert.js delete mode 100644 scripts/perps/agentic/lib/catalog.js rename scripts/perps/agentic/lib/{recipe-issues.js => issue-capture.js} (98%) delete mode 100644 scripts/perps/agentic/lib/registry.js create mode 100755 scripts/perps/agentic/lib/safe-env-parser.sh delete mode 100644 scripts/perps/agentic/lib/workflow.js delete mode 100755 scripts/perps/agentic/run-timing-benchmark.sh delete mode 100644 scripts/perps/agentic/schemas/flow.schema.json delete mode 100644 scripts/perps/agentic/teams/README.md delete mode 100644 scripts/perps/agentic/teams/mobile-platform/pre-conditions.js delete mode 100644 scripts/perps/agentic/teams/perps/evals.json delete mode 100644 scripts/perps/agentic/teams/perps/evals/core.json delete mode 100644 scripts/perps/agentic/teams/perps/evals/setup.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/activity-view.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/candle-rapid-switch.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/hl-balance-validation.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/market-discovery.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/market-visit.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/market-watchlist.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/order-limit-place-controller.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/order-limit-place.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/position-add-margin.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/select-account.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/setup-testnet.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/tpsl-create.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/tpsl-edit.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/trade-close-position.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/trade-open-market-controller.json delete mode 100644 scripts/perps/agentic/teams/perps/flows/trade-open-market.json delete mode 100644 scripts/perps/agentic/teams/perps/pre-conditions.js delete mode 100644 scripts/perps/agentic/teams/perps/recipes/app-lifecycle.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perf-add-funds.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perf-position-management.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-add-funds.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-limit-long-fill.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-no-funds-tutorial.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-liquidation.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-stop-loss.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/capabilities/performance-metrics-smoke.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/capabilities/profiler-trace-smoke.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/capabilities/recipe-issues-smoke.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/provider-smoke.json delete mode 100644 scripts/perps/agentic/teams/perps/recipes/reference-decimal-key-screens.json delete mode 100644 scripts/perps/agentic/timing-benchmark.md delete mode 100644 scripts/perps/agentic/validate-flow-schema.js delete mode 100755 scripts/perps/agentic/validate-myx.sh delete mode 100644 scripts/perps/agentic/validate-pre-conditions.js delete mode 100644 scripts/perps/agentic/validate-recipe.js delete mode 100755 scripts/perps/agentic/validate-recipe.sh diff --git a/app/core/AgenticService/AgentStepHud.test.tsx b/app/core/AgenticService/AgentStepHud.test.tsx index 45e3d4cad3dd..7ea873053b2a 100644 --- a/app/core/AgenticService/AgentStepHud.test.tsx +++ b/app/core/AgenticService/AgentStepHud.test.tsx @@ -52,11 +52,59 @@ describe('AgentStepHud', () => { const callback = getLatestCallback(); act(() => { - callback({ id: 'open-pos', description: 'Open BTC position' }); + callback({ id: 'run 2/10', description: 'Open BTC position' }); }); - expect(getByText('open-pos')).toBeOnTheScreen(); - expect(getByText('Open BTC position')).toBeOnTheScreen(); + expect(getByText('RUN 2/10')).toBeOnTheScreen(); + expect(getByText(/Open BTC position/)).toBeOnTheScreen(); + }); + + it('renders failed status in red instead of success green', () => { + const { getByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ id: 'fail 9/19', description: 'Close position failed' }); + }); + + expect(getByText('FAIL 9/19')).toHaveStyle({ color: '#FF4D4F' }); + }); + + it('shows one intent line and hides unmarked metadata lines', () => { + const { getAllByText, queryByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ + id: 'run 1/2', + description: 'Prepare clean state\nPrepare clean state\nperps setup', + }); + }); + + expect(getAllByText(/Prepare clean state/)).toHaveLength(1); + expect(queryByText('perps setup')).toBeNull(); + }); + + it('shows explicit subflow and error lines only', () => { + const { getByText, queryByText } = render(); + const callback = getLatestCallback(); + + act(() => { + callback({ + id: 'fail 1/2', + description: + 'Complete the validation checkpoint\nDuplicate metadata line should stay hidden\nsubflow: Prepare scenario\nerror: Timed out waiting for checkpoint', + }); + }); + + expect(getByText(/Complete the validation checkpoint/)).toBeOnTheScreen(); + expect(getByText('Prepare scenario')).toBeOnTheScreen(); + expect( + getByText('error: Timed out waiting for checkpoint'), + ).toBeOnTheScreen(); + expect( + queryByText('Duplicate metadata line should stay hidden'), + ).toBeNull(); }); it('hides overlay when callback fires with null', () => { diff --git a/app/core/AgenticService/AgentStepHud.tsx b/app/core/AgenticService/AgentStepHud.tsx index 14995b2bcfc2..ad4c8f762e6f 100644 --- a/app/core/AgenticService/AgentStepHud.tsx +++ b/app/core/AgenticService/AgentStepHud.tsx @@ -6,6 +6,65 @@ import { registerStepHudCallback } from './AgenticService'; interface Step { id: string; description: string; + status?: string; +} + +function statusForStep(step: Step) { + return String(step.status ?? step.id.split(/\s+/)[0] ?? '').toLowerCase(); +} + +function progressForStep(step: Step) { + const progressPattern = /\b\d+\s*\/\s*\d+\b/; + const match = progressPattern.exec(step.id); + return match ? match[0].replace(/\s+/g, '') : null; +} + +function badgeTextForStep(step: Step) { + const status = statusForStep(step); + const progress = progressForStep(step); + return [status || 'run', progress].filter(Boolean).join(' ').toUpperCase(); +} + +function statusToneForStep(step: Step) { + const status = statusForStep(step); + if (status === 'fail' || status === 'failed' || status === 'error') { + return 'fail'; + } + if (status === 'pass' || status === 'passed' || status === 'success') { + return 'pass'; + } + return 'running'; +} + +function secondaryDisplayText(part: string) { + const errorPrefix = 'error:'; + const subflowPrefix = 'subflow:'; + const detailPrefix = 'detail:'; + const normalized = part.toLowerCase(); + + if (normalized.startsWith(errorPrefix)) { + return part; + } + if (normalized.startsWith(subflowPrefix)) { + return part.slice(subflowPrefix.length).trim(); + } + if (normalized.startsWith(detailPrefix)) { + return part.slice(detailPrefix.length).trim(); + } + return null; +} + +function parseDescription(description: string) { + const parts = description + .split('\n') + .map((part) => part.trim()) + .filter(Boolean); + const deduped = parts.filter((part, index) => parts.indexOf(part) === index); + const [intent = '', ...secondaryCandidates] = deduped; + const secondary = secondaryCandidates + .map(secondaryDisplayText) + .filter((part): part is string => Boolean(part)); + return { intent, secondary }; } // Debug-only overlay — intentionally uses hardcoded colors for guaranteed @@ -14,23 +73,37 @@ interface Step { const styles = StyleSheet.create({ container: { position: 'absolute', - bottom: 0, left: 0, right: 0, zIndex: 9999, - backgroundColor: 'rgba(0, 0, 0, 0.75)', - paddingVertical: 6, + backgroundColor: 'rgba(0, 0, 0, 0.58)', + paddingVertical: 3, }, - stepId: { - color: '#00FF88', - fontFamily: 'Courier', - fontSize: 12, + line: { + color: '#FFFFFF', + fontSize: 11, fontWeight: '700', + lineHeight: 14, }, - description: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '500', + badgeText: { + fontFamily: 'Courier', + fontSize: 9, + fontWeight: '800', + }, + badgeTextRunning: { + color: '#00FF88', + }, + badgeTextPass: { + color: '#00FF88', + }, + badgeTextFail: { + color: '#FF4D4F', + }, + secondary: { + color: '#E6E6E6', + fontSize: 10, + fontWeight: '400', + lineHeight: 12, }, }); /* eslint-enable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ @@ -44,9 +117,9 @@ const AgentStepHudInner = () => { () => [ styles.container, { + bottom: Math.max(insets.bottom, 0), paddingLeft: Math.max(insets.left, 10), paddingRight: Math.max(insets.right, 10), - paddingBottom: insets.bottom > 0 ? insets.bottom : 6, }, ], [insets.left, insets.right, insets.bottom], @@ -61,12 +134,27 @@ const AgentStepHudInner = () => { if (!step) return null; + const { intent, secondary } = parseDescription(step.description); + const badge = badgeTextForStep(step); + const tone = statusToneForStep(step); + const badgeTextStyle = + tone === 'fail' + ? styles.badgeTextFail + : tone === 'pass' + ? styles.badgeTextPass + : styles.badgeTextRunning; + return ( - - {step.id} - {` ${step.description}`} + + {badge} + {intent ? ` ${intent}` : ''} + {secondary.map((detail) => ( + + {detail} + + ))} ); }; diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index 85b96e2655c6..a29dead227f9 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -5,10 +5,13 @@ import AgenticService, { tryScroll, toAccountSummary, registerStepHudCallback, + getFixtureMnemonicCount, + getFixtureAccountNames, type FiberNode, type ReactDevToolsHook, } from './AgenticService'; import Engine from '../Engine'; +import { Platform } from 'react-native'; import type { NavigationContainerRef, ParamListBase, @@ -38,6 +41,10 @@ jest.mock('../Engine', () => ({ }, }, }, + AccountTreeController: { + state: { accountTree: { wallets: {} } }, + setAccountGroupName: jest.fn(), + }, MultichainAccountService: { createMultichainAccountWallet: (...args: unknown[]) => mockCreateWallet(...args), @@ -45,13 +52,60 @@ jest.mock('../Engine', () => ({ }, KeyringController: { importAccountWithStrategy: (...args: unknown[]) => - mockImportAccount(...args), + mockImportAccount(...(args as [string, string[]])), }, PerpsController: { markTutorialCompleted: jest.fn(), + getPositions: jest.fn().mockResolvedValue([]), }, }, setSelectedAddress: jest.fn(), + setAccountLabel: jest.fn(), +})); + +// AgenticService imports the Engine *class* (for the disableAutomaticVaultBackup +// static) separately from the ../Engine facade. Stub it so the test does not +// pull in the full Engine/RewardsController/SecureKeychain stack. +jest.mock('../Engine/Engine', () => ({ + Engine: class { + static disableAutomaticVaultBackup = false; + }, +})); + +const mockEnsureConnected = jest.fn().mockResolvedValue(undefined); +const mockClearAllChannels = jest.fn(); + +jest.mock('../../components/UI/Perps/services/PerpsConnectionManager', () => ({ + __esModule: true, + default: { + ensureConnected: (...args: unknown[]) => mockEnsureConnected(...args), + }, +})); + +jest.mock('../../components/UI/Perps/providers/PerpsStreamManager', () => ({ + getStreamManagerInstance: () => ({ + clearAllChannels: (...args: unknown[]) => mockClearAllChannels(...args), + }), +})); + +// Authentication pulls in the full auth/keychain stack; stub the singleton. +jest.mock('../Authentication', () => ({ + __esModule: true, + default: { + unlockWallet: jest.fn().mockResolvedValue(undefined), + }, +})); + +// addNewHdAccount/importNewSecretRecoveryPhrase pull in a sentry/selector chain +// that cannot load in the unit-test env; stub them directly. +const mockAddNewHdAccount = jest.fn().mockResolvedValue(undefined); +const mockImportNewSecretRecoveryPhrase = jest + .fn() + .mockResolvedValue(undefined); +jest.mock('../../actions/multiSrp', () => ({ + addNewHdAccount: (...args: unknown[]) => mockAddNewHdAccount(...args), + importNewSecretRecoveryPhrase: (...args: unknown[]) => + mockImportNewSecretRecoveryPhrase(...args), })); const mockDispatch = jest.fn(); @@ -88,9 +142,18 @@ jest.mock('../../actions/settings', () => ({ jest.mock('@metamask/key-tree', () => ({ mnemonicPhraseToBytes: jest.fn((s: string) => new Uint8Array(s.length)), })); -jest.mock('../../store/storage-wrapper', () => ({ - setItem: jest.fn().mockResolvedValue(undefined), -})); +jest.mock('../../store/storage-wrapper', () => { + const storageWrapper = { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + }; + return { + __esModule: true, + default: storageWrapper, + getItem: storageWrapper.getItem, + setItem: storageWrapper.setItem, + }; +}); jest.mock('../../constants/storage', () => ({ OPTIN_META_METRICS_UI_SEEN: 'optin_meta_metrics_ui_seen', PERPS_GTM_MODAL_SHOWN: 'perps_gtm', @@ -273,6 +336,49 @@ describe('toAccountSummary', () => { }); }); +describe('getFixtureMnemonicCount', () => { + it('defaults to 1 when no count is provided', () => { + expect(getFixtureMnemonicCount(undefined)).toBe(1); + expect(getFixtureMnemonicCount({})).toBe(1); + }); + + it('prefers count, falls back to numberOfAccounts', () => { + expect(getFixtureMnemonicCount({ count: 3 })).toBe(3); + expect(getFixtureMnemonicCount({ numberOfAccounts: 2 })).toBe(2); + expect(getFixtureMnemonicCount({ count: 5, numberOfAccounts: 2 })).toBe(5); + }); + + it('throws on out-of-range or non-integer counts', () => { + expect(() => getFixtureMnemonicCount({ count: 0 })).toThrow(); + expect(() => getFixtureMnemonicCount({ count: 101 })).toThrow(); + expect(() => getFixtureMnemonicCount({ count: 1.5 })).toThrow(); + }); +}); + +describe('getFixtureAccountNames', () => { + it('uses explicit names by index when present', () => { + expect(getFixtureAccountNames({ names: ['One', 'Two'] }, 2)).toEqual([ + 'One', + 'Two', + ]); + }); + + it('uses name only for the first account', () => { + expect(getFixtureAccountNames({ name: 'Primary' }, 2)).toEqual([ + 'Primary', + 'Account 2', + ]); + }); + + it('falls back to Account N when nothing is provided', () => { + expect(getFixtureAccountNames(undefined, 3)).toEqual([ + 'Account 1', + 'Account 2', + 'Account 3', + ]); + }); +}); + describe('tryScroll', () => { it('returns false for null start', () => { expect(tryScroll(null, 100, false)).toBe(false); @@ -392,6 +498,24 @@ describe('AgenticService.install', () => { expect(mockDeferredNav.goBack).toHaveBeenCalled(); }); + it('refreshPerpsStreams reconnects streams and reports position count', async () => { + mockEnsureConnected.mockClear(); + mockClearAllChannels.mockClear(); + ( + MockEngine.context.PerpsController.getPositions as jest.Mock + ).mockResolvedValue([{ coin: 'ETH' }, { coin: 'BTC' }]); + + await expect(bridge().refreshPerpsStreams()).resolves.toEqual({ + ok: true, + positions: 2, + }); + expect(mockEnsureConnected).toHaveBeenCalledWith({ + source: 'agentic_refresh_perps_streams', + suppressError: true, + }); + expect(mockClearAllChannels).toHaveBeenCalledTimes(1); + }); + it('listAccounts returns mapped accounts', () => { ( MockEngine.context.AccountsController.listAccounts as jest.Mock @@ -641,43 +765,6 @@ describe('AgenticService.install', () => { mockDispatch.mockClear(); }); - it('creates wallet from mnemonic and returns accounts', async () => { - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [{ type: 'mnemonic', value: 'word1 word2 word3' }], - }); - expect(result.ok).toBe(true); - expect(result.accounts).toEqual([ - { id: 'a1', address: '0xABC', name: 'Account 1' }, - ]); - expect(mockCreateWallet).toHaveBeenCalledWith( - expect.objectContaining({ type: 'restore', password: 'test123' }), - ); - }); - - it('creates wallet without mnemonic when no mnemonic account', async () => { - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [{ type: 'privateKey', value: '0xkey' }], - }); - expect(result.ok).toBe(true); - expect(mockCreateWallet).toHaveBeenCalledWith( - expect.objectContaining({ type: 'create', password: 'test123' }), - ); - expect(mockImportAccount).toHaveBeenCalled(); - }); - - it('imports private key accounts', async () => { - await bridge().setupWallet({ - password: 'test123', - accounts: [ - { type: 'mnemonic', value: 'word1 word2' }, - { type: 'privateKey', value: '0xkey1' }, - ], - }); - expect(mockImportAccount).toHaveBeenCalledWith('privateKey', ['0xkey1']); - }); - it('dispatches all onboarding flags', async () => { await bridge().setupWallet({ password: 'test123', @@ -700,18 +787,6 @@ describe('AgenticService.install', () => { expect(result.error).toBe('boom'); }); - it('handles failed private key import gracefully', async () => { - mockImportAccount.mockRejectedValueOnce(new Error('bad key')); - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [ - { type: 'mnemonic', value: 'words' }, - { type: 'privateKey', value: '0xbad' }, - ], - }); - expect(result.ok).toBe(true); - }); - it('opts out of metametrics when specified', async () => { const { analytics } = jest.requireMock('../../util/analytics/analytics'); await bridge().setupWallet({ @@ -807,16 +882,43 @@ describe('AgenticService.install', () => { ); }); - it('dispatches setOsAuthEnabled(true) when deviceAuthEnabled is true', async () => { + it('dispatches setOsAuthEnabled(true) on Android when deviceAuthEnabled is true', async () => { mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { deviceAuthEnabled: true }, - }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED', enabled: true }), - ); + const originalOS = Platform.OS; + Platform.OS = 'android'; + try { + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_OS_AUTH_ENABLED', + enabled: true, + }), + ); + } finally { + Platform.OS = originalOS; + } + }); + + it('does not dispatch setOsAuthEnabled on iOS even when deviceAuthEnabled is true', async () => { + mockDispatch.mockClear(); + const originalOS = Platform.OS; + Platform.OS = 'ios'; + try { + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), + ); + } finally { + Platform.OS = originalOS; + } }); it('does not dispatch setOsAuthEnabled when deviceAuthEnabled is not set', async () => { diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index 8f98502fff3e..733ff9f880c2 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -1,21 +1,20 @@ /** - * AgenticService — __DEV__-only bridge for AI coding agents. + * AgenticService — __DEV__-only bridge for Farmslot recipe runners. * - * This file is NEVER bundled in production builds (guarded by __DEV__). - * It intentionally uses loose types, inline casts, and minimal abstractions - * because it is throwaway dev tooling — not shared library code. Do not - * apply production code standards (strict types, full error handling, - * abstraction layers) here; keep it pragmatic and easy to change. + * This file is not bundled in production builds. It exposes a stable control + * surface for deterministic local recipes while keeping user-visible proof + * flows on the real app path. */ import { NavigationContainerRef, ParamListBase, } from '@react-navigation/native'; -import { Platform } from 'react-native'; +import { Dimensions, Platform } from 'react-native'; import Logger from '../../util/Logger'; import ReduxService from '../redux'; import { persistor } from '../../store'; import Engine from '../Engine'; +import { Engine as EngineClass } from '../Engine/Engine'; import { passwordSet, setExistingUser, @@ -26,6 +25,7 @@ import { import { setCompletedOnboarding } from '../../actions/onboarding'; import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import { AccountImportStrategy } from '@metamask/keyring-controller'; +import type { AccountGroupId } from '@metamask/account-api'; import StorageWrapper from '../../store/storage-wrapper'; import { OPTIN_META_METRICS_UI_SEEN, @@ -45,6 +45,15 @@ import Routes from '../../constants/navigation/Routes'; import SecureKeychain from '../SecureKeychain'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; import DevLogger from '../SDKConnect/utils/DevLogger'; +import { + addNewHdAccount, + importNewSecretRecoveryPhrase, +} from '../../actions/multiSrp'; +import { bufferToHex, privateToAddress } from 'ethereumjs-util'; +import Authentication from '../Authentication'; +import { Wallet as EthersWallet } from 'ethers'; +import PerpsConnectionManager from '../../components/UI/Perps/services/PerpsConnectionManager'; +import { getStreamManagerInstance } from '../../components/UI/Perps/providers/PerpsStreamManager'; // ─── Fiber tree types ────────────────────────────────────────────────────── @@ -65,6 +74,19 @@ interface FiberNode { stateNode: { scrollTo?: (opts: { y: number; animated: boolean }) => void; scrollToOffset?: (opts: { offset: number; animated: boolean }) => void; + measure?: ( + callback: ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => void, + ) => void; + measureInWindow?: ( + callback: (x: number, y: number, width: number, height: number) => void, + ) => void; [key: string]: unknown; } | null; } @@ -81,6 +103,7 @@ interface ReactDevToolsHook { /** Shape of the __DEV__-only agentic bridge on globalThis. */ interface AgenticBridge { platform: string; + replayHarnessPatch?: string; navigate: (name: string, params?: object) => void; getRoute: () => unknown; getState: () => unknown; @@ -146,6 +169,9 @@ interface AgenticBridge { type: 'mnemonic' | 'privateKey'; value: string; name?: string; + count?: number; + numberOfAccounts?: number; + names?: string[]; }[]; settings?: { metametrics?: boolean; @@ -155,15 +181,41 @@ interface AgenticBridge { deviceAuthEnabled?: boolean; }; }) => Promise<{ + ok: boolean; + error?: string; + step?: string; + accounts?: { address: string; name: string }[]; + }>; + applyWalletFixture: ( + fixture: Parameters[0], + ) => Promise<{ ok: boolean; error?: string; accounts?: { address: string; name: string }[]; }>; showStep: (step: { id: string; description: string }) => void; hideStep: () => void; + refreshPerpsStreams: () => Promise<{ ok: boolean; positions: number }>; findFiberByTestId: (testId: string) => boolean; + queryUiTarget: (options: { + testId?: string; + textContains?: string; + visibility?: 'tree' | 'viewport'; + }) => Promise<{ + present: boolean; + visible: boolean; + visibility: 'tree' | 'viewport'; + testId?: string; + textContains?: string; + textMatched?: boolean; + rect?: { x: number; y: number; width: number; height: number }; + viewport?: { width: number; height: number }; + error?: string; + }>; } +type WalletFixture = Parameters[0]; + declare global { // eslint-disable-next-line no-var var __AGENTIC__: AgenticBridge | undefined; @@ -242,6 +294,334 @@ function toAccountSummary(a: { return { id: a.id, address: a.address, name: a.metadata.name }; } +export function getFixtureMnemonicCount(account?: { + count?: number; + numberOfAccounts?: number; +}): number { + const raw = account?.count ?? account?.numberOfAccounts ?? 1; + const count = Number(raw); + if (!Number.isInteger(count) || count < 1 || count > 100) { + throw new Error(`Invalid mnemonic account count: ${raw}`); + } + return count; +} + +export function getFixtureAccountNames( + account: { name?: string; names?: string[] } | undefined, + count: number, +): string[] { + return Array.from({ length: count }, (_unused, index) => { + const explicitName = account?.names?.[index]; + if (typeof explicitName === 'string' && explicitName.trim()) { + return explicitName.trim(); + } + if ( + index === 0 && + typeof account?.name === 'string' && + account.name.trim() + ) { + return account.name.trim(); + } + return `Account ${index + 1}`; + }); +} + +interface FixtureEvmAccount { + id: string; + address: string; + metadata: { name: string; keyring?: { type?: string } }; +} + +function findEvmAccounts(accounts: Record) { + return (Object.values(accounts) as FixtureEvmAccount[]).filter((account) => + account.address?.startsWith('0x'), + ); +} + +function isHdFixtureAccount(account: FixtureEvmAccount) { + return account.metadata?.keyring?.type === 'HD Key Tree'; +} + +function normalizePrivateKey(value: string) { + return value.startsWith('0x') ? value.slice(2) : value; +} + +function getPrivateKeyAddress(value: string) { + return bufferToHex( + privateToAddress(Buffer.from(normalizePrivateKey(value), 'hex')), + ).toLowerCase(); +} + +function isExpectedLegacyAccountTreeInitError(error: unknown) { + const message = String((error as Error).message || error); + return ( + message.includes('Money Keyring') || + message.includes('No keyringBuilder found') + ); +} + +async function initializeFixtureAccountTree( + options: { + allowLegacyAccountTreeInitFailure?: boolean; + } = {}, +) { + try { + await AccountTreeInitService.initializeAccountTree(); + } catch (error) { + if ( + !options.allowLegacyAccountTreeInitFailure || + !isExpectedLegacyAccountTreeInitError(error) + ) { + throw error; + } + // Historical replay vaults can lack the multichain keyring builder. In that + // mode AccountsController remains the source of truth for fixture validation + // and account labels; group renames are skipped when no account-tree group + // exists. + Logger.log( + '[AgenticService] Skipping fixture account-tree refresh for historical replay fixture setup', + ); + } +} + +function getMnemonicFirstAddress(value: string) { + return EthersWallet.fromMnemonic( + value, + "m/44'/60'/0'/0/0", + ).address.toLowerCase(); +} + +interface FixtureHdWallet { + keyringId?: string; + accounts: FixtureEvmAccount[]; +} + +function getHdFixtureWallets( + accountsController: { + state: { internalAccounts: { accounts: Record } }; + }, + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + }, +): FixtureHdWallet[] { + const evmById = new Map( + findEvmAccounts(accountsController.state.internalAccounts.accounts).map( + (account) => [account.id, account], + ), + ); + const wallets = accountTreeController.state.accountTree?.wallets ?? {}; + const result: FixtureHdWallet[] = []; + for (const wallet of Object.values(wallets) as { + metadata?: { entropy?: { id?: string } }; + groups?: Record< + string, + { accounts?: string[]; metadata?: { entropy?: { groupIndex?: number } } } + >; + }[]) { + const entropyId = wallet.metadata?.entropy?.id; + if (!entropyId) continue; + const accounts = Object.values(wallet.groups ?? {}) + .map((group) => ({ + index: group.metadata?.entropy?.groupIndex ?? Number.MAX_SAFE_INTEGER, + account: group.accounts?.[0] + ? evmById.get(group.accounts[0]) + : undefined, + })) + .filter((entry): entry is { index: number; account: FixtureEvmAccount } => + Boolean(entry.account), + ) + .sort((left, right) => left.index - right.index) + .map((entry) => entry.account); + if (accounts.length > 0) { + result.push({ keyringId: entropyId, accounts }); + } + } + if (result.length > 0) { + return result; + } + const legacyHdAccounts = findEvmAccounts( + accountsController.state.internalAccounts.accounts, + ).filter(isHdFixtureAccount); + return legacyHdAccounts.length > 0 ? [{ accounts: legacyHdAccounts }] : []; +} + +async function ensureFixtureMnemonicAccounts( + mnemonicAccount: WalletFixture['accounts'][number], + mnemonicIndex: number, + controllers: { + AccountsController: { + state: { internalAccounts: { accounts: Record } }; + }; + AccountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }; + }, + options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, +) { + const { AccountsController, AccountTreeController } = controllers; + const count = getFixtureMnemonicCount(mnemonicAccount); + const names = getFixtureAccountNames(mnemonicAccount, count); + const firstAddress = getMnemonicFirstAddress(mnemonicAccount.value); + const findWallet = () => + getHdFixtureWallets(AccountsController, AccountTreeController).find( + (hdWallet) => + hdWallet.accounts[0]?.address.toLowerCase() === firstAddress, + ); + + let wallet = findWallet(); + if (!wallet) { + await importNewSecretRecoveryPhrase(mnemonicAccount.value, { + shouldSelectAccount: false, + }); + await initializeFixtureAccountTree(options); + wallet = findWallet(); + } + + if (!wallet) { + throw new Error( + `No HD wallet found for fixture mnemonic ${mnemonicIndex + 1}`, + ); + } + + for ( + let accountIndex = wallet.accounts.length; + accountIndex < count; + accountIndex += 1 + ) { + await addNewHdAccount(wallet.keyringId, names[accountIndex]); + await initializeFixtureAccountTree(options); + wallet = findWallet(); + if (!wallet) { + throw new Error( + `No HD wallet found after adding fixture account ${accountIndex + 1}`, + ); + } + } + + wallet.accounts.slice(0, count).forEach((account, index) => { + setFixtureAccountName(AccountTreeController, account, names[index]); + }); +} + +function findAccountGroupIdByAccountId( + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + }, + accountId: string, +): string | undefined { + const wallets = accountTreeController.state.accountTree?.wallets ?? {}; + for (const wallet of Object.values(wallets) as { + groups?: Record; + }[]) { + for (const [groupId, group] of Object.entries(wallet.groups ?? {})) { + if (group.accounts?.includes(accountId)) { + return groupId; + } + } + } + return undefined; +} + +function setFixtureAccountName( + accountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }, + account: { id: string; address: string }, + name: string, +) { + Engine.setAccountLabel(account.address, name); + const groupId = findAccountGroupIdByAccountId( + accountTreeController, + account.id, + ); + // Legacy vault fallback skips account-tree init, so no group exists yet. + // The account label set above is sufficient; skip the group rename instead + // of throwing and crashing fixture setup. + if (!groupId) { + DevLogger.log( + `[AgenticService] No account group for fixture account ${account.address}; skipped group rename`, + ); + return; + } + accountTreeController.setAccountGroupName(groupId as AccountGroupId, name); +} + +async function materializeFixtureAccounts( + fixture: WalletFixture, + controllers: { + KeyringController: { + importAccountWithStrategy: ( + strategy: AccountImportStrategy, + args: string[], + ) => Promise; + }; + AccountsController: { + state: { internalAccounts: { accounts: Record } }; + }; + AccountTreeController: { + state: { accountTree?: { wallets?: Record } }; + setAccountGroupName: ( + accountGroupId: AccountGroupId, + accountGroupName: string, + ) => void; + }; + }, + options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, +) { + const { KeyringController, AccountsController, AccountTreeController } = + controllers; + const mnemonicAccounts = fixture.accounts.filter( + (account) => account.type === 'mnemonic', + ); + + for (const [mnemonicIndex, mnemonicAccount] of mnemonicAccounts.entries()) { + await ensureFixtureMnemonicAccounts( + mnemonicAccount, + mnemonicIndex, + { + AccountsController, + AccountTreeController, + }, + options, + ); + } + + for (const account of fixture.accounts) { + if (account.type !== 'privateKey') continue; + const address = getPrivateKeyAddress(account.value); + let imported = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).find((evmAccount) => evmAccount.address.toLowerCase() === address); + + if (!imported) { + await KeyringController.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [`0x${normalizePrivateKey(account.value)}`], + ); + imported = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).find((evmAccount) => evmAccount.address.toLowerCase() === address); + } + + if (!imported) { + throw new Error( + `Fixture private key import did not create account ${address}`, + ); + } + if (account.name) { + setFixtureAccountName(AccountTreeController, imported, account.name); + } + } +} + /** * Walk a fiber sub-tree looking for a scrollable stateNode (scrollTo or * scrollToOffset). When `walkSiblings` is false only the child axis is @@ -273,6 +653,182 @@ function tryScroll( return false; } +function targetMatches( + fiber: FiberNode, + options: { testId?: string; textContains?: string }, +): boolean { + if (options.testId && fiber.memoizedProps?.testID !== options.testId) { + return false; + } + if (options.textContains) { + const needle = options.textContains.toLowerCase(); + const texts = options.testId + ? collectFiberTexts(fiber) + : collectOwnFiberTexts(fiber); + return texts.some((text) => text.toLowerCase().includes(needle)); + } + return Boolean(options.testId); +} + +function findUiTargetFiber( + rootFiber: FiberNode, + options: { testId?: string; textContains?: string }, +): FiberNode | null { + let result: FiberNode | null = null; + walkFiber(rootFiber, (fiber) => { + if (targetMatches(fiber, options)) { + result = fiber; + return true; + } + return false; + }); + return result; +} + +function findMeasurableStateNode( + fiber: FiberNode | null, +): FiberNode['stateNode'] | null { + let result: FiberNode['stateNode'] | null = null; + walkFiber(fiber, (node) => { + const sn = node.stateNode; + if ( + sn && + (typeof sn.measureInWindow === 'function' || + typeof sn.measure === 'function') + ) { + result = sn; + return true; + } + return false; + }); + return result; +} + +function measureStateNode( + stateNode: FiberNode['stateNode'], +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return new Promise((resolve) => { + if (!stateNode) { + resolve(null); + return; + } + let settled = false; + const settle = ( + value: { x: number; y: number; width: number; height: number } | null, + ) => { + if (settled) return; + settled = true; + resolve(value); + }; + // Native measure callbacks never fire for detached/off-screen views; + // fall back to null after a short delay so callers don't hang forever. + const timeout = setTimeout(() => settle(null), 2500); + const finish = (x: number, y: number, width: number, height: number) => { + clearTimeout(timeout); + settle({ x, y, width, height }); + }; + try { + if (typeof stateNode.measureInWindow === 'function') { + stateNode.measureInWindow(finish); + return; + } + if (typeof stateNode.measure === 'function') { + stateNode.measure((_x, _y, width, height, pageX, pageY) => + finish(pageX, pageY, width, height), + ); + return; + } + } catch (e) { + Logger.log(String(e), 'AgenticService.measureStateNode'); + } + clearTimeout(timeout); + settle(null); + }); +} + +async function queryUiTarget(options: { + testId?: string; + textContains?: string; + visibility?: 'tree' | 'viewport'; +}): Promise<{ + present: boolean; + visible: boolean; + visibility: 'tree' | 'viewport'; + testId?: string; + textContains?: string; + textMatched?: boolean; + rect?: { x: number; y: number; width: number; height: number }; + viewport?: { width: number; height: number }; + error?: string; +}> { + const visibility: 'tree' | 'viewport' = + options.visibility === 'viewport' ? 'viewport' : 'tree'; + let target: FiberNode | null = null; + + walkFiberRoots((rootFiber) => { + target = findUiTargetFiber(rootFiber, options); + return Boolean(target); + }); + + const base = { + present: Boolean(target), + visible: Boolean(target) && visibility === 'tree', + visibility, + testId: options.testId, + textContains: options.textContains, + textMatched: options.textContains + ? Boolean( + target && + collectFiberTexts(target).some((text) => + text + .toLowerCase() + .includes(String(options.textContains).toLowerCase()), + ), + ) + : undefined, + }; + + if (!target || visibility === 'tree') { + return base; + } + + const stateNode = findMeasurableStateNode(target); + if (!stateNode) { + return { + ...base, + visible: false, + error: + 'Target exists in fiber tree but no measurable native node was found', + }; + } + + const rect = await measureStateNode(stateNode); + const viewport = Dimensions.get('window'); + if (!rect) { + return { + ...base, + visible: false, + viewport: { width: viewport.width, height: viewport.height }, + error: 'Target exists in fiber tree but measurement returned no frame', + }; + } + + const visible = + rect.width > 0 && + rect.height > 0 && + rect.x < viewport.width && + rect.y < viewport.height && + rect.x + rect.width > 0 && + rect.y + rect.height > 0; + + return { + ...base, + visible, + rect, + viewport: { width: viewport.width, height: viewport.height }, + }; +} + function appendTextContent(value: unknown, out: string[]) { if (value === null || value === undefined) { return; @@ -311,6 +867,14 @@ function collectFiberTexts(fiber: FiberNode | null): string[] { return dedupeTexts(texts); } +function collectOwnFiberTexts(fiber: FiberNode | null): string[] { + const texts: string[] = []; + if (fiber?.memoizedProps?.children !== undefined) { + appendTextContent(fiber.memoizedProps.children, texts); + } + return dedupeTexts(texts); +} + function findAncestorTexts( fiber: FiberNode | null, predicate: (texts: string[]) => boolean, @@ -382,18 +946,12 @@ function getRowValue( maxTexts?: number; } = {}, ): string | null { - try { - const rowTexts = findRowTexts(label, options); - if (!rowTexts) { - return null; - } - const matcher = new RegExp(pattern); - return ( - rowTexts.find((text) => text !== label && matcher.test(text)) ?? null - ); - } catch { + const rowTexts = findRowTexts(label, options); + if (!rowTexts) { return null; } + const matcher = new RegExp(pattern); + return rowTexts.find((text) => text !== label && matcher.test(text)) ?? null; } // ─── Step HUD callback registry ───────────────────────────────────────────── @@ -434,6 +992,7 @@ const AgenticService = { globalThis.__AGENTIC__ = { platform: Platform.OS, + replayHarnessPatch: 'legacy-wallet-fixture-r2', navigate: (name: string, params?: object) => ( deferredNav as unknown as { @@ -648,6 +1207,19 @@ const AgenticService = { hideStep: () => { _stepHudCallback?.(null); }, + refreshPerpsStreams: async () => { + await PerpsConnectionManager.ensureConnected({ + source: 'agentic_refresh_perps_streams', + suppressError: true, + }); + const streamManager = getStreamManagerInstance(); + streamManager.clearAllChannels(); + const positions = await Engine.context.PerpsController.getPositions(); + return { + ok: true, + positions: Array.isArray(positions) ? positions.length : 0, + }; + }, findFiberByTestId: (testId: string): boolean => { let found = false; walkFiberRoots((rootFiber) => { @@ -659,54 +1231,147 @@ const AgenticService = { }); return found; }, + queryUiTarget, + applyWalletFixture: async (fixture) => { + try { + const { + KeyringController, + AccountsController, + AccountTreeController, + } = Engine.context; + // The backup subscriber reads the Engine class static, so set it on + // the class (not the facade) before importing/renaming accounts to + // avoid racing native keychain export during fixture apply. + EngineClass.disableAutomaticVaultBackup = true; + // Unlock via the real auth flow (loginVaultCreation + dispatchLogin + + // post-login) rather than a bare KeyringController.submitPassword, so + // multichain services and Redux/auth state are consistent before we + // mutate accounts. + if (!KeyringController.isUnlocked()) { + await Authentication.unlockWallet({ password: fixture.password }); + } + // Existing replay vaults can have the same historical multichain + // account-tree init gap as fresh legacy setup. Only the known legacy + // init errors are tolerated by this option; unexpected errors still + // throw from initializeFixtureAccountTree(). + const legacyAccountTreeInitOptions = { + allowLegacyAccountTreeInitFailure: true, + }; + await initializeFixtureAccountTree(legacyAccountTreeInitOptions); + await materializeFixtureAccounts( + fixture, + { + KeyringController, + AccountsController, + AccountTreeController, + }, + legacyAccountTreeInitOptions, + ); + const ethAccs = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).map(toAccountSummary); + return { ok: true, accounts: ethAccs }; + } catch (e) { + return { ok: false, error: String((e as Error).message || e) }; + } + }, setupWallet: async (fixture) => { + let setupStep = 'start'; try { + setupStep = 'read-engine'; const { MultichainAccountService, KeyringController, AccountsController, + AccountTreeController, } = Engine.context; const store = ReduxService.store; const settings = fixture.settings ?? {}; + // Deliberately one-way for the dev harness process: fixture setup + // rewrites vault/account state, so automatic backup must stay disabled + // for the rest of this simulator session to avoid native keychain + // export paths racing the synthetic setup flow. + EngineClass.disableAutomaticVaultBackup = true; // 1. Create wallet from the first mnemonic (same path as onboarding UI) + setupStep = 'create-wallet'; const mnemonicAccount = fixture.accounts.find( (a) => a.type === 'mnemonic', ); + let usedLegacyVaultSetup = false; if (mnemonicAccount) { const mnemonic = mnemonicPhraseToBytes(mnemonicAccount.value); - await MultichainAccountService.createMultichainAccountWallet({ - type: 'restore', - password: fixture.password, - mnemonic, - }); + try { + await MultichainAccountService.createMultichainAccountWallet({ + type: 'restore', + password: fixture.password, + mnemonic, + }); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } + Logger.log( + '[AgenticService] Falling back to legacy vault restore for historical replay fixture setup', + ); + await KeyringController.createNewVaultAndRestore( + fixture.password, + mnemonic, + ); + usedLegacyVaultSetup = true; + } } else { - await MultichainAccountService.createMultichainAccountWallet({ - type: 'create', - password: fixture.password, - }); + try { + await MultichainAccountService.createMultichainAccountWallet({ + type: 'create', + password: fixture.password, + }); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } + Logger.log( + '[AgenticService] Falling back to legacy vault creation for historical replay fixture setup', + ); + await KeyringController.createNewVaultAndKeychain( + fixture.password, + ); + usedLegacyVaultSetup = true; + } } // 2. Initialize services (same as Authentication.dispatchLogin) - await AccountTreeInitService.initializeAccountTree(); - await MultichainAccountService.init(); - - // 3. Import private key accounts - for (const account of fixture.accounts) { - if (account.type !== 'privateKey') continue; + setupStep = 'initialize-services'; + if (!usedLegacyVaultSetup) { try { - await KeyringController.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [account.value], - ); - } catch (e) { + await AccountTreeInitService.initializeAccountTree(); + await MultichainAccountService.init(); + } catch (error) { + if (!isExpectedLegacyAccountTreeInitError(error)) { + throw error; + } Logger.log( - `[AgenticService] Failed to import key: ${(e as Error).message}`, + '[AgenticService] Skipping multichain account-tree initialization for historical replay fixture setup', ); } } + // 3. Materialize requested fixture accounts. A mnemonic account can + // declare count/numberOfAccounts plus names[]; this is generic + // fixture semantics, not a dev-account special case. + setupStep = 'materialize-srp-accounts'; + await materializeFixtureAccounts( + fixture, + { + KeyringController, + AccountsController, + AccountTreeController, + }, + { allowLegacyAccountTreeInitFailure: usedLegacyVaultSetup }, + ); + // 4. Dispatch all onboarding/auth flags + setupStep = 'dispatch-onboarding-flags'; store.dispatch(passwordSet()); store.dispatch(seedphraseBackedUp()); store.dispatch(setCompletedOnboarding(true)); @@ -714,6 +1379,7 @@ const AgenticService = { store.dispatch(logIn()); // 5. Suppress post-onboarding modals if explicitly requested + setupStep = 'persist-modal-settings'; if (settings.skipGtmModals === true) { await Promise.all([ StorageWrapper.setItem(PERPS_GTM_MODAL_SHOWN, 'true'), @@ -726,29 +1392,41 @@ const AgenticService = { // 5b. Set metrics UI as seen (prevents Authentication.unlockWallet // from navigating to OptinMetrics after setupWallet resets to Wallet) + setupStep = 'persist-metrics-setting'; if (settings.metametrics !== undefined) { await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, 'true'); } // 5c. Mark multichain accounts intro modal as seen + setupStep = 'dispatch-multichain-intro-seen'; store.dispatch(setMultichainAccountsIntroModalSeen(true)); // 6. Skip perps tutorial onboarding if requested + setupStep = 'persist-perps-tutorial-setting'; if (settings.skipPerpsTutorial === true) { Engine.context.PerpsController?.markTutorialCompleted(); } // 7. Set auto-lock to "Never" (-1) for agentic workflows + setupStep = 'dispatch-auto-lock'; if (settings.autoLockNever === true) { ReduxService.store.dispatch(setLockTime(-1)); } - // 8. Enable device authentication (biometrics/passcode bypass) - if (settings.deviceAuthEnabled === true) { + // 8. Enable device authentication only on Android fixture runs. + // On iOS simulator, toggling OS auth during synthetic setup can drive + // react-native-keychain/quick-crypto secret export on the JS runtime + // and crash the app after setupWallet returns. Android is the only + // harness path that stores the fixture password for auto-unlock here. + setupStep = 'dispatch-device-auth'; + if ( + settings.deviceAuthEnabled === true && + Platform.OS === 'android' + ) { ReduxService.store.dispatch(setOsAuthEnabled(true)); } - // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only — iOS already handles this) + // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only) if ( settings.deviceAuthEnabled === true && Platform.OS === 'android' @@ -764,6 +1442,7 @@ const AgenticService = { } // 9. Configure MetaMetrics if specified + setupStep = 'configure-metametrics'; if (settings.metametrics === false) { await analytics.optOut(); } else if (settings.metametrics === true) { @@ -771,22 +1450,24 @@ const AgenticService = { } // 10. Navigate to wallet (same as Authentication.unlockWallet) + setupStep = 'navigate-wallet'; NavigationService.navigation?.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }], }); // 11. Collect all ETH accounts for the summary - const ethAccs = ( - Object.values( - AccountsController.state.internalAccounts.accounts, - ) as { id: string; address: string; metadata: { name: string } }[] - ) - .filter((a) => a.address?.startsWith('0x')) - .map(toAccountSummary); + setupStep = 'collect-accounts'; + const ethAccs = findEvmAccounts( + AccountsController.state.internalAccounts.accounts, + ).map(toAccountSummary); return { ok: true, accounts: ethAccs }; } catch (e) { - return { ok: false, error: String((e as Error).message || e) }; + return { + ok: false, + step: setupStep, + error: String((e as Error).message || e), + }; } }, }; diff --git a/docs/perps/myx-validation-report.md b/docs/perps/myx-validation-report.md deleted file mode 100644 index 496012b866ec..000000000000 --- a/docs/perps/myx-validation-report.md +++ /dev/null @@ -1,103 +0,0 @@ -# MYX Provider Validation Report - -**Date:** 2026-02-25 -**Branch:** `fet/perps/myx-reads-write` -**Script:** `scripts/perps/agentic/validate-myx.sh` - ---- - -## Testnet (chainId 421614, Arbitrum Sepolia → `api-test.myx.cash`) - -| Category | Test | Result | Details | -| ------------ | ------------------------ | ---------- | -------------------------------- | -| Init | Provider registered | PASS | `myx` in providers map | -| Init | Markets loaded | PASS | 2 pools, 1 with price data | -| Init | Markets have name | PASS | | -| Prices | Tickers with real prices | PASS | KNY=$65,629 | -| Candles REST | 1h historical | PASS | 101 candles | -| Candles REST | 1D historical | PASS | 101 candles | -| Candles REST | 5m historical | PASS | 101 candles | -| Candles WS | Sustained kline updates | PASS | 3 WS callbacks | -| Prices WS | Live ticker update | PASS | KNY `"65587.50"` | -| Auth | isReadyToTrade | UNVERIFIED | `auth()` is sync, proves nothing | -| Positions | getPositions | UNVERIFIED | 0 (auth not validated) | -| Orders | getOrders | UNVERIFIED | 0 (auth not validated) | -| Account | getAccountState | UNVERIFIED | all zeros (auth not validated) | -| Ping | Health check | PASS | | - -**Summary:** 10 passed, 0 failed, 0 skipped, 4 unverified - -### Testnet Markets - -2 pools on API, 1 returned after filtering (pools without ticker data are excluded): - -| Symbol | Price | Candles | Volume | Chain | -| ------ | ------- | ------- | ------ | ------------------ | -| KNY | $65,629 | Yes | $0.53 | Arb Sepolia 421614 | - -SGLT (Linea Sepolia 59141) is filtered out — paused pool, no ticker data. - ---- - -## Mainnet (chainId 56, BNB Chain → `api.myx.finance`) - -| Category | Test | Result | Details | -| ------------ | ------------------------ | ---------- | ---------------------------------- | -| Init | Provider registered | PASS | `myx` in providers map | -| Init | Markets loaded | PASS | 27 pools, 3 with price data | -| Init | Markets have name | PASS | | -| Prices | Tickers with real prices | PASS | WBTC=$65,585, MYX=$0.40, WBNB=$602 | -| Candles REST | 1h historical | PASS | 101 candles (MYX token) | -| Candles REST | 1D historical | PASS | 101 candles (MYX token) | -| Candles REST | 5m historical | PASS | 101 candles (MYX token) | -| Candles WS | Sustained kline updates | PASS | 3 WS callbacks | -| Prices WS | Live ticker update | PASS | MYX `"0.4012..."` | -| Auth | isReadyToTrade | UNVERIFIED | `auth()` is sync, proves nothing | -| Positions | getPositions | UNVERIFIED | 0 (auth not validated) | -| Orders | getOrders | UNVERIFIED | 0 (auth not validated) | -| Account | getAccountState | UNVERIFIED | all zeros (auth not validated) | -| Ping | Health check | PASS | | - -**Summary:** 10 passed, 0 failed, 0 skipped, 4 unverified - -### Mainnet Markets - -27 pools on API, 3 returned after filtering: - -| Symbol | Price | Candles | Volume | -| ------ | ------- | ------- | ------ | -| WBTC | $65,585 | — | $0 | -| MYX | $0.40 | Yes | $50.34 | -| WBNB | $602 | — | $0 | - -24 pools filtered out — community/meme tokens with no ticker data. MYX uses a Multi-Pool Model where anyone can create pools; most are inactive. - ---- - -## Testnet vs Mainnet Comparison - -| Dimension | Testnet | Mainnet | -| --------------------- | ------------- | ------------------------------- | -| Pools on API | 2 | 27 | -| Active (with prices) | 1 | 3 | -| Prices | KNY=$65,629 | WBTC=$65k, MYX=$0.40, WBNB=$602 | -| REST candles | All intervals | All intervals | -| WS candles + prices | Yes | Yes | -| Auth/positions/orders | Unverified | Unverified | -| Tests passed | 10/14 | 10/14 | - ---- - -## Known Issues - -1. **Most pools have no ticker data** — API only returns prices for active pools (1/2 testnet, 3/27 mainnet). `getMarketDataWithPrices()` filters these out. -2. **Auth never validated** — `myxClient.auth()` is sync (stores callbacks, no API call). Empty results prove nothing. -3. **No mainnet credentials** — `.js.env` has testnet creds only. - ---- - -## Next Steps - -1. **Validate auth** — call token API directly or attempt a testnet order. -2. **Mainnet credentials** — get dedicated `appId`/`apiSecret` from MYX team. -3. **Curated pool list** — get from MYX team which pools to show, or rely on the active-ticker filter. diff --git a/docs/perps/perps-account-abstraction-and-balance-contract.md b/docs/perps/perps-account-abstraction-and-balance-contract.md index 6914d2c174db..a687a0486798 100644 --- a/docs/perps/perps-account-abstraction-and-balance-contract.md +++ b/docs/perps/perps-account-abstraction-and-balance-contract.md @@ -1,389 +1,13 @@ -# Perps — Account Abstraction & Balance Contract +# Perps account abstraction and balance contract -This is the reference doc for the perps `AccountState` balance contract and how mobile handles HyperLiquid's account-abstraction modes (Unified, Standard, Portfolio, DEX-abstraction). Anyone touching balance display, order-entry validation, withdraw flow, or HL-mode-aware logic should start here. +Historical agentic validation for this contract used Mobile-local recipe files. +Recipe authoring has moved out of the Mobile repository to the external Recipe +v1 runner. Keep this document focused on the product contract; executable +recipes and evidence artifacts should live with the external runner. -The current shape — three purpose-built balance fields plus a mode-aware spot-fold gate — was introduced in [TAT-3047 / PR #29303](https://github.com/MetaMask/metamask-mobile/pull/29303), which replaced the earlier overloaded `availableBalance` + optional `availableToTradeBalance` pair (TAT-3016 hotfix) and added end-to-end correctness across the abstraction modes HL exposes. +## Contract summary -## What's in this doc - -1. **The contract** — three fields and what they mean per provider (`AccountState.spendableBalance` / `withdrawableBalance` / `totalBalance`). -2. **HL abstraction modes** — Unified vs Standard vs Portfolio vs DEX-abstraction, mapped to HL web checkboxes and the SDK `userSetAbstraction` enum, with the per-mode balance semantics. -3. **The mode-aware spot-fold gate** — how the provider + subscription service detect and react to HL-side mode changes. -4. **Validation matrix** — the four fixture accounts and recipes that prove the contract holds end-to-end on mainnet. -5. **Known trade-offs** — explicit "not covered" + the cold-start behaviour for Standard-mode users. - -## Why this matters for any future change - -- **UI never branches on provider or HL abstraction mode.** Consumers read `spendableBalance` for "can the user open a trade with this much" and `withdrawableBalance` for "can the user pull this much off the venue". The provider populates each field with the right number for the mode it's in. If a future provider needs different semantics, add the translation in the adapter — not in a hook or component. -- **`addSpotBalanceToAccountState` is provider-agnostic** and takes an explicit `{ foldIntoCollateral }` flag. The HL provider computes the flag from `userAbstraction`; MYX always passes `true`. -- **`hyperLiquidModeFoldsSpot(mode)` is the single source of truth** for "does this HL mode let spot USDC act as perps collateral". Add new HL mode strings here when HL ships them. - -Last validated: 2026-04-24, mainnet (recipe run on 4 fixture accounts including a live Unified ↔ Standard mode flip). - -## The three-field contract - -`AccountState` (in `app/controllers/perps/types/index.ts`) carries three balance fields, each answering one question: - -| Field | Question | Used by | -| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | -| `totalBalance` | What is the user's total wealth at this venue, including PnL? | Balance header, portfolio aggregation, deposit progress watchers | -| `spendableBalance` | How much can immediately collateralize a new position? | Order-form max, pay-token gate, insufficient-balance alerts | -| `withdrawableBalance` | How much can leave this venue to the user's external wallet? | Withdraw-form max, withdraw validation | - -Invariant (documented, not enforced): `withdrawableBalance ≤ spendableBalance ≤ totalBalance`. - -### Per-provider mapping - -| Provider | `totalBalance` | `spendableBalance` | `withdrawableBalance` | -| ---------------------- | ------------------------------------------------------ | ----------------------------- | ----------------------------- | -| HL Unified / Portfolio | `accountValue + spot.total − spot.hold` | `withdrawable + freeSpotUSDC` | `withdrawable + freeSpotUSDC` | -| HL Standard | `accountValue + spot.total − spot.hold` (display only) | `withdrawable` (perps-only) | `withdrawable` (perps-only) | -| MYX | `walletBalance + marginUsed + unrealizedPnl` | `walletBalance` | `walletBalance` | - -On HL Unified/Portfolio, `spendable === withdrawable`: HL's backend treats spot USDC as perps collateral and `withdraw3` draws from the unified ledger server-side. On HL Standard, spot USDC is a separate ledger that HL won't auto-draw from, so spendable/withdrawable stay perps-only and only `totalBalance` reflects the combined wealth (display). - -## HL abstraction modes — glossary - -HL has four account-abstraction modes. The two user-facing ones that matter for this PR are **Unified** (default on app.hyperliquid.xyz) and **Standard**. They are composed on HL web via two checkboxes under Account Settings: - -| HL web checkbox A: "Disable HIP-3 Dex Abstraction" | HL web checkbox B: "Disable Unified Account Mode" | Resulting mode | SDK `userSetAbstraction` value | Balance semantics | -| -------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ☐ unchecked | ☐ unchecked | DEX abstraction (deprecated, to be discontinued) | `dexAbstraction` | USDC defaults to perps, other collateral defaults to spot; HIP-3 cross margin is non-intuitive. | -| ✓ **checked** | ☐ unchecked | **Unified** (default) | `unifiedAccount` | Single USDC balance unified across spot + all USDC-quoted perp DEXes. Spot USDC IS perps collateral. | -| ☐ unchecked | ✓ checked | DEX abstraction variant | — | Not exercised by this recipe. | -| ✓ **checked** | ✓ **checked** | **Standard** | `disabled` | Separate perps and spot balances. Separate per-DEX balances. Spot USDC is NOT auto-collateral for perps; moving it requires an explicit `usdClassTransfer`. | - -When this recipe's `phase2c-flip-standard` step calls `exchangeClient.userSetAbstraction({ abstraction: 'disabled' })` on dev2, it is equivalent to **checking both** boxes in the HL web UI (i.e. producing Standard). `phase2c-restore-unified` calls `userSetAbstraction({ abstraction: 'unifiedAccount' })` which leaves HIP-3 disabled but re-enables Unified (the default state — checkbox A checked, checkbox B unchecked). - -Portfolio margin (pre-alpha, `portfolioMargin`) is a separate toggle not covered here. - -## Accounts and topologies covered - -Four fixture accounts, each in a distinct state. Two abstraction modes (Unified + Standard) are exercised live by flipping `dev2` mid-recipe. - -| Fixture | Address | Modes run live | Perps clearinghouse | Spot USDC | Open positions | Topology | -| ------------- | --------------- | -------------------------------- | ------------------------------------------------- | ----------------------------- | -------------------- | ----------------------------------------------------------- | -| **Trading** | `0x316BDE…01fA` | Unified | `withdrawable=$0`, `accountValue≈$3.35` | `total≈$104.40`, `hold≈$6.87` | Yes (on HIP-3 `xyz`) | Unified, spot-funded, open HIP-3 position ⇒ `spot.hold > 0` | -| **dev1** | `0x8Dc623…9003` | Unified | `withdrawable=$0`, `accountValue=$0` | `total≈$29.67`, `hold=$0` | None | Unified, spot-only, clean | -| **dev2** | `0x5993d2…0916` | **Unified → Standard → Unified** | `withdrawable≈$10`, `accountValue≈$10` (pre-flip) | dust (`≈$0.0004`, `hold=$0`) | None | Perps-funded clean fixture flipped to Standard and back | -| **Account 6** | `0xB9b9E1…42c2` | Unified | `withdrawable=$0`, `accountValue=$0` | `total=$0`, `hold=$0` | None | Zero across all ledgers | - -Note on dev2 ledger drift: `userSetAbstraction` flipping a Unified account to Standard and back can redistribute the USDC between the perps and spot sides at the HL backend (observed: $10 moved perps→spot during the flip cycle). This is HL-side behaviour, not a recipe artefact. The account's total USDC is preserved end-to-end; only the side it reports on changes. - -## Why this set covers the refactor surface - -| Risk the refactor could introduce | Scenario that catches it | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Fields not populated (UI reads `undefined`) | All four scenarios — `hl-balance-contract-check` asserts shape | -| Spot fold applied incorrectly on Unified | Trading (spot-funded) — `hl-balance-math-check` asserts `spendable = Σ(breakdown.spendable) + freeSpot` | -| `spot.hold` not subtracted from total when a position is open | Trading has `spot.hold = $6.87` from an HIP-3 margin hold — math check flags if `totalBalance` includes the double-counted hold | -| Clean spot-only case produces wrong shape | dev1 — simpler topology, catches regressions that only appear without HIP-3 noise | -| Perps-funded clean case produces wrong shape | dev2 Unified baseline — covers the "perps-heavy, spot-light" topology the other fixtures don't | -| Contract not mode-agnostic | dev2 flipped Unified → Standard → Unified — contract + math asserted in both modes | -| Mode flip corrupts persisted state shape | dev2 post-flip-back — contract re-asserted to confirm shape survives the round trip | -| Legacy keys leak back in | All four — contract flow asserts `availableBalance` and `availableToTradeBalance` are absent | -| `spendable` diverges from `withdrawable` on HL | All four — math flow asserts `spendable === withdrawable` | - -### Not covered - -- **Portfolio margin mode** (pre-alpha): no fixture available; behaviour expected to match Unified for borrowable-asset accounts. -- **DEX-abstraction mode** (deprecated by HL): out of scope. -- **Open positions on Standard mode**: would require opening a position after the flip. Not exercised — contract shape is mode-agnostic per our refactor, and the position-open path is covered separately in Trading (Unified with HIP-3 position). -- **Cold-start inflation window for Standard-mode users**: the mode cache starts empty, and `hyperLiquidModeFoldsSpot(null)` intentionally returns `true` (Unified default — see JSDoc on the helper). For Standard-mode users on a fresh app launch, the _first_ spot WS tick can surface a spot-folded `spendableBalance` / `withdrawableBalance` until `userAbstraction` REST completes (typically sub-second). `getAccountState` (REST-driven) fetches `userAbstraction` in parallel via `Promise.allSettled` and applies the gated fold when fulfilled, so the REST path is unaffected. Explicit trade-off: under-reporting Unified on transient endpoint failure was judged a worse trust break than a brief over-report on the minority Standard population. HL rejects bad submits cleanly; no data-loss risk. Sentry logging now surfaces sustained `userAbstraction` failures so ops can track the rate. - -### Standard-mode correctness — fixed - -Earlier revisions of this PR had an unconditional spot-fold in `addSpotBalanceToAccountState`, which inflated `spendableBalance` and `withdrawableBalance` on HL Standard-mode accounts (where spot is a separate ledger, not perps collateral). That would have let the UI approve withdraw/order submissions that HL's backend would reject. - -The PR now includes a mode-aware fold gate: - -- `accountUtils.addSpotBalanceToAccountState` takes an `{ foldIntoCollateral: boolean }` option. Provider-agnostic — doesn't know about HL modes. -- `hyperliquid-types.ts` owns the HL-specific `HyperLiquidAbstractionMode` type (re-export of HL SDK's `UserAbstractionResponse`) and a `hyperLiquidModeFoldsSpot(mode)` helper that returns `true` for `unifiedAccount` / `portfolioMargin` / `default` and `false` for `disabled` (Standard) / `dexAbstraction`. -- `HyperLiquidProvider.getAccountState` fetches `userAbstraction` in parallel with clearinghouse + spot state, then passes `{ foldIntoCollateral: hyperLiquidModeFoldsSpot(mode) }` to the util. -- `HyperLiquidSubscriptionService` caches `userAbstraction` alongside `spotClearinghouseState` (refreshed together, cleared together on cleanup) and applies the same gate on every fold site. - -Migration 133 uses an **asymmetric mapping** so upgraded Standard-mode users see correct cold-start values without waiting for the first live fetch: `withdrawableBalance` migrates from the legacy perps-only `availableBalance` (not from the spot-folded `availableToTradeBalance`). - -Phase 2c of the recipe proves the fix with live numbers on dev2 flipped to Standard mode: - -- `spot.free = $10.01` -- `standardSemanticExpected.spendable = 0` (perps-only) -- `adapterActual.spendable = 0` -- `observedInflation = 0` — no inflation, gate works. - -## Reusable composable flows - -Under `scripts/perps/agentic/teams/perps/flows/`. Each takes `address` + `phaseLabel` and is reusable in any future recipe. - -### `hl-balance-contract-check.json` - -Asserts `PerpsController.accountState` carries the new three-field contract: - -- `spendableBalance: string` present -- `withdrawableBalance: string` present -- `totalBalance: string` present -- `availableBalance` absent (legacy) -- `availableToTradeBalance` absent (legacy) - -### `hl-balance-math-check.json` - -Asserts the spot-fold math by deriving expected values from the controller's own `subAccountBreakdown` (pre-fold per-DEX perps) plus live HL REST `spotClearinghouseState`: - -- Expected `spendable = Σ(breakdown[*].spendableBalance) + max(0, spot.total − spot.hold)` -- Expected `withdrawable = Σ(breakdown[*].withdrawableBalance) + max(0, spot.total − spot.hold)` -- Expected `total = Σ(breakdown[*].totalBalance) + spot.total − spot.hold` -- Asserts `spendable === withdrawable` (HL invariant) -- Tolerates `epsilon = 0.01` for rounding - -This formulation naturally covers single-DEX accounts and HIP-3 multi-DEX accounts — the per-DEX sum is the controller's own truth; the spot REST is independent. Works in both Unified and Standard modes because `freeSpot` comes from raw HL REST. - -Standard-mode regression guard is inlined in the recipe (`pathA-fold-correctness` node) rather than a separate flow — `hl-balance-math-check{foldIntoCollateral=false}` already proves the gate works; the inline `eval_async` quantifies `observedInflation = adapterActual.spendable − Σ(breakdown.spendable)` so a reviewer can read the delta in the trace. - -### `hl-provision-fixture.json` (pre-existing, reused) - -Already in the repo. Used to flip abstraction mode via `userSetAbstraction`. Required for the dev2 Standard-mode scenario. - -## Top-level recipe - -The validation recipe lives in the repo at `scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json`. It is a **single-account state machine** parameterised by EVM address. The runner's `--input address=0x...` flag picks the fixture; the recipe's preflight probe classifies the account into one of two execution paths based on whether positions / orders are open (HL rejects `userSetAbstraction` while either exists). - -``` -setup → toggle_testnet enabled=false → wait isTestnet===false (force mainnet before graph runs) -entry → gate-check-route → go-home (if inside Perps) → select-account ({{address}}) → wait-account → preflight-probe -preflight-probe (HL REST: clearinghouseState + openOrders + spotClearinghouseState + userAbstraction) - └─ classifies pathA = positions===0 && pendingOrders===0 -shared (mode-agnostic — runs on both paths) - ├─ call hl-balance-contract-check - ├─ call hl-balance-math-check - ├─ nav PerpsMarketListView → assert PerpsMarketBalanceActions shows spendableBalance - ├─ screenshot shared-market-list.png - ├─ nav PerpsWithdraw → assert perps-withdraw-available-balance-text shows withdrawableBalance (not $0) - ├─ screenshot shared-withdraw-folded.png - ├─ type_keypad 1 → assert continue-button disabled=false - └─ type_keypad 99999 → assert contract (over-amount > withdrawable) -path-switch - ├─ pathA → pathA-flip-standard (Unified → Standard) - └─ default → pathB-shift-spot (positions present — perps↔spot transfers only) -pathA (clean account, full mode-flip matrix) - ├─ call hl-provision-fixture abstraction=disabled (Unified → Standard) - ├─ wait userAbstraction REST = 'disabled' (HL-side propagation bite) - ├─ call hl-balance-contract-check (shape in Standard mode) - ├─ call hl-balance-math-check foldIntoCollateral=false (math gated for Standard semantics) - ├─ eval_async pathA-fold-correctness (inline regression guard: assert observedInflation ≤ ε) - ├─ call hl-provision-fixture abstraction=unifiedAccount (restore) - ├─ wait userAbstraction REST = 'unifiedAccount' - └─ call hl-balance-contract-check (shape survives round trip) -pathB (positions present — perps↔spot transfer only, no mode flip) - ├─ call hl-provision-fixture transferDirection=to-spot (shift free perps → spot) - ├─ call hl-balance-contract-check (shape unchanged) - ├─ call hl-balance-math-check (math under non-trivial spot.hold) - └─ call hl-provision-fixture transferDirection=to-perp (restore — positions untouched) -teardown - └─ navigate WalletView -``` - -Setup prerequisites (Metro, simulator, wallet fixture, CDP bridge): see `scripts/perps/agentic/README.md`. The recipe forces `isTestnet=false` in its setup block (HyperLiquid mainnet — real fixtures and `userAbstraction`) and expects the fixture wallet (default Trading) to hold ≥ a few USDC. - -Run against a live app (Trading by default): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json -``` - -Pick a different fixture (e.g. dev2 for guaranteed Path A): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json \ - --input address=0x5993d2153F080470BFE765aE81F4fA5fA2080916 -``` - -Dry-run graph walk only (no CDP): - -```bash -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json --dry-run -``` - -Schema validation (syntax + graph reachability): - -```bash -node scripts/perps/agentic/validate-flow-schema.js \ - scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json \ - scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json \ - scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json -``` - -## Manual reproduction - -Anyone with HL web access + a fixture wallet can validate the contract by hand. The state machine below mirrors the recipe 1:1; each row maps to an HL-web action and an expected mobile-app readout. Run on a clean account (no open positions/orders) to walk all five states; on an account with positions you can only do steps 1, 1b (perps→spot transfer), and 1c (transfer back). - -Default fixture: **Trading** (`0x316BDE…01fA`). Substitute any address you can sign for; the recipe's `--input address=...` flag does the same. - -| # | HL web action | Resulting state | Mobile app readout | What it proves | -| ------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | -| **0** | open `app.hyperliquid.xyz/portfolio`, log in with the fixture wallet, ensure no open positions/orders | baseline | (no app action) | precondition for mode flips | -| **1** | none (start in Unified, all funds on perps) | **Unified, perps-only** | PerpsMarketListView header `$X` (= perps balance); PerpsWithdrawView "Available Perps balance" `$X` | three-field shape populated, sane Unified case | -| **1b** | click _Transfer_ (Perp → Spot) for the full perps balance | **Unified, spot-only** | PerpsMarketListView still `$X` (fold); PerpsWithdrawView still `$X` (TAT-3047 fix — was `$0` before this PR) | spot fold on Unified; primary TAT-3047 user-visible fix | -| **2** | Account Settings → check **Disable Unified Account Mode**, confirm signature | **Standard, spot-only** | PerpsMarketListView shows `$0` and the Add Funds CTA; PerpsWithdrawView "Available" reads `$0.00` | mode-aware fold gate working — spot is no longer perps collateral | -| **2b** | click _Transfer_ (Spot → Perp) for half the spot balance | **Standard, mixed (perps + spot)** | PerpsMarketListView `$X/2`; total displayed wealth still `$X` | math invariant: `total = perps + spot − hold` independent of mode | -| **2c** | click _Transfer_ (Spot → Perp) for the remaining spot | **Standard, perps-only** | PerpsMarketListView `$X`; PerpsWithdrawView `$X` | Standard cap matches perps clearinghouse exactly | -| **3** | Account Settings → uncheck **Disable Unified Account Mode**, confirm signature | **Unified, perps-only** (back to baseline) | Same as state 1 | mode round trip preserves contract; HL redistributes the ledger as needed | - -Notes: - -- **HL web "Transfer between Perp and Spot"** is on the Portfolio screen. The mobile app does not expose this transfer — `usdClassTransfer` lives in the SDK and is only invoked by the agentic provisioning flow, not by user-facing UI. -- **Mode-flip restriction**: the _Disable Unified Account Mode_ checkbox is greyed out while any position, order, or TWAP is open on the account. Close everything before attempting the flip. -- **HL ledger drift on flip**: flipping from Unified to Standard can move USDC between the perps and spot sides at the HL backend (HL redistribution). Flipping back does not always restore the original split — it merely re-enables the unified view. The recipe documents this in the Path A trace. -- **Recovery if a flip leaves you in Standard unintentionally**: re-open Account Settings, uncheck the box, sign. The mobile app picks up the new mode within ~60 s via the spot WebSocket → `userAbstraction` REST refresh path documented in the [Mode-aware spot-fold gate](#standard-mode-correctness--fixed) section. - -## Live run evidence - -Most recent run: `.agent/recipe-runs/2026-04-24_08-49-49_recipe/` (local — gitignored). - -### Summary - -``` -Results: 36/36 passed -Recipe: PASS -Teardown: PASS (Trading reselected, navigated to WalletView) -``` - -### Captured values — Phase 1 (Trading, Unified spot-funded + HIP-3) - -| Field | Source | Value | -| ------------------------------------------------------ | ---------- | --------- | -| `accountState.spendableBalance` | controller | `$97.53` | -| `accountState.withdrawableBalance` | controller | `$97.53` | -| `accountState.totalBalance` | controller | `$104.40` | -| `clearinghouseState.withdrawable` (main) | HL REST | `$0.00` | -| `clearinghouseState.marginSummary.accountValue` (main) | HL REST | `$3.35` | -| `spotClearinghouseState.balances[USDC].total` | HL REST | `$104.40` | -| `spotClearinghouseState.balances[USDC].hold` | HL REST | `$6.87` | -| `subAccountBreakdown.main.totalBalance` | controller | `$3.36` | -| `subAccountBreakdown.xyz.totalBalance` (HIP-3) | controller | `$3.52` | - -Math check passes: `Σ(breakdown[*].total) + spot.total − spot.hold ≈ totalBalance` within `ε = 0.01`. - -### Captured values — Phase 2 (dev1, Unified spot-only) - -| Field | Value | -| --------------------- | -------- | -| `spendableBalance` | `$29.67` | -| `withdrawableBalance` | `$29.67` | -| `totalBalance` | `$29.67` | -| `spot.USDC.total` | `$29.67` | -| `spot.USDC.hold` | `$0.00` | - -### Captured values — Phase 2b (dev2, Unified perps-funded, baseline) - -| Field | Value | -| --------------------- | ---------------- | -| `spendableBalance` | `$10.01` | -| `withdrawableBalance` | `$10.01` | -| `totalBalance` | `$10.01` | -| `perps.withdrawable` | `$10.01` | -| `spot.USDC.total` | dust (`$0.0004`) | - -### Phase 2c (dev2, Unified → Standard → Unified) - -Equivalent to the user toggling HL web's "Disable Unified Account Mode" checkbox on, waiting, and toggling it off. Executed via `hl-provision-fixture` which calls `exchangeClient.userSetAbstraction({ abstraction: 'disabled' })` for the move to Standard and `{ abstraction: 'unifiedAccount' }` for the restore. HL accepts both operations for a clean account (no open positions / orders / TWAPs). After each flip the recipe waits for `PerpsController.getAccountState()` to refresh before asserting. - -**Observed side effect of the flip**: HL moves the $10 USDC from the perps side to the spot side as part of the Unified → Standard transition. This leaves dev2 post-flip with `perps.withdrawable = $0, spot.USDC = $10.01` — a meaningful split that exposes the Standard-mode fold limitation. - -Captured live values in Standard mode after the mode-aware fold gate landed: - -```json -{ - "phase": "dev2-standard-mode-correctness", - "spot": { "total": 10.0120682, "hold": 0, "free": 10.0120682 }, - "standardSemanticExpected": { "spendable": 0, "withdrawable": 0 }, - "adapterActual": { "spendable": 0, "withdrawable": 0 }, - "observedInflation": { "spendable": 0, "withdrawable": 0 }, - "standardModeCorrect": true -} -``` - -Interpretation: - -- **Contract-shape check**: passed in Standard mode — fields populated, no legacy keys, shape is mode-agnostic. ✓ -- **Adapter internal-consistency check** (`hl-balance-math-check` with `foldIntoCollateral=false`): passed — adapter output matches the expected perps-only formula when Standard semantics apply. ✓ -- **Standard-mode correctness check** (inline `pathA-fold-correctness` eval): `adapterActual.spendable = 0` even though `spot.free = $10.01`, proving the `hyperLiquidModeFoldsSpot` gate is wired end-to-end through both the subscription service and the provider. ✓ -- **Post-restore contract check**: passed — shape survived the Unified → Standard → Unified round trip. ✓ - -### Captured values — Phase 3 (Account 6, zero) - -| Field | Value | -| ------------------- | ----------------------------------------------------------------------------------- | -| All balance fields | `$0` | -| Empty-state surface | `perps-market-add-funds-button` mounted; `perps-market-balance-value` reads `$0.00` | - -## UI-level assertions — Phase 1 - -Observed on live app: - -| Screen | testID | Rendered text | -| --------------------- | --------------------------------------- | ----------------------------------------------------------------- | -| `PerpsMarketListView` | `perps-market-available-balance-text` | `$97.53` (matches `spendableBalance`) | -| `PerpsWithdrawView` | `perps-withdraw-available-balance-text` | `Available Perps balance: $97.53` (matches `withdrawableBalance`) | - -Continue button state after keypad input (Phase 1): - -| Typed amount | `continue-button.disabled` | Expected | Result | -| ------------------------- | -------------------------- | -------- | ----------------------------------------------------- | -| `$1` (≤ withdrawable) | `false` | enabled | ✓ | -| `$99999` (> withdrawable) | (see note) | disabled | contract-level pass; UI reactivity tracked separately | - -Note: during AC6 the UI disabled state did not flip after typing `$99999`. The recipe asserts the contract-level condition (`99999 > withdrawableBalance`) which is what the refactor is responsible for. UI reactivity on large-keypad-input is a pre-existing quirk tracked as a follow-up. - -## Real withdrawal - -The recipe intentionally does NOT submit `withdraw3` — that call costs \$1 in HL fees. A manual probe was run separately: - -``` -[1] BEFORE: { perps_withdrawable: "0.0", spot_usdc_total: "105.417..." } -[2] CALLING withdraw3({amount: "1.01"}) - response: {"status":"ok","response":{"type":"default"}} -[3] SUCCESS — HL accepted withdraw3 on Unified spot-funded account. -[4] AFTER: { spot_usdc_total: "104.407..." } - spot.usdc.total delta: -1.01 -``` - -Confirmed: on a Unified-mode account with `perps.withdrawable = 0` and spot USDC funded, `withdraw3` succeeds and pulls directly from spot via HL's unified abstraction. No client-side sweep needed — which is exactly why this PR does not carry one. - -## Migration path - -Migration `133.ts` is not covered by the recipe (recipe runs against live state, not redux-persist rehydration). Covered instead by: - -- **Unit-level**: `app/store/migrations/133.ts` maps legacy `availableBalance` / `availableToTradeBalance` into the new fields. Migration follows the repo's `ensureValidState` pattern and handles both the top-level `accountState` and `subAccountBreakdown` entries. -- **Disk cache**: `PERPS_DISK_CACHE_USER_DATA` storage key bumped to `_V2`. Upgraded installs see an empty new-key cache on first run, fall through to skeleton, then backfill from the WS tick. Old-key blob sits orphaned until any reset/logout flow clears it. - -Manual validation of the migration path requires a build-upgrade harness (install prior-version → populate state → install new build → observe rehydration). Out of scope for this recipe. - -## Thoroughness checklist - -- [x] Four distinct account topologies covered: Unified spot-funded + HIP-3 / Unified spot-only clean / Unified perps-funded clean / zero -- [x] Two abstraction modes exercised live (Unified + Standard) via in-flight flip on dev2 -- [x] Mode round trip exercised (Unified → Standard → Unified) and shape re-asserted after restore -- [x] Standard-mode fold-limitation quantified with live numbers (`observedInflation ≈ freeSpot`) -- [x] Controller field shape asserted on every account (no `undefined`, no legacy keys) -- [x] Math check independent of HIP-3 knowledge (uses controller breakdown as perps truth, HL REST as spot truth) -- [x] UI assertions on both `PerpsMarketListView` and `PerpsWithdrawView` -- [x] Keypad input → validation hook behavior asserted for valid and over-amount cases -- [x] Empty-state UI asserted via Add Funds affordance -- [x] Teardown restores fixture account to Trading -- [x] Schema-validated (composable flows + recipe) -- [x] Run repeatable and free (no withdraw3, no fund movement outside the HL-internal ledger redistribution on mode flip) -- [x] Real `withdraw3` behavior verified separately via one-shot script (cost: \$1) -- [x] Flows are reusable via `call` — any future perps PR touching balance fields can compose them - -## Files - -| Path | Purpose | Tracked | -| ------------------------------------------------------------------------ | --------------------------------------------------------- | ------- | -| `scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json` | Composable shape check | ✓ git | -| `scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json` | Composable math check | ✓ git | -| `scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json` | Pre-existing fixture-provisioning flow (abstraction flip) | ✓ git | -| `scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json` | Top-level single-account state-machine recipe | ✓ git | -| `docs/perps/perps-account-abstraction-and-balance-contract.md` | This document | ✓ git | +Perps account balance display must preserve the distinction between spendable +balance, collateral, and HyperLiquid account abstraction state. Validation should +exercise clean accounts, accounts with positions/orders, and Unified/Standard +mode transitions through the runner-owned recipe suite. diff --git a/docs/perps/perps-agentic-feedback-loop.md b/docs/perps/perps-agentic-feedback-loop.md index b4541207a873..b3acf75f9f14 100644 --- a/docs/perps/perps-agentic-feedback-loop.md +++ b/docs/perps/perps-agentic-feedback-loop.md @@ -1,403 +1,47 @@ -# Perps Agentic Toolkit +# Perps agentic feedback loop -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. +MetaMask Mobile exposes a development-only agentic bridge so external Farmslot +Recipe v1 runners can control the app, seed deterministic fixtures, show a small +HUD, and capture proof from the real UI path. -The toolkit lives at `scripts/perps/agentic/`. It works on both iOS Simulator and Android Emulator. +## Repository boundary ---- +The Mobile repository owns the product integration only: -## Architecture +- `app/core/AgenticService/` installs `globalThis.__AGENTIC__` in `__DEV__`. +- `scripts/perps/agentic/cdp-bridge.js` connects external tools to the React + Native CDP target. +- `scripts/perps/agentic/setup-wallet.sh` applies wallet fixtures through the + bridge. +- `scripts/perps/agentic/app-state.sh`, `app-navigate.sh`, and `screenshot.sh` + are small diagnostics used by humans and external runners. -``` -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 -``` +Recipe definitions, flow composition, action manifests, trace/summary output, +and MetaMask domain actions are maintained by the external Recipe v1 runner. +Do not add task-specific recipes or reusable runner actions to Mobile. ---- +## Human workflow -## Quick Start +Use Mobile scripts to start and inspect a controllable runtime: ```bash -# 1. Check app + Metro + CDP are connected +yarn a:ios yarn a:status - -# 2. Run a built-in eval ref (single CDP eval) -bash scripts/perps/agentic/app-state.sh eval-ref perps/positions - -# 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. 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 - -# 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 -``` - ---- - -## Flows - -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`. - -### Parameter Templating - -Flows use `{{param}}` tokens in titles, expressions, test_ids, and params. Defaults come from the `inputs` block: - -```json -{ - "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}", - "inputs": { - "side": { "type": "string", "default": "long" }, - "symbol": { "type": "string", "default": "BTC" }, - "usdAmount": { "type": "string", "default": "10" } - } -} -``` - -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. - -### Pre-Conditions - -Pre-conditions gate flow execution. If any check fails, the runner aborts with a clear error and hint. - -```json -"pre_conditions": [ - "wallet.unlocked", - "perps.ready_to_trade", - { "name": "perps.open_position", "symbol": "{{symbol}}" } -] -``` - -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 | - ---- - -## Eval Refs - -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 -# List all eval refs -bash scripts/perps/agentic/app-state.sh eval-ref --list - -# 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 -``` - -**Built-in eval refs** (`perps/`): positions, auth, balances, markets, orders, state, providers, pre-trade, post-trade, place-order - -**Extended eval refs** (`perps/core/`): pump-market, tpsl-orders, positions-by-symbol, leverage-config, watchlist - -**Setup eval refs** (`perps/setup/`): testnet-mode, current-provider - -### eval_ref in Flows - -Use `eval_ref` inside a flow to run a built-in eval ref and assert on its result: - -```json -{ - "id": "check-pos", - "action": "eval_ref", - "ref": "positions", - "assert": { "operator": "length_gt", "field": "positions", "value": 0 } -} +yarn a:navigate WalletView +yarn a:reload ``` ---- - -## CDP Commands +Use the recipe-harness skill or external runner to execute Recipe v1 scenarios. +The runner consumes Mobile's bridge and writes its own evidence artifacts. -All CDP commands go through `cdp-bridge.js` or `app-state.sh` wrappers: +## HUD intent -```bash -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 -``` - -**ES5 only.** No arrow functions, no `const`/`let`, no template literals, no top-level `await`. - -```bash -# 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) })" - -# Bad: -$CDP eval "const x = () => Engine.context" # arrow + const -$CDP eval-async "await Engine.context.getPos()" # top-level await -``` - ---- - -## Shell Commands - -| 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` | - ---- - -## Assertions - -Every asserting step includes `"assert": { "operator": "", "field": "", "value": }`. - -| 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)` | - -`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. - ---- - -## UI Interactions - -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 -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 -``` - -In flows, use `press`, `scroll`, `set_input`, `type_keypad`, `clear_keypad`, and `wait_for` actions. - -**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. - ---- - -## Gherkin to Flow Translation - -Gherkin maps naturally to flow JSON: - -| 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:** - -```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 -``` - -```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" - } - ] - } - } -} -``` - ---- - -## Recipes - -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. - -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`. - -```bash -# Run a recipe -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json - -# Dry-run -bash scripts/perps/agentic/validate-recipe.sh \ - scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json --dry-run -``` - ---- - -## Error Recovery - -| 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 | - ---- - -## Routes and State Paths - -### 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 | | - -Other useful routes: `WalletTabHome`, `SettingsView`, `DeveloperOptions`, `BrowserTabHome`. - -### Engine Controller Paths - -```bash -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 -``` +The HUD is a human-facing proof aid. It should display one concise current +intent, optionally one subflow/context line, and failure details when useful. It +must not duplicate internal action names or task-specific debug text. -### Common PerpsController Methods +## Fixture workflow -| 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 | +Local fixture files belong under `.agent/` and must not be committed. The bridge +supports fixture setup so recipes can start from a deterministic wallet state, +while still validating behavior through the real app runtime. diff --git a/docs/perps/perps-agentic-scripts-quickref.md b/docs/perps/perps-agentic-scripts-quickref.md deleted file mode 100644 index 2e59a61cbec2..000000000000 --- a/docs/perps/perps-agentic-scripts-quickref.md +++ /dev/null @@ -1,106 +0,0 @@ -# Agentic Scripts — Quick Reference - -## Yarn Shortcuts - -| Command | What it does | Time | -| ---------------------- | ---------------------------------------------------------- | ------- | -| `yarn a:setup:ios` | Clean install + build + Metro + launch + CDP + wallet seed | ~2.5min | -| `yarn a:setup:android` | Same as above for Android | ~3min | -| `yarn a:ios` | Metro + launch + CDP + unlock/seed (no clean, no rebuild) | ~30s | -| `yarn a:android` | Same as above for Android | ~30s | -| `yarn a:watch` | Interactive Metro with live reload | — | -| `yarn a:stop` | Stop Metro | — | -| `yarn a:reload` | Reload JS bundle on connected app | — | -| `yarn a:status` | App state snapshot (route + account) | — | -| `yarn a:navigate` | Navigate to a route | — | - -## When to use what - -- **First time / after `git clean`**: `yarn a:setup:ios` (full clean) -- **Daily dev / branch switch**: `yarn a:ios` (reuses existing build, unlocks wallet) -- **Just want Metro**: `yarn a:watch` - -## Prerequisites - -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 -CDP="node scripts/perps/agentic/cdp-bridge.js" - -$CDP status # Route + account snapshot -$CDP navigate PerpsMarketListView # Navigate to a screen -$CDP get-route # Current route -$CDP get-state engine.backgroundState.NetworkController # Redux state -$CDP eval "1+1" # Eval JS in app -$CDP eval-async "fetch('...')" # Eval async JS -$CDP unlock # Unlock wallet on login screen -$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 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 - -```bash -scripts/perps/agentic/app-navigate.sh # Navigate + screenshot -scripts/perps/agentic/app-navigate.sh --list # Discover all live routes -scripts/perps/agentic/screenshot.sh # Capture simulator screenshot -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 - -``` -NavigationService.ts (set navigation) - --> AgenticService.install(navRef, deferredNav) [__DEV__ only] - --> globalThis.__AGENTIC__ = { setupWallet, pressTestId, scrollView, ... } - -CDP Bridge (cdp-bridge.js) - --> Metro /json/list --> WebSocket --> Runtime.evaluate - --> reads globalThis.__AGENTIC__.* -``` - -## Worktree / Multi-Device Mapping - -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 deleted file mode 100644 index e1b56c531d60..000000000000 --- a/docs/perps/perps-agentic-system-design.md +++ /dev/null @@ -1,295 +0,0 @@ -# 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 b9be50b6acfa..f08a3c9bc48c 100644 --- a/package.json +++ b/package.json @@ -160,10 +160,10 @@ "a:status": "scripts/perps/agentic/app-state.sh status", "a:reload": "scripts/perps/agentic/reload-metro.sh", "a:navigate": "scripts/perps/agentic/app-navigate.sh", - "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --wallet-setup", - "a:android": "scripts/perps/agentic/preflight.sh --platform android --wallet-setup", - "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup", - "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --clean --wallet-setup" + "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup", + "a:android": "scripts/perps/agentic/preflight.sh --platform android --mode fast --wallet-setup", + "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode clean --wallet-setup", + "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --mode clean --wallet-setup" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/scripts/perps/agentic/CDP-capabilities-mobile.md b/scripts/perps/agentic/CDP-capabilities-mobile.md index 5d9473da8a8e..1c8b3e6ee4d7 100644 --- a/scripts/perps/agentic/CDP-capabilities-mobile.md +++ b/scripts/perps/agentic/CDP-capabilities-mobile.md @@ -1,127 +1,17 @@ -# MetaMask Mobile — CDP Capabilities +# Mobile CDP bridge capabilities -Mobile mirror of the extension's CDP capabilities study. Records which runner capabilities are exposed, how they're validated, and which families are structurally absent. +Mobile keeps a small product-side bridge for local development builds. External +Recipe v1 runners use this bridge to implement portable actions. -Substrate: +Supported bridge commands include: -- **Hermes CDP** via Metro inspector-proxy (Runtime.evaluate, Profiler) -- **Device layer** via `xcrun simctl` (iOS) / `adb` (Android) -- **In-app bridges**: `globalThis.__AGENTIC__`, Redux `store`, `Engine.context.*`, React DevTools hook +- route/status inspection; +- navigation and back navigation; +- selected-account reads and account switching; +- testID press, input, and scroll helpers; +- screenshot capture through the companion shell script; +- Hermes profiler start/stop; +- console/error issue capture for validation evidence. -## Supported families - -| Family | Recipe verbs | Mobile path | Status | -| --- | --- | --- | --- | -| Runtime / eval | `eval_sync`, `eval_async`, `eval_ref` | `Runtime.evaluate` | validated | -| UI interaction | `navigate`, `press`, `scroll`, `set_input`, `type_keypad`, `wait_for` | `__AGENTIC__` + fiber walk | validated | -| Lifecycle | `app_background`, `app_foreground`, `app_restart` | simctl / adb | validated | -| App surface | `select_account`, `toggle_testnet`, `switch_provider` | Redux + `Engine.context.*` | validated | -| Evidence (manual) | `screenshot`, `log_watch`, `manual` | `screenshot.sh` / Metro log | validated | -| Evidence (automatic) | built-in run-wide issue review | Metro log + in-app console hook | validated 2026-04-17 | -| Performance | `eval_sync` on `HermesInternal.getInstrumentedStats()` | Hermes built-in | validated 2026-04-17 | -| Tracing | `trace_start`, `trace_stop` | Hermes `Profiler` via CDP | validated 2026-04-17 | - -The runner exposes intent, not raw CDP plumbing. Canonical recipes live in `teams/perps/recipes/capabilities/`. - -## Structurally absent (document, don't force) - -| Family | Why | Workaround | -| --- | --- | --- | -| Network (offline/throttling) | no Hermes Network domain; iOS NLC is device-wide | XHR/fetch monkey-patch via `eval_sync` (narrow); simctl NLC (blunt) | -| Emulation — CPU | no Hermes Emulation domain | synthetic JS burn loop (not equivalent) | -| Emulation — media / timezone | no Hermes Emulation domain | `xcrun simctl status_bar` + appearance | -| Storage (web) | no Hermes Storage domain | MMKV / Redux clear via `eval_ref` | -| Service worker | no RN analog | `app_background` / `app_foreground` | -| Target (multi-page) | one Hermes target per simulator | N/A | -| Browser permissions | no Browser CDP domain | `xcrun simctl privacy` (deferred) | -| Fetch (request failure) | no Hermes Fetch domain | `global.fetch` / XHR monkey-patch | - -## Capability details - -### Performance metrics snapshot - -`eval_sync` on `HermesInternal.getInstrumentedStats()` — GC counters, heap/allocation stats, RN bridge counters — plus `global.performance.now()` for timestamps. Direct Hermes analog to Chrome's `Metrics.Timestamp`. Canonical: `capabilities/performance-metrics-smoke.json`. - -### Hermes sampling profiler - -`trace_start` / `trace_stop` call the CDP `Profiler` domain. Output is a Chrome-compatible `.cpuprofile` under `.agent/recipe-runs//traces/trace-