From 2f18776dab55ebfee84eb3e7703179f9e73fa0e9 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 4 May 2026 12:52:56 +0000 Subject: [PATCH 01/28] bump semvar version to 7.75.1 && build version to 4778 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c2599bfffeb1..13324f455ae5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.75.0" + versionName "7.75.1" versionCode 4754 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index e0077f7e49a8..edb191b73465 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3555,13 +3555,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.75.0 + VERSION_NAME: 7.75.1 - opts: is_expand: false VERSION_NUMBER: 4754 - opts: is_expand: false - FLASK_VERSION_NAME: 7.75.0 + FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false FLASK_VERSION_NUMBER: 4754 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 9eba28f8fefc..2094752e143c 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1307,7 +1307,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1373,7 +1373,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1442,7 +1442,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1506,7 +1506,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1672,7 +1672,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1739,7 +1739,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.75.0; + MARKETING_VERSION = 7.75.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 36fc367ad13b..b38cad9fe495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.75.0", + "version": "7.75.1", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From b166fafda126f068c2a96aa5140fe66d0bfca3cc Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 20:43:38 +0200 Subject: [PATCH 02/28] chore(runway): cherry-pick feat(perps): force unified account (#29673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(perps): force unified account (#29492) ## **Description** HyperLiquid is deprecating DEX Abstraction mode (~May 9). This PR forces every Perps user onto **Unified Account** mode on app open and fixes the withdraw + balance-display flows that were broken in the target state. ### 1. Forced migration to Unified Account Migration paths by current abstraction mode: - `default` / `disabled` → silently migrated via `agentSetAbstraction({ abstraction: 'u' })` — no signing prompt - `dexAbstraction` → one-time EIP-712 prompt via `userSetAbstraction({ user, abstraction: 'unifiedAccount' })` — agent-key path is blocked by HL for this transition - `unifiedAccount` → no-op, cached immediately Key details: - Replaces deprecated `agentEnableDexAbstraction` / `userDexAbstraction` with `agentSetAbstraction` / `userSetAbstraction` / `userAbstraction` - Runs on perps section open (`#ensureReady()`) so users are set up before trading - `TradingReadinessCache` prevents repeated prompts (critical for hardware/QR wallets); `KEYRING_LOCKED` skips the cache so it retries on unlock - In-flight deduplication blocks concurrent signing attempts across provider instances - Segment analytics: `Perp Account Setup` event tracks mode distribution + outcome (`already_enabled` / `migration_required` / `success` / `failed`) ### 2. Withdraw + balance display fix (folded in from #29537) In Unified mode, USDC collateral lives in the spot clearinghouse, so `clearinghouseState.withdrawable` is $0 — pre-fix the withdraw screen showed $0 max with the button disabled, and the confirm-flow alert blocked submission. - `accountUtils.addSpotBalanceToAccountState` folds free spot USDC into `availableToTradeBalance` for Unified / Portfolio Margin; `dexAbstraction` / Standard keep spot separate (fold gated on resolved abstraction mode) - `HyperLiquidSubscriptionService.invalidateUserAbstractionCache(addr)` evicts stale pre-migration mode and re-aggregates immediately. Called by `HyperLiquidProvider` after both successful migration paths so the WS-driven aggregator doesn't serve a $0 balance for ~60s after migration completes. - Withdraw screen, withdraw validation, confirm-flow insufficient-balance alert, and percentage buttons all read `availableToTradeBalance ?? availableBalance` — fallback keeps Standard / legacy callers correct. ## **Changelog** CHANGELOG entry: Fixed Hyperliquid withdraw showing $0 and being blocked for users on Unified Account mode. ## **Related issues** Fixes: TAT-3112 (Unified Account migration), withdrawal break tracked in [TAT-3047](https://consensyssoftware.atlassian.net/browse/TAT-3047) ## **Manual testing steps** ```gherkin Feature: Unified Account migration + withdraw Scenario: First-time migration (default/disabled mode) Given the user has never used Perps When they open the Perps section Then migration runs silently (no prompt) And HIP-3 markets are visible Scenario: dexAbstraction → unifiedAccount migration Given the user has DEX Abstraction enabled When they open the Perps section Then a one-time EIP-712 signing prompt appears When they sign Then HIP-3 markets are visible and trades succeed And reopening Perps does not prompt again Scenario: Unified Account user withdraws spot-funded balance Given the user is in Unified Mode with $0 perps withdrawable and >$0 spot USDC When they open the Withdraw screen Then "Available Perps balance" shows the unified value (perps + free spot USDC) And Max enables and submission proceeds via withdraw3 And spot USDC drops by amount + fee ``` ### Live validation evidence Validated on dev1 mainnet (`0x8dc6…9003`) in the exact bug-class state: - HL mode: `unifiedAccount` / perps `withdrawable`: $0 / spot USDC free: $26.41 - App: `availableBalance` = $0 / `availableToTradeBalance` = $26.41 - Withdraw screen renders **"Available Perps balance: $26.41"** + Max enabled (pre-fix would show $0 / disabled) ## **Screenshots/Recordings** ### **Before** ### **After** ## **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)). #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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. [TAT-3047]: https://consensyssoftware.atlassian.net/browse/TAT-3047?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **High Risk** > High risk because it changes Perps account-mode migration/signing flow (including hardware-wallet behavior) and alters withdraw/payment balance calculations that gate user funds and transaction validation. > > **Overview** > Forces Perps users onto HyperLiquid **Unified Account** by replacing deprecated DEX-abstraction checks/calls with `userAbstraction` + `agentSetAbstraction`/`userSetAbstraction`, adding global in-flight/cached gating, retry semantics, and new `Perp Account Setup` analytics. > > Updates withdraw, confirmation, and pay-with flows to prefer `availableToTradeBalance ?? availableBalance`, and changes spot→perps folding to be **mode-gated** (fail-closed when abstraction mode is unknown) so Unified/Portfolio Margin users see spendable USDC while Standard/dexAbstraction users don’t over-report withdrawable funds. > > Renames cache-clearing APIs from DEX abstraction to Unified Account, adds hardware-wallet detection to defer user-sign prompts on browse, and expands tests/docs to cover unified-mode folding, migration paths, and race conditions in spot/account aggregation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e5495f955f50c05b00985722749a1e08f6a8121b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: geositta Co-authored-by: Nick Gambino Co-authored-by: Arthur Breton Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> [cc44460](https://github.com/MetaMask/metamask-mobile/commit/cc444605ab4a0e0863f448b3aa14ccd541f70189) [TAT-3047]: https://consensyssoftware.atlassian.net/browse/TAT-3047?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Alejandro Garcia Anglada --- .../PerpsWithdrawView.test.tsx | 38 + .../PerpsWithdrawView/PerpsWithdrawView.tsx | 15 +- .../hooks/usePerpsBalanceTokenFilter.test.ts | 22 + .../Perps/hooks/usePerpsBalanceTokenFilter.ts | 10 +- .../Perps/hooks/usePerpsPaymentTokens.test.ts | 20 + .../UI/Perps/hooks/usePerpsPaymentTokens.ts | 9 +- .../Perps/hooks/useWithdrawValidation.test.ts | 17 + .../UI/Perps/hooks/useWithdrawValidation.ts | 7 +- .../services/PerpsConnectionManager.test.ts | 20 +- .../Perps/services/PerpsConnectionManager.ts | 10 +- app/components/UI/Perps/utils/formatUtils.ts | 21 + .../perps-withdraw-balance.tsx | 15 +- .../useInsufficientPerpsBalanceAlert.ts | 8 + .../useTransactionCustomAmount.ts | 8 +- app/controllers/perps/constants/eventNames.ts | 6 + .../providers/HyperLiquidProvider.test.ts | 1492 ++++++++++++----- .../perps/providers/HyperLiquidProvider.ts | 405 ++++- .../HyperLiquidSubscriptionService.test.ts | 275 +++ .../HyperLiquidSubscriptionService.ts | 159 +- .../services/HyperLiquidWalletService.test.ts | 49 + .../services/HyperLiquidWalletService.ts | 38 +- .../services/TradingReadinessCache.test.ts | 32 +- .../perps/services/TradingReadinessCache.ts | 38 +- .../perps/types/hyperliquid-types.test.ts | 34 + .../perps/types/hyperliquid-types.ts | 45 + app/controllers/perps/types/index.ts | 1 + .../perps/utils/accountUtils.test.ts | 108 +- app/controllers/perps/utils/accountUtils.ts | 37 +- app/core/Analytics/MetaMetrics.events.ts | 2 + docs/perps/hyperliquid/init-flow.md | 51 +- docs/perps/perps-caching-architecture.md | 10 +- 31 files changed, 2398 insertions(+), 604 deletions(-) create mode 100644 app/controllers/perps/types/hyperliquid-types.test.ts diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx index 036552897acd..f9655ca53e88 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx @@ -277,6 +277,18 @@ describe('PerpsWithdrawView', () => { beforeEach(() => { jest.clearAllMocks(); (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + const mockUsePerpsLiveAccount = + jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '1000.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '1000.00', + }, + isInitialLoading: false, + }); }); describe('Component Rendering', () => { @@ -296,6 +308,32 @@ describe('PerpsWithdrawView', () => { ).toBeOnTheScreen(); }); + it('uses availableToTradeBalance for the displayed Unified Account balance', () => { + const mockUsePerpsLiveAccount = + jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + availableToTradeBalance: '2500.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '2500.00', + }, + isInitialLoading: false, + }); + + renderWithProviders(); + + expect( + screen.getByText( + strings('perps.withdrawal.available_balance', { + amount: '$2,500', + }), + ), + ).toBeOnTheScreen(); + }); + it('renders percentage buttons when focused', () => { renderWithProviders(); diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx index c99fdfaa5282..a4570976d81a 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx @@ -108,11 +108,16 @@ const PerpsWithdrawView: React.FC = () => { // Get withdrawal tokens from hook const { destToken } = useWithdrawTokens(); - // Truncate to 2 decimals so the user can withdraw exactly what they see. + // Release-branch bridge for Unified Account: availableToTradeBalance includes + // collateral HL can use in target mode. The full balance contract will replace + // this with an explicit withdrawableBalance field. Truncate so users can + // withdraw exactly the amount they see. const availableBalance = useMemo(() => { - if (!account?.availableBalance) return 0; - return truncateToTwoDecimals(parseCurrencyString(account.availableBalance)); - }, [account?.availableBalance]); + const balance = + account?.availableToTradeBalance ?? account?.availableBalance; + if (!balance) return 0; + return truncateToTwoDecimals(parseCurrencyString(balance)); + }, [account?.availableBalance, account?.availableToTradeBalance]); const formattedBalance = useMemo( () => formatPerpsFiat(availableBalance), @@ -154,7 +159,7 @@ const PerpsWithdrawView: React.FC = () => { usePerpsMeasurement({ traceName: TraceName.PerpsWithdrawView, conditions: [ - !!account?.availableBalance, + !!(account?.availableToTradeBalance ?? account?.availableBalance), !!destToken, availableBalance !== undefined, ], diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index c8b704486107..3f9ca045db2c 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -207,6 +207,28 @@ describe('usePerpsBalanceTokenFilter', () => { } }); + it('prefers availableToTradeBalance for Unified Account users', () => { + // Unified Account / Portfolio Margin: collateral lives in spot, so + // HL's `clearinghouseState.withdrawable` (mirrored as availableBalance) + // is $0. The synthetic Perps balance row in the Pay-with sheet must + // read the unified-aware `availableToTradeBalance` instead. + mockUseSelector.mockReturnValue({ + availableBalance: '0.00', + availableToTradeBalance: '2500.00', + }); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true); + if (isHighlightedItemOutsideAssetList(output[0])) { + expect(output[0].name_description).toBe('$2500.00'); + expect(output[0].fiat).toBe('$2500.00'); + } + }); + it('uses zero balance when perps account is null', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 5c7d50628e1d..2e4a5a0ee570 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -83,7 +83,14 @@ export function usePerpsBalanceTokenFilter(): ( return tokens; } - const availableBalance = perpsAccount?.availableBalance || '0'; + // Prefer `availableToTradeBalance` so Unified Account / Portfolio + // Margin users see their real spendable balance in the Pay-with + // header — `availableBalance` mirrors HL's perps-only + // `clearinghouseState.withdrawable`, which is $0 in unified mode. + const availableBalance = + perpsAccount?.availableToTradeBalance ?? + perpsAccount?.availableBalance ?? + '0'; const balanceInSelectedCurrency = formatFiat( new BigNumber(availableBalance), ); @@ -135,6 +142,7 @@ export function usePerpsBalanceTokenFilter(): ( onPerpsPaymentTokenChange, isPerpsBalanceSelected, perpsAccount?.availableBalance, + perpsAccount?.availableToTradeBalance, transactionMeta, ], ); diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts index 520504851a16..a22f57316768 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts @@ -294,6 +294,26 @@ describe('usePerpsPaymentTokens', () => { expect(hyperliquidUsdc.balanceFiat).toBe('$0.00'); }); + it('uses availableToTradeBalance for Unified Account users', () => { + // Unified Account / Portfolio Margin: collateral lives in spot, so HL's + // `clearinghouseState.withdrawable` is $0. The Pay-with sheet must read + // `availableToTradeBalance` (perps + folded spot USDC) instead. + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + ...mockAccountState, + availableBalance: '0', + availableToTradeBalance: '2500.00', + }, + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsPaymentTokens()); + + const hyperliquidUsdc = result.current[0]; + expect(hyperliquidUsdc.balance).toBe('2500000000'); + expect(hyperliquidUsdc.balanceFiat).toBe('$2500.00'); + }); + it('handles null account state', () => { mockUsePerpsLiveAccount.mockReturnValue({ account: null, diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts index bb7aef357130..e7ec860c2c17 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts @@ -34,11 +34,16 @@ export function usePerpsPaymentTokens(): PerpsToken[] { // Use ref to store previous token array const previousTokensRef = useRef([]); - // Get Hyperliquid account balance + // Get Hyperliquid account balance. Prefer `availableToTradeBalance` so + // Unified Account / Portfolio Margin users see their real spendable balance + // in the Pay-with sheet — `availableBalance` mirrors HL's perps-only + // `clearinghouseState.withdrawable`, which is $0 in unified mode. const { account } = usePerpsLiveAccount(); const currentNetwork = usePerpsNetwork(); const hyperliquidBalance = Number.parseFloat( - account?.availableBalance?.toString() || '0', + ( + account?.availableToTradeBalance ?? account?.availableBalance + )?.toString() || '0', ); // Get all chain IDs to search for tokens (exclude Hyperliquid chains) diff --git a/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts b/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts index cf4d6a389393..9277463c6f23 100644 --- a/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts +++ b/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts @@ -81,6 +81,23 @@ describe('useWithdrawValidation', () => { expect(result.current.availableBalance).toBe('1000'); }); + it('prefers availableToTradeBalance for Unified Account target state', () => { + (usePerpsLiveAccount as jest.Mock).mockReturnValue({ + account: { + availableBalance: '$0.00', + availableToTradeBalance: '$2500.00', + }, + isInitialLoading: false, + }); + + const { result } = renderHook(() => + useWithdrawValidation({ withdrawAmount: '100' }), + ); + + expect(result.current.availableBalance).toBe('2500'); + expect(result.current.hasInsufficientBalance).toBe(false); + }); + it('should handle empty balance', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { diff --git a/app/components/UI/Perps/hooks/useWithdrawValidation.ts b/app/components/UI/Perps/hooks/useWithdrawValidation.ts index 3d5d8833f13f..3a8adb552638 100644 --- a/app/components/UI/Perps/hooks/useWithdrawValidation.ts +++ b/app/components/UI/Perps/hooks/useWithdrawValidation.ts @@ -28,9 +28,12 @@ export const useWithdrawValidation = ({ const perpsNetwork = usePerpsNetwork(); const isTestnet = perpsNetwork === 'testnet'; - // Truncate to 2 decimal places so validation matches the displayed balance. + // Release-branch bridge for Unified Account: availableToTradeBalance includes + // collateral HL can use in target mode. The full balance contract will replace + // this with an explicit withdrawableBalance field. const availableBalance = useMemo(() => { - const balance = account?.availableBalance || '0'; + const balance = + account?.availableToTradeBalance ?? account?.availableBalance ?? '0'; return truncateToTwoDecimals(parseCurrencyString(balance)).toString(); }, [account]); diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 135f66a551d8..b726877b5706 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -7,7 +7,7 @@ jest.mock('@metamask/perps-controller', () => { TradingReadinessCache: { clear: jest.fn(), clearAll: jest.fn(), - clearDexAbstraction: jest.fn(), + clearUnifiedAccount: jest.fn(), clearBuilderFee: jest.fn(), clearReferral: jest.fn(), get: jest.fn(), @@ -919,27 +919,27 @@ describe('PerpsConnectionManager', () => { }); }); - describe('DEX Abstraction Cache Clearing (PR 25334)', () => { + describe('Unified Account Cache Clearing (PR 25334)', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('clearDexAbstractionCache', () => { - it('clears only DEX abstraction for specific network and user address', () => { + describe('clearUnifiedAccountCache', () => { + it('clears only unified account for specific network and user address', () => { // Arrange const network = 'mainnet' as const; const userAddress = '0x1234567890123456789012345678901234567890'; // Act - PerpsConnectionManager.clearDexAbstractionCache(network, userAddress); + PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress); - // Assert - should call clearDexAbstraction, NOT clear (which deletes entire entry) + // Assert - should call clearUnifiedAccount, NOT clear (which deletes entire entry) expect( - mockTradingReadinessCache.clearDexAbstraction, + mockTradingReadinessCache.clearUnifiedAccount, ).toHaveBeenCalledWith(network, userAddress); expect(mockTradingReadinessCache.clear).not.toHaveBeenCalled(); expect(mockDevLogger.log).toHaveBeenCalledWith( - 'PerpsConnectionManager: DEX abstraction cache cleared', + 'PerpsConnectionManager: Unified Account cache cleared', { network, userAddress }, ); }); @@ -950,11 +950,11 @@ describe('PerpsConnectionManager', () => { const userAddress = '0xTestnetUser12345678901234567890123456'; // Act - PerpsConnectionManager.clearDexAbstractionCache(network, userAddress); + PerpsConnectionManager.clearUnifiedAccountCache(network, userAddress); // Assert expect( - mockTradingReadinessCache.clearDexAbstraction, + mockTradingReadinessCache.clearUnifiedAccount, ).toHaveBeenCalledWith(network, userAddress); }); }); diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index cfcaed170c0d..5effc3f7ef40 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -1441,16 +1441,16 @@ class PerpsConnectionManagerClass { } /** - * Clear DEX abstraction cache for a specific address + * Clear unified account cache for a specific address * Useful for debugging or allowing user to retry after rejecting signature - * Note: This only clears DEX abstraction state, preserving builder fee and referral states + * Note: This only clears unified account state, preserving builder fee and referral states */ - clearDexAbstractionCache( + clearUnifiedAccountCache( network: 'mainnet' | 'testnet', userAddress: string, ): void { - TradingReadinessCache.clearDexAbstraction(network, userAddress); - DevLogger.log('PerpsConnectionManager: DEX abstraction cache cleared', { + TradingReadinessCache.clearUnifiedAccount(network, userAddress); + DevLogger.log('PerpsConnectionManager: Unified Account cache cleared', { network, userAddress, }); diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index fc3b2dde0bac..f5d408104dde 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -334,6 +334,27 @@ export const parseCurrencyString = (formattedValue: string): number => { return isNegative ? -parsed : parsed; }; +/** + * Formats a perps balance (availableBalance, totalBalance, etc.) as fiat, + * truncating down to 2 decimals first so the displayed value never exceeds + * the actual withdrawable amount. Use this for any balance that a user might + * try to act on (withdraw Max, insufficient-balance comparisons), so the + * display matches what the underlying flow can actually transact. + * + * Accepts raw numeric strings (e.g. "50.389"), formatted strings + * (e.g. "$1,232.39"), or numbers. See `parseCurrencyString`. + */ +export const formatPerpsBalance = ( + balance: string | number | null | undefined, +): string => { + if (balance === null || balance === undefined || balance === '') { + return formatPerpsFiat(0); + } + const numeric = + typeof balance === 'string' ? parseCurrencyString(balance) : balance; + return formatPerpsFiat(truncateToTwoDecimals(numeric)); +}; + /** * Parses formatted percentage strings back to numeric values * @param formattedValue - Formatted percentage string (handles %, +/- signs) diff --git a/app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx b/app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx index fcf9a96ee0b1..6319aea25cb6 100644 --- a/app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx +++ b/app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { strings } from '../../../../../../../locales/i18n'; import Text, { TextColor, @@ -8,27 +8,22 @@ import { useStyles } from '../../../../../../component-library/hooks'; import { Box } from '../../../../../UI/Box/Box'; import { AlignItems } from '../../../../../UI/Box/box.types'; import { usePerpsLiveAccount } from '../../../../../UI/Perps/hooks/stream/usePerpsLiveAccount'; -import { - formatPerpsFiat, - parseCurrencyString, -} from '../../../../../UI/Perps/utils/formatUtils'; +import { formatPerpsBalance } from '../../../../../UI/Perps/utils/formatUtils'; import styleSheet from './perps-withdraw-balance.styles'; export function PerpsWithdrawBalance() { const { styles } = useStyles(styleSheet, {}); const { account } = usePerpsLiveAccount(); - const balanceFormatted = useMemo(() => { - if (!account?.availableBalance) return formatPerpsFiat(0); - return formatPerpsFiat(parseCurrencyString(account.availableBalance)); - }, [account?.availableBalance]); + const availableBalance = + account?.availableToTradeBalance ?? account?.availableBalance; return ( {`${strings('confirm.available_balance')}${balanceFormatted}`} + >{`${strings('confirm.available_balance')}${formatPerpsBalance(availableBalance)}`} ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPerpsBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPerpsBalanceAlert.ts index b15f2818a496..17e835875767 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPerpsBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPerpsBalanceAlert.ts @@ -30,8 +30,16 @@ export function useInsufficientPerpsBalanceAlert({ const quotes = useTransactionPayQuotes(); const hasQuotes = Boolean(quotes?.length); + // For Unified Account Mode users, `availableBalance` mirrors HL's + // `clearinghouseState.withdrawable` which is $0 (collateral lives in spot). + // `availableToTradeBalance` is the unified-aware value computed by + // `addSpotBalanceToAccountState` and matches what `withdraw3` actually draws + // from. Fall back to `availableBalance` for legacy / Standard-mode accounts + // where the unified field is undefined. const availableBalance = useSelector( (state: RootState) => + state.engine.backgroundState.PerpsController?.accountState + ?.availableToTradeBalance ?? state.engine.backgroundState.PerpsController?.accountState ?.availableBalance, ); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts index ec7722c21d47..fd40eecf5503 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -207,7 +207,13 @@ function useTokenBalance(tokenUsdRate: number) { if (hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw])) { const perpsState = Engine.context.PerpsController?.state; - const availableBalance = perpsState?.accountState?.availableBalance; + // Prefer `availableToTradeBalance` so Unified Account / Portfolio Margin + // users see the correct balance behind the percentage buttons. Falls back + // to `availableBalance` for Standard-mode accounts where the unified + // field isn't populated. + const availableBalance = + perpsState?.accountState?.availableToTradeBalance ?? + perpsState?.accountState?.availableBalance; return availableBalance ? parseFloat(availableBalance) : 0; } diff --git a/app/controllers/perps/constants/eventNames.ts b/app/controllers/perps/constants/eventNames.ts index dd4bebabf97e..13a6dee16a9a 100644 --- a/app/controllers/perps/constants/eventNames.ts +++ b/app/controllers/perps/constants/eventNames.ts @@ -151,6 +151,10 @@ export const PERPS_EVENT_PROPERTY = { // Pay-with UI (PERPS_UI_INTERACTION) INITIAL_PAYMENT_METHOD: 'initial_payment_method', NEW_PAYMENT_METHOD: 'new_payment_method', + + // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) + ABSTRACTION_MODE: 'abstraction_mode', + PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', } as const; /** @@ -354,6 +358,8 @@ export const PERPS_EVENT_VALUE = { PARTIALLY_FILLED: 'partially_filled', FAILED: 'failed', SUCCESS: 'success', + ALREADY_ENABLED: 'already_enabled', + MIGRATION_REQUIRED: 'migration_required', }, SCREEN_TYPE: { MARKETS: 'markets', diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 363f30eb8e67..d665ace78f4b 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -216,6 +216,7 @@ const createMockInfoClient = (overrides: Record = {}) => ({ ], ]), perpDexs: jest.fn().mockResolvedValue([null]), + userAbstraction: jest.fn().mockResolvedValue('default'), allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), frontendOpenOrders: jest.fn().mockResolvedValue([]), referral: jest.fn().mockResolvedValue({ @@ -301,7 +302,10 @@ const createMockExchangeClient = (overrides: Record = {}) => ({ sendAsset: jest.fn().mockResolvedValue({ status: 'ok', }), - agentEnableDexAbstraction: jest.fn().mockResolvedValue({ + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ status: 'ok', }), ...overrides, @@ -320,7 +324,7 @@ const mockMessenger = createMockMessenger(); * @param options.hip3Enabled * @param options.allowlistMarkets * @param options.blocklistMarkets - * @param options.useDexAbstraction + * @param options.useUnifiedAccount */ const createTestProvider = ( options: { @@ -328,7 +332,7 @@ const createTestProvider = ( hip3Enabled?: boolean; allowlistMarkets?: string[]; blocklistMarkets?: string[]; - useDexAbstraction?: boolean; + useUnifiedAccount?: boolean; initialAssetMapping?: [string, number][]; } = {}, ): HyperLiquidProvider => @@ -402,6 +406,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -427,6 +432,8 @@ describe('HyperLiquidProvider', () => { getCachedOrders: jest.fn().mockReturnValue([]), // Atomic getter - returns null when cache not initialized (prevents race condition) getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), } as Partial as jest.Mocked; // Mock constructors @@ -1282,7 +1289,7 @@ describe('HyperLiquidProvider', () => { const hip3Provider = createTestProvider({ hip3Enabled: true, allowlistMarkets: ['xyz:*'], - useDexAbstraction: true, + useUnifiedAccount: true, }); const mockOrder = jest.fn().mockResolvedValue({ status: 'ok', @@ -2176,6 +2183,127 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalled(); }); + it('does not fold non-USDC spot balance in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.availableToTradeBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + }); + + it('folds USDC spot balance into availableToTradeBalance in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.availableBalance).toBe('0'); + expect(accountState.availableToTradeBalance).toBe('90'); + expect(accountState.totalBalance).toBe('90'); + }); + + it.each(['default', 'disabled'] as const)( + 'does not fold USDC spot balance in %s account mode', + async (abstractionMode) => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue(abstractionMode), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.availableToTradeBalance).toBe('0'); + expect(accountState.totalBalance).toBe('90'); + }, + ); + it('gets markets successfully', async () => { const markets = await provider.getMarkets(); @@ -2213,6 +2341,46 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); }); + it('runs user-signed unified account migration before withdrawing for dexAbstraction users', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + availableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + abstraction: 'unifiedAccount', + }); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + }); + it('handles withdrawal errors', async () => { mockValidateWithdrawalParams.mockReturnValueOnce({ isValid: false, @@ -4203,6 +4371,41 @@ describe('HyperLiquidProvider', () => { }); }); + it('validates withdrawal against availableToTradeBalance when Unified Account has zero availableBalance', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + availableBalance: '0', + availableToTradeBalance: '2500', + totalBalance: '2500', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(mockValidateBalance).toHaveBeenCalledWith(1000, 2500); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + }); + it('handles withdrawal API error', async () => { mockClientService.getExchangeClient = jest.fn().mockReturnValue({ withdraw3: jest.fn().mockResolvedValue({ @@ -7963,154 +8166,57 @@ describe('HyperLiquidProvider', () => { }); }); - describe('ensureDexAbstractionEnabled', () => { - interface ProviderWithDexAbstraction { - ensureDexAbstractionEnabled(): Promise; - useDexAbstraction: boolean; + describe('ensureReadyForTrading', () => { + interface ProviderWithTradingSetup { + ensureReadyForTrading(): Promise; + ensureReady(): Promise; + tradingSetupComplete: boolean; } // eslint-disable-next-line @typescript-eslint/no-shadow - let testableProvider: ProviderWithDexAbstraction; + let testableProvider: ProviderWithTradingSetup; beforeEach(() => { - testableProvider = provider as unknown as ProviderWithDexAbstraction; - testableProvider.useDexAbstraction = true; - }); - - it('returns early when feature is disabled', async () => { - // Arrange - testableProvider.useDexAbstraction = false; - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); - expect( - mockWalletService.getUserAddressWithDefault, - ).not.toHaveBeenCalled(); - }); - - it('returns early when DEX abstraction is already enabled', async () => { - // Arrange - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(true), - }), - ); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + testableProvider = provider as unknown as ProviderWithTradingSetup; + testableProvider.tradingSetupComplete = false; }); - it('enables DEX abstraction when not yet enabled', async () => { - // Arrange - const mockExchangeClient = createMockExchangeClient(); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(false), - }), - ); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(mockExchangeClient); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); + it('calls ensureReady first before trading setup', async () => { + // Arrange - spy on ensureReady + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); // Act - await testableProvider.ensureDexAbstractionEnabled(); + await testableProvider.ensureReadyForTrading(); // Assert - expect( - mockExchangeClient.agentEnableDexAbstraction, - ).toHaveBeenCalledTimes(1); + expect(ensureReadySpy).toHaveBeenCalled(); }); - it('enables DEX abstraction when status is null', async () => { + it('returns immediately when tradingSetupComplete is true', async () => { // Arrange - const mockExchangeClient = createMockExchangeClient(); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(null), - }), - ); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(mockExchangeClient); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); + testableProvider.tradingSetupComplete = true; + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); // Act - await testableProvider.ensureDexAbstractionEnabled(); + await testableProvider.ensureReadyForTrading(); - // Assert + // Assert - should call ensureReady but skip trading setup + expect(ensureReadySpy).toHaveBeenCalled(); + // No signing operations should be called expect( - mockExchangeClient.agentEnableDexAbstraction, - ).toHaveBeenCalledTimes(1); - }); - - it('logs error but does not throw when enable fails', async () => { - // Arrange - const mockError = new Error('Enable failed'); - const mockExchangeClient = createMockExchangeClient(); - mockExchangeClient.agentEnableDexAbstraction = jest - .fn() - .mockRejectedValue(mockError); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(false), - }), - ); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(mockExchangeClient); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( - mockError, - expect.objectContaining({ - context: expect.objectContaining({ - name: 'HyperLiquidProvider', - data: expect.objectContaining({ - method: 'ensureDexAbstractionEnabled', - }), - }), - }), - ); + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); }); - it('propagates error when user address fetch fails', async () => { + it('sets tradingSetupComplete to true after successful setup', async () => { // Arrange - const mockError = new Error('Address fetch failed'); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockRejectedValue(mockError); - - // Act & Assert - error propagates since getUserAddressWithDefault is called - // before the try-catch block that handles signing errors - await expect( - testableProvider.ensureDexAbstractionEnabled(), - ).rejects.toThrow('Address fetch failed'); - }); - - // ===== Global Cache Tests (PR #25334) ===== - - it('returns early when global cache indicates already attempted', async () => { - // Arrange - simulate cached state + jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); + // Mock all caches as already attempted to skip signing ( TradingReadinessCache as jest.Mocked ).get.mockReturnValue({ @@ -8118,271 +8224,21 @@ describe('HyperLiquidProvider', () => { enabled: true, timestamp: Date.now(), }); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - should not call API when cached - expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); - expect( - (TradingReadinessCache as jest.Mocked) - .get, - ).toHaveBeenCalledWith('mainnet', '0xUserAddress'); - }); - - it('waits for in-flight operation instead of duplicating request', async () => { - // Arrange - ensure global cache returns undefined (not cached) ( TradingReadinessCache as jest.Mocked - ).get.mockReturnValue(undefined); - - // Simulate in-flight operation from another provider - let resolveInFlight: () => void = () => undefined; - const inFlightPromise = new Promise((resolve) => { - resolveInFlight = resolve; + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, }); ( TradingReadinessCache as jest.Mocked - ).isInFlight.mockReturnValue(inFlightPromise); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); - // Act - start the method (won't complete until in-flight resolves) - const enablePromise = testableProvider.ensureDexAbstractionEnabled(); - - // Resolve the in-flight operation - resolveInFlight(); - await enablePromise; - - // Verify it called isInFlight to check for concurrent operations - expect( - (TradingReadinessCache as jest.Mocked) - .isInFlight, - ).toHaveBeenCalledWith('dexAbstraction', 'mainnet', '0xUserAddress'); - - // Assert - should not have made its own API calls - expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); - expect( - (TradingReadinessCache as jest.Mocked) - .setInFlight, - ).not.toHaveBeenCalled(); - }); - - it('sets in-flight lock and caches success on successful enable', async () => { - // Arrange - const mockCompleteInFlight = jest.fn(); - ( - TradingReadinessCache as jest.Mocked - ).setInFlight.mockReturnValue(mockCompleteInFlight); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(false), - }), - ); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - expect( - (TradingReadinessCache as jest.Mocked) - .setInFlight, - ).toHaveBeenCalledWith('dexAbstraction', 'mainnet', '0xUserAddress'); - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).toHaveBeenCalledWith('mainnet', '0xUserAddress', { - attempted: true, - enabled: true, - }); - expect(mockCompleteInFlight).toHaveBeenCalled(); - }); - - it('caches failure to prevent repeated signing requests', async () => { - // Arrange - const mockCompleteInFlight = jest.fn(); - ( - TradingReadinessCache as jest.Mocked - ).setInFlight.mockReturnValue(mockCompleteInFlight); - const mockError = new Error('User rejected signing'); - const mockExchangeClient = createMockExchangeClient(); - mockExchangeClient.agentEnableDexAbstraction = jest - .fn() - .mockRejectedValue(mockError); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(false), - }), - ); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(mockExchangeClient); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - failure should be cached to prevent QR popup spam - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).toHaveBeenCalledWith('mainnet', '0xUserAddress', { - attempted: true, - enabled: false, - }); - expect(mockCompleteInFlight).toHaveBeenCalled(); - }); - - it('caches enabled status when already enabled on-chain', async () => { - // Arrange - const mockCompleteInFlight = jest.fn(); - ( - TradingReadinessCache as jest.Mocked - ).setInFlight.mockReturnValue(mockCompleteInFlight); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(true), // Already enabled - }), - ); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - should cache the enabled status - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).toHaveBeenCalledWith('mainnet', '0xUserAddress', { - attempted: true, - enabled: true, - }); - expect(mockCompleteInFlight).toHaveBeenCalled(); - // Should NOT call exchange client since already enabled - expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); - }); - - it('skips cache and Sentry when KEYRING_LOCKED error is thrown', async () => { - // Arrange - const mockCompleteInFlight = jest.fn(); - ( - TradingReadinessCache as jest.Mocked - ).setInFlight.mockReturnValue(mockCompleteInFlight); - const mockExchangeClient = createMockExchangeClient(); - mockExchangeClient.agentEnableDexAbstraction = jest - .fn() - .mockRejectedValue(new Error('KEYRING_LOCKED')); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userDexAbstraction: jest.fn().mockResolvedValue(false), - }), - ); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(mockExchangeClient); - mockWalletService.getUserAddressWithDefault = jest - .fn() - .mockResolvedValue('0xUserAddress'); - - // Act - should resolve without throwing - await testableProvider.ensureDexAbstractionEnabled(); - - // Assert - cache should NOT be set (so it retries when unlocked) - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).not.toHaveBeenCalled(); - // Assert - Sentry logger.error should NOT be called - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - // Assert - in-flight lock should be released - expect(mockCompleteInFlight).toHaveBeenCalled(); - }); - }); - - describe('ensureReadyForTrading', () => { - interface ProviderWithTradingSetup { - ensureReadyForTrading(): Promise; - ensureReady(): Promise; - tradingSetupComplete: boolean; - } - - // eslint-disable-next-line @typescript-eslint/no-shadow - let testableProvider: ProviderWithTradingSetup; - - beforeEach(() => { - testableProvider = provider as unknown as ProviderWithTradingSetup; - testableProvider.tradingSetupComplete = false; - }); - - it('calls ensureReady first before trading setup', async () => { - // Arrange - spy on ensureReady - const ensureReadySpy = jest - .spyOn(testableProvider, 'ensureReady') - .mockResolvedValue(); - - // Act - await testableProvider.ensureReadyForTrading(); - - // Assert - expect(ensureReadySpy).toHaveBeenCalled(); - }); - - it('returns immediately when tradingSetupComplete is true', async () => { - // Arrange - testableProvider.tradingSetupComplete = true; - const ensureReadySpy = jest - .spyOn(testableProvider, 'ensureReady') - .mockResolvedValue(); - - // Act - await testableProvider.ensureReadyForTrading(); - - // Assert - should call ensureReady but skip trading setup - expect(ensureReadySpy).toHaveBeenCalled(); - // No signing operations should be called - expect( - (TradingReadinessCache as jest.Mocked) - .setInFlight, - ).not.toHaveBeenCalled(); - }); - - it('sets tradingSetupComplete to true after successful setup', async () => { - // Arrange - jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); - // Mock all caches as already attempted to skip signing - ( - TradingReadinessCache as jest.Mocked - ).get.mockReturnValue({ - attempted: true, - enabled: true, - timestamp: Date.now(), - }); - ( - TradingReadinessCache as jest.Mocked - ).getBuilderFee.mockReturnValue({ - attempted: true, - success: true, - }); - ( - TradingReadinessCache as jest.Mocked - ).getReferral.mockReturnValue({ - attempted: true, - success: true, - }); - - // Act - await testableProvider.ensureReadyForTrading(); + // Act + await testableProvider.ensureReadyForTrading(); // Assert expect(testableProvider.tradingSetupComplete).toBe(true); @@ -8655,6 +8511,861 @@ describe('HyperLiquidProvider', () => { }); }); + describe('ensureUnifiedAccountEnabled', () => { + // These tests verify the unified account migration behaviour that runs + // inside #ensureReady() → #ensureUnifiedAccountEnabled(). Because the + // method is native-private (#), we trigger it via the public + // getMarketDataWithPrices() entry point, which calls #ensureReady() on + // every fresh provider instance. + + // The user address used by mockWalletService.getUserAddressWithDefault + const USER_ADDRESS = '0x1234567890123456789012345678901234567890'; + + // ───────────────────────────────────────────────── + // Early-exit paths + // ───────────────────────────────────────────────── + + it('does not call userAbstraction when useUnifiedAccount is false', async () => { + // Arrange - provider created with the feature disabled + const disabledProvider = createTestProvider({ useUnifiedAccount: false }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await disabledProvider.getMarketDataWithPrices(); + + // Assert - userAbstraction never queried (feature is off) + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + }); + + it('does not call userAbstraction when global cache indicates already attempted', async () => { + // Arrange - cache says setup was already tried (success or failure) + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - skipped because of cache + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS); + }); + + it('waits for in-flight then returns when another provider already cached the result', async () => { + // Arrange — another provider instance is mid-setup AND will land a + // cache entry by the time we resume from the await. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Outer cache check returns undefined; post-await cache check reflects + // the other instance's recorded result. + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) + .mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + const marketDataPromise = provider.getMarketDataWithPrices(); + resolveInFlight(); + await marketDataPromise; + + // Assert — checked for in-flight, saw the cache landed, returned without + // acquiring a new lock or re-fetching userAbstraction. + expect( + (TradingReadinessCache as jest.Mocked) + .isInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('waits for in-flight then runs its own attempt when no cache was written (deferred dexAbstraction case)', async () => { + // Scenario: another provider's init-time call (allowUserSigning=false) + // hit the dexAbstraction defer branch and finished without writing the + // cache. Our caller is action-time (allowUserSigning=true via withdraw) + // and must not skip the migration just because another instance was + // mid-setup. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Cache stays empty across both checks (no entry was written by the + // other instance because it deferred). + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue(undefined); + + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // Act — withdraw is the action-time entry that requires migration. + const withdrawPromise = provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + resolveInFlight(); + await withdrawPromise; + + // Assert — fell through, acquired our own lock, and migrated. + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + }); + + it('returns early when re-check cache (inside lock) shows another provider completed', async () => { + // Arrange - first get() → undefined, second get() (inside try) → cached + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) // outer check + .mockReturnValueOnce({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); // inner re-check after lock acquired + + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock was acquired and released, but no API call made + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Already on a compatible mode (unifiedAccount or portfolioMargin) + // ───────────────────────────────────────────────── + + it('tracks already_enabled and caches success when mode is already unifiedAccount', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracks the already_enabled event + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'unifiedAccount', + status: 'already_enabled', + }), + ); + // Caches success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client for unified account transition + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT migrate portfolioMargin users — tracks already_enabled and skips exchange call', async () => { + // portfolioMargin is a superset of unifiedAccount: it already supports + // HIP-3 auto-collateral management and is more capital-efficient. + // Downgrading these users would be harmful. + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracked as already_enabled with the correct mode + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'portfolioMargin', + status: 'already_enabled', + }), + ); + // Caches success — no retry needed + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client — user must NOT be downgraded + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Migration from default / disabled → unifiedAccount (silent agent path) + // ───────────────────────────────────────────────── + + it('calls agentSetAbstraction silently when mode is default', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - uses silent agent-key path (no user prompt) + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('calls agentSetAbstraction silently when mode is disabled', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('disabled'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required then success for default → unifiedAccount', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - two analytics events emitted in order + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + + // First event: migration_required with current mode + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'default', + status: 'migration_required', + }), + ]); + // Second event: success with before/after modes + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + // Cache reflects success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it('skips migration and does not cache success for unknown abstraction modes', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('futureMode'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - fail closed for unknown modes rather than silently forcing 'u' + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // dexAbstraction → unifiedAccount migration on init + // + // The transition requires an EIP-712 prompt (HL blocks the agent path), + // so software-wallet users migrate during initial setup to ensure the + // first trade sees unified collateral. Hardware wallets remain deferred + // to avoid QR / Ledger prompt spam while browsing. + // ───────────────────────────────────────────────── + + it('calls userSetAbstraction on init for software-wallet dexAbstraction users', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - software wallets migrate during setup so first trade sees + // unified collateral folded into the size slider. + expect(mockExchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required and writes cache for software-wallet dexAbstraction on init', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - analytics fire because software-wallet init performs the + // migration attempt. + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'dexAbstraction', + status: 'migration_required', + }), + ]); + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'dexAbstraction', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // setUserAbstractionMode is called on every success path with the + // resolved mode so the subscription service can fold spot correctly. + // ───────────────────────────────────────────────── + + it('records unifiedAccount mode when account is already unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records portfolioMargin mode when account is already portfolioMargin', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'portfolioMargin'); + }); + + it('records unifiedAccount mode after migrating from default → unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records unifiedAccount mode after migrating software-wallet dexAbstraction on init', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('defers dexAbstraction migration on init for hardware wallets', async () => { + // Arrange + mockWalletService.isSelectedHardwareWallet.mockReturnValue(true); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - no browsing-time hardware prompt; action-time setup can still run. + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }); + + it('does NOT call setUserAbstractionMode when migration fails', async () => { + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('network error')); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Failure paths + // ───────────────────────────────────────────────── + + it('does NOT cache when silent agentSetAbstraction fails (default/disabled paths retry on next entry)', async () => { + // Silent agent-key migration (default/disabled) shows no UI prompt, so + // the "don't re-prompt rejected users" rationale doesn't apply. Caching + // a transient HL/network failure here would pin the user in the + // deprecated mode for the rest of the session — instead we leave the + // cache empty so the next #ensureReady or action-time call retries. + const mockError = new Error('Transient HL network blip'); + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + // No cache write — next entry can retry. + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Failure analytics still emitted for observability. + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'failed', + error_message: expect.stringContaining('Transient HL network blip'), + }), + ); + // Sentry logger still records for debugging. + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Transient HL network blip'), + }), + expect.objectContaining({ + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'ensureUnifiedAccountEnabled', + }), + }), + }), + ); + }); + + it('retries migration on the next #ensureReady after a silent agent failure', async () => { + // Without resetting #ensureReadyPromise on the silent-failure path, + // a transient agentSetAbstraction blip during the first Perps section + // open would pin the user in the deprecated mode for the entire + // provider lifetime — every subsequent #ensureReady would just return + // the memoized resolved promise and skip the migration. + const userAbstractionMock = jest.fn().mockResolvedValue('default'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: userAbstractionMock, + }), + ); + const agentSetAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('Transient HL network blip')) + .mockResolvedValueOnce({ status: 'ok' }); + const exchangeClient = createMockExchangeClient(); + exchangeClient.agentSetAbstraction = agentSetAbstractionMock; + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + // First entry: migration fails silently, no cache write. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(1); + + // Second entry: must re-run the migration because #ensureReadyPromise + // was reset on the silent-failure exit. agentSetAbstraction succeeds + // this time → cache attempted/enabled → no further retries. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(2); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it("caches failure when user-signed userSetAbstraction throws (don't re-prompt rejected users)", async () => { + // The dexAbstraction → unifiedAccount migration goes through + // userSetAbstraction which surfaces an EIP-712 signing dialog. Once + // the user has been prompted (and either rejected or signed but the + // call failed), we should not pop the dialog again this session. + const mockError = new Error('User rejected signing'); + const exchangeClient = createMockExchangeClient(); + exchangeClient.userSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // withdraw() is an action-time caller that passes allowUserSigning=true, + // so the dexAbstraction path actually attempts userSetAbstraction. + await provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + + it('does NOT cache or log to Sentry when KEYRING_LOCKED is thrown', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('KEYRING_LOCKED')); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT set (so it retries when keyring is unlocked) + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Sentry NOT called + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + // In-flight lock still released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT cache failure when userAbstraction read itself rejects', async () => { + // Read-only userAbstraction lookup failures (transient HL outage / + // network) must not block all future migration attempts for the rest + // of the session — no signing prompt has happened yet, so the + // "don't re-prompt the user" rationale doesn't apply. + const lookupError = new Error('HL info endpoint timeout'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockRejectedValue(lookupError), + }), + ); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT written so the next call retries the lookup + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // No signing happened + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Network key (mainnet vs testnet) + // ───────────────────────────────────────────────── + + it('uses testnet network key when client is in testnet mode', async () => { + // Arrange - testnet provider with cache already hit (so we only check the key) + mockClientService.isTestnetMode = jest.fn().mockReturnValue(true); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - cache keyed by 'testnet' + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('testnet', USER_ADDRESS); + }); + + // ───────────────────────────────────────────────── + // In-flight lock management + // ───────────────────────────────────────────────── + + it('sets in-flight lock with unifiedAccount key and releases it on success', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock key uses 'unifiedAccount' + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); + describe('WebSocket connection state methods', () => { // Import actual enum to ensure type compatibility const { WebSocketConnectionState } = jest.requireActual( @@ -8899,6 +9610,7 @@ describe('HyperLiquidProvider', () => { frontendOpenOrders: jest.fn(), perpDexs: jest.fn().mockResolvedValue([null]), spotClearinghouseState: jest.fn().mockResolvedValue({ balances: [] }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), }; }); diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 93795c0fcfa2..ef4b49991df6 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -1,9 +1,16 @@ import { CaipAccountId, hasProperty } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { ExchangeClient } from '@nktkas/hyperliquid'; +import type { + ExchangeClient, + UserAbstractionResponse, +} from '@nktkas/hyperliquid'; import { v4 as uuidv4 } from 'uuid'; import type { CandlePeriod } from '../constants/chartConfig'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../constants/eventNames'; import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, @@ -40,6 +47,7 @@ import { TradingReadinessCache, PerpsSigningCache, } from '../services/TradingReadinessCache'; +import { PerpsAnalyticsEvent } from '../types'; import type { AccountState, AssetRoute, @@ -109,6 +117,11 @@ import type { FrontendOrder, SpotMetaResponse, } from '../types/hyperliquid-types'; +import { + HL_ABSTRACTION_WIRE, + HL_UNIFIED_ACCOUNT_MODE, + hyperLiquidModeFoldsSpot, +} from '../types/hyperliquid-types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types'; import { @@ -344,13 +357,26 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #blocklistMarkets: string[]; - #useDexAbstraction: boolean; + // Emergency kill-switch for the Unified Account migration flow. Defaults + // to true and is the expected production state after HL's DEX Abstraction + // deprecation. Kept as a constructor option (not removed) so we can + // disable the migration via a hot-fix release if a regression surfaces + // in the wild — flipping this to false reverts to the legacy programmatic + // HIP-3 transfer path that already lives in the codebase. + #useUnifiedAccount: boolean; // True once DEX discovery has succeeded with real data (not a fallback). // When false, #ensureReadyPromise is reset after each init so the next // caller retries DEX discovery instead of reusing a degraded mapping. #dexDiscoveryComplete = false; + // True when the most recent #ensureUnifiedAccountEnabled run ended in a + // transient state that warrants retry (silent agent-key failure, REST + // userAbstraction lookup failure, or keyring locked). #ensureReady resets + // its memoized promise when this is set so the next entry retries the + // migration instead of returning the cached resolved promise. + #unifiedAccountSetupNeedsRetry = false; + // Pending promise to deduplicate concurrent getValidatedDexs() calls #pendingValidatedDexsPromise: Promise<(string | null)[]> | null = null; @@ -381,7 +407,7 @@ export class HyperLiquidProvider implements PerpsProvider { hip3Enabled?: boolean; allowlistMarkets?: string[]; blocklistMarkets?: string[]; - useDexAbstraction?: boolean; + useUnifiedAccount?: boolean; platformDependencies: PerpsPlatformDependencies; messenger: PerpsControllerMessengerBase; initialAssetMapping?: [string, number][]; @@ -399,8 +425,8 @@ export class HyperLiquidProvider implements PerpsProvider { this.#allowlistMarkets = options.allowlistMarkets ?? []; this.#blocklistMarkets = options.blocklistMarkets ?? []; - // Attempt native balance abstraction, fallback to programmatic transfer if unsupported - this.#useDexAbstraction = options.useDexAbstraction ?? true; + // Attempt unified account mode, fallback to programmatic transfer if unsupported + this.#useUnifiedAccount = options.useUnifiedAccount ?? true; // Initialize services with injected platform dependencies this.#clientService = new HyperLiquidClientService(this.#deps, { @@ -564,7 +590,7 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Attempt to enable HIP-3 native balance abstraction + * Attempt to enable HyperLiquid Unified Account mode for HIP-3 orders * * If successful, HyperLiquid automatically manages collateral transfers for HIP-3 orders. * If not supported, disables the flag to trigger programmatic transfer fallback. @@ -572,10 +598,28 @@ export class HyperLiquidProvider implements PerpsProvider { * IMPORTANT: Uses global singleton cache to prevent repeated signing requests * across provider reconnections (critical for hardware wallets). * + * @param options - Optional configuration. + * @param options.allowUserSigning - When true, runs the EIP-712 user-signed migration for `dexAbstraction` accounts. Defaults to false so init does not surface a signing prompt; action-time entry points (trading, withdraw) pass true. * @private */ - async #ensureDexAbstractionEnabled(): Promise { - if (!this.#useDexAbstraction) { + async #ensureUnifiedAccountEnabled(options?: { + allowUserSigning?: boolean; + }): Promise { + // dexAbstraction → unifiedAccount requires an EIP-712 prompt (HL blocks + // the agent path for that transition). Init calls with allowUserSigning=false so + // viewing the Perps section never surfaces a signing dialog. Trading and + // withdraw entry points pass allowUserSigning=true to drive the migration when + // the user actually intends to act. + const allowUserSigning = options?.allowUserSigning ?? false; + + // Optimistic reset — set true below only at the failure points that + // warrant retry (silent agent failure, REST lookup failure, keyring + // locked). Final-state outcomes (success, prompted-failure cached, + // already-on-compatible, defer, unknown mode, feature off) leave it + // false so #ensureReady can keep the memoized promise. + this.#unifiedAccountSetupNeedsRetry = false; + + if (!this.#useUnifiedAccount) { return; // Feature disabled } @@ -587,7 +631,7 @@ export class HyperLiquidProvider implements PerpsProvider { const cachedStatus = TradingReadinessCache.get(network, userAddress); if (cachedStatus?.attempted) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction already attempted (from global cache)', + 'HyperLiquidProvider: Unified Account setup already attempted (from global cache)', { user: userAddress, network, @@ -601,32 +645,43 @@ export class HyperLiquidProvider implements PerpsProvider { // Check if another provider instance is currently attempting this operation // This prevents concurrent signing attempts across providers during reconnection const inFlightPromise = PerpsSigningCache.isInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); if (inFlightPromise) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction in-flight, waiting...', + 'HyperLiquidProvider: Unified Account setup in-flight, waiting...', { network, userAddress }, ); await inFlightPromise; - return; // After waiting, the cache should be set by the other provider + // The other instance may have finished without writing the cache (e.g. + // an init-time call deferred a dexAbstraction migration). If the cache + // is still empty and we are an action-time caller (allowUserSigning=true), + // we must run our own attempt — otherwise the trade/withdraw would + // proceed in the deprecated mode. + const postWaitCache = TradingReadinessCache.get(network, userAddress); + if (postWaitCache?.attempted) { + return; + } + // Fall through to acquire our own lock and retry. } // Set in-flight lock to prevent concurrent attempts const completeInFlight = PerpsSigningCache.setInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); + let currentMode: UserAbstractionResponse | undefined; + try { // Re-check cache after acquiring lock (another provider might have finished) const recheckCache = TradingReadinessCache.get(network, userAddress); if (recheckCache?.attempted) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction completed by another provider', + 'HyperLiquidProvider: Unified Account setup completed by another provider', { network, userAddress }, ); completeInFlight(); @@ -635,84 +690,197 @@ export class HyperLiquidProvider implements PerpsProvider { const infoClient = this.#clientService.getInfoClient(); - // Check if already enabled on-chain (returns boolean | null) - const isEnabled = await infoClient.userDexAbstraction({ + // Check current abstraction mode on-chain + currentMode = await infoClient.userAbstraction({ user: userAddress, }); - if (isEnabled === true) { + if ( + currentMode === 'unifiedAccount' || + currentMode === 'portfolioMargin' + ) { + // portfolioMargin is a superset of unifiedAccount — it already supports + // auto-collateral management for HIP-3 orders and is more capital-efficient. + // Downgrading portfolio margin users to unifiedAccount would be harmful, + // so we treat both modes as already-enabled and skip migration. this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction already enabled on-chain', - { user: userAddress, network }, + 'HyperLiquidProvider: Account already in a compatible mode, skipping migration', + { user: userAddress, network, mode: currentMode }, ); - // Cache the enabled status to skip future checks + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.ALREADY_ENABLED, + }); TradingReadinessCache.set(network, userAddress, { attempted: true, enabled: true, }); + // Record the resolved mode in the subscription service so the next + // aggregation folds spot correctly without waiting for #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + currentMode, + ); + completeInFlight(); + return; + } + + // Defer the user-signed transition until the user attempts an action. + // Cache is intentionally left untouched so the next entry re-evaluates; + // the read-only userAbstraction call is cheap and gated by the in-flight + // lock, preventing concurrent prompts. + if (currentMode === 'dexAbstraction' && !allowUserSigning) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Deferring dexAbstraction → unifiedAccount migration to action time', + { user: userAddress, network }, + ); + completeInFlight(); + return; + } + + // Bail on unknown modes BEFORE firing analytics or attempting dispatch. + // Keeps `migration_required` actionable (only fires for modes we can + // actually migrate) and avoids re-emitting on every reconnection. + if ( + currentMode !== 'dexAbstraction' && + currentMode !== 'default' && + currentMode !== 'disabled' + ) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unknown abstraction mode, skipping Unified Account migration', + { user: userAddress, network, mode: currentMode }, + ); completeInFlight(); return; } - // Enable DEX abstraction (one-time, irreversible, requires signature) + // Track which mode users are currently on before we attempt migration. + // This tells us the distribution of legacy modes across our user base. + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }); + + // Enable Unified Account mode. + // - default / disabled: agent wallet can do this silently (no prompt) + // - dexAbstraction: HL blocks the agent transition — requires the user's main + // wallet to sign an EIP-712 action via userSetAbstraction (one-time prompt) this.#deps.debugLogger.log( - 'HyperLiquidProvider: Enabling DEX abstraction (requires signature)', + 'HyperLiquidProvider: Enabling Unified Account mode', { user: userAddress, network, + previousMode: currentMode, note: 'HyperLiquid will auto-manage collateral for HIP-3 orders', }, ); const exchangeClient = this.#clientService.getExchangeClient(); - await exchangeClient.agentEnableDexAbstraction(); + if (currentMode === 'dexAbstraction') { + // Requires EIP-712 signature from the user's main wallet (one-time migration). + // HL blocks the dexAbstraction → unifiedAccount transition via the agent wallet, + // so userSetAbstraction (user-signed) is the only path for legacy users. + await exchangeClient.userSetAbstraction({ + user: userAddress, + abstraction: HL_UNIFIED_ACCOUNT_MODE, + }); + } else { + // default / disabled — silent agent transition, no user prompt + await exchangeClient.agentSetAbstraction({ + abstraction: HL_ABSTRACTION_WIRE.unifiedAccount, + }); + } this.#deps.debugLogger.log( - '✅ HyperLiquidProvider: DEX abstraction enabled successfully', + '✅ HyperLiquidProvider: Unified Account enabled successfully', ); - - // Cache success to prevent re-attempts on reconnection + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + }); TradingReadinessCache.set(network, userAddress, { attempted: true, enabled: true, }); + // Record the post-migration mode in the subscription service so it + // immediately re-aggregates with fold=true and surfaces the unified + // balance rather than waiting for the next #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + HL_UNIFIED_ACCOUNT_MODE, + ); completeInFlight(); } catch (error) { // If keyring is locked, don't cache so it retries when unlocked if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { this.#deps.debugLogger.log( - '[ensureDexAbstractionEnabled] Keyring locked, will retry later', + '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', ); + this.#unifiedAccountSetupNeedsRetry = true; completeInFlight(); return; } - // Cache the attempt (even on failure) to prevent repeated signing requests - // This is CRITICAL for hardware wallets - if user rejects, don't ask again - TradingReadinessCache.set(network, userAddress, { - attempted: true, - enabled: false, - }); + // Cache failure ONLY for the user-prompted path + // (`dexAbstraction → unifiedAccount` via `userSetAbstraction`). The + // rationale for caching is "don't re-prompt a user who already saw the + // signature dialog and rejected it" — that doesn't apply to: + // - Read-only userAbstraction lookup failures (no prompt; transient). + // - Silent agent-key paths (`default`/`disabled` → `agentSetAbstraction` + // does not show a UI prompt; failures are typically transient HL + // outages and pinning them would leave users stuck in the + // deprecated mode for the rest of the session). + // Action-time retries pick up the unmigrated state and try again. + if (currentMode === 'dexAbstraction') { + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + } else { + // Silent agent-key failure (default/disabled) or read-only + // userAbstraction lookup failure — neither is a final state, so + // signal #ensureReady to drop its memoized promise and retry on + // the next entry instead of pinning the user in the deprecated + // mode for the provider's lifetime. + this.#unifiedAccountSetupNeedsRetry = true; + } + + const errorMessage = ensureError( + error, + 'HyperLiquidProvider.ensureUnifiedAccountEnabled', + ).message; this.#deps.debugLogger.log( - 'HyperLiquidProvider: DEX abstraction failed, cached to prevent retries', + 'HyperLiquidProvider: Unified Account setup failed', { user: userAddress, network, - error: ensureError( - error, - 'HyperLiquidProvider.ensureDexAbstractionEnabled', - ).message, + error: errorMessage, + // Cache writes only happen on the user-prompted dexAbstraction + // path (see P2-B logic above). Reflect that here so retry + // behaviour is debuggable from the log alone. + cached: currentMode === 'dexAbstraction', }, ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + ...(currentMode && { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + }), + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }); + completeInFlight(); - // Don't blindly disable the flag on any error this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.ensureDexAbstractionEnabled'), - this.#getErrorContext('ensureDexAbstractionEnabled', { - note: 'Could not enable DEX abstraction (may already be enabled, user rejected, or network error)', + ensureError(error, 'HyperLiquidProvider.ensureUnifiedAccountEnabled'), + this.#getErrorContext('ensureUnifiedAccountEnabled', { + note: 'Could not enable Unified Account (user rejected, or network error)', }), ); } @@ -758,9 +926,14 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#buildAssetMapping(); } - // NOTE: Signing operations (DEX abstraction, builder fee, referral) are now DEFERRED - // They are called on-demand in ensureReadyForTrading() when user attempts to trade - // This prevents QR popups when just viewing the Perps section (critical for hardware wallets) + // Attempt Unified Account migration as early as possible so users aren't + // blocked when they try to trade. Software-wallet dexAbstraction users can + // complete the one-time EIP-712 migration during initial setup so the first + // trade sees the unified balance. Hardware wallets remain deferred to + // action time to avoid QR / Ledger prompt spam while browsing. + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), + }); })(); // Await initialization - keep the promise so subsequent calls resolve immediately @@ -772,6 +945,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Trading still works (main DEX mapping is populated), but HIP-3 markets // will be re-discovered on the next #ensureReady() call. this.#ensureReadyPromise = null; + } else if (this.#unifiedAccountSetupNeedsRetry) { + // Silent migration / lookup / keyring-locked failure left the cache + // empty. Without resetting the memoized promise, subsequent + // #ensureReady calls would skip retry and the user would be stuck + // in the deprecated mode for the provider's lifetime. + this.#ensureReadyPromise = null; } this.#deps.debugLogger.log('[ensureReady] Initialization complete'); } @@ -797,6 +976,11 @@ export class HyperLiquidProvider implements PerpsProvider { // First ensure basic initialization is complete await this.#ensureReady(); + // dexAbstraction users were deferred during init to avoid an EIP-712 prompt + // on Perps section open. Drive the migration here, gated by its own cache so + // already-migrated or already-rejected users are not re-prompted. + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + // If trading setup already complete, return immediately if (this.#tradingSetupComplete) { return; @@ -830,9 +1014,6 @@ export class HyperLiquidProvider implements PerpsProvider { } } - // Attempt to enable native balance abstraction - await this.#ensureDexAbstractionEnabled(); - // Set up builder fee approval try { await this.#ensureBuilderFeeApproval(); @@ -3201,12 +3382,12 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#ensureUsdhCollateralForOrder(dexName, requiredMargin); - // DEX abstraction will pull USDH from spot automatically + // Unified Account will pull USDH from spot automatically return { transferInfo: null }; } - if (this.#useDexAbstraction) { - this.#deps.debugLogger.log('Using DEX abstraction (no manual transfer)', { + if (this.#useUnifiedAccount) { + this.#deps.debugLogger.log('Using Unified Account (no manual transfer)', { symbol, dex: dexName, }); @@ -3240,7 +3421,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#deps.debugLogger.log( 'Detected DEX abstraction is enabled, switching mode', ); - this.#useDexAbstraction = true; + this.#useUnifiedAccount = true; return { transferInfo: null }; } @@ -3959,7 +4140,7 @@ export class HyperLiquidProvider implements PerpsProvider { const totalMarginUsed = parseFloat(position.marginUsed); // Track HIP-3 transfers (full position close means all margin is freed) - if (isHip3Position && dexName && !this.#useDexAbstraction) { + if (isHip3Position && dexName && !this.#useUnifiedAccount) { hip3Transfers.push({ sourceDex: dexName, freedMargin: totalMarginUsed, @@ -4027,7 +4208,7 @@ export class HyperLiquidProvider implements PerpsProvider { const failureCount = statuses.length - successCount; // Handle HIP-3 margin transfers for successful closes - if (!this.#useDexAbstraction) { + if (!this.#useUnifiedAccount) { for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; const isSuccess = @@ -4495,7 +4676,7 @@ export class HyperLiquidProvider implements PerpsProvider { result.success && isHip3Position && hip3Dex && - !this.#useDexAbstraction + !this.#useUnifiedAccount ) { this.#deps.debugLogger.log( 'Position closed successfully, initiating manual auto-transfer back', @@ -4510,10 +4691,10 @@ export class HyperLiquidProvider implements PerpsProvider { result.success && isHip3Position && hip3Dex && - this.#useDexAbstraction + this.#useUnifiedAccount ) { this.#deps.debugLogger.log( - 'Position closed - DEX abstraction will auto-return freed margin', + 'Position closed - Unified Account will auto-return freed margin', { coin: params.symbol, dex: hip3Dex, @@ -5588,28 +5769,45 @@ export class HyperLiquidProvider implements PerpsProvider { isTestnet: this.#clientService.isTestnetMode(), }); const dexs = await this.#getStandaloneValidatedDexs(); - const [standaloneSpotStateResult, standalonePerpsResults] = - await Promise.all([ - standaloneInfoClient - .spotClearinghouseState({ user: userAddress }) - .catch((error: unknown) => { - this.#deps.debugLogger.log( - 'Standalone spot state fetch failed — falling back to perps-only totals', - { - error: ensureError( - error, - 'HyperLiquidProvider.getAccountState.standalone.spot', - ).message, - }, - ); - return null; - }), - queryStandaloneClearinghouseStates( - standaloneInfoClient, - userAddress, - dexs, - ), - ]); + const [ + standaloneSpotStateResult, + standalonePerpsResults, + standaloneAbstractionResult, + ] = await Promise.all([ + standaloneInfoClient + .spotClearinghouseState({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone spot state fetch failed — falling back to perps-only totals', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.spot', + ).message, + }, + ); + return null; + }), + queryStandaloneClearinghouseStates( + standaloneInfoClient, + userAddress, + dexs, + ), + standaloneInfoClient + .userAbstraction({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone userAbstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.abstraction', + ).message, + }, + ); + return null; + }), + ]); // Aggregate account states across all DEXs, then apply spot-backed // adjustments so streamed/standalone/full paths report the same totals. @@ -5619,6 +5817,11 @@ export class HyperLiquidProvider implements PerpsProvider { const aggregatedAccountState = addSpotBalanceToAccountState( aggregateAccountStates(dexAccountStates), standaloneSpotStateResult, + { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + standaloneAbstractionResult, + ), + }, ); this.#deps.debugLogger.log( @@ -5651,14 +5854,31 @@ export class HyperLiquidProvider implements PerpsProvider { // Get Spot balance (global, not DEX-specific) and Perps states across all DEXs. // One transient DEX failure should not blank the entire account state. - const [spotStateResult, perpsStateResult] = await Promise.allSettled([ - infoClient.spotClearinghouseState({ user: userAddress }), - this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => - infoClient.clearinghouseState(userParam), - ), - ]); + const [spotStateResult, perpsStateResult, abstractionResult] = + await Promise.allSettled([ + infoClient.spotClearinghouseState({ user: userAddress }), + this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => + infoClient.clearinghouseState(userParam), + ), + infoClient.userAbstraction({ user: userAddress }), + ]); const spotState = spotStateResult.status === 'fulfilled' ? spotStateResult.value : null; + const abstractionMode = + abstractionResult.status === 'fulfilled' + ? abstractionResult.value + : null; + if (abstractionResult.status === 'rejected') { + this.#deps.debugLogger.log( + 'User abstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidProvider.getAccountState.abstraction', + ).message, + }, + ); + } const perpsResponse = perpsStateResult.status === 'fulfilled' ? perpsStateResult.value @@ -5737,6 +5957,9 @@ export class HyperLiquidProvider implements PerpsProvider { const aggregatedAccountState = addSpotBalanceToAccountState( aggregateAccountStates(dexAccountStates), spotState, + { + foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode), + }, ); // Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts) @@ -6824,6 +7047,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -6832,9 +7056,16 @@ export class HyperLiquidProvider implements PerpsProvider { 'HyperLiquidProvider: CHECKING ACCOUNT BALANCE', ); const accountState = await this.getAccountState(); - const availableBalance = parseFloat(accountState.availableBalance); + // Release-branch bridge for Unified Account: availableToTradeBalance + // includes collateral HL can draw in target mode. The larger balance + // contract will replace this with an explicit withdrawableBalance field. + const availableBalance = parseFloat( + accountState.availableToTradeBalance ?? accountState.availableBalance, + ); this.#deps.debugLogger.log('HyperLiquidProvider: ACCOUNT BALANCE', { availableBalance, + clearinghouseAvailableBalance: accountState.availableBalance, + availableToTradeBalance: accountState.availableToTradeBalance, totalBalance: accountState.totalBalance, marginUsed: accountState.marginUsed, unrealizedPnl: accountState.unrealizedPnl, @@ -7838,7 +8069,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches (ensures fresh state on reconnect/account switch) this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); - // NOTE: DexAbstractionCache is global and NOT cleared on disconnect + // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); this.#cachedSpotMeta = null; diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts index 4e3fd17171f3..581606fa894d 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts @@ -391,6 +391,7 @@ describe('HyperLiquidSubscriptionService', () => { getSubscriptionClient: jest.fn(() => mockSubscriptionClient), getInfoClient: jest.fn(() => ({ spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), })), isTestnetMode: jest.fn(() => false), ensureTransportReady: jest.fn().mockResolvedValue(undefined), @@ -3194,6 +3195,7 @@ describe('HyperLiquidSubscriptionService', () => { callback: mockCallback, includeMarketData: true, }); + await jest.runAllTimersAsync(); const initialCallCount = mockSubscriptionClient.assetCtxs ? (mockSubscriptionClient.assetCtxs as jest.Mock).mock.calls.length @@ -3760,6 +3762,98 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); + it('preserves the abstraction REST result when WS spot push arrives first, and re-aggregates with the correct fold', async () => { + // Setup: first userAbstraction call hangs until we manually resolve it. + // This simulates a slow REST response while the WS spot subscription + // pushes a snapshot first, bumping #spotStateGeneration so the in-flight + // refresh would otherwise discard the abstraction result. + let resolveAbstraction: (mode: 'unifiedAccount') => void = jest.fn(); + const abstractionPromise = new Promise<'unifiedAccount'>((resolve) => { + resolveAbstraction = resolve; + }); + let resolveAbstractionStarted: () => void = jest.fn(); + const abstractionStarted = new Promise((resolve) => { + resolveAbstractionStarted = resolve; + }); + const userAbstractionMock = jest.fn().mockImplementationOnce(() => { + resolveAbstractionStarted(); + return abstractionPromise; + }); + + let spotListener: ((event: any) => void) | undefined; + let resolveSpotStateSubscribed: () => void = jest.fn(); + const spotStateSubscribed = new Promise((resolve) => { + resolveSpotStateSubscribed = resolve; + }); + mockSubscriptionClient.spotState.mockImplementationOnce( + (_params: any, callback: any) => { + spotListener = callback; + resolveSpotStateSubscribed(); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await Promise.all([abstractionStarted, spotStateSubscribed]); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Simulate the WS spot push arriving before REST userAbstraction + // resolves. The WS callback bumps #spotStateGeneration so the + // in-flight refresh's spot result would be discarded by the + // generation guard. + expect(spotListener).toBeDefined(); + spotListener?.({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + await jest.runAllTimersAsync(); + + // Resolve the REST userAbstraction. The refresh path must record the + // mode (it's user-keyed, independent of spot generation) and trigger + // a re-aggregation so the active subscriber sees folded balance — + // not wait for another subscribe/action to repair the state. + accountCallback.mockClear(); + resolveAbstraction('unifiedAccount'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const recoveredCall = accountCallback.mock.calls.at(-1)?.[0]; + // unifiedAccount → fold=true → spot USDC ($123.45) folds into + // availableToTradeBalance (default $1000 perps + $123.45 spot ≈ $1123.45). + expect(parseFloat(recoveredCall?.availableToTradeBalance)).toBeCloseTo( + 1123.45, + 2, + ); + + // A subsequent subscribe must take the fast path — the cache is now + // sealed for this user, so no redundant userAbstraction REST round-trip. + service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + it('ignores spotState events for a different user', async () => { // First seed perps state so the handler's re-aggregate guard could fire. const unsubscribe = service.subscribeToAccount({ @@ -3805,6 +3899,114 @@ describe('HyperLiquidSubscriptionService', () => { }); }); + describe('setUserAbstractionMode', () => { + it('does not throw for an address with no prior cache entry', async () => { + expect(() => + service.setUserAbstractionMode('0x123', 'unifiedAccount'), + ).not.toThrow(); + }); + + it('lowercases the key so checksummed addresses hit the cached entry', async () => { + expect(() => + service.setUserAbstractionMode( + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + 'unifiedAccount', + ), + ).not.toThrow(); + }); + + it('flips the fold state and notifies subscribers when the mode changes', async () => { + // Start without a resolved mode — the spot WS push and REST fetch run + // through the standard subscribeToAccount path. The default mock + // resolves userAbstraction = 'unifiedAccount' so the initial subscribe + // already records that mode and folds spot. Setting back to + // dexAbstraction should flip the fold off and re-notify. + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + accountCallback.mockClear(); + + // Switch the recorded mode to dexAbstraction (no fold). Account state + // hash flips because availableToTradeBalance drops the folded spot. + service.setUserAbstractionMode('0x123', 'dexAbstraction'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const lastCall = accountCallback.mock.calls.at(-1)?.[0]; + expect(lastCall?.availableToTradeBalance).toBeDefined(); + + unsubscribe(); + }); + }); + + describe('userAbstraction fetch failure handling', () => { + it('does not seal the spot cache when userAbstraction fails, so the next refresh retries', async () => { + // Without this guard, a transient userAbstraction failure leaves + // #cachedSpotStateUserAddress set, the early-return in #ensureSpotState + // takes the fast path forever, and Standard / dexAbstraction users + // keep seeing spot folded into availableToTradeBalance via the + // fail-open Unified default. + const userAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('transient HL outage')) + .mockResolvedValueOnce('dexAbstraction'); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Second subscribe (same user) must trigger another refresh because + // the prior failure left the cache unsealed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + + unsub1(); + unsub2(); + }); + + it('seals the cache normally once a prior abstraction mode has been resolved', async () => { + // Sanity check: when userAbstraction has already resolved successfully, + // a subsequent refresh failure must not force pointless retries. + const userAbstractionMock = jest + .fn() + .mockResolvedValueOnce('dexAbstraction') + .mockRejectedValueOnce(new Error('transient HL outage')); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Subsequent subscribe takes the early-return path; the second mock + // entry (the rejection) is never consumed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + }); + }); + describe('spot-adjusted account balance parity', () => { it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => { jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ @@ -3864,6 +4066,7 @@ describe('HyperLiquidSubscriptionService', () => { const accountState = mockCallback.mock.calls.at(-1)[0]; expect(accountState.totalBalance).toBe('100.76531791'); expect(accountState.availableBalance).toBe('0'); + expect(accountState.availableToTradeBalance).toBe('100.76531791'); expect(accountState.subAccountBreakdown).toEqual({ main: { availableBalance: '0', totalBalance: '0' }, xyz: { availableBalance: '0', totalBalance: '0' }, @@ -3873,6 +4076,78 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); + it('does not use non-USDC spot coins in streamed availableToTradeBalance', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const infoClient = { + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }; + mockClientService.getInfoClient = jest.fn(() => infoClient) as never; + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + withdrawable: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ); + + await hip3Service.updateFeatureFlags(true, ['xyz'], [], []); + + const mockCallback = jest.fn(); + const unsubscribe = hip3Service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const accountState = mockCallback.mock.calls.at(-1)[0]; + expect(accountState.availableToTradeBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + + unsubscribe(); + }); + it('includes spot balance in webData2 (single-DEX) account updates without flickering', async () => { jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ availableBalance: '50', diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index e9f3a0f9d1ab..3a7f4f755f9d 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -39,11 +39,16 @@ import type { PerpsPlatformDependencies, PerpsLogger, } from '../types'; -import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; +import { hyperLiquidModeFoldsSpot } from '../types/hyperliquid-types'; +import type { + SpotClearinghouseStateResponse, + UserAbstractionResponse, +} from '../types/hyperliquid-types'; import { addSpotBalanceToAccountState, calculateWeightedReturnOnEquity, } from '../utils/accountUtils'; +import type { AddSpotBalanceOptions } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptPositionFromSDK, @@ -175,6 +180,8 @@ export class HyperLiquidSubscriptionService { #cachedSpotStateUserAddress: string | null = null; + readonly #abstractionModeByUser = new Map(); + #spotStatePromise?: Promise; #spotStatePromiseUserAddress?: string; @@ -712,7 +719,9 @@ export class HyperLiquidSubscriptionService { } #hashAccountState(account: AccountState): string { - return `${account.availableBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`; + return `${account.availableBalance}:${account.availableToTradeBalance ?? ''}:${ + account.totalBalance + }:${account.marginUsed}:${account.unrealizedPnl}`; } // Cache hashes to avoid recomputation @@ -1020,16 +1029,74 @@ export class HyperLiquidSubscriptionService { returnOnEquity, }, this.#cachedSpotState, + this.#getSpotBalanceOptions(), ); } + #getAbstractionModeForUser( + userAddress?: string | null, + ): UserAbstractionResponse | null { + if (!userAddress) { + return null; + } + + return this.#abstractionModeByUser.get(userAddress.toLowerCase()) ?? null; + } + + #getSpotBalanceOptions(): AddSpotBalanceOptions { + return { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + this.#getAbstractionModeForUser(this.#cachedSpotStateUserAddress), + ), + }; + } + + /** + * Record a user's resolved abstraction mode and immediately re-aggregate. + * Call after the provider has confirmed the on-chain mode (already-enabled + * or just-migrated). Setting the mode (rather than deleting it) ensures + * `hyperLiquidModeFoldsSpot` returns the correct fold decision on the next + * aggregation — a delete would leave the user pinned to fail-closed + * (no fold) until the next refresh, under-reporting balance for Unified + * and Portfolio Margin users. + * + * Seals `#cachedSpotStateUserAddress` if spot is already cached for this + * user (fast-path optimization for the next `#ensureSpotState`). Skips the + * seal if spot belongs to a different user — the next refresh will sort + * everything out. + * + * @param userAddress - The EVM address whose mode is being recorded. + * @param mode - The current abstraction mode for this user. + */ + public setUserAbstractionMode( + userAddress: string, + mode: UserAbstractionResponse, + ): void { + const lower = userAddress.toLowerCase(); + this.#abstractionModeByUser.set(lower, mode); + + // No need to seal #cachedSpotStateUserAddress here — the WS handler and + // #refreshSpotState success path always set it to the spot owner. The + // re-aggregation below will pick up the new mode via the now-populated + // #abstractionModeByUser entry. + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } + async #ensureSpotState(accountId?: CaipAccountId): Promise { const userAddress = await this.#walletService.getUserAddressWithDefault(accountId); + const lowerUserAddress = userAddress.toLowerCase(); + // Fast-path only when we have spot for this user AND a resolved + // abstraction mode. Without the mode, `#getSpotBalanceOptions` would + // fall back to fail-closed (no fold), under-reporting Unified / + // Portfolio Margin balances — force a refresh instead. if ( this.#cachedSpotState && - this.#cachedSpotStateUserAddress === userAddress + this.#cachedSpotStateUserAddress === lowerUserAddress && + this.#abstractionModeByUser.has(lowerUserAddress) ) { return; } @@ -1076,23 +1143,75 @@ export class HyperLiquidSubscriptionService { this.#walletService.createWalletAdapter(), ); - if (generation !== this.#spotStateGeneration) { - return; - } - + // Don't bail here even if generation has bumped (e.g. WS spot snapshot + // arrived while we awaited the subscription client). We still need to + // resolve `userAbstraction` for this user — the mode is user-keyed, + // independent of the spot generation, and the post-fetch path below + // correctly handles the generation-changed case (seal + re-aggregate + // instead of overwriting WS spot). const infoClient = this.#clientService.getInfoClient(); - const result = await infoClient.spotClearinghouseState({ - user: userAddress, - }); + const [spotResult, abstractionResult] = await Promise.allSettled([ + infoClient.spotClearinghouseState({ + user: userAddress, + }), + infoClient.userAbstraction({ user: userAddress }), + ]); + + const lowerUserAddress = userAddress.toLowerCase(); + + // Record the abstraction mode regardless of generation. The mode is + // user-keyed (independent of the spot snapshot generation) so a WS + // push that bumped generation while we awaited cannot make this + // result wrong for this user. Discarding it would strand + // Unified / Portfolio Margin users at fail-closed until another + // subscribe runs — exactly the race the WS-vs-REST guard creates. + if (abstractionResult.status === 'fulfilled') { + this.#abstractionModeByUser.set( + lowerUserAddress, + abstractionResult.value, + ); + } else { + this.#deps.debugLogger.log( + 'User abstraction fetch failed during spot refresh; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidSubscriptionService.refreshSpotState.abstraction', + ).message, + }, + ); + } - // Drop stale results: cleanUp/clearAll or a newer fetch bumped generation. - // Writing here would re-populate the cache with a different user's data. if (generation !== this.#spotStateGeneration) { + // A WS push superseded our spot snapshot. The earlier WS-driven + // aggregation ran with a null mode (fail-closed), so subscribers + // may currently be under-reported. If we just resolved the mode + // for the user whose spot is cached (strict match — null cache + // owner could mean cleanUp ran for a different user), re-aggregate + // now so the active subscribers immediately see the correct fold. + if ( + abstractionResult.status === 'fulfilled' && + this.#cachedSpotState && + this.#cachedSpotStateUserAddress === lowerUserAddress + ) { + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } return; } - this.#cachedSpotState = result; - this.#cachedSpotStateUserAddress = userAddress; + if (spotResult.status === 'rejected') { + throw spotResult.reason; + } + + this.#cachedSpotState = spotResult.value; + // Always record the spot owner so subsequent #ensureSpotState calls + // and recovery branches can identify whose data is cached. Fast-path + // eligibility is gated separately by #abstractionModeByUser.has(...); + // a transient abstraction failure leaves the user out of the map and + // the next #ensureSpotState retries both fetches. + this.#cachedSpotStateUserAddress = lowerUserAddress; if (this.#dexAccountCache.size > 0) { this.#aggregateAndNotifySubscribers(); @@ -1144,10 +1263,11 @@ export class HyperLiquidSubscriptionService { // its result instead of overwriting this fresher WS snapshot. this.#spotStateGeneration += 1; this.#cachedSpotState = event.spotState; - // Normalize to match REST path (stores lowercase) so the - // #ensureSpotState strict-equal check hits the cache regardless - // of whether HL returns a checksummed or lowercase user field. - this.#cachedSpotStateUserAddress = event.user.toLowerCase(); + // Always record the spot owner so subsequent generation guards + // and recovery branches can identify whose data is cached. + // Fast-path eligibility is gated separately in #ensureSpotState + // by checking #abstractionModeByUser.has(...). + this.#cachedSpotStateUserAddress = userAddress.toLowerCase(); if (this.#dexAccountCache.size > 0) { this.#aggregateAndNotifySubscribers(); @@ -1623,6 +1743,7 @@ export class HyperLiquidSubscriptionService { const spotAdjustedAccount = addSpotBalanceToAccountState( accountState, this.#cachedSpotState, + this.#getSpotBalanceOptions(), ); const positionsHash = this.#hashPositions(positionsWithTPSL); @@ -2167,6 +2288,7 @@ export class HyperLiquidSubscriptionService { this.#cachedAccount = null; this.#cachedSpotState = null; this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); // Bump generation so any in-flight spot fetch from a prior user discards // its result instead of re-populating the cache post-cleanup. this.#spotStateGeneration += 1; @@ -3945,6 +4067,7 @@ export class HyperLiquidSubscriptionService { this.#dexAccountCache.clear(); this.#cachedSpotState = null; this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); this.#spotStateGeneration += 1; this.#spotStatePromise = undefined; this.#spotStatePromiseUserAddress = undefined; diff --git a/app/controllers/perps/services/HyperLiquidWalletService.test.ts b/app/controllers/perps/services/HyperLiquidWalletService.test.ts index 3c9363ace640..3b7495633c5e 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.test.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.test.ts @@ -13,6 +13,9 @@ jest.mock('@metamask/keyring-api', () => ({ // Mock MetaMask utils jest.mock('@metamask/utils', () => ({ + hasProperty: jest.fn((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ), parseCaipAccountId: jest.fn((accountId: string) => { const parts = accountId.split(':'); return { @@ -314,6 +317,52 @@ describe('HyperLiquidWalletService', () => { expect(address).toBe(mockEvmAccount.address); }); + + it('returns false for software wallet', () => { + expect(service.isSelectedHardwareWallet()).toBe(false); + }); + + it('returns true for Ledger hardware wallet', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + ...mockEvmAccount, + metadata: { + ...mockEvmAccount.metadata, + keyring: { type: 'Ledger Hardware' }, + }, + }, + ]; + } + return undefined; + }); + + expect(service.isSelectedHardwareWallet()).toBe(true); + }); + + it('returns true for QR hardware wallet', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + ...mockEvmAccount, + metadata: { + ...mockEvmAccount.metadata, + keyring: { type: 'QR Hardware Wallet Device' }, + }, + }, + ]; + } + return undefined; + }); + + expect(service.isSelectedHardwareWallet()).toBe(true); + }); }); describe('Network Management', () => { diff --git a/app/controllers/perps/services/HyperLiquidWalletService.ts b/app/controllers/perps/services/HyperLiquidWalletService.ts index d1ac687ce7a9..144f53af3261 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.ts @@ -1,4 +1,8 @@ -import { parseCaipAccountId, isValidHexAddress } from '@metamask/utils'; +import { + hasProperty, + isValidHexAddress, + parseCaipAccountId, +} from '@metamask/utils'; import type { CaipAccountId, Hex } from '@metamask/utils'; import { getChainId } from '../constants/hyperLiquidConfig'; @@ -8,7 +12,14 @@ import type { PerpsTypedMessageParams, } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; -import { getSelectedEvmAccount } from '../utils/accountUtils'; +import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; + +// Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this +// service portable between mobile and the core monorepo. +const HARDWARE_KEYRING_TYPES = new Set([ + 'Ledger Hardware', + 'QR Hardware Wallet Device', +]); /** * Service for MetaMask wallet integration with HyperLiquid SDK @@ -41,6 +52,29 @@ export class HyperLiquidWalletService { return this.#messenger.call('KeyringController:getState').isUnlocked; } + /** + * Check whether the selected EVM account is backed by hardware. + * + * @returns True for Ledger / QR hardware keyrings; false for software accounts. + */ + public isSelectedHardwareWallet(): boolean { + const selectedEvmAccount = findEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); + if (!selectedEvmAccount || !hasProperty(selectedEvmAccount, 'metadata')) { + return false; + } + + const metadata = selectedEvmAccount.metadata as + | { keyring?: { type?: string } } + | undefined; + const keyringType = metadata?.keyring?.type; + + return Boolean(keyringType && HARDWARE_KEYRING_TYPES.has(keyringType)); + } + /** * Sign typed data via DI keyring controller * diff --git a/app/controllers/perps/services/TradingReadinessCache.test.ts b/app/controllers/perps/services/TradingReadinessCache.test.ts index 3b03a34575c4..db4e6a54690c 100644 --- a/app/controllers/perps/services/TradingReadinessCache.test.ts +++ b/app/controllers/perps/services/TradingReadinessCache.test.ts @@ -228,7 +228,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { describe('isInFlight()', () => { it('returns undefined when no in-flight operation', () => { const result = PerpsSigningCache.isInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); @@ -236,10 +236,10 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { }); it('returns promise when operation is in-flight', () => { - PerpsSigningCache.setInFlight('dexAbstraction', network, userAddress); + PerpsSigningCache.setInFlight('unifiedAccount', network, userAddress); const result = PerpsSigningCache.isInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); @@ -260,7 +260,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { // Different operation type should not be in-flight expect( PerpsSigningCache.isInFlight( - 'dexAbstraction', + 'unifiedAccount', network, uniqueAddress, ), @@ -293,7 +293,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { describe('setInFlight()', () => { it('returns a completion function', () => { const complete = PerpsSigningCache.setInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); @@ -302,14 +302,14 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { it('calling completion function removes in-flight status', () => { const complete = PerpsSigningCache.setInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); // Should be in-flight expect( - PerpsSigningCache.isInFlight('dexAbstraction', network, userAddress), + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), ).toBeDefined(); // Complete the operation @@ -317,7 +317,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { // Should no longer be in-flight expect( - PerpsSigningCache.isInFlight('dexAbstraction', network, userAddress), + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), ).toBeUndefined(); }); @@ -384,7 +384,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { const mainnetAddress = '0xMainnetUser1234567890123456789012345'; const testnetAddress = '0xTestnetUser1234567890123456789012345'; - describe('clearDexAbstraction()', () => { + describe('clearUnifiedAccount()', () => { it('clears only DEX abstraction state, preserving other states', () => { // Setup all three operation states TradingReadinessCache.set('mainnet', mainnetAddress, { @@ -401,7 +401,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { }); // Clear only DEX abstraction - TradingReadinessCache.clearDexAbstraction('mainnet', mainnetAddress); + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); // DEX abstraction should be reset const dexResult = TradingReadinessCache.get('mainnet', mainnetAddress); @@ -421,7 +421,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { }); it('does nothing when entry does not exist', () => { - TradingReadinessCache.clearDexAbstraction('mainnet', mainnetAddress); + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); expect(TradingReadinessCache.size()).toBe(0); }); }); @@ -639,7 +639,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { const state = TradingReadinessCache.debugState(); expect(state).toContain('mainnet:'); - expect(state).toContain('dex=true/true'); + expect(state).toContain('unified=true/true'); expect(state).toContain('builder=true/false'); expect(state).toContain('referral=false/false'); }); @@ -686,7 +686,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { // Start all three operations const completeDex = PerpsSigningCache.setInFlight( - 'dexAbstraction', + 'unifiedAccount', network, userAddress, ); @@ -703,7 +703,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { // All should be in-flight expect( - PerpsSigningCache.isInFlight('dexAbstraction', network, userAddress), + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), ).toBeDefined(); expect( PerpsSigningCache.isInFlight('builderFee', network, userAddress), @@ -718,7 +718,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { PerpsSigningCache.isInFlight('builderFee', network, userAddress), ).toBeUndefined(); expect( - PerpsSigningCache.isInFlight('dexAbstraction', network, userAddress), + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), ).toBeDefined(); completeDex(); @@ -726,7 +726,7 @@ describe('TradingReadinessCache / PerpsSigningCache', () => { // All should be cleared expect( - PerpsSigningCache.isInFlight('dexAbstraction', network, userAddress), + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), ).toBeUndefined(); expect( PerpsSigningCache.isInFlight('referral', network, userAddress), diff --git a/app/controllers/perps/services/TradingReadinessCache.ts b/app/controllers/perps/services/TradingReadinessCache.ts index d1fc53432260..082c7e27b046 100644 --- a/app/controllers/perps/services/TradingReadinessCache.ts +++ b/app/controllers/perps/services/TradingReadinessCache.ts @@ -8,13 +8,13 @@ * are recreated on account/network changes, which would reset instance-level caches. * * Tracks three signing operations: - * 1. DEX Abstraction enablement (one-time, irreversible) + * 1. Unified Account enablement (one-time, replaces deprecated DEX abstraction) * 2. Builder Fee approval (required for trading) * 3. Referral code setup (one-time per account) * * Cache Structure: * - Key: `network:userAddress` (e.g., "mainnet:0x123...") - * - Value: { dexAbstraction, builderFee, referral, timestamp } + * - Value: { unifiedAccount, builderFee, referral, timestamp } * * Lifecycle: * - Cache persists throughout app session @@ -28,7 +28,7 @@ type SigningOperationState = { }; type PerpsSigningCacheEntry = { - dexAbstraction: SigningOperationState; + unifiedAccount: SigningOperationState; builderFee: SigningOperationState; referral: SigningOperationState; timestamp: number; // When this entry was last updated @@ -71,7 +71,7 @@ class PerpsSigningCacheManager { * @returns The resulting string value. */ public isInFlight( - operationType: 'dexAbstraction' | 'builderFee' | 'referral', + operationType: 'unifiedAccount' | 'builderFee' | 'referral', network: 'mainnet' | 'testnet', userAddress: string, ): Promise | undefined { @@ -89,7 +89,7 @@ class PerpsSigningCacheManager { * @returns The resulting string value. */ public setInFlight( - operationType: 'dexAbstraction' | 'builderFee' | 'referral', + operationType: 'unifiedAccount' | 'builderFee' | 'referral', network: 'mainnet' | 'testnet', userAddress: string, ): () => void { @@ -117,7 +117,7 @@ class PerpsSigningCacheManager { let entry = this.#cache.get(key); if (!entry) { entry = { - dexAbstraction: { attempted: false, success: false }, + unifiedAccount: { attempted: false, success: false }, builderFee: { attempted: false, success: false }, referral: { attempted: false, success: false }, timestamp: Date.now(), @@ -127,10 +127,10 @@ class PerpsSigningCacheManager { return entry; } - // ===== DEX Abstraction Methods ===== + // ===== Unified Account Methods ===== /** - * Get DEX abstraction cache entry (legacy compatibility) + * Get unified account cache entry (legacy compatibility) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -146,14 +146,14 @@ class PerpsSigningCacheManager { return undefined; } return { - attempted: entry.dexAbstraction.attempted, - enabled: entry.dexAbstraction.success, + attempted: entry.unifiedAccount.attempted, + enabled: entry.unifiedAccount.success, timestamp: entry.timestamp, }; } /** - * Set DEX abstraction cache entry (legacy compatibility) + * Set unified account cache entry (legacy compatibility) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -167,7 +167,7 @@ class PerpsSigningCacheManager { data: { attempted: boolean; enabled: boolean }, ): void { const entry = this.#getOrCreateEntry(network, userAddress); - entry.dexAbstraction = { attempted: data.attempted, success: data.enabled }; + entry.unifiedAccount = { attempted: data.attempted, success: data.enabled }; entry.timestamp = Date.now(); } @@ -244,27 +244,27 @@ class PerpsSigningCacheManager { // ===== General Methods ===== /** - * Clear only DEX abstraction state for a specific network and user address + * Clear only unified account state for a specific network and user address * This preserves builder fee and referral states * * @param network - The network environment. * @param userAddress - The user's wallet address. */ - public clearDexAbstraction( + public clearUnifiedAccount( network: 'mainnet' | 'testnet', userAddress: string, ): void { const key = this.#getCacheKey(network, userAddress); const entry = this.#cache.get(key); if (entry) { - entry.dexAbstraction = { attempted: false, success: false }; + entry.unifiedAccount = { attempted: false, success: false }; entry.timestamp = Date.now(); } } /** * Clear only builder fee state for a specific network and user address - * This preserves DEX abstraction and referral states + * This preserves unified account and referral states * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -283,7 +283,7 @@ class PerpsSigningCacheManager { /** * Clear only referral state for a specific network and user address - * This preserves DEX abstraction and builder fee states + * This preserves unified account and builder fee states * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -302,7 +302,7 @@ class PerpsSigningCacheManager { /** * Clear entire cache entry for a specific network and user address - * WARNING: This clears ALL signing operation states (dexAbstraction, builderFee, referral) + * WARNING: This clears ALL signing operation states (unifiedAccount, builderFee, referral) * * @param network - The network environment. * @param userAddress - The user's wallet address. @@ -347,7 +347,7 @@ class PerpsSigningCacheManager { const entries: string[] = []; this.#cache.forEach((entry, key) => { entries.push( - `${key}: dex=${entry.dexAbstraction.attempted}/${entry.dexAbstraction.success}, ` + + `${key}: unified=${entry.unifiedAccount.attempted}/${entry.unifiedAccount.success}, ` + `builder=${entry.builderFee.attempted}/${entry.builderFee.success}, ` + `referral=${entry.referral.attempted}/${entry.referral.success}`, ); diff --git a/app/controllers/perps/types/hyperliquid-types.test.ts b/app/controllers/perps/types/hyperliquid-types.test.ts new file mode 100644 index 000000000000..f9647a44d527 --- /dev/null +++ b/app/controllers/perps/types/hyperliquid-types.test.ts @@ -0,0 +1,34 @@ +import { hyperLiquidModeFoldsSpot } from './hyperliquid-types'; + +describe('hyperLiquidModeFoldsSpot', () => { + it('folds for unifiedAccount', () => { + expect(hyperLiquidModeFoldsSpot('unifiedAccount')).toBe(true); + }); + + it('folds for portfolioMargin', () => { + expect(hyperLiquidModeFoldsSpot('portfolioMargin')).toBe(true); + }); + + it('does not fold for dexAbstraction', () => { + expect(hyperLiquidModeFoldsSpot('dexAbstraction')).toBe(false); + }); + + it('does not fold for default', () => { + expect(hyperLiquidModeFoldsSpot('default')).toBe(false); + }); + + it('does not fold for disabled', () => { + expect(hyperLiquidModeFoldsSpot('disabled')).toBe(false); + }); + + it('fail-closes (no fold) when mode is null', () => { + // Critical: must not over-report withdrawable funds for Standard / + // dexAbstraction users when the abstraction mode hasn't been resolved + // yet (e.g. WS spot push arrives before REST userAbstraction completes). + expect(hyperLiquidModeFoldsSpot(null)).toBe(false); + }); + + it('fail-closes (no fold) when mode is undefined', () => { + expect(hyperLiquidModeFoldsSpot(undefined)).toBe(false); + }); +}); diff --git a/app/controllers/perps/types/hyperliquid-types.ts b/app/controllers/perps/types/hyperliquid-types.ts index d18f9d1cb20c..f0e0f8795e11 100644 --- a/app/controllers/perps/types/hyperliquid-types.ts +++ b/app/controllers/perps/types/hyperliquid-types.ts @@ -17,8 +17,52 @@ import type { PredictedFundingsResponse, OrderParameters, SpotMetaResponse, + UserAbstractionResponse, } from '@nktkas/hyperliquid'; +/** + * Wire codes accepted by `agentSetAbstraction({ abstraction })`. The SDK + * types these as a `"i" | "u" | "p"` literal union with no exported constant. + * + * Only `unifiedAccount` is referenced by the current migration flow; the + * other entries document the full SDK wire format so a future caller + * (e.g. emergency rollback to `disabled`, or opting into `portfolioMargin`) + * does not have to re-discover the codes. + */ +export const HL_ABSTRACTION_WIRE = { + disabled: 'i', + unifiedAccount: 'u', + portfolioMargin: 'p', +} as const; + +/** + * Long-form abstraction-mode value targeted by the migration. Used as the + * `abstraction` parameter for `userSetAbstraction` and as the success / target + * value reported by Account Setup analytics. + */ +export const HL_UNIFIED_ACCOUNT_MODE = 'unifiedAccount' as const; + +/** + * True when the given HL abstraction mode treats spot balances as perps + * collateral. Fail-CLOSED on missing mode: until userAbstraction has been + * resolved we do not fold spot, because over-reporting withdrawable funds + * for Standard / dexAbstraction users (which `withdraw3` cannot actually + * draw) is worse than briefly under-reporting for Unified users during the + * initial subscription window or a transient REST outage. + * + * @param mode - Abstraction mode returned by HyperLiquid. + * @returns Whether spot balances should fold into perps collateral. + */ +export function hyperLiquidModeFoldsSpot( + mode?: UserAbstractionResponse | null, +): boolean { + if (mode === null || mode === undefined) { + return false; + } + + return mode === 'unifiedAccount' || mode === 'portfolioMargin'; +} + // Clearinghouse (Account) Types export type AssetPosition = ClearinghouseStateResponse['assetPositions'][number]; @@ -44,4 +88,5 @@ export type { MetaAndAssetCtxsResponse, PredictedFundingsResponse, SpotMetaResponse, + UserAbstractionResponse, }; diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index e5861d2a44f7..5ea8521416e1 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -1231,6 +1231,7 @@ export enum PerpsAnalyticsEvent { UiInteraction = 'Perp UI Interaction', RiskManagement = 'Perp Risk Management', PerpsError = 'Perp Error', + AccountSetup = 'Perp Account Setup', } /** diff --git a/app/controllers/perps/utils/accountUtils.test.ts b/app/controllers/perps/utils/accountUtils.test.ts index 20d78c997d61..f1014e10540d 100644 --- a/app/controllers/perps/utils/accountUtils.test.ts +++ b/app/controllers/perps/utils/accountUtils.test.ts @@ -183,16 +183,21 @@ describe('spot balance helpers', () => { returnOnEquity: '0', }; - const result = addSpotBalanceToAccountState(accountState, { - balances: [ - { coin: 'USDC', total: '25.5' }, - { coin: 'HYPE', total: '0.5' }, - ], - } as never); + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [ + { coin: 'USDC', total: '25.5' }, + { coin: 'HYPE', total: '0.5' }, + ], + } as never, + { foldIntoCollateral: true }, + ); // Only USDC contributes — non-stablecoin spot assets are not convertible // to perps collateral and must not inflate totalBalance. expect(result.totalBalance).toBe('125.5'); + expect(result.availableToTradeBalance).toBe('25.5'); expect(accountState.totalBalance).toBe('100'); }); @@ -258,7 +263,96 @@ describe('spot balance helpers', () => { expect(result.totalBalance).toBe('30'); }); - it('preserves numeric fields and defaults availableToTradeBalance when spot balance is zero', () => { + it('does not add non-USDC spot balances to availableToTradeBalance', () => { + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'mUSD', total: '25', hold: '5' }, + { coin: 'HYPE', total: '999' }, + ], + } as never); + + expect(result.totalBalance).toBe('10'); + expect(result.availableToTradeBalance).toBe('0'); + }); + + it('does not fold USDC spot collateral into availableToTradeBalance for Standard modes', () => { + const accountState: AccountState = { + availableBalance: '7', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '25', hold: '5' }], + } as never, + { + foldIntoCollateral: false, + }, + ); + + expect(result.totalBalance).toBe('30'); + expect(result.availableToTradeBalance).toBe('7'); + }); + + it('keeps spot USDC separate from availableToTradeBalance even when withdrawable=0 in Standard mode', () => { + // Standard / DEX-abstraction users with $0 perps withdrawable but free + // spot USDC must NOT see spot fold into availableToTradeBalance — + // withdraw3 only draws from the perps ledger in those modes. Folding + // would surface a withdrawable amount the API can't actually fulfill. + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '2500', hold: '0' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.availableToTradeBalance).toBe('0'); + expect(result.totalBalance).toBe('2500'); + }); + + it('keeps spot separate when Standard mode has both perps and spot balances', () => { + const accountState: AccountState = { + availableBalance: '7', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '25', hold: '0' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.availableToTradeBalance).toBe('7'); + expect(result.totalBalance).toBe('35'); + }); + + it('returns the original account state when spot balance is zero', () => { const accountState: AccountState = { availableBalance: '1', totalBalance: '2', diff --git a/app/controllers/perps/utils/accountUtils.ts b/app/controllers/perps/utils/accountUtils.ts index bf8efa35cff7..13c90d960a0d 100644 --- a/app/controllers/perps/utils/accountUtils.ts +++ b/app/controllers/perps/utils/accountUtils.ts @@ -16,7 +16,7 @@ function isEvmAccountType(type: string): boolean { export function findEvmAccount( accounts: (InternalAccount | PerpsInternalAccount)[], -): { address: string; type: string } | null { +): InternalAccount | PerpsInternalAccount | null { const evmAccount = accounts.find( (account) => account && isEvmAccountType(account.type as InternalAccount['type']), @@ -90,10 +90,16 @@ export function calculateWeightedReturnOnEquity( return weightedROE.toString(); } -// Spot coins counted toward currently supported funded-state gating. -// Today the in-app HyperLiquid market surface is USDC-collateralized only, -// so USDH must not inflate the shared funded-state path that hides Add Funds. -// Non-stablecoin spot assets (HYPE, PURR, …) also remain excluded. +export type AddSpotBalanceOptions = { + /** + * Whether the user's abstraction mode folds spot balances into perps + * collateral. Standard / DEX abstraction keep spot separate. + */ + foldIntoCollateral?: boolean; +}; + +// The release-branch balance bridge is USDC-only. Non-USDC spot assets must +// not inflate the balances shown or validated by withdraw/payment flows. const SPOT_COLLATERAL_COINS = new Set(['USDC']); export function getSpotBalance( @@ -137,7 +143,12 @@ export function getSpotHold( export function addSpotBalanceToAccountState( accountState: AccountState, spotState?: SpotClearinghouseStateResponse | null, + options?: AddSpotBalanceOptions, ): AccountState { + // Fail-closed default: align with `hyperLiquidModeFoldsSpot(null) → false`. + // A caller that omits `options` should NOT silently fold spot — that would + // over-report withdrawable funds for Standard / dexAbstraction users. + const foldIntoCollateral = options?.foldIntoCollateral ?? false; const spotBalance = getSpotBalance(spotState); const spotHold = getSpotHold(spotState); const freeSpot = Math.max(0, spotBalance - spotHold); @@ -160,9 +171,19 @@ export function addSpotBalanceToAccountState( }; } - const availableToTrade = Number.isFinite(currentAvailable) - ? (currentAvailable + freeSpot).toString() - : freeSpot.toString(); + // Folding is gated strictly on the resolved abstraction mode. Standard / + // DEX-abstraction users keep perps and spot independent, so spot must NOT + // surface as a perps-withdrawable balance for them — withdraw3 only draws + // from the perps ledger in those modes. Unified / portfolio-margin users + // get the fold; live callers fail-CLOSED via `hyperLiquidModeFoldsSpot` + // when mode is unresolved (avoids over-reporting funds withdraw3 cannot + // actually draw during the initial subscription window). + let availableToTrade = accountState.availableBalance; + if (foldIntoCollateral) { + availableToTrade = Number.isFinite(currentAvailable) + ? (currentAvailable + freeSpot).toString() + : freeSpot.toString(); + } // Subtract spotHold to avoid double-counting on Unified/PM accounts: // marginSummary.accountValue already includes the margin that HL diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 42b069a24e98..187b2b36c4c7 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -597,6 +597,7 @@ enum EVENT_NAME { PERPS_UI_INTERACTION = 'Perp UI Interaction', PERPS_RISK_MANAGEMENT = 'Perp Risk Management', PERPS_ERROR = 'Perp Error', + PERPS_ACCOUNT_SETUP = 'Perp Account Setup', // Card CARD_BUTTON_VIEWED = 'Card Button Viewed', @@ -1630,6 +1631,7 @@ const events = { PERPS_UI_INTERACTION: generateOpt(EVENT_NAME.PERPS_UI_INTERACTION), PERPS_RISK_MANAGEMENT: generateOpt(EVENT_NAME.PERPS_RISK_MANAGEMENT), PERPS_ERROR: generateOpt(EVENT_NAME.PERPS_ERROR), + PERPS_ACCOUNT_SETUP: generateOpt(EVENT_NAME.PERPS_ACCOUNT_SETUP), // Asset Filter ASSET_FILTER_SELECTED: generateOpt(EVENT_NAME.ASSET_FILTER_SELECTED), diff --git a/docs/perps/hyperliquid/init-flow.md b/docs/perps/hyperliquid/init-flow.md index d3d748c300b0..ecbcdd86ff5a 100644 --- a/docs/perps/hyperliquid/init-flow.md +++ b/docs/perps/hyperliquid/init-flow.md @@ -67,7 +67,7 @@ sequenceDiagram HLP->>API: referral (user) HLP->>API: referral (builder) HLP->>API: maxBuilderFee - HLP->>API: userDexAbstraction + HLP->>API: userAbstraction Note over CM: Phase 3: Market Data CM->>HLP: getMarketDataWithPrices() @@ -107,12 +107,27 @@ These calls are made regardless of which entry point is used. #### Phase 2: User Setup (first connection only) -| Call | Endpoint | Purpose | Count | -| ---- | -------------------- | ------------------------------- | ----- | -| 7 | `referral` | Check user's referral status | 1 | -| 8 | `referral` | Check builder's referral status | 1 | -| 9 | `maxBuilderFee` | Get max builder fee for user | 1 | -| 10 | `userDexAbstraction` | Check DEX abstraction status | 1 | +| Call | Endpoint | Purpose | Count | +| ---- | ----------------- | ---------------------------------------------------------- | ----- | +| 7 | `referral` | Check user's referral status | 1 | +| 8 | `referral` | Check builder's referral status | 1 | +| 9 | `maxBuilderFee` | Get max builder fee for user | 1 | +| 10 | `userAbstraction` | Check current abstraction mode (Unified Account migration) | 1 | + +##### Unified Account migration paths + +`#ensureUnifiedAccountEnabled()` reads `userAbstraction` once per session and dispatches by mode: + +| Current mode | Action on init | When user signing happens | +| ------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `unifiedAccount` / `portfolioMargin` | no-op, cache success | never | +| `default` / `disabled` | `agentSetAbstraction({ abstraction: 'u' })` silent — agent-key, no prompt | never | +| `dexAbstraction` | **deferred** — no prompt, cache untouched | `#ensureReadyForTrading()` calls `userSetAbstraction` (EIP-712) at first trade/withdraw | +| Unknown mode | log + skip | never | + +The deferral exists because `dexAbstraction → unifiedAccount` requires an EIP-712 user signature (HL blocks the agent-key path for that transition). Prompting on Perps section open would surface a signing dialog before the user has expressed any intent to trade — costly UX for hardware/QR wallets and likely to be reflexively rejected. Action-time prompting fires at a contextual moment (Trade/Withdraw tap) where users expect a signature and approval rates are higher. + +Builder fee approval and referral setup remain in `#ensureReadyForTrading()` for the same reason. #### Phase 3: Market Data (`getMarketDataWithPrices`) @@ -122,13 +137,13 @@ These calls are made regardless of which entry point is used. #### Core Init Summary -| Category | Calls | Details | -| ------------- | ------ | ----------------------------------------------------- | -| DEX Discovery | 1 | `perpDexs` | -| Metadata | 5 | `metaAndAssetCtxs` × 5 DEXes | -| User Setup | 4 | `referral` × 2, `maxBuilderFee`, `userDexAbstraction` | -| Prices | 5 | `allMids` × 5 DEXes | -| **Total** | **15** | | +| Category | Calls | Details | +| ------------- | ------ | -------------------------------------------------- | +| DEX Discovery | 1 | `perpDexs` | +| Metadata | 5 | `metaAndAssetCtxs` × 5 DEXes | +| User Setup | 4 | `referral` × 2, `maxBuilderFee`, `userAbstraction` | +| Prices | 5 | `allMids` × 5 DEXes | +| **Total** | **15** | | ### View-Specific: PerpsHomeView Only - 2 Additional Calls @@ -164,7 +179,11 @@ flowchart TD subgraph Phase2["Phase 2: User Setup"] H[ensureReferralSet] --> I["referral × 2"] J[ensureBuilderFeeApproval] --> K[maxBuilderFee] - L[enableDexAbstraction] --> M[userDexAbstraction] + L[ensureUnifiedAccountEnabled] --> M[userAbstraction] + M --> M1{mode} + M1 -->|default / disabled| M2[agentSetAbstraction silent] + M1 -->|dexAbstraction| M3[defer to ensureReadyForTrading] + M1 -->|unifiedAccount / portfolioMargin| M4[no-op] end subgraph Phase3["Phase 3: Market Data"] @@ -439,7 +458,7 @@ Check network logs for expected call counts: - `metaAndAssetCtxs`: 5× (one per DEX) - `referral`: 2× (user + builder) - `maxBuilderFee`: 1× -- `userDexAbstraction`: 1× +- `userAbstraction`: 1× - `allMids`: 5× (one per DEX) - **Subtotal**: 15× diff --git a/docs/perps/perps-caching-architecture.md b/docs/perps/perps-caching-architecture.md index 91f42b77d865..3f464c3e9235 100644 --- a/docs/perps/perps-caching-architecture.md +++ b/docs/perps/perps-caching-architecture.md @@ -78,12 +78,12 @@ All 9 channels support `pause()`/`resume()` — pausing blocks emission to React ### Session Layer: TradingReadinessCache (Global Singleton) -| Cache | What it stores | Key format | Cleared on disconnect? | -| --------------- | -------------------------------------- | ---------------------------- | ---------------------- | -| Signing state | DEX abstraction, builder fee, referral | `network:userAddress` | Never (intentional) | -| In-flight locks | Concurrent signing operation guards | `opType:network:userAddress` | Self-clearing | +| Cache | What it stores | Key format | Cleared on disconnect? | +| --------------- | ------------------------------------------------ | ---------------------------- | ---------------------- | +| Signing state | Unified Account migration, builder fee, referral | `network:userAddress` | Never (intentional) | +| In-flight locks | Concurrent signing operation guards | `opType:network:userAddress` | Self-clearing | -This is the most important "survives everything" cache. Providers are recreated on account/network changes, which resets all instance-level caches. TradingReadinessCache persists as a global singleton specifically to remember that a hardware wallet user already approved DEX abstraction — without it, every reconnect would trigger another QR code scan. +This is the most important "survives everything" cache. Providers are recreated on account/network changes, which resets all instance-level caches. TradingReadinessCache persists as a global singleton specifically to remember that a hardware wallet user already approved the Unified Account migration — without it, every reconnect would trigger another QR code scan. ### Disk Layer: MMKV Cold-Start Cache From a38905a08c4fe6068e098cd4c5cf95d117aac66f Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 4 May 2026 18:45:34 +0000 Subject: [PATCH 03/28] [skip ci] Bump version number to 4788 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 13324f455ae5..c208f62c1c77 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4754 + versionCode 4788 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index edb191b73465..998523139203 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4754 + VERSION_NUMBER: 4788 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4754 + FLASK_VERSION_NUMBER: 4788 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 2094752e143c..fe3a82f2215a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4754; + CURRENT_PROJECT_VERSION = 4788; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 376b240b9224ca9bd6add21a4e1d9d66e81fc03d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 5 May 2026 06:45:52 +0000 Subject: [PATCH 04/28] [skip ci] Bump version number to 4795 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c208f62c1c77..0c0a2dacb533 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4788 + versionCode 4795 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 998523139203..4678e54e06fc 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4788 + VERSION_NUMBER: 4795 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4788 + FLASK_VERSION_NUMBER: 4795 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index fe3a82f2215a..eadcb405da8e 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4788; + CURRENT_PROJECT_VERSION = 4795; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From b3ad241d55edda961db6a4c999f49d53ce736dd8 Mon Sep 17 00:00:00 2001 From: chloeYue Date: Tue, 5 May 2026 08:48:38 +0200 Subject: [PATCH 05/28] edit changelog --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dba34e05cd..17e9fc6db661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.75.1] + +### Fixed + +- Fixed Hyperliquid withdraw showing $0 and being blocked for users on Unified Account mode. (#29492) + ## [7.75.0] ### Added @@ -11365,7 +11371,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.1...HEAD +[7.75.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...v7.75.1 [7.75.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.3...v7.75.0 [7.74.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.2...v7.74.3 [7.74.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.74.1...v7.74.2 From 43cafc814401e3bd6ba58748e20202dff397fe37 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 5 May 2026 06:50:56 +0000 Subject: [PATCH 06/28] [skip ci] Bump version number to 4796 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0c0a2dacb533..11465b495198 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4795 + versionCode 4796 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 4678e54e06fc..856fa31910c0 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4795 + VERSION_NUMBER: 4796 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4795 + FLASK_VERSION_NUMBER: 4796 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index eadcb405da8e..de1d0f536202 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4795; + CURRENT_PROJECT_VERSION = 4796; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From fe173dbf8b77441450a5807452e73b5418ea8391 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 5 May 2026 10:35:43 +0000 Subject: [PATCH 07/28] [skip ci] Bump version number to 4800 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 11465b495198..ac494fc0e95a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4796 + versionCode 4800 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 856fa31910c0..a2ce9a789f91 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4796 + VERSION_NUMBER: 4800 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4796 + FLASK_VERSION_NUMBER: 4800 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index de1d0f536202..328024540c70 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4796; + CURRENT_PROJECT_VERSION = 4800; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 1698fe90260031b847e037c7c3fedc375244092e Mon Sep 17 00:00:00 2001 From: chloeYue Date: Tue, 5 May 2026 19:10:13 +0200 Subject: [PATCH 08/28] trigger ci Co-authored-by: Cursor From 3cc5d592b0bae1e50e5ba2031619316ccb2c8384 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 5 May 2026 17:12:30 +0000 Subject: [PATCH 09/28] [skip ci] Bump version number to 4806 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ac494fc0e95a..4a2300f398f3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4800 + versionCode 4806 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a2ce9a789f91..1873ef7eb7c3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4800 + VERSION_NUMBER: 4806 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4800 + FLASK_VERSION_NUMBER: 4806 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 328024540c70..4e09a513c81f 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4800; + CURRENT_PROJECT_VERSION = 4806; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 86b4a4e91c08fa11561b8d2279e8d298129927fb Mon Sep 17 00:00:00 2001 From: chloeYue Date: Tue, 5 May 2026 19:12:48 +0200 Subject: [PATCH 10/28] trigger ci From b3ca333743e00302d0e6e4919873e3bd57a95d05 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 5 May 2026 17:15:32 +0000 Subject: [PATCH 11/28] [skip ci] Bump version number to 4809 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 4a2300f398f3..54bbc3f52eb2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.75.1" - versionCode 4806 + versionCode 4809 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 1873ef7eb7c3..52f221b6c349 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3558,13 +3558,13 @@ app: VERSION_NAME: 7.75.1 - opts: is_expand: false - VERSION_NUMBER: 4806 + VERSION_NUMBER: 4809 - opts: is_expand: false FLASK_VERSION_NAME: 7.75.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4806 + FLASK_VERSION_NUMBER: 4809 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 4e09a513c81f..3afe1d70f079 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1269,7 +1269,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1338,7 +1338,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1404,7 +1404,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1471,7 +1471,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1634,7 +1634,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4806; + CURRENT_PROJECT_VERSION = 4809; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 42b8c183da98fdb1b11a9d246a849b0673fba637 Mon Sep 17 00:00:00 2001 From: chloeYue Date: Wed, 6 May 2026 09:43:39 +0200 Subject: [PATCH 12/28] trigger ci From c0b1d1ce36d33aac3c99da63345d23fe51ed0f92 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Tue, 5 May 2026 09:52:19 +0100 Subject: [PATCH 13/28] chore: bump axios to 1.15.1 (#29711) ## **Description** Bump axios to 1.15.1 ## **Changelog** CHANGELOG entry: ## **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** > Low risk dependency bump limited to `axios` via Yarn resolutions/lockfile, with potential for minor runtime behavior changes in HTTP requests. > > **Overview** > Updates the Yarn `resolutions` override to `axios@^1.15.1` and refreshes `yarn.lock` to pull in `axios@1.15.2`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c6c607305657356bf1810de8459041e74e82f884. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b38cad9fe495..c87e35628015 100644 --- a/package.json +++ b/package.json @@ -175,7 +175,7 @@ "@unrs/resolver-binding-wasm32-wasi": "npm:npm-empty-package@1.0.0", "d3-color": "3.1.0", "napi-postinstall": "npm:npm-empty-package@1.0.0", - "axios": "^1.15.0", + "axios": "^1.15.1", "lodash": "4.18.1", "redux-persist-filesystem-storage/react-native-blob-util": "^0.19.9", "@ethersproject/providers/ws": "^7.5.10", diff --git a/yarn.lock b/yarn.lock index 67bf52ab043d..040b56ae780e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22798,14 +22798,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:^1.15.1": + version: 1.15.2 + resolution: "axios@npm:1.15.2" dependencies: follow-redirects: "npm:^1.15.11" form-data: "npm:^4.0.5" proxy-from-env: "npm:^2.1.0" - checksum: 10/d39a2c0ebc7ff4739401b282e726cc2673377949d6c46d60eb619458f8d7a2f7eadbcada7097f4dbc7d5c59abb4d3bf6fac33d474412bc3415d3f5aa7ed45530 + checksum: 10/eebbd8cb777316d4252cd994a06ec9fb956ef519214a62dab6c5443ae8b753b5116e9a770502316789e6cdef1101e6aae53b6936d6a3791b2d66d75f4d7d2462 languageName: node linkType: hard From bf54b9b8711f3563a279c41eb1cab341a3d4fe64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 6 May 2026 12:10:32 -0600 Subject: [PATCH 14/28] feat(predict): remove CLOB v1 support and migrate to pUSD cp-7.76.0 (#29451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR removes legacy Predict Polymarket CLOB v1 support and completes the pUSD migration in Predict-owned code. It includes: - CLOB v2 as the unconditional Polymarket protocol path. - Removal of CLOB v1 protocol selection, order codec branches, and v1 Safe helper wrappers. - pUSD as the canonical Predict trading, fee, deposit, withdraw, reward-fee, and claim gas top-up token. - Legacy Safe USDC.e retained only as temporary hidden sweep state. - Legacy USDC.e → pUSD sweep preflight for deposit, withdraw, trade, and claim operations. - Displayed Predict balance as total recoverable Predict funds (`pUSD + legacy USDC.e`). - In-memory cache to stop refetching legacy USDC.e balance for a Safe after a zero balance is observed. Stack note: this PR is stacked on the confirmations-only pUSD PR so the diff stays limited to Predict-owned files. ## **Changelog** CHANGELOG entry: Updated Predict to use pUSD and the Polymarket CLOB v2 protocol. ## **Related issues** Fixes: [PRED-851](https://consensyssoftware.atlassian.net/browse/PRED-851) , [PRED-852](https://consensyssoftware.atlassian.net/browse/PRED-852) ## **Manual testing steps** ```gherkin Feature: Predict pUSD migration Scenario: user with pUSD places a Predict trade Given the user has a funded Predict Safe with pUSD When the user places a Predict trade Then the order is submitted through the CLOB v2 path And pUSD is used for trading and fee authorization Scenario: legacy user performs their first Predict operation Given the user has legacy USDC.e in their Predict Safe When the user deposits, withdraws, trades, or claims Then the Safe preflight wraps legacy USDC.e into pUSD before the main operation ``` ## **Screenshots/Recordings** ### **Before** N/A — code-only protocol migration PR; no screenshots captured. ### **After** N/A — code-only protocol migration PR; no screenshots captured. ## **Testing** - `yarn lint:tsc` - `yarn jest app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts app/components/UI/Predict/controllers/PredictController.test.ts app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts app/components/UI/Predict/hooks/usePredictRewards.test.ts app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx --runInBand --forceExit` ## **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** > Updates on-chain token identifiers/addresses used for Predict gas fee and rewards calculations, which could impact transactions if misconfigured. Changes are localized to Predict hooks/controller and accompanying tests. > > **Overview** > Predict now consistently references the **pUSD / CLOB v2 collateral** across Predict-owned code: `PredictController` uses `MATIC_CONTRACTS_V2.collateral` as the `gasFeeToken` for claim/withdraw `addTransactionBatch`, `usePredictBalanceTokenFilter` displays the Predict balance row using the `POLYGON_PUSD` token (icon + symbol), and `usePredictRewards` estimates points using `POLYGON_PUSD_CAIP_ASSET_ID`. > > Cleans up account-state handling by removing `hasAllowances` expectations/tests and updating `usePredictAccountState` docs to reflect the slimmer `AccountState` shape. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7dfda6197aa7d5628a92d19c1c03da8936df5c60. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). [PRED-851]: https://consensyssoftware.atlassian.net/browse/PRED-851?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Caainã Jeronimo --- .../controllers/PredictController.test.ts | 1 - .../Predict/controllers/PredictController.ts | 6 +- .../hooks/usePredictAccountState.test.ts | 20 - .../Predict/hooks/usePredictAccountState.ts | 2 +- .../usePredictBalanceTokenFilter.test.ts | 12 +- .../hooks/usePredictBalanceTokenFilter.ts | 18 +- .../Predict/hooks/usePredictRewards.test.ts | 8 +- .../UI/Predict/hooks/usePredictRewards.ts | 6 +- .../polymarket/PolymarketProvider.test.ts | 9258 +---------------- .../polymarket/PolymarketProvider.ts | 450 +- .../Predict/providers/polymarket/constants.ts | 22 +- .../providers/polymarket/preflight/claim.ts | 113 +- .../providers/polymarket/preflight/core.ts | 43 +- .../providers/polymarket/preflight/deposit.ts | 40 +- .../providers/polymarket/preflight/trade.ts | 35 +- .../preflight/v2AllowanceRequirements.test.ts | 22 +- .../preflight/v2AllowanceRequirements.ts | 30 +- .../polymarket/preflight/withdraw.test.ts | 95 +- .../polymarket/preflight/withdraw.ts | 106 +- .../polymarket/preflight/workflows.test.ts | 39 +- .../polymarket/protocol/definitions.test.ts | 73 +- .../polymarket/protocol/definitions.ts | 114 +- .../polymarket/protocol/orderCodec.test.ts | 61 +- .../polymarket/protocol/orderCodec.ts | 187 +- .../polymarket/protocol/transport.test.ts | 27 +- .../polymarket/protocol/transport.ts | 11 +- .../providers/polymarket/safe/constants.ts | 15 - .../providers/polymarket/safe/types.ts | 8 - .../providers/polymarket/safe/utils.test.ts | 1616 +-- .../providers/polymarket/safe/utils.ts | 357 +- .../UI/Predict/providers/polymarket/types.ts | 92 - .../providers/polymarket/utils.test.ts | 5660 +--------- .../UI/Predict/providers/polymarket/utils.ts | 295 +- .../selectors/featureFlags/index.test.ts | 78 - .../Predict/selectors/featureFlags/index.ts | 5 - app/components/UI/Predict/types/flags.ts | 6 - app/components/UI/Predict/types/index.ts | 1 - .../utils/resolvePredictFeatureFlags.test.ts | 126 - .../utils/resolvePredictFeatureFlags.ts | 33 - .../PredictPayWithRow.test.tsx | 12 +- .../PredictPayWithRow/PredictPayWithRow.tsx | 4 +- .../polymarket/polymarket-mocks.ts | 64 +- .../polymarket/polymarket-rpc-response.ts | 3 + .../transactions/transaction-pay.spec.ts | 5 +- 44 files changed, 1162 insertions(+), 18017 deletions(-) diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 2e221b190c46..6b0122eb2580 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -5721,7 +5721,6 @@ describe('PredictController', () => { const mockAccountState = { address: '0xProxyAddress' as `0x${string}`, isDeployed: true, - hasAllowances: true, balance: 100.5, }; diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index b447b43a3f9b..4396fb809e1f 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -57,7 +57,7 @@ import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { - MATIC_CONTRACTS, + MATIC_CONTRACTS_V2, POLYMARKET_PROVIDER_ID, } from '../providers/polymarket/constants'; import { Signer } from '../providers/types'; @@ -1453,7 +1453,7 @@ export class PredictController extends BaseController< disableHook: true, disableSequential: true, // Temporarily breaking abstraction, can instead be abstracted via provider. - gasFeeToken: MATIC_CONTRACTS.collateral as Hex, + gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions, }); @@ -2564,7 +2564,7 @@ export class PredictController extends BaseController< disableSequential: true, requireApproval: true, // Temporarily breaking abstraction, can instead be abstracted via provider. - gasFeeToken: MATIC_CONTRACTS.collateral as Hex, + gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions: [transaction], }); diff --git a/app/components/UI/Predict/hooks/usePredictAccountState.test.ts b/app/components/UI/Predict/hooks/usePredictAccountState.test.ts index bfb564a4f720..29bf63833129 100644 --- a/app/components/UI/Predict/hooks/usePredictAccountState.test.ts +++ b/app/components/UI/Predict/hooks/usePredictAccountState.test.ts @@ -63,7 +63,6 @@ describe('usePredictAccountState', () => { const mockAccountState = { address: '0x1234567890abcdef1234567890abcdef12345678', isDeployed: true, - hasAllowances: true, }; beforeEach(() => { @@ -117,7 +116,6 @@ describe('usePredictAccountState', () => { expect(mockGetAccountState).toHaveBeenCalledWith({}); expect(result.current.data?.address).toEqual(mockAccountState.address); expect(result.current.data?.isDeployed).toBe(true); - expect(result.current.data?.hasAllowances).toBe(true); expect(result.current.error).toBeNull(); }); @@ -215,24 +213,6 @@ describe('usePredictAccountState', () => { expect(result.current.data?.isDeployed).toBe(false); }); - it('returns hasAllowances as false when account lacks allowances', async () => { - const { Wrapper } = createWrapper(); - mockGetAccountState.mockResolvedValue({ - ...mockAccountState, - hasAllowances: false, - }); - - const { result } = renderHook(() => usePredictAccountState(), { - wrapper: Wrapper, - }); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - expect(result.current.data?.hasAllowances).toBe(false); - }); - it('has undefined data when query is disabled', () => { const { Wrapper } = createWrapper(); const { result } = renderHook( diff --git a/app/components/UI/Predict/hooks/usePredictAccountState.ts b/app/components/UI/Predict/hooks/usePredictAccountState.ts index 02cfa57c0241..1a75f0b99920 100644 --- a/app/components/UI/Predict/hooks/usePredictAccountState.ts +++ b/app/components/UI/Predict/hooks/usePredictAccountState.ts @@ -15,7 +15,7 @@ interface UsePredictAccountStateOptions { } /** - * Fetches the Predict account state (address, deployment status, allowances). + * Fetches the Predict account state (address and deployment status). */ export function usePredictAccountState( options: UsePredictAccountStateOptions = {}, diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index 7328b19b4606..2b1ba17cb242 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -52,10 +52,6 @@ jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); -jest.mock('../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => 'polygon-network-badge'), -})); - const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; @@ -85,7 +81,7 @@ describe('usePredictBalanceTokenFilter', () => { mockPredictBalance = 100; mockTransactionMeta = null; mockHasTransactionType.mockReturnValue(false); - mockUseSelector.mockReturnValue({ image: 'usdce-token-image' }); + mockUseSelector.mockReturnValue({ image: 'pusd-token-image' }); mockNavigate.mockReset(); }); @@ -165,7 +161,7 @@ describe('usePredictBalanceTokenFilter', () => { expect((filteredTokens[0] as HighlightedItem).fiat).toBe('$42.50'); }); - it('shows name_description as USDC.e on the Predict balance row', () => { + it('shows name_description as pUSD on the Predict balance row', () => { mockHasTransactionType.mockReturnValue(true); const tokens = [createMockToken()]; @@ -173,11 +169,11 @@ describe('usePredictBalanceTokenFilter', () => { const filteredTokens = result.current(tokens); expect((filteredTokens[0] as HighlightedItem).name_description).toBe( - 'USDC.e', + 'pUSD', ); }); - it('uses empty string for icon when usdceToken is null', () => { + it('uses empty string for icon when pusdToken is null', () => { mockHasTransactionType.mockReturnValue(true); mockUseSelector.mockReturnValue(null); const tokens = [createMockToken()]; diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index 4cd3d82b0f99..5ea39972534a 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -1,12 +1,14 @@ +import { TransactionType } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; import { useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; import { RootState } from '../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; -import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; -import { TransactionType } from '@metamask/transaction-controller'; +import { POLYGON_PUSD } from '../../../Views/confirmations/constants/predict'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType, @@ -17,8 +19,6 @@ import { hasTransactionType } from '../../../Views/confirmations/utils/transacti import { PREDICT_BALANCE_CHAIN_ID } from '../constants/transactions'; import { usePredictBalance } from './usePredictBalance'; import { usePredictPaymentToken } from './usePredictPaymentToken'; -import { strings } from '../../../../../locales/i18n'; -import Routes from '../../../../constants/navigation/Routes'; export function usePredictBalanceTokenFilter( forceEnabled = false, @@ -29,10 +29,10 @@ export function usePredictBalanceTokenFilter( const { isPredictBalanceSelected } = usePredictPaymentToken(); const { data: predictBalance = 0 } = usePredictBalance(); const formatFiat = useFiatFormatter({ currency: 'usd' }); - const usdceToken = useSelector((state: RootState) => + const pusdToken = useSelector((state: RootState) => selectSingleTokenByAddressAndChainId( state, - POLYGON_USDCE.address, + POLYGON_PUSD.address, PREDICT_BALANCE_CHAIN_ID, ), ); @@ -60,9 +60,9 @@ export function usePredictBalanceTokenFilter( const predictBalanceHighlightedItem: HighlightedItem = { position: 'in_asset_list', - icon: usdceToken?.image ?? '', + icon: pusdToken?.image ?? '', name: strings('predict.payment.predict_balance'), - name_description: POLYGON_USDCE.symbol, + name_description: POLYGON_PUSD.symbol, fiat: balanceFormatted, isSelected: isPredictBalanceSelected, action: onSelect ?? (() => undefined), @@ -90,7 +90,7 @@ export function usePredictBalanceTokenFilter( isPredictBalanceSelected, predictBalance, formatFiat, - usdceToken, + pusdToken, handleAddFunds, onSelect, ], diff --git a/app/components/UI/Predict/hooks/usePredictRewards.test.ts b/app/components/UI/Predict/hooks/usePredictRewards.test.ts index 662543c810b0..08cda0c29563 100644 --- a/app/components/UI/Predict/hooks/usePredictRewards.test.ts +++ b/app/components/UI/Predict/hooks/usePredictRewards.test.ts @@ -7,7 +7,7 @@ import Logger from '../../../../util/Logger'; import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; import { POLYGON_MAINNET_CAIP_CHAIN_ID, - POLYGON_USDC_CAIP_ASSET_ID, + POLYGON_PUSD_CAIP_ASSET_ID, } from '../providers/polymarket/constants'; jest.mock('react-redux', () => ({ @@ -47,8 +47,8 @@ jest.mock('../constants/errors', () => ({ jest.mock('../providers/polymarket/constants', () => ({ POLYGON_MAINNET_CAIP_CHAIN_ID: 'eip155:137', - POLYGON_USDC_CAIP_ASSET_ID: - 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + POLYGON_PUSD_CAIP_ASSET_ID: + 'eip155:137/erc20:0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', COLLATERAL_TOKEN_DECIMALS: 6, })); @@ -185,7 +185,7 @@ describe('usePredictRewards', () => { activityContext: { predictContext: { feeAsset: { - id: POLYGON_USDC_CAIP_ASSET_ID, + id: POLYGON_PUSD_CAIP_ASSET_ID, amount: expect.any(String), }, }, diff --git a/app/components/UI/Predict/hooks/usePredictRewards.ts b/app/components/UI/Predict/hooks/usePredictRewards.ts index e1d2d908957c..ae464624e59e 100644 --- a/app/components/UI/Predict/hooks/usePredictRewards.ts +++ b/app/components/UI/Predict/hooks/usePredictRewards.ts @@ -17,7 +17,7 @@ import { selectSelectedInternalAccountByScope } from '../../../../selectors/mult import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils'; import { POLYGON_MAINNET_CAIP_CHAIN_ID, - POLYGON_USDC_CAIP_ASSET_ID, + POLYGON_PUSD_CAIP_ASSET_ID, COLLATERAL_TOKEN_DECIMALS, } from '../providers/polymarket/constants'; import { parseUnits } from 'ethers/lib/utils'; @@ -186,9 +186,9 @@ export const usePredictRewards = ( } // Prepare fee asset - // Convert USD amount to atomic units (6 decimals for USDC) + // Convert USD amount to atomic units (6 decimals for pUSD) const feeAsset: EstimateAssetDto = { - id: POLYGON_USDC_CAIP_ASSET_ID, + id: POLYGON_PUSD_CAIP_ASSET_ID, amount: parseUnits( totalFeeAmountUsd.toString(), COLLATERAL_TOKEN_DECIMALS, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 69b7b49e0606..2e6bf89212e8 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -1,145 +1,91 @@ +import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; +import type { OrderPreview } from '../types'; +import { Side } from '../../types'; +import type { PredictFeatureFlags } from '../../types/flags'; +import { PolymarketProvider } from './PolymarketProvider'; import { DEFAULT_CLOB_BASE_URL, - LEGACY_V2_CLOB_BASE_URL, + MATIC_CONTRACTS_V2, POLYMARKET_PROVIDER_ID, USDC_E_ADDRESS, } from './constants'; -// Mock external dependencies -jest.mock('../../../../../core/Engine', () => ({ - context: { - NetworkController: { - findNetworkClientIdByChainId: jest.fn(), - getNetworkClientById: jest.fn(), - }, - KeyringController: { - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }, -})); - -jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => { - const mockLogger = { - log: jest.fn(), - }; - return { - __esModule: true, - DevLogger: mockLogger, - default: mockLogger, - }; -}); - -import { query } from '@metamask/controller-utils'; -import Engine from '../../../../../core/Engine'; -import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; -import { - generateTransferData, - isSmartContractAddress, -} from '../../../../../util/transactions'; -import { - PredictPosition, - PredictPositionStatus, - PredictPriceHistoryInterval, - Recurrence, - Side, -} from '../../types'; -import { PREDICT_ERROR_CODES } from '../../constants/errors'; -import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; -import type { PredictFeatureFlags } from '../../types/flags'; -import { submitProtocolClobOrder } from './protocol/transport'; -import { - extractNeededTeamsFromEvents, - getEventLeague, - isLiveSportsEvent, -} from '../../utils/gameParser'; -import { OrderPreview, PlaceOrderParams } from '../types'; -import { PolymarketProvider } from './PolymarketProvider'; import { computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getClaimTransaction, getDeployProxyWalletTransaction, - getProxyWalletAllowancesTransaction, - getWithdrawTransactionCallData, - hasAllowances, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './safe/utils'; -import { PERMIT2_ADDRESS } from './safe/constants'; import { createApiKey, - encodeClaim, - fetchChildEventsFromGammaApi, + encodeErc20Transfer, getBalance, - getRawBalance, - getContractConfig, - getFeeRateBps, - fetchEventsFromPolymarketApi, - fetchCarouselFromPolymarketApi, getL2Headers, - getMarketDetailsFromGammaApi, - getOrderTypedData, - getPolymarketEndpoints, - mergeChildEventsIntoParent, - parsePolymarketEvents, + getRawBalance, parsePolymarketPositions, previewOrder, - priceValid, - submitClobOrder, } from './utils'; +import { submitProtocolClobOrder } from './protocol/transport'; +import { buildDepositMaintenanceTransaction } from './preflight/deposit'; +import { buildTradeAllowancesTx } from './preflight/trade'; +import { buildWithdrawTransaction } from './preflight/withdraw'; +import { + generateTransferData, + isSmartContractAddress, +} from '../../../../../util/transactions'; -jest.mock('@metamask/controller-utils', () => { - const actual = jest.requireActual('@metamask/controller-utils'); - return { - ...actual, - query: jest.fn(), - }; -}); +jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { log: jest.fn() }, +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn(), log: jest.fn() }, +})); + +jest.mock('../../../../../util/analytics/analytics', () => ({ + analytics: { identify: jest.fn() }, +})); + +jest.mock('../../../../../util/transactions', () => ({ + generateTransferData: jest.fn(), + isSmartContractAddress: jest.fn(), +})); + +jest.mock('./safe/utils', () => ({ + computeProxyAddress: jest.fn(), + createPermit2FeeAuthorization: jest.fn(), + getDeployProxyWalletTransaction: jest.fn(), + getSafeTransferAmount: jest.fn(), + getSafeTransferAmountRaw: jest.fn(), +})); jest.mock('./utils', () => { const actual = jest.requireActual('./utils'); + return { ...actual, + createApiKey: jest.fn(), + encodeErc20Transfer: jest.fn(), + fetchCarouselFromPolymarketApi: jest.fn(), + fetchEventsFromPolymarketApi: jest.fn(), + getBalance: jest.fn(), + getL2Headers: jest.fn(), + getRawBalance: jest.fn(), + getMarketDetailsFromGammaApi: jest.fn(), getPolymarketEndpoints: jest.fn(() => ({ DATA_API_ENDPOINT: 'https://data-api.polymarket.com', GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', CLOB_ENDPOINT: 'https://clob.polymarket.com', + CLOB_RELAYER: 'https://predict.api.cx.metamask.io', GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', - CRYPTO_PRICE_ENDPOINT: 'https://polymarket.com/api/crypto/crypto-price', })), - getParsedMarketsFromPolymarketApi: jest.fn(), - fetchCarouselFromPolymarketApi: jest.fn(), - fetchEventsFromPolymarketApi: jest.fn().mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }), - getMarketsFromPolymarketApi: jest.fn(), - getMarketDetailsFromGammaApi: jest.fn(), - getTickSize: jest.fn(), - calculateMarketPrice: jest.fn(), - buildMarketOrderCreationArgs: jest.fn(), - encodeApprove: jest.fn(), - encodeClaim: jest.fn(), - encodeErc1155Approve: jest.fn(), - getAllowance: jest.fn().mockResolvedValue(1n), - getIsApprovedForAll: jest.fn().mockResolvedValue(true), - getContractConfig: jest.fn(), - getL2Headers: jest.fn(), - getFeeRateBps: jest.fn(), - getOrderBook: jest.fn(), - getOrderTypedData: jest.fn(), + parsePolymarketActivity: jest.fn(), parsePolymarketEvents: jest.fn(), parsePolymarketPositions: jest.fn(), - priceValid: jest.fn(), - createApiKey: jest.fn(), - submitClobOrder: jest.fn(), - getMarketPositions: jest.fn(), - fetchChildEventsFromGammaApi: jest.fn(), - mergeChildEventsIntoParent: jest.fn(), - getBalance: jest.fn(), - getRawBalance: jest.fn(), previewOrder: jest.fn(), - POLYGON_MAINNET_CHAIN_ID: 137, }; }); @@ -147,151 +93,85 @@ jest.mock('./protocol/transport', () => ({ submitProtocolClobOrder: jest.fn(), })); -jest.mock('./safe/utils', () => ({ - computeProxyAddress: jest.fn(), - createPermit2FeeAuthorization: jest.fn(), - createSafeFeeAuthorization: jest.fn(), - getClaimTransaction: jest.fn(), - getDeployProxyWalletTransaction: jest.fn(), - getProxyWalletAllowancesTransaction: jest.fn(), - hasAllowances: jest.fn(), - aggregateTransaction: jest.fn((txs) => txs[0]), - getSafeTransactionCallData: jest.fn().mockResolvedValue('0xsignedsafeexec'), - getWithdrawTransactionCallData: jest - .fn() - .mockResolvedValue('0xsignedcalldata'), - getSafeUsdcAmount: jest.fn().mockReturnValue(1), - getSafeUsdcAmountRaw: jest.fn().mockReturnValue(1000000n), +jest.mock('./preflight/deposit', () => ({ + buildDepositMaintenanceTransaction: jest.fn(), })); -const mockGameCacheInstance = { - overlayOnMarket: jest.fn((market) => market), - overlayOnMarkets: jest.fn((markets) => markets), - updateGame: jest.fn(), - getGame: jest.fn(), - pruneStaleEntries: jest.fn(), - cleanup: jest.fn(), - clear: jest.fn(), - getCacheSize: jest.fn(), - getCachedGameIds: jest.fn(), -}; - -jest.mock('./GameCache', () => ({ - GameCache: { - getInstance: jest.fn(() => mockGameCacheInstance), - resetInstance: jest.fn(), - }, +jest.mock('./preflight/trade', () => ({ + buildTradeAllowancesTx: jest.fn(), })); -jest.mock('../../constants/sports', () => ({ - SUPPORTED_SPORTS_LEAGUES: ['nfl'], - filterSupportedLeagues: (leagues: string[]) => - leagues.filter((l) => ['nfl'].includes(l)), +jest.mock('./preflight/withdraw', () => ({ + buildWithdrawTransaction: jest.fn(), })); -const mockTeamsCacheInstance = { - ensureLeagueLoaded: jest.fn().mockResolvedValue(undefined), - ensureLeaguesLoaded: jest.fn().mockResolvedValue(undefined), - ensureTeamsLoaded: jest.fn().mockResolvedValue(undefined), - getTeam: jest.fn(), - getNflTeam: jest.fn(), - isLeagueLoaded: jest.fn().mockReturnValue(true), - clear: jest.fn(), - getTeamCount: jest.fn().mockReturnValue(0), -}; +const mockComputeProxyAddress = jest.mocked(computeProxyAddress); +const mockCreateApiKey = jest.mocked(createApiKey); +const mockCreatePermit2FeeAuthorization = jest.mocked( + createPermit2FeeAuthorization, +); +const mockEncodeErc20Transfer = jest.mocked(encodeErc20Transfer); +const mockGenerateTransferData = jest.mocked(generateTransferData); +const mockGetBalance = jest.mocked(getBalance); +const mockGetDeployProxyWalletTransaction = jest.mocked( + getDeployProxyWalletTransaction, +); +const mockGetL2Headers = jest.mocked(getL2Headers); +const mockGetRawBalance = jest.mocked(getRawBalance); +const mockGetSafeTransferAmount = jest.mocked(getSafeTransferAmount); +const mockGetSafeTransferAmountRaw = jest.mocked(getSafeTransferAmountRaw); +const mockIsSmartContractAddress = jest.mocked(isSmartContractAddress); +const mockParsePolymarketPositions = jest.mocked(parsePolymarketPositions); +const mockPreviewOrder = jest.mocked(previewOrder); +const mockSubmitProtocolClobOrder = jest.mocked(submitProtocolClobOrder); +const mockBuildDepositMaintenanceTransaction = jest.mocked( + buildDepositMaintenanceTransaction, +); +const mockBuildTradeAllowancesTx = jest.mocked(buildTradeAllowancesTx); +const mockBuildWithdrawTransaction = jest.mocked(buildWithdrawTransaction); -jest.mock('./TeamsCache', () => ({ - TeamsCache: { - getInstance: jest.fn(() => mockTeamsCacheInstance), - resetInstance: jest.fn(), - }, -})); +const signer = { + address: '0x1111111111111111111111111111111111111111', + signPersonalMessage: jest.fn(), + signTypedMessage: jest.fn(), +}; -const mockWebSocketManagerInstance = { - subscribeToGame: jest.fn(), - subscribeToMarketPrices: jest.fn(), - subscribeToCryptoPrices: jest.fn(), - getConnectionStatus: jest.fn(), - disconnect: jest.fn(), - cleanup: jest.fn(), +const basePreview: OrderPreview = { + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcomeTokenId: '123', + timestamp: 1, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 10, + minAmountReceived: 19, + slippage: 0.03, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + feeRateBps: '99', }; -jest.mock('./WebSocketManager', () => ({ - WebSocketManager: { - getInstance: jest.fn(() => mockWebSocketManagerInstance), - resetInstance: jest.fn(), +const defaultFeatureFlags: PredictFeatureFlags = { + feeCollection: DEFAULT_FEE_COLLECTION_FLAG, + liveSportsLeagues: [], + extendedSportsMarketsLeagues: [], + marketHighlightsFlag: { + enabled: false, + highlights: [], + minimumVersion: '7.64.0', }, -})); - -jest.mock('../../utils/gameParser', () => ({ - ...jest.requireActual('../../utils/gameParser'), - extractNeededTeamsFromEvents: jest.fn(() => new Map()), - getEventLeague: jest.fn(() => null), - isLiveSportsEvent: jest.fn(), - parseGameSlugTeams: jest.fn(() => null), -})); - -jest.mock('../../../../../util/transactions', () => ({ - generateTransferData: jest.fn(), - isSmartContractAddress: jest.fn(), -})); - -const mockFindNetworkClientIdByChainId = Engine.context.NetworkController - .findNetworkClientIdByChainId as jest.Mock; -const mockGetNetworkClientById = Engine.context.NetworkController - .getNetworkClientById as jest.Mock; -const mockSignTypedMessage = Engine.context.KeyringController - .signTypedMessage as jest.Mock; -const mockSignPersonalMessage = Engine.context.KeyringController - .signPersonalMessage as jest.Mock; -const mockFetchEventsFromPolymarketApi = - fetchEventsFromPolymarketApi as jest.Mock; -const mockFetchCarouselFromPolymarketApi = - fetchCarouselFromPolymarketApi as jest.Mock; -const mockGetMarketDetailsFromGammaApi = - getMarketDetailsFromGammaApi as jest.Mock; -const mockGetContractConfig = getContractConfig as jest.Mock; -const mockGetFeeRateBps = getFeeRateBps as jest.Mock; -const mockGetL2Headers = getL2Headers as jest.Mock; -const mockGetOrderTypedData = getOrderTypedData as jest.Mock; -const mockParsePolymarketEvents = parsePolymarketEvents as jest.Mock; -const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock; -const mockPriceValid = priceValid as jest.Mock; -const mockCreateApiKey = createApiKey as jest.Mock; -const mockSubmitClobOrder = submitClobOrder as jest.Mock; -const mockEncodeClaim = encodeClaim as jest.Mock; -const mockComputeProxyAddress = computeProxyAddress as jest.Mock; -const mockCreatePermit2FeeAuthorization = - createPermit2FeeAuthorization as jest.Mock; -const mockCreateSafeFeeAuthorization = createSafeFeeAuthorization as jest.Mock; -const mockGetClaimTransaction = getClaimTransaction as jest.Mock; -const mockHasAllowances = hasAllowances as jest.Mock; -const mockQuery = query as jest.Mock; -const mockPreviewOrder = previewOrder as jest.Mock; -const mockGetBalance = getBalance as jest.Mock; -const mockGetRawBalance = getRawBalance as jest.Mock; -const mockSubmitProtocolClobOrder = submitProtocolClobOrder as jest.Mock; -const mockIsLiveSportsEvent = isLiveSportsEvent as jest.Mock; -const mockGetEventLeague = getEventLeague as jest.Mock; -const mockFetchChildEventsFromGammaApi = - fetchChildEventsFromGammaApi as jest.Mock; -const mockMergeChildEventsIntoParent = mergeChildEventsIntoParent as jest.Mock; -const mockExtractNeededTeamsFromEvents = - extractNeededTeamsFromEvents as jest.Mock; -const { getEventLeague: actualGetEventLeague } = jest.requireActual( - '../../utils/gameParser', -); + fakOrdersEnabled: false, + predictWithAnyTokenEnabled: false, + predictUpDownEnabled: false, +}; -mockIsLiveSportsEvent.mockImplementation( - ( - event: Parameters[0], - enabledLeagues: string[], - extendedSportsMarketsLeagues: string[] = [], - ) => { - const league = mockGetEventLeague(event, extendedSportsMarketsLeagues); - return league !== null && enabledLeagues.includes(league); - }, -); +function createProvider(featureFlags?: Partial) { + return new PolymarketProvider({ + getFeatureFlags: () => ({ ...defaultFeatureFlags, ...featureFlags }), + }); +} describe('PolymarketProvider', () => { const originalBuilderCode = process.env.MM_PREDICT_BUILDER_CODE; @@ -310,8754 +190,252 @@ describe('PolymarketProvider', () => { process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; }); - const defaultFeatureFlags: PredictFeatureFlags = { - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - liveSportsLeagues: [], - extendedSportsMarketsLeagues: [], - marketHighlightsFlag: { - enabled: false, - highlights: [], - minimumVersion: '7.64.0', - }, - fakOrdersEnabled: false, - predictWithAnyTokenEnabled: false, - predictUpDownEnabled: false, - predictClobV2Enabled: false, - }; - const createProvider = ( - featureFlagsOverride?: Partial, - ) => - new PolymarketProvider({ - getFeatureFlags: () => ({ - ...defaultFeatureFlags, - ...featureFlagsOverride, - }), + beforeEach(() => { + jest.clearAllMocks(); + mockComputeProxyAddress.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + mockCreateApiKey.mockResolvedValue({ + apiKey: 'api-key', + secret: 'secret', + passphrase: 'passphrase', + }); + mockGetL2Headers.mockResolvedValue({ + POLY_ADDRESS: signer.address, + POLY_SIGNATURE: 'sig', + POLY_TIMESTAMP: '1', + POLY_API_KEY: 'api-key', + POLY_PASSPHRASE: 'passphrase', }); - - it('exposes the correct providerId', () => { - const provider = createProvider(); - expect(provider.providerId).toBe(POLYMARKET_PROVIDER_ID); - }); - - it('getMarkets returns an array with some length', async () => { - const mockEvents = [ - { - id: 'market-1', - title: 'Test Market 1', - description: 'A test market', - icon: 'https://example.com/icon1.png', - closed: false, - series: 'Test Series', - tags: [{ slug: 'trending' }, { slug: 'crypto' }], - markets: [ - { - conditionId: 'cond-1', - question: 'Will Bitcoin reach $100k?', - description: 'Bitcoin price prediction', - icon: 'https://example.com/market1.png', - image: 'https://example.com/market1.png', - groupItemTitle: 'Bitcoin', - closed: false, - volume: '1000000', - clobTokenIds: '["0","1"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.6","0.4"]', - }, - ], - }, - { - id: 'market-2', - title: 'Test Market 2', - description: 'Another test market', - icon: 'https://example.com/icon2.png', - closed: false, - series: 'Test Series 2', - tags: [{ slug: 'sports' }], - markets: [ - { - conditionId: 'cond-2', - question: 'Will the Lakers win?', - description: 'NBA prediction', - icon: 'https://example.com/market2.png', - image: 'https://example.com/market2.png', - groupItemTitle: 'Lakers', - closed: false, - volume: '500000', - clobTokenIds: '["0","1"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.7","0.3"]', - }, - ], - }, - ]; - - const parsedMarkets = [ - { - id: 'market-1', - title: 'Test Market 1', + mockParsePolymarketPositions.mockResolvedValue([]); + mockSubmitProtocolClobOrder.mockResolvedValue({ + success: true, + response: { + success: true, + orderID: 'order-1', + makingAmount: '10', + takingAmount: '19', }, - { - id: 'market-2', - title: 'Test Market 2', + }); + mockPreviewOrder.mockResolvedValue(basePreview); + mockBuildTradeAllowancesTx.mockResolvedValue({ + to: '0x9999999999999999999999999999999999999999', + data: '0xallowances', + }); + mockGenerateTransferData.mockReturnValue('0xtransferData'); + mockIsSmartContractAddress.mockResolvedValue(true); + mockGetDeployProxyWalletTransaction.mockResolvedValue({ + params: { to: '0xFactory', data: '0xdeploy' }, + type: TransactionType.contractInteraction, + }); + mockBuildDepositMaintenanceTransaction.mockResolvedValue(undefined); + mockEncodeErc20Transfer.mockReturnValue('0xtransfer'); + mockGetRawBalance.mockResolvedValue(0n); + mockGetSafeTransferAmount.mockReturnValue(1); + mockGetSafeTransferAmountRaw.mockReturnValue(1_000_000n); + mockBuildWithdrawTransaction.mockResolvedValue({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xsignedWithdraw', }, - ]; - - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, + type: TransactionType.predictWithdraw, }); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - const markets = await createProvider({ - liveSportsLeagues: ['nfl'], - }).getMarkets(); - expect(Array.isArray(markets)).toBe(true); - expect(markets.length).toBeGreaterThan(0); - expect(markets.length).toBe(2); - expect(mockFetchEventsFromPolymarketApi).toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ - category: 'trending', - sortMarketsBy: 'price', - teamLookup: expect.any(Function), - }), - ); - }); - - it('getMarkets returns empty array when API fails', async () => { - const apiError = new Error('API request failed'); - mockFetchEventsFromPolymarketApi.mockRejectedValue(apiError); - - const result = await createProvider({ - liveSportsLeagues: ['nfl'], - }).getMarkets(); - - expect(result).toEqual([]); - expect(mockFetchEventsFromPolymarketApi).toHaveBeenCalled(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + signer.signTypedMessage.mockResolvedValue('0xsigned-order'); }); - it('getMarkets returns empty array when non-Error exception is thrown', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockRejectedValue('String error'); - - const result = await provider.getMarkets(); - - expect(result).toEqual([]); + it('exposes the Polymarket provider id', () => { + expect(createProvider().providerId).toBe(POLYMARKET_PROVIDER_ID); }); - it('getPositions returns an empty array when API returns none', async () => { + it('previews orders through canonical CLOB v2 with zero fee-rate bps', async () => { const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - mockParsePolymarketPositions.mockResolvedValue([]); - const result = await provider.getPositions({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(result).toEqual([]); - expect(mockParsePolymarketPositions).toHaveBeenCalledWith({ - positions: [], + const preview = await provider.previewOrder({ + ...basePreview, + size: 10, + signer, }); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(preview.feeRateBps).toBe('0'); + expect(mockPreviewOrder).toHaveBeenCalledWith( + expect.objectContaining({ feeCollection: DEFAULT_FEE_COLLECTION_FLAG }), + ); }); - it('getPositions maps providerId to polymarket on each returned position', async () => { + it('submits orders through the protocol CLOB v2 relayer path', async () => { const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - // Mock API response with PolymarketPosition format - const mockApiResponse = [ - { - providerId: 'external', - conditionId: 'c-1', - icon: 'https://example.com/icon.png', - title: 'Some Market', - slug: 'some-market', - size: 2, - outcome: 'Yes', - outcomeIndex: 0, - cashPnl: 1.23, - curPrice: 0.45, - currentValue: 0.9, - percentPnl: 10, - initialValue: 0.82, - avgPrice: 0.41, - redeemable: false, - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - }, - ]; - - // Mock the parsed result - const mockParsedPositions = [ - { - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'c-1', - outcomeTokenId: 0, - title: 'Some Market', - icon: 'https://example.com/icon.png', - size: 2, - outcome: 'Yes', - cashPnl: 1.23, - curPrice: 0.45, - currentValue: 0.9, - percentPnl: 10, - initialValue: 0.82, - avgPrice: 0.41, - redeemable: false, - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - }, - ]; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockApiResponse), - }); + const result = await provider.placeOrder({ signer, preview: basePreview }); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', + expect(result.success).toBe(true); + expect(mockCreateApiKey).toHaveBeenCalledWith({ address: signer.address }); + expect(signer.signTypedMessage).toHaveBeenCalledWith( + expect.any(Object), + SignTypedDataVersion.V4, + ); + expect(mockSubmitProtocolClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + protocol: expect.objectContaining({ + key: 'v2', + transport: expect.objectContaining({ + clobBaseUrl: DEFAULT_CLOB_BASE_URL, + clobVersionHeader: '2', + }), + }), + allowancesTx: { + to: '0x9999999999999999999999999999999999999999', + data: '0xallowances', + }, + }), ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - mockParsePolymarketPositions.mockResolvedValue(mockParsedPositions); - - const result = await provider.getPositions({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(result).toHaveLength(1); - expect(result[0].providerId).toBe(POLYMARKET_PROVIDER_ID); - expect(result[0].marketId).toBe('c-1'); - expect(result[0].outcomeTokenId).toBe(0); - expect(mockParsePolymarketPositions).toHaveBeenCalledWith({ - positions: mockApiResponse, - }); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions uses default pagination and correct query params', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), + it('uses pUSD Permit2 fee authorization when fees are present', async () => { + mockCreatePermit2FeeAuthorization.mockResolvedValue({ + type: 'safe-permit2', + authorization: { + permit: { + permitted: { token: MATIC_CONTRACTS_V2.collateral, amount: '100000' }, + nonce: '1', + deadline: '2', + }, + spender: '0x2222222222222222222222222222222222222222', + signature: '0xsig', + }, }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + const provider = createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0x2222222222222222222222222222222222222222'], + }, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - const userAddress = '0x1111111111111111111111111111111111111111'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress }); + await provider.placeOrder({ + signer, + preview: { + ...basePreview, + fees: { + metamaskFee: 0.05, + providerFee: 0.05, + totalFee: 0.1, + totalFeePercentage: 1, + collector: '0x3333333333333333333333333333333333333333', + executors: ['0x2222222222222222222222222222222222222222'], + permit2Enabled: true, + }, + }, + }); - expect(mockFetch).toHaveBeenCalledTimes(1); - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - const { DATA_API_ENDPOINT } = getPolymarketEndpoints(); - expect(calledWithUrl.startsWith(`${DATA_API_ENDPOINT}/positions?`)).toBe( - true, + expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: '0x9999999999999999999999999999999999999999', + tokenAddress: MATIC_CONTRACTS_V2.collateral, + }), ); - expect(calledWithUrl).toContain('limit=100'); - expect(calledWithUrl).toContain('offset=0'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - expect(calledWithUrl).toContain('sortBy=CURRENT'); - expect(calledWithUrl).not.toContain('redeemable'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions applies offset and uses provided limit in the request', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + it('prepares pUSD deposits and optional legacy sweep maintenance', async () => { + mockIsSmartContractAddress.mockResolvedValue(false); + mockBuildDepositMaintenanceTransaction.mockResolvedValue({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xmaintenance', + }, + type: TransactionType.contractInteraction, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const userAddress = '0x2222222222222222222222222222222222222222'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress, limit: 5, offset: 15 }); - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('limit=5'); - expect(calledWithUrl).toContain('offset=15'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - expect(calledWithUrl).toContain('sortBy=CURRENT'); - expect(calledWithUrl).not.toContain('redeemable'); + const result = await createProvider().prepareDeposit({ signer }); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(result.chainId).toBe(CHAIN_IDS.POLYGON); + expect(result.transactions).toEqual([ + expect.objectContaining({ + params: { to: '0xFactory', data: '0xdeploy' }, + }), + { + params: { + to: MATIC_CONTRACTS_V2.collateral, + data: '0xtransferData', + }, + type: TransactionType.predictDeposit, + }, + expect.objectContaining({ + params: { + to: '0x9999999999999999999999999999999999999999', + data: '0xmaintenance', + }, + }), + ]); }); - it('getPositions rejects when the network request fails', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockRejectedValue(new Error('network failure')); + it('reads displayed Predict balance from pUSD plus legacy USDC.e', async () => { + mockGetBalance.mockResolvedValue(12.5); + mockGetRawBalance.mockResolvedValue(2_500_000n); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, + const balance = await createProvider().getBalance({ + address: signer.address, }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - await expect( - provider.getPositions({ - address: '0x3333333333333333333333333333333333333333', - }), - ).rejects.toThrow('network failure'); - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; + expect(balance).toBe(15); + expect(mockGetBalance).toHaveBeenCalledTimes(1); + expect(mockGetBalance).toHaveBeenCalledWith({ + address: '0x9999999999999999999999999999999999999999', + tokenAddress: MATIC_CONTRACTS_V2.collateral, + }); + expect(mockGetRawBalance).toHaveBeenCalledWith({ + address: '0x9999999999999999999999999999999999999999', + tokenAddress: USDC_E_ADDRESS, + }); }); - it('throws error when address is missing in getPositions', async () => { + it('caches zero legacy USDC.e balances in memory', async () => { + mockGetBalance.mockResolvedValue(12.5); + mockGetRawBalance.mockResolvedValue(0n); const provider = createProvider(); - await expect(provider.getPositions({ address: '' })).rejects.toThrow( - 'Address is required', - ); + await provider.getBalance({ address: signer.address }); + await provider.getBalance({ address: signer.address }); + + expect(mockGetBalance).toHaveBeenCalledTimes(2); + expect(mockGetRawBalance).toHaveBeenCalledTimes(1); }); - it('throws error when API response is not ok in getPositions', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: false, - status: 500, - }); + it('prepares editable pUSD withdraw transfers', async () => { + const result = await createProvider().prepareWithdraw({ signer }); - mockComputeProxyAddress.mockReturnValue( + expect(result.predictAddress).toBe( '0x9999999999999999999999999999999999999999', ); - - await expect( - provider.getPositions({ - address: '0x1234567890123456789012345678901234567890', + expect(result.transaction).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + to: MATIC_CONTRACTS_V2.collateral, + data: '0xtransfer', + }), + type: TransactionType.predictWithdraw, }), - ).rejects.toThrow('Failed to get positions'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - it('getPositions uses claimable parameter correctly', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const userAddress = '0x4444444444444444444444444444444444444444'; - const safeAddress = '0x9999999999999999999999999999999999999999'; - await provider.getPositions({ address: userAddress, claimable: true }); - - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('redeemable=true'); - expect(calledWithUrl).toContain(`user=${safeAddress}`); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; }); - it('getPositions includes marketId in query when provided', async () => { - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), + it('signs pUSD Safe withdraw executions', async () => { + const result = await createProvider().signWithdraw?.({ + signer, + callData: '0xtransfer', }); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', + expect(mockBuildWithdrawTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + signer, + safeAddress: '0x9999999999999999999999999999999999999999', + requestedAmountRaw: 1_000_000n, + protocol: expect.objectContaining({ key: 'v2' }), + }), ); - - const userAddress = '0x5555555555555555555555555555555555555555'; - await provider.getPositions({ - address: userAddress, - marketId: 'market-123', - }); - - const calledWithUrl = mockFetch.mock.calls[0][0] as string; - expect(calledWithUrl).toContain('eventId=market-123'); - - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - it('getPositions filters out claimable positions when claimable parameter is false', async () => { - // Arrange - const provider = createProvider(); - const originalFetch = globalThis.fetch as typeof fetch | undefined; - - mockFindNetworkClientIdByChainId.mockReturnValue('polygon-network-client'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockQuery.mockResolvedValue( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // Mock balance - - const mockApiResponse = [ - { - id: 'pos-1', - market: 'c-1', - outcome: 0, - size: 1, - price: 0.5, - outcomeIndex: 0, - cashPnl: 0, - curPrice: 0.5, - currentValue: 0.5, - percentPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - redeemable: true, // This should be filtered out - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-1', - conditionId: 'c-1', - icon: 'https://example.com/icon.png', - title: 'Some Market', - slug: 'some-market', - }, - { - id: 'pos-2', - market: 'c-2', - outcome: 0, - size: 2, - price: 0.6, - outcomeIndex: 0, - cashPnl: 0, - curPrice: 0.6, - currentValue: 1.2, - percentPnl: 0, - initialValue: 1.0, - avgPrice: 0.5, - redeemable: false, // This should be kept - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-2', - conditionId: 'c-2', - icon: 'https://example.com/icon2.png', - title: 'Another Market', - slug: 'another-market', - }, - ]; - - // Mock the parsed result with only non-claimable positions (API should filter when claimable=false) - const mockParsedPositions = [ - { - id: 'pos-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'c-2', - outcomeTokenId: 0, - title: 'Another Market', - icon: 'https://example.com/icon2.png', - size: 2, - outcome: 'Yes', - cashPnl: 0, - curPrice: 0.6, - currentValue: 1.2, - percentPnl: 0, - initialValue: 1.0, - avgPrice: 0.5, - claimable: false, // This should be kept - negativeRisk: false, - endDate: '2025-01-01T00:00:00Z', - asset: 'asset-2', - outcomeIndex: 0, - outcomeId: 'c-2', - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - amount: 2, - price: 0.6, - }, - ]; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockApiResponse), - }); - - mockParsePolymarketPositions.mockResolvedValue(mockParsedPositions); - - // Act - const result = await provider.getPositions({ - address: '0x123', - claimable: false, // This should filter out claimable positions - }); - - // Assert - expect(result).toHaveLength(1); - expect(result[0].id).toBe('pos-2'); // Only the non-claimable position should remain - expect(result[0].claimable).toBe(false); - - // Restore fetch - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - // Helper function to create a mock PredictPosition - function createMockPosition( - overrides?: Partial, - ): PredictPosition { - return { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: 'token-1', - currentValue: 100, - title: 'Test Market', - icon: 'https://example.com/icon.png', - amount: 10, - price: 0.5, - status: PredictPositionStatus.OPEN, - size: 10, - outcomeIndex: 0, - percentPnl: 0, - cashPnl: 0, - claimable: false, - initialValue: 100, - avgPrice: 0.5, - endDate: '2025-12-31T23:59:59Z', - ...overrides, - }; - } - - // Helper function to create a mock OrderPreview - function createMockOrderPreview( - overrides?: Partial, - ): OrderPreview { - return { - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeTokenId: '0', - timestamp: Date.now(), - side: Side.BUY, - sharePrice: 0.5, - maxAmountSpent: 1, - minAmountReceived: 2, - slippage: 0.005, - tickSize: 0.01, - minOrderSize: 0.01, - negRisk: false, - feeRateBps: '0', - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - ...overrides, - }; - } - - // Helper function to setup place order test environment - function setupPlaceOrderTest( - featureFlagsOverride?: Partial, - ) { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const provider = createProvider(featureFlagsOverride); - - const mockMarket = { - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - slug: 'test-market', - title: 'Test Market', - description: 'A test market for prediction', - image: 'test-image.png', - status: 'open' as const, - recurrence: Recurrence.NONE, - categories: [], - outcomes: [], - }; - - // Setup default mocks - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - mockCreatePermit2FeeAuthorization.mockResolvedValue({ - type: 'safe-permit2', - authorization: { - permit: { - permitted: { - token: '0xCollateralAddress', - amount: '40000', - }, - nonce: '0', - deadline: '1700000000', - }, - spender: '0x1111111111111111111111111111111111111111', - signature: '0xpermit2sig', - }, - }); - - mockPriceValid.mockReturnValue(true); - - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - orderID: 'order-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - mockSubmitProtocolClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - orderID: 'order-v2-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - mockGetRawBalance.mockResolvedValue(0n); - - mockGetFeeRateBps.mockResolvedValue('0'); - - return { - provider, - mockAddress, - mockSigner, - mockMarket, - }; - } - - // Helper function to create optimistic position for testing - function createOptimisticPosition( - overrides?: Partial, - ): PredictPosition { - return { - ...createMockPosition(overrides), - optimistic: true, - ...overrides, - }; - } - - // Helper function to setup optimistic update test environment - function setupOptimisticUpdateTest() { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - const provider = createProvider(); - - // Setup common mocks - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - mockQuery.mockResolvedValue('0x1'); - - const mockFetch = jest.fn(); - (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch; - - return { - provider, - mockAddress, - mockSigner, - mockFetch, - }; - } - - // Helper function to mock getMarketDetails response - function mockMarketDetailsForOptimistic(params: { - marketId: string; - outcomes: { - id: string; - title: string; - tokenId: string; - price: number; - }[]; - }) { - mockGetMarketDetailsFromGammaApi.mockResolvedValue({ - id: params.marketId, - question: 'Test Market', - markets: [], - }); - - mockParsePolymarketEvents.mockReturnValue([ - { - id: params.marketId, - providerId: POLYMARKET_PROVIDER_ID, - slug: 'test-market', - title: 'Test Market', - description: 'A test market', - image: 'https://example.com/market.png', - status: 'open', - recurrence: Recurrence.NONE, - categories: [], - outcomes: params.outcomes.map((outcome) => ({ - id: outcome.id, - providerId: POLYMARKET_PROVIDER_ID, - marketId: params.marketId, - title: outcome.title, - description: outcome.title, - image: 'https://example.com/outcome.png', - status: 'open', - tokens: [ - { - id: outcome.tokenId, - title: outcome.title, - price: outcome.price, - }, - ], - volume: 1000, - groupItemTitle: 'Test Group', - })), - liquidity: 10000, - volume: 20000, - }, - ]); - } - - describe('placeOrder', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('successfully places a buy order and returns correct result', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result).toMatchObject({ - success: true, - response: expect.any(Object), - }); - expect(result).not.toHaveProperty('error'); - }); - - it('successfully places a sell order and returns correct result', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result).toMatchObject({ - success: true, - response: expect.any(Object), - }); - expect(result).not.toHaveProperty('error'); - }); - - it('handles order submission failure', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: 'Submission failed', - }); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Submission failed'); - }); - - it('catches exceptions and returns error result instead of throwing', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSignTypedMessage.mockRejectedValue(new Error('Signature rejected')); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Signature rejected'); - }); - - it('catches non-Error exceptions and returns error result', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSignTypedMessage.mockRejectedValue('String error'); - const preview = createMockOrderPreview({ side: Side.BUY }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to place order'); - }); - - it('logs error details when exception occurs', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const mockError = new Error('Network error'); - mockSignTypedMessage.mockRejectedValue(mockError); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - expect(DevLogger.log).toHaveBeenCalledWith( - 'PolymarketProvider: Place order failed', - expect.objectContaining({ - error: 'Network error', - side: Side.SELL, - outcomeTokenId: preview.outcomeTokenId, - }), - ); - }); - - it('calls all required utility functions with correct parameters', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - expect(mockSignTypedMessage).toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalled(); - }); - - it('uses the protocol transport and zero preview fee rate when CLOB v2 is enabled', async () => { - const { provider, mockSigner } = setupPlaceOrderTest({ - predictClobV2Enabled: true, - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0x1111111111111111111111111111111111111111'], - }, - fakOrdersEnabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: '123', - fees: { - totalFee: 1, - metamaskFee: 0.5, - providerFee: 0.5, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - const submitArgs = mockSubmitProtocolClobOrder.mock.calls[0][0]; - - expect(result.success).toBe(true); - expect(submitArgs.protocol).toEqual( - expect.objectContaining({ key: 'v2' }), - ); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner.address, - clobVersion: 'v2', - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - expect(submitArgs.clobOrder).toEqual( - expect.objectContaining({ - orderType: 'FAK', - order: expect.objectContaining({ - metadata: expect.any(String), - builder: expect.any(String), - }), - }), - ); - expect(submitArgs.clobOrder.order).not.toHaveProperty('feeRateBps'); - expect(mockSubmitClobOrder).not.toHaveBeenCalled(); - }); - - it('reuses the protocol resolved in placeOrder for v1 submission', async () => { - const { mockSigner } = setupPlaceOrderTest(); - let featureFlagReadCount = 0; - const provider = new PolymarketProvider({ - getFeatureFlags: () => { - featureFlagReadCount += 1; - return { - ...defaultFeatureFlags, - predictClobV2Enabled: featureFlagReadCount > 1, - }; - }, - }); - jest.spyOn(provider, 'getPositions').mockResolvedValue([]); - const preview = createMockOrderPreview({ side: Side.BUY }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - expect(mockSubmitClobOrder).toHaveBeenCalledTimes(1); - expect(mockSubmitProtocolClobOrder).not.toHaveBeenCalled(); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner.address, - clobVersion: 'v1', - }); - }); - - it('aborts v2 order placement when trade preflight fails', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - predictClobV2Enabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: undefined, - }); - - mockGetRawBalance.mockRejectedValueOnce(new Error('balance read failed')); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to prepare v2 trade preflight'); - expect(mockSubmitProtocolClobOrder).not.toHaveBeenCalled(); - }); - - it('returns error result when maker address is not found', async () => { - // Arrange - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockComputeProxyAddress.mockReturnValue(''); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - mockQuery.mockResolvedValue('0x0'); - - // Act - const result = await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Maker address not found'); - }); - - it('returns BUY_ORDER_NOT_FULLY_FILLED error when buy order cannot be fully filled', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: `order couldn't be fully filled`, - }); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe(PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED); - }); - - it('returns SELL_ORDER_NOT_FULLY_FILLED error when sell order cannot be fully filled', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: `order couldn't be fully filled`, - }); - const preview = createMockOrderPreview({ side: Side.SELL }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - const result = await provider.placeOrder(orderParams); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe( - PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED, - ); - }); - - it('fetches account state when not cached during placeOrder', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - - it('uses negRiskExchange contract for negRisk orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - negRisk: true, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - verifyingContract: '0x0987654321098765432109876543210987654321', - }), - ); - }); - - it('uses exchange contract for non-negRisk orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - negRisk: false, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - verifyingContract: '0x1234567890123456789012345678901234567890', - }), - ); - }); - - it('uses preview feeRateBps when creating signed order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: '30', - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - order: expect.objectContaining({ - feeRateBps: '30', - }), - }), - ); - }); - - it('uses zero feeRateBps when preview feeRateBps is missing', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - feeRateBps: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockGetOrderTypedData).toHaveBeenCalledWith( - expect.objectContaining({ - order: expect.objectContaining({ - feeRateBps: '0', - }), - }), - ); - }); - }); - - describe('previewOrder', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockPreviewOrder.mockResolvedValue({}); - }); - - const createPreviewSigner = () => ({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }); - - const createPreviewOrderParams = () => ({ - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'token-789', - side: Side.BUY, - size: 100, - signer: createPreviewSigner(), - }); - - const createPermit2PreviewProvider = (fakOrdersEnabled: boolean) => - createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled, - }); - - const mockPreviewOrderWithFees = () => { - mockPreviewOrder.mockResolvedValue({ - fees: { - totalFee: 1, - metamaskFee: 0.5, - providerFee: 0.5, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - }; - - it('calls previewOrder utility function with correct parameters', async () => { - const provider = createProvider(); - const mockParams = { - ...createPreviewOrderParams(), - amount: 100, - }; - - await provider.previewOrder(mockParams); - - expect(mockPreviewOrder).toHaveBeenCalledWith({ - ...mockParams, - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - isV2: false, - }); - }); - it('returns FOK orderType by default', async () => { - const provider = createProvider(); - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe('FOK'); - }); - - it('forces preview feeRateBps to zero when CLOB v2 is enabled', async () => { - const provider = createProvider({ predictClobV2Enabled: true }); - mockPreviewOrder.mockResolvedValue({ feeRateBps: '123' }); - - const previewParams = createPreviewOrderParams(); - const result = await provider.previewOrder(previewParams); - - expect(result.feeRateBps).toBe('0'); - expect(mockPreviewOrder).toHaveBeenCalledWith({ - ...previewParams, - feeCollection: DEFAULT_FEE_COLLECTION_FLAG, - isV2: true, - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - }); - - it.each([ - { fakOrdersEnabled: true, expectedOrderType: 'FAK' }, - { fakOrdersEnabled: false, expectedOrderType: 'FOK' }, - ] as const)( - 'returns $expectedOrderType orderType when fakOrdersEnabled=$fakOrdersEnabled and permit2 config is active', - async ({ fakOrdersEnabled, expectedOrderType }) => { - mockPreviewOrderWithFees(); - const provider = createPermit2PreviewProvider(fakOrdersEnabled); - - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe(expectedOrderType); - }, - ); - - it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { - mockPreviewOrder.mockResolvedValue({}); - const provider = createPermit2PreviewProvider(true); - - const result = await provider.previewOrder(createPreviewOrderParams()); - - expect(result.orderType).toBe('FAK'); - }); - }); - - describe('API key caching', () => { - function setupApiKeyCachingTest() { - jest.clearAllMocks(); - - const mockAddress1 = '0x1111111111111111111111111111111111111111'; - const mockAddress2 = '0x2222222222222222222222222222222222222222'; - - const mockSigner1 = { - address: mockAddress1, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const mockSigner2 = { - address: mockAddress2, - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - - // Setup minimal mocks needed for placeOrder - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { success: true, orderId: 'test-order' }, - error: undefined, - }); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockComputeProxyAddress.mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: {}, - }); - - return { - provider, - mockSigner1, - mockSigner2, - mockAddress1, - mockAddress2, - }; - } - - it('caches API keys by address and reuses them', async () => { - // Arrange - const { provider, mockSigner1 } = setupApiKeyCachingTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - First call - await provider.placeOrder(orderParams); - - // Act - Second call with same address - await provider.placeOrder(orderParams); - - // Assert - createApiKey should only be called once due to caching - expect(mockCreateApiKey).toHaveBeenCalledTimes(1); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner1.address, - clobVersion: 'v1', - }); - }); - - it('creates separate API keys for different addresses', async () => { - // Arrange - const { provider, mockSigner1, mockSigner2 } = setupApiKeyCachingTest(); - - const preview1 = createMockOrderPreview({ side: Side.BUY }); - const orderParams1 = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview: preview1, - }; - - const preview2 = createMockOrderPreview({ side: Side.SELL }); - const orderParams2 = { - signer: mockSigner2, - providerId: POLYMARKET_PROVIDER_ID, - preview: preview2, - }; - - // Act - await provider.placeOrder(orderParams1); - await provider.placeOrder(orderParams2); - - // Assert - createApiKey should be called twice for different addresses - expect(mockCreateApiKey).toHaveBeenCalledTimes(2); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner1.address, - clobVersion: 'v1', - }); - expect(mockCreateApiKey).toHaveBeenCalledWith({ - address: mockSigner2.address, - clobVersion: 'v1', - }); - }); - - it('creates separate cached v2 API keys when the resolved CLOB host changes', async () => { - // Arrange - const { mockSigner1 } = setupApiKeyCachingTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - const orderParams = { - signer: mockSigner1, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - let currentFeatureFlags: PredictFeatureFlags = { - ...defaultFeatureFlags, - predictClobV2Enabled: true, - predictClobV2ClobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }; - const provider = new PolymarketProvider({ - getFeatureFlags: () => currentFeatureFlags, - }); - - // Act - First call uses temporary v2 host - await provider.placeOrder(orderParams); - - // Act - Second call uses canonical host for the same address - currentFeatureFlags = { - ...currentFeatureFlags, - predictClobV2ClobBaseUrl: DEFAULT_CLOB_BASE_URL, - }; - await provider.placeOrder(orderParams); - - // Assert - expect(mockCreateApiKey).toHaveBeenCalledTimes(2); - expect(mockCreateApiKey).toHaveBeenNthCalledWith(1, { - address: mockSigner1.address, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - expect(mockCreateApiKey).toHaveBeenNthCalledWith(2, { - address: mockSigner1.address, - clobVersion: 'v2', - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }); - }); - }); - - describe('placeOrder with Safe fee authorization', () => { - it('computes Safe address before creating order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockComputeProxyAddress).toHaveBeenCalledWith(mockSigner.address); - }); - - it('calculates 4% fee from maker amount', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - const expectedFeeAmount = BigInt(40000); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - amount: expectedFeeAmount, - }), - ); - }); - - it('creates fee authorization with correct parameters', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith({ - safeAddress: '0x9999999999999999999999999999999999999999', - signer: mockSigner, - amount: expect.any(BigInt), - to: '0x100c7b833bbd604a77890783439bbb9d65e31de7', - }); - }); - - it('includes feeAuthorization when submitting order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - feeAuthorization: { - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }, - }), - ); - }); - - it('uses collector from fees as recipient', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - const orderParams: PlaceOrderParams = { - preview, - }; - - await provider.placeOrder({ ...orderParams, signer: mockSigner }); - - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - to: '0x100c7b833bbd604a77890783439bbb9d65e31de7', - }), - ); - }); - - it('uses Permit2 fee authorization when permit2Enabled and allowance is set', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - safeAddress: '0x9999999999999999999999999999999999999999', - spender: '0x1111111111111111111111111111111111111111', - }), - ); - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - executor: '0x1111111111111111111111111111111111111111', - feeAuthorization: expect.objectContaining({ type: 'safe-permit2' }), - }), - ); - }); - - it('uses Permit2 fee authorization even when Permit2 allowance is not yet set on-chain', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - }); - - it('falls back to Safe fee authorization when permit2Enabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: false, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); - }); - - it('falls back to Safe fee authorization when executors are missing', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: [], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); - expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); - }); - - it('submits FOK order type when fakOrdersEnabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - - it('submits FAK order type when Permit2 is used and fakOrdersEnabled is true', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - mockHasAllowances.mockResolvedValue(true); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - - it('submits FOK order type when Permit2 is used but fakOrdersEnabled is false', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: false, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - - it('submits FAK order type when Permit2 fee auth and allowance are ready', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - mockHasAllowances.mockResolvedValue(true); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - executors: ['0xexecutor1'], - permit2Enabled: true, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - }); - - describe('placeOrder with allowancesTx', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - function setupAllowancesTxTest(overrides?: { - permit2Enabled?: boolean; - hasAllowances?: boolean; - executors?: string[]; - }) { - const result = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: overrides?.permit2Enabled ?? true, - executors: overrides?.executors ?? ['0xexecutor1'], - }, - }); - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - mockHasAllowances.mockResolvedValue(overrides?.hasAllowances ?? false); - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ provider: {} }); - return result; - } - - it('attaches allowancesTx when proxy wallet lacks allowances with fees', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('attaches allowancesTx when proxy wallet lacks allowances without fees', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0, - providerFee: 0, - totalFee: 0, - totalFeePercentage: 0, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('attaches allowancesTx regardless of Permit2 on-chain allowance status', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - - it('does not attach allowancesTx when hasAllowances is true', async () => { - const { provider, mockSigner } = setupAllowancesTxTest({ - hasAllowances: true, - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - }); - - it('does not attach allowancesTx when permit2 is disabled', async () => { - const { provider, mockSigner } = setupAllowancesTxTest({ - permit2Enabled: false, - executors: [], - }); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); - }); - - it('continues order placement when getProxyWalletAllowancesTransaction throws', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0.02, - providerFee: 0.02, - totalFee: 0.04, - totalFeePercentage: 0.04, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockRejectedValue( - new Error('TX generation failed'), - ); - - const result = await provider.placeOrder({ preview, signer: mockSigner }); - - expect(result.success).toBe(true); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: undefined, - }), - ); - }); - - it('attaches allowancesTx for SELL orders', async () => { - const { provider, mockSigner } = setupAllowancesTxTest(); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(getProxyWalletAllowancesTransaction).toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - allowancesTx: { to: '0xSafe', data: '0xallowances' }, - }), - ); - }); - }); - - describe('placeOrder FAK order type for sell orders', () => { - it('submits FAK order type for sell order without fees when FAK is enabled', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: true, - }); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FAK' }), - }), - ); - }); - - it('submits FOK order type for sell order without fees when FAK is disabled', async () => { - jest.clearAllMocks(); - const { provider, mockSigner } = setupPlaceOrderTest({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - executors: ['0xexecutor1'], - }, - fakOrdersEnabled: false, - }); - const preview = createMockOrderPreview({ - side: Side.SELL, - fees: undefined, - }); - - await provider.placeOrder({ preview, signer: mockSigner }); - - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.objectContaining({ orderType: 'FOK' }), - }), - ); - }); - }); - - describe('placeOrder edge cases', () => { - it('places order without fee authorization when totalFee is zero', async () => { - // Clear mock to ensure clean state for this test - mockCreateSafeFeeAuthorization.mockClear(); - - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: { - metamaskFee: 0, - providerFee: 0, - totalFee: 0, - totalFeePercentage: 0, - collector: '0x0', - }, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - clobOrder: expect.any(Object), - headers: expect.any(Object), - feeAuthorization: undefined, - }), - ); - }); - - it('places order without fee authorization when fees is undefined', async () => { - mockCreateSafeFeeAuthorization.mockClear(); - - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - fees: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); - expect(mockSubmitClobOrder).toHaveBeenCalledWith( - expect.objectContaining({ - feeAuthorization: undefined, - }), - ); - }); - - it('returns error result when submitClobOrder returns no response', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ side: Side.BUY }); - - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: null, - error: 'Submission failed', - }); - - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Submission failed'); - expect(result.response).toBeUndefined(); - }); - }); - - describe('getActivity', () => { - it('fetches activity and resolves without throwing', async () => { - const provider = createProvider(); - global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => [] }); - const getAccountStateSpy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - await expect( - provider.getActivity({ - address: '0x1234567890123456789012345678901234567890', - }), - ).resolves.toEqual([]); - - expect(getAccountStateSpy).toHaveBeenCalled(); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => [] }); - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.getActivity({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - }); - - describe('claimWinnings', () => { - it('throws error when method is not implemented', () => { - const provider = createProvider(); - - expect(() => provider.claimWinnings()).toThrow('Method not implemented.'); - }); - }); - - describe('prepareClaim', () => { - function setupPrepareClaimTest() { - jest.clearAllMocks(); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockEncodeClaim.mockReturnValue('0xencodedclaim'); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - // Mock getBalance to return a balance above the threshold by default - mockGetBalance.mockResolvedValue(1); - - // Mock computeProxyAddress to return a safe address - mockComputeProxyAddress.mockReturnValue( - '0xSafeAddress123456789012345678901234567890', - ); - - // Mock hasAllowances used by getAccountState - mockHasAllowances.mockResolvedValue(true); - - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - return { provider: createProvider(), signer: mockSigner }; - } - - it('successfully prepares a claim for regular position', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, // POLYGON_MAINNET_CHAIN_ID - transactions: [ - { - params: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, - }, - ], - }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('successfully prepares a claim for negRisk position', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-2', - outcomeId: 'outcome-789', - outcomeIndex: 1, - outcome: 'No', - outcomeTokenId: '1', - title: 'Test NegRisk Position', - icon: 'test-icon.png', - amount: 2.0, - price: 0.3, - size: 2.0, - negRisk: true, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.3, - conditionId: 'outcome-789', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.3, - avgPrice: 0.3, - currentValue: 0.3, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, - transactions: [ - { - params: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, - }, - ], - }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('calls encodeClaim with correct amounts array based on outcomeIndex', async () => { - const { provider, signer } = setupPrepareClaimTest(); - const position = { - id: 'position-3', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-3', - outcomeId: 'outcome-123', - outcomeIndex: 1, - outcome: 'No', - outcomeTokenId: '1', - title: 'Test Position Index 1', - icon: 'test-icon.png', - amount: 0.75, - price: 0.4, - size: 0.75, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.4, - conditionId: 'outcome-123', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.4, - avgPrice: 0.4, - currentValue: 0.4, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ positions: [position], signer }); - - // encodeClaim is called internally by getClaimTransaction - // The exact call verification depends on the implementation details - }); - - it('throws error when signer address is missing', async () => { - jest.clearAllMocks(); - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await expect( - provider.prepareClaim({ - positions: [position], - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('throws error when no positions provided', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareClaim({ - positions: [], - signer: mockSigner, - }), - ).rejects.toThrow('No positions provided for claim'); - }); - - it('throws error when getClaimTransaction returns empty array', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetClaimTransaction.mockResolvedValue([]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await expect( - provider.prepareClaim({ - positions: [position], - signer, - }), - ).rejects.toThrow('No claim transaction generated'); - }); - - it('calls getBalance to check signer collateral balance', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(1); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetBalance).toHaveBeenCalledWith({ address: signer.address }); - }); - - it('does not include transfer when signer balance is above minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(1); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: false, - }), - ); - }); - - it('does not include transfer when signer balance equals minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.5); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: false, - }), - ); - }); - - it('includes transfer when signer balance is below minimum collateral threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.3); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('includes transfer when signer balance is zero', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('includes transfer when signer balance is slightly below threshold', async () => { - const { provider, signer } = setupPrepareClaimTest(); - mockGetBalance.mockResolvedValue(0.49); - mockGetClaimTransaction.mockResolvedValue([ - { - params: { - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }, - }, - ]); - - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-456', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(mockGetClaimTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - includeTransferTransaction: true, - }), - ); - }); - - it('builds a signed Safe claim transaction when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const signer = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const position = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: - '0x1111111111111111111111111111111111111111111111111111111111111111', - outcomeIndex: 0, - outcome: 'Yes', - outcomeTokenId: '0', - title: 'Test Market Position', - icon: 'test-icon.png', - amount: 1.5, - price: 0.5, - size: 1.5, - negRisk: false, - redeemable: true, - status: PredictPositionStatus.OPEN, - realizedPnl: 0, - curPrice: 0.5, - conditionId: 'outcome-456', - percentPnl: 0, - cashPnl: 0, - initialValue: 0.5, - avgPrice: 0.5, - currentValue: 0.5, - endDate: '2025-01-01T00:00:00Z', - claimable: false, - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance.mockResolvedValue(0n); - - const result = await provider.prepareClaim({ - positions: [position], - signer, - }); - - expect(result).toEqual({ - chainId: 137, - transactions: [ - { - params: { - to: '0x1234567890123456789012345678901234567891', - data: '0xsignedsafeexec', - }, - type: 'predictClaim', - }, - ], - }); - expect(mockGetClaimTransaction).not.toHaveBeenCalled(); - expect(mockGetBalance).not.toHaveBeenCalled(); - }); - }); - - describe('isEligible', () => { - const originalFetch = globalThis.fetch; - - function setupIsEligibleTest() { - jest.clearAllMocks(); - return { provider: createProvider() }; - } - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns true when user is not geoblocked', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: false, country: 'PT' }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: true, country: 'PT' }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when user is geoblocked', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: true, country: 'US' }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false, country: 'US' }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when API response does not contain blocked field', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({}), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when API response blocked field is undefined', async () => { - const { provider } = setupIsEligibleTest(); - const mockResponse = { - json: jest.fn().mockResolvedValue({ blocked: undefined }), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when fetch request fails', async () => { - const { provider } = setupIsEligibleTest(); - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('returns false when JSON parsing fails', async () => { - const provider = createProvider(); - const mockResponse = { - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://polymarket.com/api/geoblock', - ); - }); - - it('handles non-Error exceptions gracefully', async () => { - const provider = createProvider(); - globalThis.fetch = jest.fn().mockRejectedValue('String error'); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - }); - - it('returns false for malformed API response', async () => { - const provider = createProvider(); - const mockResponse = { - json: jest.fn().mockResolvedValue('invalid response'), - }; - globalThis.fetch = jest.fn().mockResolvedValue(mockResponse); - - const result = await provider.isEligible(); - - expect(result).toEqual({ isEligible: false }); - }); - }); - - describe('getMarketDetails', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetEventLeague.mockReturnValue(null); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - }); - - const mockEvent = { - id: 'market-1', - question: 'Will it rain tomorrow?', - markets: [ - { outcome: 'YES', price: 0.6 }, - { outcome: 'NO', price: 0.4 }, - ], - }; - - const mockParsedMarket = { - id: 'market-1', - question: 'Will it rain tomorrow?', - outcomes: ['YES', 'NO'], - status: 'open', - providerId: POLYMARKET_PROVIDER_ID, - }; - - it('get market details successfully', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValueOnce('nfl'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(result).toEqual(mockParsedMarket); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mockEvent], - expect.objectContaining({ - category: 'trending', - teamLookup: expect.any(Function), - }), - ); - }); - - it('throw error when marketId is missing', async () => { - const provider = createProvider(); - - await expect(provider.getMarketDetails({ marketId: '' })).rejects.toThrow( - 'marketId is required', - ); - - await expect( - provider.getMarketDetails({ marketId: null as unknown as string }), - ).rejects.toThrow('marketId is required'); - }); - - it('throw error when getMarketDetailsFromGammaApi fails', async () => { - const provider = createProvider(); - const errorMessage = 'API request failed'; - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error(errorMessage), - ); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow(errorMessage); - }); - - it('throw error when parsePolymarketEvents returns empty array', async () => { - const provider = createProvider(); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - }); - - it('throw error when parsed market is undefined', async () => { - const provider = createProvider(); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([undefined]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - }); - - describe('child event fetching', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetEventLeague.mockReturnValue(null); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - }); - - const parentEvent = { - id: 'game-1', - slug: 'nfl-kc-buf-2026-01-01', - question: 'Who wins the game?', - tags: [{ slug: 'games' }, { slug: 'nfl' }], - }; - const requestedChildEvent = { - id: 'child-player-props', - slug: 'nfl-kc-buf-2026-01-01-player-props', - parentEventId: 'game-1', - question: 'Player props?', - tags: [{ slug: 'games' }, { slug: 'nfl' }], - teams: [{ league: 'nfl' }, { league: 'nfl' }], - }; - const childEvent1 = { - id: 'child-player-props', - slug: 'nfl-kc-buf-2026-01-01-player-props', - question: 'Player props?', - }; - const childEvent2 = { - id: 'child-halftime', - slug: 'nfl-kc-buf-2026-01-01-halftime-result', - question: 'Halftime result?', - }; - const mergedEvent = { - id: 'game-1', - question: 'Who wins the game?', - markets: [ - { outcome: 'Team A', price: 0.6 }, - { outcome: 'Team B', price: 0.4 }, - { outcome: 'Over', price: 0.5 }, - { outcome: 'Under', price: 0.5 }, - ], - }; - - it('promotes suffixed child events to their parent when the parent is an extended sports game', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(requestedChildEvent); - mockFetchChildEventsFromGammaApi.mockResolvedValue([ - parentEvent, - childEvent1, - childEvent2, - ]); - mockMergeChildEventsIntoParent.mockReturnValue(mergedEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: requestedChildEvent.id }); - - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(1); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: requestedChildEvent.id, - }); - - expect(mockFetchChildEventsFromGammaApi).toHaveBeenCalledWith({ - parentEventId: 'game-1', - }); - expect(mockMergeChildEventsIntoParent).toHaveBeenCalledWith([ - parentEvent, - childEvent1, - childEvent2, - ]); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mergedEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('does not fetch child events for non-sports event', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - }); - - it('keeps the requested child event when the parent league is not extended', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: [], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve( - marketId === requestedChildEvent.id - ? requestedChildEvent - : parentEvent, - ), - ); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: requestedChildEvent.id }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [requestedChildEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('falls back to the requested event when child fetch fails', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(requestedChildEvent); - mockFetchChildEventsFromGammaApi.mockRejectedValue( - new Error('Network error'), - ); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: requestedChildEvent.id, - }); - - expect(result).toEqual(mockParsedMarket); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [requestedChildEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('uses event.id to fetch children when the event has no parentEventId', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetEventLeague.mockImplementation(actualGetEventLeague); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockFetchChildEventsFromGammaApi.mockResolvedValue([ - parentEvent, - childEvent1, - childEvent2, - ]); - mockMergeChildEventsIntoParent.mockReturnValue(mergedEvent); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: parentEvent.id }); - - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(1); - expect(mockFetchChildEventsFromGammaApi).toHaveBeenCalledWith({ - parentEventId: 'game-1', - }); - expect(mockMergeChildEventsIntoParent).toHaveBeenCalledWith([ - parentEvent, - childEvent1, - childEvent2, - ]); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mergedEvent], - expect.objectContaining({ category: 'trending' }), - ); - }); - - it('does not fetch child events when getEventLeague returns null', async () => { - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: ['nfl'], - }); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(parentEvent); - mockGetEventLeague.mockReturnValue(null); - mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockFetchChildEventsFromGammaApi).not.toHaveBeenCalled(); - }); - }); - }); - - describe('getMarketsByIds', () => { - const createMockEvent = (id: string) => ({ - id, - question: `Question for ${id}?`, - markets: [ - { outcome: 'YES', price: 0.6 }, - { outcome: 'NO', price: 0.4 }, - ], - }); - - const createMockParsedMarket = (id: string) => ({ - id, - question: `Question for ${id}?`, - outcomes: ['YES', 'NO'], - status: 'open', - providerId: POLYMARKET_PROVIDER_ID, - }); - - beforeEach(() => { - mockGetMarketDetailsFromGammaApi.mockReset(); - mockParsePolymarketEvents.mockReset(); - }); - - it('returns empty array when marketIds is empty', async () => { - const provider = createProvider(); - - const result = await provider.getMarketsByIds([]); - - expect(result).toEqual([]); - expect(mockGetMarketDetailsFromGammaApi).not.toHaveBeenCalled(); - }); - - it('returns empty array when marketIds is undefined', async () => { - const provider = createProvider(); - - const result = await provider.getMarketsByIds( - undefined as unknown as string[], - ); - - expect(result).toEqual([]); - }); - - it('fetches multiple markets in parallel and preserves order', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-2', 'market-3']; - - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve(createMockEvent(marketId)), - ); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(3); - expect(result[0].id).toBe('market-1'); - expect(result[1].id).toBe('market-2'); - expect(result[2].id).toBe('market-3'); - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledTimes(3); - }); - - it('filters out failed market fetches gracefully', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-fail', 'market-3']; - - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => { - if (marketId === 'market-fail') { - return Promise.reject(new Error('API error')); - } - return Promise.resolve(createMockEvent(marketId)); - }); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe('market-1'); - expect(result[1].id).toBe('market-3'); - }); - - it('returns empty array when all market fetches fail', async () => { - const provider = createProvider(); - const marketIds = ['market-1', 'market-2']; - - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error('API error'), - ); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toEqual([]); - }); - - it('fetches single market correctly', async () => { - const provider = createProvider(); - const marketIds = ['market-1']; - - mockGetMarketDetailsFromGammaApi.mockResolvedValue( - createMockEvent('market-1'), - ); - mockParsePolymarketEvents.mockReturnValue([ - createMockParsedMarket('market-1'), - ]); - - const result = await provider.getMarketsByIds(marketIds); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('market-1'); - }); - - it('calls getMarketDetails for each market id', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const marketIds = ['market-1', 'market-2']; - - const getMarketDetailsSpy = jest.spyOn(provider, 'getMarketDetails'); - mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => - Promise.resolve(createMockEvent(marketId)), - ); - mockParsePolymarketEvents.mockImplementation((events) => - events.map((event: { id: string }) => createMockParsedMarket(event.id)), - ); - - await provider.getMarketsByIds(marketIds); - - expect(getMarketDetailsSpy).toHaveBeenCalledTimes(2); - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-2', - }); - - getMarketDetailsSpy.mockRestore(); - }); - - it('calls getMarketDetails without extra params by default', async () => { - const provider = createProvider(); - const marketIds = ['market-1']; - - const getMarketDetailsSpy = jest.spyOn(provider, 'getMarketDetails'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue( - createMockEvent('market-1'), - ); - mockParsePolymarketEvents.mockReturnValue([ - createMockParsedMarket('market-1'), - ]); - - await provider.getMarketsByIds(marketIds); - - expect(getMarketDetailsSpy).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - - getMarketDetailsSpy.mockRestore(); - }); - }); - - describe('getUnrealizedPnL', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = jest.fn(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it('successfully fetches unrealized P&L data', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: -7.337110036077004, - percentUpnl: -31.32290842628039, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toEqual(mockUnrealizedPnL[0]); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://data-api.polymarket.com/upnl?user=0x9999999999999999999999999999999999999999', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - }); - - it('throws error when API response is not ok', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 404, - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Failed to fetch unrealized P&L'); - }); - - it('returns undefined when API returns empty array', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toBeUndefined(); - }); - - it('throws error when API returns non-array response', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('No unrealized P&L data found'); - }); - - it('handles network errors', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Network error'); - }); - - it('handles JSON parsing errors', async () => { - const provider = createProvider(); - - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }); - - await expect( - provider.getUnrealizedPnL({ - address: '0x1234567890123456789012345678901234567890', - }), - ).rejects.toThrow('Invalid JSON'); - }); - - it('uses default address when not provided', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: 0, - percentUpnl: 0, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - await provider.getUnrealizedPnL({ - address: '0x0000000000000000000000000000000000000000', - }); - - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://data-api.polymarket.com/upnl?user=0x9999999999999999999999999999999999999999', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - const mockUnrealizedPnL = [ - { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: 5.5, - percentUpnl: 10.5, - }, - ]; - - (computeProxyAddress as jest.Mock).mockReturnValue( - '0x9999999999999999999999999999999999999999', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (globalThis.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockUnrealizedPnL), - }); - - const result = await provider.getUnrealizedPnL({ - address: '0xNewAddress', - }); - - expect(result).toEqual(mockUnrealizedPnL[0]); - expect(computeProxyAddress).toHaveBeenCalled(); - }); - }); - - describe('getPriceHistory', () => { - const mockHistoryData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 1234567900, p: 0.47 }, - { t: 1234567910, p: 0.49 }, - ], - }; - - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('get price history successfully', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567900, price: 0.47 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1', - { method: 'GET' }, - ); - }); - - it('include fidelity parameter in request', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - fidelity: 100, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&fidelity=100', - { method: 'GET' }, - ); - }); - - it('include interval parameter in request', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - interval: PredictPriceHistoryInterval.ONE_HOUR, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&interval=1h', - { method: 'GET' }, - ); - }); - - it('include both fidelity and interval parameters', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockHistoryData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getPriceHistory({ - marketId: 'market-1', - fidelity: 50, - interval: PredictPriceHistoryInterval.ONE_DAY, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices-history?market=market-1&fidelity=50&interval=1d', - { method: 'GET' }, - ); - }); - - it('throw error when marketId is missing', async () => { - const provider = createProvider(); - - await expect(provider.getPriceHistory({ marketId: '' })).rejects.toThrow( - 'marketId parameter is required', - ); - - await expect( - provider.getPriceHistory({ marketId: null as unknown as string }), - ).rejects.toThrow('marketId parameter is required'); - }); - - it('return empty array when response is not ok', async () => { - const provider = createProvider(); - const mockResponse = { - ok: false, - status: 404, - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when fetch throws error', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when response has no history array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({}), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('return empty array when history is not an array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ history: 'not-an-array' }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('filter out entries with missing timestamp', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { p: 0.47 }, // Missing timestamp - { t: 1234567910, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - }); - - it('filter out entries with missing price', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 1234567900 }, // Missing price - { t: 1234567910, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567910, price: 0.49 }, - ]); - }); - - it('filter out entries with non-numeric timestamp or price', async () => { - const provider = createProvider(); - const mockData = { - history: [ - { t: 1234567890, p: 0.45 }, - { t: 'invalid', p: 0.47 }, - { t: 1234567900, p: 'invalid' }, - { t: null, p: 0.48 }, - { t: 1234567910, p: null }, - { t: 1234567920, p: 0.49 }, - ], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([ - { timestamp: 1234567890, price: 0.45 }, - { timestamp: 1234567920, price: 0.49 }, - ]); - }); - - it('return empty array when history has no valid entries', async () => { - const provider = createProvider(); - const mockData = { - history: [{ t: 'invalid', p: 'invalid' }, { t: null, p: null }, {}], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('handle JSON parsing error', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('handle empty history array', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ history: [] }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - - it('returns empty array when non-Error exception is thrown', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue('String error'); - - const result = await provider.getPriceHistory({ marketId: 'market-1' }); - - expect(result).toEqual([]); - }); - }); - - describe('getCryptoTargetPrice', () => { - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns openPrice on successful fetch', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - openPrice: 82615.22, - closePrice: 82352.85, - timestamp: 1700000000000, - completed: true, - incomplete: false, - cached: false, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBe(82615.22); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining( - 'polymarket.com/api/crypto/crypto-price?symbol=BTC', - ), - ); - }); - - it('returns null when API returns non-ok response', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - }); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBeNull(); - }); - - it('returns null when response has unexpected shape', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ value: 'not-a-number' }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getCryptoTargetPrice({ - symbol: 'BTC', - eventStartTime: '2025-01-01T00:00:00Z', - variant: 'up', - endDate: '2025-01-02', - }); - - expect(result).toBeNull(); - }); - - it('encodes query parameters in the URL', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ openPrice: 100 }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - await provider.getCryptoTargetPrice({ - symbol: 'ETH/USD', - eventStartTime: '2025-01-01 00:00:00', - variant: 'up', - endDate: '2025-01-02', - }); - - const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; - expect(calledUrl).toContain('symbol=ETH%2FUSD'); - expect(calledUrl).toContain('eventStartTime=2025-01-01%2000%3A00%3A00'); - }); - }); - - describe('getPrices', () => { - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('get prices successfully', async () => { - const provider = createProvider(); - const mockPricesData = { - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: '0.35', SELL: '0.34' }, - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockPricesData), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - entry: { buy: 0.65, sell: 0.64 }, - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - entry: { buy: 0.35, sell: 0.34 }, - }, - ], - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/prices', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify([ - { token_id: 'token-1', side: Side.BUY }, - { token_id: 'token-1', side: Side.SELL }, - { token_id: 'token-2', side: Side.BUY }, - { token_id: 'token-2', side: Side.SELL }, - ]), - }, - ); - }); - - it('convert string prices to numbers correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.123456', SELL: '0.123' }, - 'token-2': { BUY: '0.987', SELL: '0.987654' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.123456); - expect(result.results[0].entry.sell).toBe(0.123); - expect(result.results[1].entry.buy).toBe(0.987); - expect(result.results[1].entry.sell).toBe(0.987654); - }); - - it('handle multiple sides for same token', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[0].entry.sell).toBe(0.64); - }); - - it('throw error when queries is empty', async () => { - const provider = createProvider(); - - await expect( - provider.getPrices({ - queries: [], - }), - ).rejects.toThrow('queries parameter is required and must not be empty'); - }); - - it('return empty object when response is not ok', async () => { - const provider = createProvider(); - const mockResponse = { - ok: false, - status: 400, - statusText: 'Bad Request', - text: jest.fn().mockResolvedValue('Bad Request'), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('return empty object when fetch fails', async () => { - const provider = createProvider(); - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('return empty object when invalid JSON response', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('handle non-numeric price values', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65' }, - 'token-2': { BUY: 'invalid' }, - 'token-3': { BUY: '0.35' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.buy).toBeNaN(); - expect(result.results[2].entry.buy).toBe(0.35); - }); - - it('handle null or undefined prices', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: null, SELL: null }, - 'token-3': {}, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.buy).toBe(0); - expect(result.results[2].entry.buy).toBe(0); - }); - - it('return empty object when response body is null', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(null), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result).toEqual({ - providerId: POLYMARKET_PROVIDER_ID, - results: [], - }); - }); - - it('handle BUY side correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry).toHaveProperty('buy'); - expect(result.results[0].entry.buy).toBe(0.65); - }); - - it('handle SELL side correctly', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - ], - }); - - expect(result.results[0].entry).toHaveProperty('sell'); - expect(result.results[0].entry.sell).toBe(0.64); - }); - - it('handle multiple tokens with different sides', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - 'token-1': { BUY: '0.65', SELL: '0.64' }, - 'token-2': { BUY: '0.36', SELL: '0.34' }, - 'token-3': { BUY: '0.35', SELL: '0.33' }, - }), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getPrices({ - queries: [ - { - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - }, - { - marketId: 'market-2', - outcomeId: 'outcome-2', - outcomeTokenId: 'token-2', - }, - { - marketId: 'market-3', - outcomeId: 'outcome-3', - outcomeTokenId: 'token-3', - }, - ], - }); - - expect(result.results[0].entry.buy).toBe(0.65); - expect(result.results[1].entry.sell).toBe(0.34); - expect(result.results[2].entry.buy).toBe(0.35); - }); - }); - - describe('prepareDeposit', () => { - const mockSigner = { - address: '0x123', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - beforeEach(() => { - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (generateTransferData as jest.Mock).mockReturnValue('0xtransferData'); - }); - - it('prepares deploy and allowance transactions when wallet not deployed', async () => { - // Given a wallet that is not deployed - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(0); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xFactory', data: '0xdeploy' }, - }); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then all three transactions are included - expect(result.transactions).toHaveLength(3); - expect(result.transactions[0].params.data).toBe('0xdeploy'); - expect(result.transactions[1].params.data).toBe('0xallowances'); - expect(result.transactions[2].type).toBe('predictDeposit'); - expect(result.chainId).toBe('0x89'); - }); - - it('prepares only allowance transaction when wallet deployed but no allowances', async () => { - // Given a deployed wallet without allowances - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(100); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then only allowance and deposit transactions are included - expect(result.transactions).toHaveLength(2); - expect(result.transactions[0].params.data).toBe('0xallowances'); - expect(result.transactions[1].type).toBe('predictDeposit'); - }); - - it('passes Permit2 spender when creating allowance transaction and permit2Enabled is true', async () => { - const provider = createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - }, - }); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ - params: { to: '0xSafe', data: '0xallowances' }, - }); - - await provider.prepareDeposit({ signer: mockSigner }); - - expect(getProxyWalletAllowancesTransaction).toHaveBeenCalledWith({ - signer: mockSigner, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - }); - - it('prepares only deposit transaction when wallet deployed and has allowances', async () => { - // Given a fully set up wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then only deposit transaction is included - expect(result.transactions).toHaveLength(1); - expect(result.transactions[0].type).toBe('predictDeposit'); - }); - - it('throws error when deploy transaction fails', async () => { - // Given deploy transaction returns undefined - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getBalance as jest.Mock).mockResolvedValue(0); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue( - undefined, - ); - - // When preparing deposit - // Then it throws an error - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Failed to get deploy proxy wallet transaction params'); - }); - - it('uses correct collateral address in deposit transaction', async () => { - // Given a fully set up wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - // When preparing deposit - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - // Then deposit transaction targets collateral contract - expect(result.transactions[0].params.to).toBeDefined(); - expect(generateTransferData).toHaveBeenCalledWith('transfer', { - toAddress: '0xSafeAddress', - amount: '0x0', - }); - }); - - it('throws error when signer address is missing', async () => { - const provider = createProvider(); - const mockSignerWithoutAddress = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareDeposit({ - signer: mockSignerWithoutAddress, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('throws error when deploy transaction has no params', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getDeployProxyWalletTransaction as jest.Mock).mockResolvedValue({}); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Invalid deploy transaction: missing params'); - }); - - it('throws error when allowance transaction has no params', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({}); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow('Invalid allowance transaction: missing params'); - }); - - it('throws error when generateTransferData returns undefined', async () => { - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (generateTransferData as jest.Mock).mockReturnValue(undefined); - - await expect( - provider.prepareDeposit({ - signer: mockSigner, - }), - ).rejects.toThrow( - 'Failed to generate transfer data for deposit transaction', - ); - }); - - it('adds a maintenance Safe transaction instead of v1 allowances when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(false); - mockGetRawBalance.mockResolvedValue(1n); - - const result = await provider.prepareDeposit({ - signer: mockSigner, - }); - - expect(result.transactions).toHaveLength(2); - expect(result.transactions[0]).toEqual({ - params: { - to: USDC_E_ADDRESS, - data: '0xtransferData', - }, - type: 'predictDeposit', - }); - expect(result.transactions[1]).toEqual({ - params: { - to: '0x1234567890123456789012345678901234567891', - data: '0xsignedsafeexec', - }, - type: 'contractInteraction', - }); - expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); - }); - }); - - describe('Rate Limiting', () => { - describe('previewOrder with rate limiting', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const setupPreviewOrderMock = () => { - mockPreviewOrder.mockResolvedValue({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - timestamp: Date.now(), - side: Side.BUY, - sharePrice: 0.5, - maxAmountSpent: 100, - minAmountReceived: 200, - slippage: 0.005, - tickSize: 0.01, - minOrderSize: 1, - negRisk: false, - fees: { - metamaskFee: 0.5, - providerFee: 0.5, - totalFee: 1, - totalFeePercentage: 1, - collector: DEFAULT_FEE_COLLECTION_FLAG.collector, - }, - }); - }; - - it('sets rateLimited for SELL orders after BUY order', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - // Place a BUY order first to set rate limit state - const preview = createMockOrderPreview({ side: Side.BUY }); - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Now try to preview a SELL order - should also be rate limited - const sellPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.SELL, - size: 10, - signer: mockSigner, - }); - - expect(sellPreview.rateLimited).toBe(true); - }); - - it('does not set rateLimited when address has never placed an order', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(preview.rateLimited).toBeUndefined(); - }); - - it('sets rateLimited to true when BUY order is rate limited', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - // Place a BUY order first to set rate limit state - const preview = createMockOrderPreview({ side: Side.BUY }); - await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Try to preview another BUY order immediately - should be rate limited - const secondPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(secondPreview.rateLimited).toBe(true); - }); - - it('sets rateLimited to true when BUY order is in progress', async () => { - setupPreviewOrderMock(); - const { provider, mockSigner } = setupPlaceOrderTest(); - - mockSubmitClobOrder.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - success: true, - response: { - makingAmount: '1000000', - orderID: 'order-123', - status: 'success', - takingAmount: '0', - transactionsHashes: [], - }, - error: undefined, - }); - }, 100); - }), - ); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const placeOrderPromise = provider.placeOrder({ - signer: mockSigner, - preview, - }); - - const secondPreview = await provider.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: '0', - side: Side.BUY, - size: 10, - signer: mockSigner, - }); - - expect(secondPreview.rateLimited).toBe(true); - - await placeOrderPromise; - }); - }); - - describe('placeOrder rate limiting behavior', () => { - it('successfully places BUY order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - }); - - it('successfully places SELL order', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - - const preview = createMockOrderPreview({ side: Side.SELL }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(true); - }); - - it('handles failed BUY orders', async () => { - const { provider, mockSigner } = setupPlaceOrderTest(); - mockSubmitClobOrder.mockResolvedValue({ - success: false, - response: undefined, - error: 'Order submission failed', - }); - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - expect(result.success).toBe(false); - }); - - it('handles different addresses independently', async () => { - const { provider } = setupPlaceOrderTest(); - const mockSigner1 = { - address: '0x1111111111111111111111111111111111111111', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - const mockSigner2 = { - address: '0x2222222222222222222222222222222222222222', - signTypedMessage: mockSignTypedMessage, - signPersonalMessage: mockSignPersonalMessage, - }; - - const preview = createMockOrderPreview({ side: Side.BUY }); - const result1 = await provider.placeOrder({ - signer: mockSigner1, - preview, - }); - - const result2 = await provider.placeOrder({ - signer: mockSigner2, - preview, - }); - - expect(result1.success).toBe(true); - expect(result2.success).toBe(true); - }); - }); - }); - - describe('getAccountState', () => { - beforeEach(() => { - jest.clearAllMocks(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - }); - - it('returns account state for an undeployed wallet', async () => { - // Given an undeployed wallet - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(false); - (hasAllowances as jest.Mock).mockResolvedValue(false); - - // When getting account state - const result = await provider.getAccountState({ - ownerAddress: '0x123', - }); - - // Then correct state is returned - expect(result).toEqual({ - address: '0xSafeAddress', - isDeployed: false, - hasAllowances: false, - }); - }); - - it('returns account state for a deployed wallet with allowances', async () => { - // Given a deployed wallet with allowances - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state - const result = await provider.getAccountState({ - ownerAddress: '0x456', - }); - - // Then correct state is returned - expect(result).toEqual({ - address: '0xSafeAddress', - isDeployed: true, - hasAllowances: true, - }); - }); - - it('caches account state by owner address', async () => { - // Given an account state check - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state twice - await provider.getAccountState({ ownerAddress: '0x123' }); - await provider.getAccountState({ ownerAddress: '0x123' }); - - // Then Safe address is only computed once - expect(computeProxyAddress).toHaveBeenCalledTimes(1); - }); - - it('computes Safe address for each unique owner', async () => { - // Given multiple owner addresses - const provider = createProvider(); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - // When getting account state for different owners - await provider.getAccountState({ ownerAddress: '0x123' }); - await provider.getAccountState({ ownerAddress: '0x456' }); - - // Then Safe address is computed for each owner - expect(computeProxyAddress).toHaveBeenCalledTimes(2); - expect(computeProxyAddress).toHaveBeenCalledWith('0x123'); - expect(computeProxyAddress).toHaveBeenCalledWith('0x456'); - }); - - it('calls all required functions in parallel', async () => { - // Given account state check - const provider = createProvider(); - const isDeployedPromise = Promise.resolve(true); - const hasAllowancesPromise = Promise.resolve(true); - - (isSmartContractAddress as jest.Mock).mockReturnValue(isDeployedPromise); - (hasAllowances as jest.Mock).mockReturnValue(hasAllowancesPromise); - - // When getting account state - await provider.getAccountState({ ownerAddress: '0x123' }); - - // Then all functions are called - expect(isSmartContractAddress).toHaveBeenCalledWith( - '0xSafeAddress', - '0x89', - ); - expect(hasAllowances).toHaveBeenCalledWith({ - address: '0xSafeAddress', - extraUsdcSpenders: [], - }); - }); - - it('passes Permit2 spender to hasAllowances when permit2Enabled is true', async () => { - const provider = createProvider({ - feeCollection: { - ...DEFAULT_FEE_COLLECTION_FLAG, - permit2Enabled: true, - }, - }); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - await provider.getAccountState({ ownerAddress: '0x123' }); - - expect(hasAllowances).toHaveBeenCalledWith({ - address: '0xSafeAddress', - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - }); - - it('throws error when ownerAddress is missing', async () => { - const provider = createProvider(); - - await expect( - provider.getAccountState({ ownerAddress: '' }), - ).rejects.toThrow('Owner address is required'); - }); - - it('throws error when computeProxyAddress fails', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockImplementation(() => { - throw new Error('Failed to compute'); - }); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to compute safe address'); - }); - - it('throws error when computeProxyAddress returns empty string', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue(''); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to get safe address'); - }); - - it('throws error when checking account state fails', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - - await expect( - provider.getAccountState({ ownerAddress: '0x123' }), - ).rejects.toThrow('Failed to check account state'); - }); - }); - - describe('getBalance', () => { - it('returns balance for the given address', async () => { - // Given a provider - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (getBalance as jest.Mock).mockResolvedValue(123.45); - - // When getting balance - const result = await provider.getBalance({ - address: '0x1234567890123456789012345678901234567890', - }); - - // Then balance is returned - expect(result).toBe(123.45); - expect(getBalance).toHaveBeenCalledWith({ address: '0xSafeAddress' }); - }); - - it('throws error when address is missing', async () => { - const provider = createProvider(); - - await expect(provider.getBalance({ address: '' })).rejects.toThrow( - 'address is required', - ); - }); - - it('uses cached address when available', async () => { - const provider = createProvider(); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - (getBalance as jest.Mock).mockResolvedValue(100); - - const userAddress = '0x1234567890123456789012345678901234567890'; - - await provider.getAccountState({ ownerAddress: userAddress }); - jest.clearAllMocks(); - - await provider.getBalance({ - address: userAddress, - }); - - expect(computeProxyAddress).not.toHaveBeenCalled(); - }); - - it('aggregates Safe USDC.e and pUSD balances when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - (computeProxyAddress as jest.Mock).mockReturnValue('0xSafeAddress'); - mockGetBalance.mockResolvedValueOnce(12.5).mockResolvedValueOnce(7.25); - - const result = await provider.getBalance({ - address: '0x1234567890123456789012345678901234567890', - }); - - expect(result).toBe(19.75); - expect(mockGetBalance).toHaveBeenNthCalledWith(1, { - address: '0xSafeAddress', - tokenAddress: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - }); - expect(mockGetBalance).toHaveBeenNthCalledWith(2, { - address: '0xSafeAddress', - tokenAddress: '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', - }); - }); - }); - - describe('prepareWithdraw', () => { - it('prepares withdraw transaction successfully', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - jest - .spyOn(PolymarketProvider.prototype, 'getAccountState') - .mockResolvedValue({ - address: '0xSafeAddress', - isDeployed: true, - hasAllowances: true, - }); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result).toHaveProperty('chainId'); - expect(result).toHaveProperty('transaction'); - expect(result).toHaveProperty('predictAddress'); - expect(result.predictAddress).toBe('0xSafeAddress'); - }); - - it('throws error when signer address is missing in prepareWithdraw', async () => { - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.prepareWithdraw({ - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('fetches account state when not cached', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result.predictAddress).toBe('0xSafeAddress'); - expect(mockComputeProxyAddress).toHaveBeenCalled(); - }); - - it('prepares a legacy USDC.e edit transaction when CLOB v2 is enabled', async () => { - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - (isSmartContractAddress as jest.Mock).mockResolvedValue(true); - (hasAllowances as jest.Mock).mockResolvedValue(true); - - const result = await provider.prepareWithdraw({ - signer: mockSigner, - }); - - expect(result.predictAddress).toBe('0xSafeAddress'); - expect(result.transaction.params.to).toBe(USDC_E_ADDRESS); - }); - }); - - describe('prepareWithdrawConfirmation', () => { - it('prepares withdraw confirmation successfully', async () => { - const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); - - const result = await provider.signWithdraw({ - callData: '0xcalldata', - signer: mockSigner, - }); - - expect(result).toHaveProperty('callData'); - expect(result).toHaveProperty('amount'); - }); - - it('throws error when signer address is missing in signWithdraw', async () => { - const provider = createProvider(); - const mockSigner = { - address: '', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - await expect( - provider.signWithdraw({ - callData: '0xcalldata', - signer: mockSigner, - }), - ).rejects.toThrow('Signer address is required'); - }); - - it('builds a signed Safe withdraw execution when CLOB v2 is enabled', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance - .mockResolvedValueOnce(0n) - .mockResolvedValueOnce(1_000_000n); - - const result = await provider.signWithdraw({ - callData: - '0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000f4240', - signer: mockSigner, - }); - - expect(result).toEqual({ - callData: '0xsignedsafeexec', - amount: 1, - }); - expect(getWithdrawTransactionCallData).not.toHaveBeenCalled(); - }); - - it('throws when Safe pUSD is insufficient for fallback v2 withdraw', async () => { - jest.clearAllMocks(); - const provider = createProvider({ predictClobV2Enabled: true }); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - mockComputeProxyAddress.mockReturnValue( - '0x1234567890123456789012345678901234567891', - ); - mockGetRawBalance - .mockResolvedValueOnce(0n) - .mockResolvedValueOnce(999_999n); - - await expect( - provider.signWithdraw({ - callData: - '0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000f4240', - signer: mockSigner, - }), - ).rejects.toThrow('Insufficient Safe pUSD balance for fallback withdraw'); - }); - }); - - describe('fetchActivity', () => { - const provider = createProvider(); - - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - it('throws when address is missing', async () => { - await expect(provider.getActivity({ address: '' })).rejects.toThrow(); - }); - - it('calls fetch with derived predictAddress and parses activity', async () => { - const jsonData = [{ id: 'x1' }]; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: () => jsonData, - }); - - // Mock getAccountState used to derive predict address - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - - expect(spy).toHaveBeenCalledWith({ ownerAddress: '0xuser' }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('user=0xSAFE'), - expect.objectContaining({ method: 'GET' }), - ); - expect(Array.isArray(result)).toBe(true); - }); - - it('returns empty array on non-ok response', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - json: () => ({}), - }); - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - expect(spy).toHaveBeenCalled(); - expect(result).toEqual([]); - }); - }); - - describe('Activity', () => { - const provider = createProvider(); - - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - it('throws when address is missing', async () => { - await expect(provider.getActivity({ address: '' })).rejects.toThrow(); - }); - - it('calls fetch with derived predictAddress and parses activity', async () => { - const jsonData = [{ id: 'x1' }]; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: () => jsonData, - }); - - // Mock getAccountState used to derive predict address - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - - expect(spy).toHaveBeenCalledWith({ ownerAddress: '0xuser' }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('user=0xSAFE'), - expect.objectContaining({ method: 'GET' }), - ); - expect(Array.isArray(result)).toBe(true); - }); - - it('returns empty array on non-ok response', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - json: () => ({}), - }); - const spy = jest - .spyOn( - provider as unknown as { - getAccountState: (p: { ownerAddress: string }) => Promise<{ - address: string; - isDeployed: boolean; - hasAllowances: boolean; - balance: number; - }>; - }, - 'getAccountState', - ) - .mockResolvedValue({ - address: '0xSAFE', - isDeployed: true, - hasAllowances: true, - balance: 0, - }); - - const result = await provider.getActivity({ address: '0xuser' }); - expect(spy).toHaveBeenCalled(); - expect(result).toEqual([]); - }); - }); - - describe('optimistic position updates', () => { - let originalFetch: typeof fetch | undefined; - - beforeEach(() => { - originalFetch = globalThis.fetch as typeof fetch | undefined; - jest.clearAllMocks(); - }); - - afterEach(() => { - (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = - originalFetch; - }); - - describe('confirmClaim', () => { - it('marks claimed positions for optimistic removal', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; - - // Mock fetch for getPositions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'position-2', - market: 'market-1', - size: '20', - value: '200', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }, - ]); - - // Act - provider.confirmClaim({ positions: mockPositions, signer: mockSigner }); - - // Assert - subsequent getPositions should filter out claimed positions - const result = await provider.getPositions({ address: mockAddress }); - expect(result).toHaveLength(0); - }); - - it('handles single position claim', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - const mockPosition = createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }); - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }, - ]); - - // Act - provider.confirmClaim({ - positions: [mockPosition], - signer: mockSigner, - }); - - // Assert - const result = await provider.getPositions({ address: mockAddress }); - expect(result).toHaveLength(0); - }); - }); - - describe('createOptimisticPositionFromPreview', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('creates optimistic position for a new position using preview data', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - maxAmountSpent: 10, - minAmountReceived: 20, - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - expect(positions).toHaveLength(1); - expect(positions[0]).toEqual( - expect.objectContaining({ - marketId: 'market-1', - outcomeTokenId: 'token-456', - optimistic: true, - }), - ); - }); - - it('updates existing position when one already exists', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - const existingPosition = createMockPosition({ - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - amount: 10, - size: 10, - initialValue: 5, - }); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([existingPosition]); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - maxAmountSpent: 5, - minAmountReceived: 10, - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - mockParsePolymarketPositions.mockResolvedValue([existingPosition]); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - const optimisticPosition = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPosition?.optimistic).toBe(true); - expect(optimisticPosition?.amount).toBe(20); - expect(optimisticPosition?.initialValue).toBe(10); - }); - }); - - describe('clearOptimisticPosition', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('removes optimistic position so it no longer appears in getPositions', async () => { - const { provider, mockAddress, mockFetch } = - setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - }); - - await provider.createOptimisticPositionFromPreview({ - address: mockAddress, - preview, - }); - - provider.clearOptimisticPosition(mockAddress, 'token-456'); - - const positions = await provider.getPositions({ - address: mockAddress, - }); - - expect(positions).toHaveLength(0); - }); - - it('is a no-op when no optimistic position exists for the address', () => { - const provider = createProvider(); - - expect(() => { - provider.clearOptimisticPosition('0xunknown', 'token-1'); - }).not.toThrow(); - }); - }); - - describe('getPositions with optimistic removal filtering', () => { - it('filters out positions marked for optimistic removal', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // First, mark position-2 (token-2) for removal - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Mock fetch to return 3 positions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'position-2', - market: 'market-1', - size: '20', - value: '200', - }, - { - id: 'position-3', - market: 'market-1', - size: '30', - value: '300', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - should return only 2 positions (position-2 filtered out) - expect(result).toHaveLength(2); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-1' }), - expect.objectContaining({ outcomeTokenId: 'token-3' }), - ]), - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-2' }), - ]), - ); - }); - - it('cleans up optimistic updates older than 1 minute', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // Save the original Date.now - const realDateNow = Date.now.bind(global.Date); - const twoMinutesAgo = realDateNow() - 2 * 60 * 1000; - - // Mock Date.now to return 2 minutes ago for the first confirmClaim - const dateNowStub = jest.fn(); - global.Date.now = dateNowStub; - dateNowStub.mockReturnValueOnce(twoMinutesAgo); - - // Mark a position for removal 2 minutes ago (should be cleaned up) - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'old-position', - outcomeTokenId: 'token-old', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Now make Date.now return current time - dateNowStub.mockImplementation(realDateNow); - - // Add a new position for removal (this should trigger cleanup of old updates) - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'new-sold-position', - outcomeTokenId: 'token-new', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - // Mock fetch to return positions - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'old-position', - market: 'market-1', - size: '10', - value: '100', - }, - { - id: 'new-sold-position', - market: 'market-1', - size: '15', - value: '150', - }, - { - id: 'visible-position', - market: 'market-1', - size: '20', - value: '200', - }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'old-position', - outcomeTokenId: 'token-old', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'new-sold-position', - outcomeTokenId: 'token-new', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'visible-position', - outcomeTokenId: 'token-visible', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - old position should NOT be filtered (cleaned up by timeout), new-sold-position SHOULD be filtered - expect(result).toHaveLength(2); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-old' }), - expect.objectContaining({ outcomeTokenId: 'token-visible' }), - ]), - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcomeTokenId: 'token-new' }), - ]), - ); - - // Cleanup - global.Date.now = realDateNow; - }); - - it('tracks multiple optimistic removals for same address', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockSigner = { - address: mockAddress, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; - - // Mark 3 positions for removal - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - createMockPosition({ - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: mockSigner, - }); - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - { id: 'position-3', market: 'market-1' }, - { id: 'position-4', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-4', - outcomeTokenId: 'token-4', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - only position-4 should remain - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ outcomeTokenId: 'token-4' }); - }); - - it('handles multiple addresses independently', async () => { - // Arrange - const provider = createProvider(); - const addressA = '0x1111111111111111111111111111111111111111'; - const addressB = '0x2222222222222222222222222222222222222222'; - - // Mark position-1 (token-1) for removal for address A - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: { - address: addressA, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }); - - // Mark position-2 (token-2) for removal for address B - provider.confirmClaim({ - positions: [ - createMockPosition({ - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 0, - cashPnl: 0, - }), - ], - signer: { - address: addressB, - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }, - }); - - // Mock fetch for address A - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - get positions for address A - const resultA = await provider.getPositions({ address: addressA }); - - // Assert - only position-2 should be returned (position-1 filtered for addressA) - expect(resultA).toHaveLength(1); - expect(resultA[0]).toMatchObject({ outcomeTokenId: 'token-2' }); - }); - - it('returns all positions when no optimistic updates exist', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { id: 'position-1', market: 'market-1' }, - { id: 'position-2', market: 'market-1' }, - { id: 'position-3', market: 'market-1' }, - { id: 'position-4', market: 'market-1' }, - { id: 'position-5', market: 'market-1' }, - ]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-2', - outcomeTokenId: 'token-2', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-3', - outcomeTokenId: 'token-3', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-4', - outcomeTokenId: 'token-4', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - { - id: 'position-5', - outcomeTokenId: 'token-5', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - expect(result).toHaveLength(5); - }); - - it('handles empty optimistic updates list gracefully', async () => { - // Arrange - const provider = createProvider(); - const mockAddress = '0x1234567890123456789012345678901234567890'; - - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest - .fn() - .mockResolvedValue([{ id: 'position-1', market: 'market-1' }]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-1', - outcomeTokenId: 'token-1', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - // Act - const result = await provider.getPositions({ address: mockAddress }); - - // Assert - no errors, returns all positions - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ outcomeTokenId: 'token-1' }); - }); - }); - - describe('placeOrder with optimistic updates', () => { - it('marks position for optimistic removal when selling', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.SELL, - outcomeTokenId: 'token-123', - positionId: 'position-123', - }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Act - await provider.placeOrder(orderParams); - - // Assert - subsequent getPositions should filter out the sold position - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest - .fn() - .mockResolvedValue([{ id: 'position-123', market: 'market-1' }]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([ - { - id: 'position-123', - outcomeTokenId: 'token-123', - marketId: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - }, - ]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - expect(positions).toHaveLength(0); - }); - - it('creates optimistic position when buying', async () => { - // Arrange - const { provider, mockSigner } = setupPlaceOrderTest(); - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - }); - const orderParams = { - signer: mockSigner, - providerId: POLYMARKET_PROVIDER_ID, - preview, - }; - - // Mock getMarketDetails for optimistic position creation - mockGetMarketDetailsFromGammaApi.mockResolvedValue({ - id: 'market-1', - question: 'Test Market', - markets: [], - }); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokens: [{ id: 'token-456', title: 'Yes', price: 0.5 }], - }, - ], - }, - ]); - - // Mock submitClobOrder to return transaction amounts - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', // $1 USDC (6 decimals) - takingAmount: '2000000', // 2 shares - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder(orderParams); - - // Assert - getPositions should return API position OR optimistic position - (globalThis as unknown as { fetch: jest.Mock }).fetch = jest - .fn() - .mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockComputeProxyAddress.mockReturnValue('0xproxy'); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Should have the optimistic position - expect(positions.length).toBeGreaterThanOrEqual(1); - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - }); - - describe('optimistic position creation - BUY orders', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('creates optimistic position when buying new shares', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-456', - title: 'Yes', - tokenId: 'token-456', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-456', - outcomeId: 'outcome-456', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockSignPersonalMessage.mockResolvedValue('0xpersonalsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect(positions.length).toBeGreaterThanOrEqual(1); - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-456', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('verifies createOptimisticPosition helper creates position with optimistic flag', () => { - // Arrange - const basePosition = createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - }); - - // Act - const optimisticPosition = createOptimisticPosition({ - id: 'position-1', - outcomeTokenId: 'token-1', - }); - - // Assert - expect(optimisticPosition.optimistic).toBe(true); - expect(optimisticPosition.id).toBe(basePosition.id); - expect(optimisticPosition.outcomeTokenId).toBe( - basePosition.outcomeTokenId, - ); - }); - - it('calculates initial values correctly for new position', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-789', - title: 'Yes', - tokenId: 'token-789', - price: 0.6, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-789', - outcomeId: 'outcome-789', - marketId: 'market-1', - sharePrice: 0.6, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '5000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-789', - ); - - expect(optimisticPos?.amount).toBe(5000000); - expect(optimisticPos?.initialValue).toBe(3000000); - expect(optimisticPos?.avgPrice).toBeCloseTo(0.6); - expect(optimisticPos?.size).toBe(5000000); - }); - - it('sets expected size for validation', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-999', - title: 'No', - tokenId: 'token-999', - price: 0.4, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-999', - outcomeId: 'outcome-999', - marketId: 'market-1', - sharePrice: 0.4, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '10000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-999', - ); - - expect(optimisticPos?.size).toBe(10000000); - }); - - it('fetches market details for complete position data', async () => { - // Arrange - const { provider, mockSigner } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-test', - outcomes: [ - { - id: 'outcome-test', - title: 'Maybe', - tokenId: 'token-test', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-test', - outcomeId: 'outcome-test', - marketId: 'market-test', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ - marketId: 'market-test', - }); - }); - - it('handles market details fetch failure gracefully', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockGetMarketDetailsFromGammaApi.mockRejectedValue( - new Error('API error'), - ); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-error', - outcomeId: 'outcome-error', - marketId: 'market-error', - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - order still succeeds - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-error', - ); - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('does not create optimistic update for claimable positions', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-1', - market: 'market-1', - size: '10', - value: '100', - }, - ]), - }); - - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-1', - outcomeTokenId: 'token-claimable', - marketId: 'market-1', - claimable: true, - }), - ]); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-claimable', - outcomeId: 'outcome-claimable', - marketId: 'market-1', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - - // Act - const result = await provider.placeOrder({ - signer: mockSigner, - preview, - }); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBe('Cannot place orders on claimable positions'); - }); - }); - - describe('optimistic position updates - UPDATE existing positions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('updates existing position when buying more shares', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-update', - title: 'Yes', - tokenId: 'token-update', - price: 0.5, - }, - ], - }); - - // First order - create initial position - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-update', - outcomeId: 'outcome-update', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - // Second order - update existing position - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-update', - outcomeId: 'outcome-update', - marketId: 'market-1', - sharePrice: 0.6, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '5000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-update', - ); - - expect(optimisticPos).toBeDefined(); - expect(optimisticPos?.optimistic).toBe(true); - }); - - it('accumulates amount and initialValue correctly', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-accum', - title: 'Yes', - tokenId: 'token-accum', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-accum', - outcomeId: 'outcome-accum', - marketId: 'market-1', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '4000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-accum', - outcomeId: 'outcome-accum', - marketId: 'market-1', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '3000000', - takingAmount: '6000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-accum', - ); - - // Second order creates a new optimistic position, not an update - // because optimistic positions don't persist in API - expect(optimisticPos?.amount).toBe(6000000); - expect(optimisticPos?.initialValue).toBe(3000000); - }); - - it('recalculates avgPrice after update', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-1', - outcomes: [ - { - id: 'outcome-price', - title: 'Yes', - tokenId: 'token-price', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-price', - outcomeId: 'outcome-price', - marketId: 'market-1', - sharePrice: 0.5, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '5000000', - takingAmount: '10000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-price', - outcomeId: 'outcome-price', - marketId: 'market-1', - sharePrice: 0.7, - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '7000000', - takingAmount: '10000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positions = await provider.getPositions({ - address: mockSigner.address, - }); - - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-price', - ); - - // avgPrice is based on the second order only since optimistic - // positions aren't returned from API for accumulation - expect(optimisticPos?.avgPrice).toBeCloseTo(0.7); - }); - - it('preserves existing position data', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-preserve', - outcomes: [ - { - id: 'outcome-preserve', - title: 'Maybe', - tokenId: 'token-preserve', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - const firstPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-preserve', - outcomeId: 'outcome-preserve', - marketId: 'market-preserve', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-1', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ - signer: mockSigner, - preview: firstPreview, - }); - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - const positionsAfterFirst = await provider.getPositions({ - address: mockSigner.address, - }); - - const firstPos = positionsAfterFirst.find( - (p) => p.outcomeTokenId === 'token-preserve', - ); - - const secondPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-preserve', - outcomeId: 'outcome-preserve', - marketId: 'market-preserve', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-2', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - await provider.placeOrder({ - signer: mockSigner, - preview: secondPreview, - }); - - // Assert - const positionsAfterSecond = await provider.getPositions({ - address: mockSigner.address, - }); - - const updatedPos = positionsAfterSecond.find( - (p) => p.outcomeTokenId === 'token-preserve', - ); - - expect(updatedPos?.marketId).toBe(firstPos?.marketId); - expect(updatedPos?.outcomeId).toBe(firstPos?.outcomeId); - expect(updatedPos?.title).toBe(firstPos?.title); - }); - }); - - describe('integration tests - end-to-end flows', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('creates optimistic position on BUY then removes when API confirms', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-integration', - outcomes: [ - { - id: 'outcome-integration', - title: 'Yes', - tokenId: 'token-integration', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-integration', - outcomeId: 'outcome-integration', - marketId: 'market-integration', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - Place order - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - Position is optimistic - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - - let optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-integration', - ); - expect(optimisticPos?.optimistic).toBe(true); - - // Act - API now returns the confirmed position - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-123', - market: 'market-integration', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-123', - outcomeTokenId: 'token-integration', - size: 2000000, - optimistic: false, - }), - ]); - - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Assert - Optimistic update removed, API position returned - optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-integration', - ); - expect(optimisticPos?.optimistic).toBeFalsy(); - }); - - it('cleans up after timeout if API never confirms', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-timeout', - outcomes: [ - { - id: 'outcome-timeout', - title: 'Yes', - tokenId: 'token-timeout', - price: 0.5, - }, - ], - }); - - const preview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-timeout', - outcomeId: 'outcome-timeout', - marketId: 'market-timeout', - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - mockSubmitClobOrder.mockResolvedValue({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-123', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - // Act - Place order - await provider.placeOrder({ signer: mockSigner, preview }); - - // Assert - Position is optimistic - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - mockParsePolymarketPositions.mockResolvedValue([]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect( - positions.find((p) => p.outcomeTokenId === 'token-timeout') - ?.optimistic, - ).toBe(true); - - // Act - Advance time by 2 minutes (past 1 minute timeout) - jest.advanceTimersByTime(2 * 60 * 1000); - - // Act - getPositions should clean up expired optimistic updates - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - // Assert - Optimistic position should not be returned (expired) - const optimisticPos = positions.find( - (p) => p.outcomeTokenId === 'token-timeout', - ); - expect(optimisticPos).toBeUndefined(); - }); - - it('handles BUY order followed by SELL order on same position', async () => { - // Arrange - const { provider, mockSigner, mockFetch } = setupOptimisticUpdateTest(); - - mockMarketDetailsForOptimistic({ - marketId: 'market-buysell', - outcomes: [ - { - id: 'outcome-buysell', - title: 'Yes', - tokenId: 'token-buysell', - price: 0.5, - }, - ], - }); - - mockSignTypedMessage.mockResolvedValue('0xsignature'); - mockCreateApiKey.mockResolvedValue({ - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }); - mockPriceValid.mockReturnValue(true); - mockGetContractConfig.mockReturnValue({ - exchange: '0x1234567890123456789012345678901234567890', - negRiskExchange: '0x0987654321098765432109876543210987654321', - collateral: '0xCollateralAddress', - conditionalTokens: '0xConditionalTokensAddress', - negRiskAdapter: '0xNegRiskAdapterAddress', - }); - mockGetOrderTypedData.mockReturnValue({ - types: {}, - primaryType: 'Order', - domain: {}, - message: {}, - }); - mockGetL2Headers.mockReturnValue({ - POLY_ADDRESS: 'address', - POLY_SIGNATURE: 'signature', - POLY_TIMESTAMP: 'timestamp', - POLY_API_KEY: 'apiKey', - POLY_PASSPHRASE: 'passphrase', - }); - mockCreateSafeFeeAuthorization.mockResolvedValue({ - type: 'safe-transaction', - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }); - - // Act - BUY order - const buyPreview = createMockOrderPreview({ - side: Side.BUY, - outcomeTokenId: 'token-buysell', - outcomeId: 'outcome-buysell', - marketId: 'market-buysell', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '1000000', - takingAmount: '2000000', - orderID: 'order-buy', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ signer: mockSigner, preview: buyPreview }); - - // API returns the bought position - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-buysell', - market: 'market-buysell', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-buysell', - outcomeTokenId: 'token-buysell', - size: 2000000, - }), - ]); - - let positions = await provider.getPositions({ - address: mockSigner.address, - }); - expect(positions).toHaveLength(1); - - // Act - SELL order - const sellPreview = createMockOrderPreview({ - side: Side.SELL, - outcomeTokenId: 'token-buysell', - positionId: 'position-buysell', - }); - - mockSubmitClobOrder.mockResolvedValueOnce({ - success: true, - response: { - success: true, - makingAmount: '2000000', - takingAmount: '1000000', - orderID: 'order-sell', - status: 'success', - transactionsHashes: [], - }, - error: undefined, - }); - - await provider.placeOrder({ signer: mockSigner, preview: sellPreview }); - - // Assert - Position should be marked for removal - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue([ - { - id: 'position-buysell', - market: 'market-buysell', - size: '2000000', - value: '100', - }, - ]), - }); - mockParsePolymarketPositions.mockResolvedValue([ - createMockPosition({ - id: 'position-buysell', - outcomeTokenId: 'token-buysell', - size: 2000000, - }), - ]); - - positions = await provider.getPositions({ - address: mockSigner.address, - }); - - expect(positions).toHaveLength(0); - }); - }); - }); - - describe('provider interface properties', () => { - it('exposes chainId property with value 137', () => { - const provider = createProvider(); - - expect(provider.chainId).toBe(137); - }); - - it('exposes name property with value Polymarket', () => { - const provider = createProvider(); - - expect(provider.name).toBe('Polymarket'); - }); - - it('exposes providerId property with value polymarket', () => { - const provider = createProvider(); - - expect(provider.providerId).toBe(POLYMARKET_PROVIDER_ID); - }); - }); - - describe('GameCache integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGameCacheInstance.overlayOnMarket.mockImplementation( - (market) => market, - ); - mockGameCacheInstance.overlayOnMarkets.mockImplementation( - (markets) => markets, - ); - }); - - describe('getMarkets', () => { - it('applies GameCache overlay to fetched markets when live sports are enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }, { id: 'event-2' }]; - const mockMarkets = [ - { id: 'market-1', title: 'Test Market 1' }, - { id: 'market-2', title: 'Test Market 2' }, - ]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - - await provider.getMarkets(); - - expect(mockGameCacheInstance.overlayOnMarkets).toHaveBeenCalledWith( - mockMarkets, - ); - }); - - it('returns markets with cached game data overlay applied when live sports are enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }]; - const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; - const overlaidMarkets = [ - { - id: 'market-1', - title: 'Test Market', - gameData: { score: '3-2', status: 'live' }, - }, - ]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockGameCacheInstance.overlayOnMarkets.mockReturnValue(overlaidMarkets); - - const result = await provider.getMarkets(); - - expect(result).toEqual(overlaidMarkets); - }); - - it('returns empty array when API fails without calling GameCache overlay', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockFetchEventsFromPolymarketApi.mockRejectedValue( - new Error('API error'), - ); - - const result = await provider.getMarkets(); - - expect(result).toEqual([]); - expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); - }); - }); - - describe('getCarouselMarkets', () => { - it('returns parsed markets from carousel API', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }, { id: 'event-2' }]; - const parsedMarkets = [ - { id: 'market-1', status: 'open', outcomes: [{ id: 'o1' }] }, - { id: 'market-2', status: 'open', outcomes: [{ id: 'o2' }] }, - ]; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: mockEvents[0] }, - { event: mockEvents[1] }, - ]); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual(parsedMarkets); - expect(mockFetchCarouselFromPolymarketApi).toHaveBeenCalled(); - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ - category: 'trending', - sortMarketsBy: 'price', - }), - ); - }); - - it('returns empty array on error', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockRejectedValue( - new Error('carousel error'), - ); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([]); - }); - - it('filters out closed markets and markets with no outcomes', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { id: 'open-market', status: 'open', outcomes: [{ id: 'o1' }] }, - { id: 'closed-market', status: 'closed', outcomes: [{ id: 'o2' }] }, - { id: 'empty-outcomes', status: 'open', outcomes: [] }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([ - { id: 'open-market', status: 'open', outcomes: [{ id: 'o1' }] }, - ]); - }); - - it('excludes events with ended: true before parsing', async () => { - const provider = createProvider(); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-live', ended: false } }, - { event: { id: 'event-ended', ended: true } }, - { event: { id: 'event-scheduled' } }, - ]); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [{ id: 'event-live', ended: false }, { id: 'event-scheduled' }], - expect.any(Object), - ); - }); - - it('does not load teams for events with ended: true', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-live', ended: false } }, - { event: { id: 'event-ended', ended: true } }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockExtractNeededTeamsFromEvents).toHaveBeenCalledWith( - [{ id: 'event-live', ended: false }], - ['nfl'], - ); - }); - - it('loads teams when live sports is enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - const mockEvents = [{ id: 'event-1' }]; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: mockEvents[0] }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockExtractNeededTeamsFromEvents).toHaveBeenCalledWith( - mockEvents, - ['nfl'], - ); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - }); - - it('collapses outcomes to the moneyline outcome when present', async () => { - const provider = createProvider(); - const moneylineOutcome = { - id: 'match-winner', - sportsMarketType: 'moneyline', - tokens: [{ title: 'Spirit' }, { title: 'MOUZ' }], - }; - const overUnderOutcome = { - id: 'ou-2.5', - sportsMarketType: 'totals', - tokens: [{ title: 'Over' }, { title: 'Under' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'cs-spirit-vs-mouz', - status: 'open', - outcomes: [overUnderOutcome, moneylineOutcome], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([ - { - id: 'cs-spirit-vs-mouz', - status: 'open', - outcomes: [moneylineOutcome], - }, - ]); - }); - - it('matches moneyline regardless of sportsMarketType casing', async () => { - const provider = createProvider(); - const moneylineOutcome = { - id: 'match-winner', - sportsMarketType: 'MoneyLine', - tokens: [{ title: 'Home' }, { title: 'Away' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'm1', - status: 'open', - outcomes: [ - { id: 'spread', sportsMarketType: 'spreads' }, - moneylineOutcome, - ], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result[0].outcomes).toEqual([moneylineOutcome]); - }); - - it('passes markets through unchanged when no moneyline outcome exists', async () => { - const provider = createProvider(); - const marketWithoutMoneyline = { - id: 'binary-market', - status: 'open', - outcomes: [ - { id: 'yes', tokens: [{ title: 'Yes' }, { title: 'No' }] }, - ], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([marketWithoutMoneyline]); - - const result = await provider.getCarouselMarkets(); - - expect(result).toEqual([marketWithoutMoneyline]); - }); - - it('preserves all home/draw/away tokens on the moneyline outcome for soccer markets', async () => { - const provider = createProvider(); - const soccerMoneyline = { - id: 'match-winner', - sportsMarketType: 'moneyline', - tokens: [ - { id: 'tot', title: 'Tottenham' }, - { id: 'draw', title: 'Draw' }, - { id: 'bri', title: 'Brighton' }, - ], - }; - const totalGoals = { - id: 'total-goals', - sportsMarketType: 'totals', - tokens: [{ title: 'Over' }, { title: 'Under' }], - }; - - mockFetchCarouselFromPolymarketApi.mockResolvedValue([{ event: {} }]); - mockParsePolymarketEvents.mockReturnValue([ - { - id: 'tot-vs-bri', - status: 'open', - game: { - homeTeam: { name: 'Tottenham' }, - awayTeam: { name: 'Brighton' }, - }, - outcomes: [soccerMoneyline, totalGoals], - }, - ]); - - const result = await provider.getCarouselMarkets(); - - expect(result[0].outcomes).toEqual([soccerMoneyline]); - expect(result[0].outcomes[0].tokens).toHaveLength(3); - expect( - result[0].outcomes[0].tokens.map((t: { title: string }) => t.title), - ).toEqual(['Tottenham', 'Draw', 'Brighton']); - }); - }); - - describe('getMarketDetails', () => { - it('applies GameCache overlay to fetched market details when event is a sports event', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue('nfl'); - const mockEvent = { - id: 'market-1', - slug: 'sea-vs-den-2024-01-15', - question: 'Test Market?', - }; - const parsedMarket = { - id: 'market-1', - title: 'Test Market', - providerId: POLYMARKET_PROVIDER_ID, - }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - expect(mockGameCacheInstance.overlayOnMarket).toHaveBeenCalledWith( - parsedMarket, - ); - }); - - it('returns market with cached game data overlay applied when event is a sports event', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue('nfl'); - const mockEvent = { - id: 'market-1', - slug: 'sea-vs-den-2024-01-15', - question: 'Test Market?', - }; - const parsedMarket = { id: 'market-1', title: 'Test Market' }; - const overlaidMarket = { - id: 'market-1', - title: 'Test Market', - gameData: { score: '1-0', status: 'live', elapsed: '45:00' }, - }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue( - new Map([['nfl', ['sea', 'den']]]), - ); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - mockGameCacheInstance.overlayOnMarket.mockReturnValue(overlaidMarket); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(result).toEqual(overlaidMarket); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).toHaveBeenCalledWith( - 'nfl', - ['sea', 'den'], - ); - }); - - it('skips GameCache overlay when event is not a sports event despite leagues being enabled', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValue(null); - const mockEvent = { id: 'market-1', question: 'Will BTC hit 100k?' }; - const parsedMarket = { id: 'market-1', title: 'Will BTC hit 100k?' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - expect(result).toEqual(parsedMarket); - }); - - it('throws error when parsing fails without calling GameCache overlay', async () => { - const provider = createProvider({ liveSportsLeagues: ['nfl'] }); - mockGetEventLeague.mockReturnValueOnce('nfl'); - mockGetMarketDetailsFromGammaApi.mockResolvedValue({}); - mockParsePolymarketEvents.mockReturnValue([]); - - await expect( - provider.getMarketDetails({ marketId: 'market-1' }), - ).rejects.toThrow('Failed to parse market details'); - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - }); - }); - }); - - describe('WebSocket methods', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('subscribeToGameUpdates', () => { - it('delegates to WebSocketManager.subscribeToGame', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToGameUpdates( - 'game-123', - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToGame, - ).toHaveBeenCalledWith('game-123', mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToGameUpdates( - 'game-456', - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('subscribeToMarketPrices', () => { - it('delegates to WebSocketManager.subscribeToMarketPrices', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToMarketPrices( - ['token-1', 'token-2'], - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToMarketPrices, - ).toHaveBeenCalledWith(['token-1', 'token-2'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribe = jest.fn(); - mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( - mockUnsubscribe, - ); - - const unsubscribe = provider.subscribeToMarketPrices( - ['token-1'], - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('subscribeToCryptoPrices', () => { - it('delegates to WebSocketManager.subscribeToCryptoPrices', () => { - const provider = createProvider(); - const mockCallback = jest.fn(); - const mockUnsubscribeCrypto = jest.fn(); - mockWebSocketManagerInstance.subscribeToCryptoPrices.mockReturnValue( - mockUnsubscribeCrypto, - ); - - const unsubscribe = provider.subscribeToCryptoPrices( - ['btcusdt', 'ethusdt'], - mockCallback, - ); - - expect( - mockWebSocketManagerInstance.subscribeToCryptoPrices, - ).toHaveBeenCalledWith(['btcusdt', 'ethusdt'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribeCrypto); - }); - - it('returns unsubscribe function from WebSocketManager', () => { - const provider = createProvider(); - const mockUnsubscribeCrypto = jest.fn(); - mockWebSocketManagerInstance.subscribeToCryptoPrices.mockReturnValue( - mockUnsubscribeCrypto, - ); - - const unsubscribe = provider.subscribeToCryptoPrices( - ['btcusdt'], - jest.fn(), - ); - - unsubscribe(); - - expect(mockUnsubscribeCrypto).toHaveBeenCalled(); - }); - }); - - describe('getConnectionStatus', () => { - it('returns connection status from WebSocketManager', () => { - const provider = createProvider(); - mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ - sportsConnected: true, - marketConnected: false, - rtdsConnected: false, - gameSubscriptionCount: 5, - priceSubscriptionCount: 10, - cryptoPriceSubscriptionCount: 0, - }); - - const status = provider.getConnectionStatus(); - - expect(status).toEqual({ - sportsConnected: true, - marketConnected: false, - rtdsConnected: false, - }); - }); - - it('maps WebSocketManager status to ConnectionStatus interface', () => { - const provider = createProvider(); - mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ - sportsConnected: false, - marketConnected: true, - rtdsConnected: true, - gameSubscriptionCount: 0, - priceSubscriptionCount: 3, - cryptoPriceSubscriptionCount: 1, - }); - - const status = provider.getConnectionStatus(); - - expect(status.sportsConnected).toBe(false); - expect(status.marketConnected).toBe(true); - expect(Object.keys(status)).toEqual([ - 'sportsConnected', - 'marketConnected', - 'rtdsConnected', - ]); - }); - }); - }); - - describe('Live sports disabled', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getMarkets', () => { - it('skips TeamsCache loading when live sports leagues are empty', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - - it('skips GameCache overlay when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue(mockMarkets); - - const result = await provider.getMarkets(); - - expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); - expect(result).toEqual(mockMarkets); - }); - - it('does not pass teamLookup when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: mockEvents, - category: 'sports', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets({ category: 'sports' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ teamLookup: undefined }), - ); - }); - - it('skips TeamsCache loading when live sports config is defaulted', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - }); - - describe('getMarketDetails', () => { - it('skips TeamsCache loading when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockTeamsCacheInstance.ensureTeamsLoaded).not.toHaveBeenCalled(); - }); - - it('skips GameCache overlay when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - const result = await provider.getMarketDetails({ - marketId: 'market-1', - }); - - expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); - expect(result).toEqual(parsedMarket); - }); - - it('does not pass teamLookup when live sports leagues are empty', async () => { - const provider = createProvider(); - const mockEvent = { id: 'market-1', question: 'Test?' }; - const parsedMarket = { id: 'market-1', title: 'Test' }; - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - [mockEvent], - expect.objectContaining({ teamLookup: undefined }), - ); - }); - }); - }); - - describe('getMarketSeries', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('calls the series events endpoint with the requested params', async () => { - const provider = createProvider(); - const mockEvents = [{ id: 'event-1' }]; - const parsedMarkets = [{ id: 'market-1' }]; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockEvents), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockParsePolymarketEvents.mockReturnValue(parsedMarkets); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - limit: 10, - }); - - const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]); - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('series_id=10684'), - ); - expect(requestUrl.origin + requestUrl.pathname).toBe( - 'https://gamma-api.polymarket.com/events', - ); - expect(requestUrl.searchParams.get('series_id')).toBe('10684'); - expect(requestUrl.searchParams.get('end_date_min')).toBe( - '2026-04-06T00:00:00.000Z', - ); - expect(requestUrl.searchParams.get('end_date_max')).toBe( - '2026-04-07T00:00:00.000Z', - ); - expect(requestUrl.searchParams.get('limit')).toBe('10'); - expect(requestUrl.searchParams.get('order')).toBe('endDate'); - expect(requestUrl.searchParams.get('ascending')).toBe('true'); - }); - - it('returns an empty array when the API returns no events', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - expect(result).toEqual([]); - expect(mockParsePolymarketEvents).not.toHaveBeenCalled(); - }); - - it('uses the default limit when one is not provided', async () => { - const provider = createProvider(); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([{ id: 'event-1' }]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]); - - expect(requestUrl.searchParams.get('limit')).toBe('50'); - }); - }); - - describe('extendedSportsMarketsLeagues pass-through', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('getMarkets passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl', 'nba']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [{ id: 'event-1' }], - category: 'trending', - isSearch: false, - }); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getMarkets passes empty extendedSportsMarketsLeagues when flag has no leagues', async () => { - const provider = createProvider(); - mockFetchEventsFromPolymarketApi.mockResolvedValue({ - events: [{ id: 'event-1' }], - category: 'trending', - isSearch: false, - }); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: [] }), - ); - }); - - it('getMarketDetails passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - const mockEvent = { id: 'market-1', question: 'Test?' }; - mockGetEventLeague.mockReturnValue(null); - mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([ - { id: 'market-1', title: 'Test' }, - ]); - - await provider.getMarketDetails({ marketId: 'market-1' }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getMarketSeries passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl', 'nba']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue([{ id: 'event-1' }]), - }; - (global.fetch as jest.Mock).mockResolvedValue(mockResponse); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getMarketSeries({ - seriesId: '10684', - endDateMin: '2026-04-06T00:00:00.000Z', - endDateMax: '2026-04-07T00:00:00.000Z', - }); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); - - it('getCarouselMarkets passes extendedSportsMarketsLeagues to parsePolymarketEvents', async () => { - const leagues = ['nfl']; - const provider = createProvider({ - liveSportsLeagues: ['nfl'], - extendedSportsMarketsLeagues: leagues, - }); - mockFetchCarouselFromPolymarketApi.mockResolvedValue([ - { event: { id: 'event-1' } }, - ]); - mockExtractNeededTeamsFromEvents.mockReturnValue(new Map()); - mockParsePolymarketEvents.mockReturnValue([]); - - await provider.getCarouselMarkets(); - - expect(mockParsePolymarketEvents).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ extendedSportsMarketsLeagues: leagues }), - ); - }); + expect(result).toEqual({ callData: '0xsignedWithdraw', amount: 1 }); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 35f5890083b3..92bad4e401b1 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -60,26 +60,20 @@ import { SignWithdrawResponse, } from '../types'; import { - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + COLLATERAL_TOKEN_DECIMALS, ORDER_RATE_LIMIT_MS, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, SAFE_EXEC_GAS_LIMIT, } from './constants'; -import { PERMIT2_ADDRESS } from './safe/constants'; import { computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getClaimTransaction, getDeployProxyWalletTransaction, - getProxyWalletAllowancesTransaction, - getSafeUsdcAmount, - getSafeUsdcAmountRaw, - getWithdrawTransactionCallData, - hasAllowances, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './safe/utils'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; +import { Permit2FeeAuthorization } from './safe/types'; import { ApiKeyCreds, OrderType, @@ -94,18 +88,16 @@ import { fetchEventsFromPolymarketApi, fetchCarouselFromPolymarketApi, getBalance, - getContractConfig, getL2Headers, fetchChildEventsFromGammaApi, getMarketDetailsFromGammaApi, - mergeChildEventsIntoParent, - getOrderTypedData, getPolymarketEndpoints, + getRawBalance, + mergeChildEventsIntoParent, parsePolymarketActivity, parsePolymarketEvents, parsePolymarketPositions, previewOrder, - submitClobOrder, } from './utils'; import { PredictFeatureFlags } from '../../types/flags'; import { @@ -119,7 +111,7 @@ import { WebSocketManager } from './WebSocketManager'; import { getProtocolDepositTokenAddress, getProtocolWithdrawTokenAddress, - resolvePolymarketProtocol, + POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from './protocol/definitions'; import { @@ -168,6 +160,7 @@ export class PolymarketProvider implements PredictProvider { #apiKeysByProtocolAddress: Map = new Map(); #accountStateByAddress: Map = new Map(); + #safeAddressesWithZeroLegacyUsdceBalance = new Set(); #lastBuyOrderTimestampByAddress: Map = new Map(); #buyOrderInProgressByAddress: Map = new Map(); #optimisticPositionUpdatesByAddress = new Map< @@ -324,7 +317,36 @@ export class PolymarketProvider implements PredictProvider { } #getProtocol(): PolymarketProtocolDefinition { - return resolvePolymarketProtocol(this.#getFeatureFlags()); + return POLYMARKET_V2_PROTOCOL; + } + + #getLegacyUsdceBalanceCacheKey(safeAddress: string): string { + return getAddress(safeAddress).toLowerCase(); + } + + async #getLegacyUsdceBalance({ + safeAddress, + protocol, + }: { + safeAddress: string; + protocol: PolymarketProtocolDefinition; + }): Promise { + const cacheKey = this.#getLegacyUsdceBalanceCacheKey(safeAddress); + + if (this.#safeAddressesWithZeroLegacyUsdceBalance.has(cacheKey)) { + return 0n; + } + + const balance = await getRawBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + }); + + if (balance === 0n) { + this.#safeAddressesWithZeroLegacyUsdceBalance.add(cacheKey); + } + + return balance; } #pickExecutor(executors: string[]): string { @@ -391,185 +413,14 @@ export class PolymarketProvider implements PredictProvider { throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); } - async #submitOrderV1({ + async #submitOrder({ signer, preview, protocol, }: { signer: Signer; preview: OrderPreview; - protocol: Extract; - }) { - const chainId = POLYGON_MAINNET_CHAIN_ID; - const makerAddress = - this.#accountStateByAddress.get(signer.address)?.address ?? - computeProxyAddress(signer.address); - - if (!makerAddress) { - throw new Error('Maker address not found'); - } - - const order = buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress: signer.address, - }); - - const typedData = getOrderTypedData({ - order, - chainId, - verifyingContract: - getContractConfig(chainId)[ - preview.negRisk ? 'negRiskExchange' : 'exchange' - ], - }); - - const signature = await signer.signTypedMessage( - { data: typedData, from: signer.address }, - SignTypedDataVersion.V4, - ); - - const signedOrder = { - ...order, - signature, - }; - const signerApiKey = await this.getApiKey({ - address: signer.address, - protocol, - }); - const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const shouldUsePermit2 = this.#hasPermit2Config({ - permit2Enabled: preview.fees?.permit2Enabled, - executors: preview.fees?.executors, - }); - - let feeAuthorization: - | SafeFeeAuthorization - | Permit2FeeAuthorization - | undefined; - let executor: string | undefined; - let permit2FeeReady = false; - - if (preview.fees !== undefined && preview.fees.totalFee > 0) { - const safeAddress = computeProxyAddress(signer.address); - const feeAmountInUsdc = BigInt( - parseUnits(preview.fees.totalFee.toString(), 6).toString(), - ); - - if (shouldUsePermit2) { - permit2FeeReady = true; - executor = this.#pickExecutor(preview.fees.executors ?? []); - feeAuthorization = await createPermit2FeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - spender: executor, - }); - } else { - feeAuthorization = await createSafeFeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - to: preview.fees.collector, - }); - } - } - - let allowancesTx: { to: string; data: string } | undefined; - let permit2AllowanceReady = false; - const hasSafeFeeAuth = feeAuthorization !== undefined && !permit2FeeReady; - - if (feeCollection.permit2Enabled && !hasSafeFeeAuth) { - try { - const accountState = await this.getAccountState({ - ownerAddress: signer.address, - }); - - if (accountState.hasAllowances) { - permit2AllowanceReady = true; - } else { - const allowanceTx = await getProxyWalletAllowancesTransaction({ - signer, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - allowancesTx = allowanceTx.params; - permit2AllowanceReady = true; - } - } catch (allowanceError) { - DevLogger.log( - 'PolymarketProvider: Failed to generate allowances transaction', - { error: allowanceError }, - ); - Logger.error( - allowanceError instanceof Error - ? allowanceError - : new Error(String(allowanceError)), - this.getErrorContext('placeOrder:allowancesTx', { - operation: 'generate_allowances_tx', - }), - ); - } - } - - const orderType = this.#getPlaceOrderType({ - preview, - feeCollection, - fakOrdersEnabled, - permit2FeeReady, - permit2AllowanceReady, - }); - - const clobOrder = serializeProtocolRelayerOrder({ - signedOrder, - owner: signerApiKey.apiKey, - orderType, - side: preview.side, - }); - const body = JSON.stringify(clobOrder); - const headers = await getL2Headers({ - l2HeaderArgs: { - method: 'POST', - requestPath: `/order`, - body, - }, - address: clobOrder.order.signer ?? '', - apiKey: signerApiKey, - }); - - const orderResult = await submitClobOrder({ - headers, - clobOrder, - feeAuthorization, - executor, - allowancesTx, - }); - - if (!orderResult.success) { - DevLogger.log('PolymarketProvider: Place order failed', { - error: orderResult.error, - errorDetails: undefined, - side: preview.side, - outcomeTokenId: preview.outcomeTokenId, - }); - this.#throwPlaceOrderError({ - error: orderResult.error, - side: preview.side, - }); - } - - return orderResult.response; - } - - async #submitOrderV2({ - signer, - preview, - protocol, - }: { - signer: Signer; - preview: OrderPreview; - protocol: Extract; + protocol: PolymarketProtocolDefinition; }) { const safeAddress = this.#accountStateByAddress.get(signer.address)?.address ?? @@ -579,7 +430,7 @@ export class PolymarketProvider implements PredictProvider { protocol, preview: { ...preview, - feeRateBps: getPreviewFeeRateBpsForProtocol({ protocol, preview }), + feeRateBps: getPreviewFeeRateBpsForProtocol(), }, makerAddress: safeAddress, signerAddress: getAddress(signer.address), @@ -605,7 +456,6 @@ export class PolymarketProvider implements PredictProvider { }; const signerApiKey = await this.getApiKey({ address: signer.address, - protocol, }); const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); const shouldUsePermit2 = this.#hasPermit2Config({ @@ -640,10 +490,15 @@ export class PolymarketProvider implements PredictProvider { let permit2AllowanceReady = false; try { + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); allowancesTx = await buildTradeAllowancesTx({ signer, safeAddress, protocol, + safeUsdceBalance: safeLegacyUsdceBalance, }); permit2AllowanceReady = true; } catch (allowanceError) { @@ -697,7 +552,7 @@ export class PolymarketProvider implements PredictProvider { }); if (!orderResult.success) { - DevLogger.log('PolymarketProvider: Place order V2 failed', { + DevLogger.log('PolymarketProvider: Place order failed', { error: orderResult.error, errorDetails: undefined, side: preview.side, @@ -824,12 +679,10 @@ export class PolymarketProvider implements PredictProvider { private async getApiKey({ address, - protocol, }: { address: string; - protocol: Pick; }): Promise { - const cacheKey = `${protocol.key}:${protocol.transport.clobBaseUrl}:${address}`; + const cacheKey = address; const cachedApiKey = this.#apiKeysByProtocolAddress.get(cacheKey); if (cachedApiKey) { return cachedApiKey; @@ -837,9 +690,6 @@ export class PolymarketProvider implements PredictProvider { const apiKeyCreds = await createApiKey({ address, - clobVersion: protocol.key, - clobBaseUrl: - protocol.key === 'v2' ? protocol.transport.clobBaseUrl : undefined, }); this.#apiKeysByProtocolAddress.set(cacheKey, apiKeyCreds); return apiKeyCreds; @@ -1734,20 +1584,13 @@ export class PolymarketProvider implements PredictProvider { }, ): Promise { const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const protocol = this.#getProtocol(); const basePreview = await previewOrder({ ...params, feeCollection, - isV2: protocol.key === 'v2', - clobBaseUrl: - protocol.key === 'v2' ? protocol.transport.clobBaseUrl : undefined, }); const normalizedPreview = { ...basePreview, - feeRateBps: getPreviewFeeRateBpsForProtocol({ - protocol, - preview: basePreview, - }), + feeRateBps: getPreviewFeeRateBpsForProtocol(), }; let orderType = OrderType.FOK; @@ -1829,18 +1672,11 @@ export class PolymarketProvider implements PredictProvider { try { const protocol = this.#getProtocol(); - const orderResponse = - protocol.key === 'v2' - ? await this.#submitOrderV2({ - signer, - preview, - protocol, - }) - : await this.#submitOrderV1({ - signer, - preview, - protocol, - }); + const orderResponse = await this.#submitOrder({ + signer, + preview, + protocol, + }); if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); @@ -1952,48 +1788,21 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Safe address not found for claim'); } - if (protocol.key === 'v2') { - const claimTransaction = await buildClaimTransaction({ - signer, - positions, - safeAddress, - protocol, - }); - - return { - chainId: POLYGON_MAINNET_CHAIN_ID, - transactions: [claimTransaction], - }; - } - - const signerBalance = await getBalance({ address: signer.address }); - const includeTransferTransaction = - signerBalance < MIN_COLLATERAL_BALANCE_FOR_CLAIM; - - // Generate claim transaction - let claimTransaction; - try { - claimTransaction = await getClaimTransaction({ - signer, - positions, - safeAddress, - includeTransferTransaction, - }); - } catch (error) { - throw new Error( - `Failed to generate claim transaction: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ); - } - - if (!claimTransaction || claimTransaction.length === 0) { - throw new Error('No claim transaction generated'); - } + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); + const claimTransaction = await buildClaimTransaction({ + signer, + positions, + safeAddress, + protocol, + safeLegacyUsdceBalance, + }); return { chainId: POLYGON_MAINNET_CHAIN_ID, - transactions: claimTransaction, + transactions: [claimTransaction], }; } catch (error) { // Log error for debugging @@ -2137,51 +1946,23 @@ export class PolymarketProvider implements PredictProvider { type: TransactionType.predictDeposit, }; - if (protocol.key === 'v2') { - transactions.push(depositTransaction); - - const maintenanceTransaction = await buildDepositMaintenanceTransaction({ - signer, - safeAddress: accountState.address, - protocol, - }); - - if (maintenanceTransaction) { - transactions.push(maintenanceTransaction); - } - - return { - chainId: CHAIN_IDS.POLYGON, - transactions, - }; - } - - if (!accountState.hasAllowances) { - const { feeCollection: depositFeeCollection } = this.#getFeatureFlags(); - const extraUsdcSpenders = depositFeeCollection.permit2Enabled - ? [PERMIT2_ADDRESS] - : []; - const allowanceTransaction = await getProxyWalletAllowancesTransaction({ - signer, - extraUsdcSpenders, - }); - - if (!allowanceTransaction) { - throw new Error('Failed to get proxy wallet allowances transaction'); - } + transactions.push(depositTransaction); - if ( - !allowanceTransaction.params?.to || - !allowanceTransaction.params?.data - ) { - throw new Error('Invalid allowance transaction: missing params'); - } + const preExistingSafeUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress: accountState.address, + protocol, + }); + const maintenanceTransaction = await buildDepositMaintenanceTransaction({ + signer, + safeAddress: accountState.address, + protocol, + preExistingSafeUsdceBalance, + }); - transactions.push(allowanceTransaction); + if (maintenanceTransaction) { + transactions.push(maintenanceTransaction); } - transactions.push(depositTransaction); - return { chainId: CHAIN_IDS.POLYGON, transactions, @@ -2215,21 +1996,12 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Failed to get safe address'); } - // Check deployment status and allowances let isDeployed: boolean; - let hasAllowancesResult: boolean; - const { feeCollection: flagFeeCollection } = this.#getFeatureFlags(); - const extraUsdcSpenders = flagFeeCollection.permit2Enabled - ? [PERMIT2_ADDRESS] - : []; try { - [isDeployed, hasAllowancesResult] = await Promise.all([ - isSmartContractAddress( - address, - numberToHex(POLYGON_MAINNET_CHAIN_ID), - ), - hasAllowances({ address, extraUsdcSpenders }), - ]); + isDeployed = await isSmartContractAddress( + address, + numberToHex(POLYGON_MAINNET_CHAIN_ID), + ); } catch (error) { throw new Error( `Failed to check account state: ${ @@ -2241,7 +2013,6 @@ export class PolymarketProvider implements PredictProvider { const accountState = { address: address as `0x${string}`, isDeployed, - hasAllowances: hasAllowancesResult, }; this.#accountStateByAddress.set(ownerAddress, accountState); @@ -2267,20 +2038,20 @@ export class PolymarketProvider implements PredictProvider { computeProxyAddress(address); const protocol = this.#getProtocol(); - if (protocol.key !== 'v2') { - return await getBalance({ address: predictAddress }); - } + const [pusdBalance, legacyUsdceBalance] = await Promise.all([ + getBalance({ + address: predictAddress, + tokenAddress: protocol.collateral.tradingToken, + }), + this.#getLegacyUsdceBalance({ + safeAddress: predictAddress, + protocol, + }), + ]); - const balances = await Promise.all( - protocol.collateral.balanceTokens.map((tokenAddress) => - getBalance({ - address: predictAddress, - tokenAddress, - }), - ), + return ( + pusdBalance + Number(legacyUsdceBalance) / 10 ** COLLATERAL_TOKEN_DECIMALS ); - - return balances.reduce((sum, balance) => sum + balance, 0); } public async prepareWithdraw( @@ -2332,32 +2103,23 @@ export class PolymarketProvider implements PredictProvider { this.#accountStateByAddress.get(signer.address)?.address ?? computeProxyAddress(signer.address); - const amount = getSafeUsdcAmount(callData); - const requestedAmountRaw = getSafeUsdcAmountRaw(callData); + const amount = getSafeTransferAmount(callData); + const requestedAmountRaw = getSafeTransferAmountRaw(callData); - if (protocol.key === 'v2') { - const signedWithdrawTransaction = await buildWithdrawTransaction({ - signer, - safeAddress, - requestedAmountRaw, - mode: protocol.workflow.withdrawMode, - protocol, - }); - - return { - callData: signedWithdrawTransaction.params.data, - amount, - }; - } - - const signedCallData = await getWithdrawTransactionCallData({ - data: callData, + const safeLegacyUsdceBalance = await this.#getLegacyUsdceBalance({ + safeAddress, + protocol, + }); + const signedWithdrawTransaction = await buildWithdrawTransaction({ signer, safeAddress, + requestedAmountRaw, + protocol, + safeLegacyUsdceBalance, }); return { - callData: signedCallData, + callData: signedWithdrawTransaction.params.data, amount, }; } diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 01fe31fb2aee..56d34f5e0f61 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -5,19 +5,18 @@ export const POLYMARKET_PROVIDER_ID = 'polymarket'; export const POLYMARKET_TERMS_URL = 'https://polymarket.com/tos'; export const DEFAULT_CLOB_BASE_URL = 'https://clob.polymarket.com'; -export const LEGACY_V2_CLOB_BASE_URL = 'https://clob-v2.polymarket.com'; /** * Default slippage for market orders. */ export const SLIPPAGE_BUY = 0.03; // 3% export const SLIPPAGE_SELL = 0.05; // 5% -// BUY is floored at maxAmountSpent + tickSize. SELL has no floor — user accepts up to 99% less USDC. +// BUY is floored at maxAmountSpent + tickSize. SELL has no floor — user accepts up to 99% less pUSD. export const SLIPPAGE_BEST_AVAILABLE = 0.99; // 99% export const ORDER_RATE_LIMIT_MS = 5000; -export const MIN_COLLATERAL_BALANCE_FOR_CLAIM = 0.5; +export const MIN_PUSD_BALANCE_FOR_CLAIM_GAS = 0.5; export const POLYGON_MAINNET_CHAIN_ID = 137; export const POLYGON_MAINNET_CAIP_CHAIN_ID = @@ -76,14 +75,6 @@ export const ROUNDING_CONFIG: Record = { */ export const SAFE_EXEC_GAS_LIMIT = 121000; -export const MATIC_CONTRACTS: ContractConfig = { - exchange: '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E', - negRiskAdapter: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', - negRiskExchange: '0xC5d563A36AE78145C45a50134d48A1215220f80a', - collateral: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', -}; - export const MATIC_CONTRACTS_V2: ContractConfig = { exchange: '0xE111180000d2663C0091e4f400237545B87B996B', negRiskAdapter: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', @@ -92,22 +83,19 @@ export const MATIC_CONTRACTS_V2: ContractConfig = { conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', }; -export const USDC_E_ADDRESS = MATIC_CONTRACTS.collateral; +export const USDC_E_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; export const COLLATERAL_ONRAMP_ADDRESS = '0x93070a847efEf7F70739046A929D47a521F5B8ee'; -export const COLLATERAL_OFFRAMP_ADDRESS = - '0x2957922Eb93258b93368531d39fAcCA3B4dC5854'; - export const CTF_COLLATERAL_ADAPTER_ADDRESS = '0xAdA100Db00Ca00073811820692005400218FcE1f'; export const NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS = '0xadA2005600Dec949baf300f4C6120000bDB6eAab'; -export const POLYGON_USDC_CAIP_ASSET_ID = - `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS.collateral}` as const; +export const POLYGON_PUSD_CAIP_ASSET_ID = + `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS_V2.collateral}` as const; export const SPORTS_MARKET_TYPE_TO_GROUP: Record = { first_half_moneyline: 'first_half', diff --git a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts index 3061f11eb26f..01eb962364e4 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts @@ -4,34 +4,31 @@ import type { PredictPosition } from '../../../types'; import type { Signer } from '../../types'; import { HASH_ZERO_BYTES32, - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + MIN_PUSD_BALANCE_FOR_CLAIM_GAS, } from '../constants'; import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from '../protocol/definitions'; import { OperationType, type SafeTransaction } from '../safe/types'; -import { encodeRedeemPositions } from '../utils'; +import { encodeErc20Transfer, encodeRedeemPositions } from '../utils'; import { buildSignedSafeExecution, - buildUnwrapTransaction, compileAllowanceMaintenanceTransactions, getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; import { - getCanonicalV2AllowanceRequirements, + getActiveV2AllowanceRequirements, + getLegacySweepAllowanceRequirements, type V2AllowanceRequirement, } from './v2AllowanceRequirements'; -const MIN_GAS_STATION_USDCE_BALANCE_RAW = BigInt( - parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), +const MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW = BigInt( + parseUnits(MIN_PUSD_BALANCE_FOR_CLAIM_GAS.toString(), 6).toString(), ); -type PolymarketV2ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v2' } ->; +type PolymarketV2ProtocolDefinition = PolymarketProtocolDefinition; function buildClaimSubtransactions({ positions, @@ -58,9 +55,11 @@ function buildClaimSubtransactions({ export function getClaimRequirements({ positions, protocol = POLYMARKET_V2_PROTOCOL, + includeLegacySweep = true, }: { positions: PredictPosition[]; protocol?: PolymarketV2ProtocolDefinition; + includeLegacySweep?: boolean; }): V2AllowanceRequirement[] { const requiresStandardAdapter = positions.some( (position) => !position.negRisk, @@ -68,7 +67,10 @@ export function getClaimRequirements({ const requiresNegRiskAdapter = positions.some((position) => position.negRisk); return [ - ...getCanonicalV2AllowanceRequirements(protocol), + ...(includeLegacySweep + ? getLegacySweepAllowanceRequirements(protocol) + : []), + ...getActiveV2AllowanceRequirements(protocol), ...(requiresStandardAdapter ? [ { @@ -91,9 +93,9 @@ export function getClaimRequirements({ } export interface ClaimPlan { - gasStationDeficit: bigint; - safeUsdceBalance: bigint; - eoaUsdceBalance: bigint; + gasTokenDeficit: bigint; + safeLegacyUsdceBalance: bigint; + eoaPusdBalance: bigint; missingRequirements: V2AllowanceRequirement[]; transactions: SafeTransaction[]; } @@ -103,32 +105,40 @@ export async function planClaim({ positions, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance: providedSafeLegacyUsdceBalance, }: { signer: Signer; positions: PredictPosition[]; safeAddress: string; protocol?: PolymarketV2ProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance, eoaUsdceBalance] = - await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getClaimRequirements({ positions, protocol }), - }), - getRawTokenBalance({ - address: safeAddress, - tokenAddress: protocol.collateral.legacyUsdceToken, - }), - getRawTokenBalance({ - address: signer.address, - tokenAddress: protocol.collateral.legacyUsdceToken, + const safeLegacyUsdceBalance = + providedSafeLegacyUsdceBalance ?? + (await getRawTokenBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + })); + + const [missingRequirements, eoaPusdBalance] = await Promise.all([ + inspectMissingRequirements({ + address: safeAddress, + requirements: getClaimRequirements({ + positions, + protocol, + includeLegacySweep: safeLegacyUsdceBalance > 0n, }), - ]); + }), + getRawTokenBalance({ + address: signer.address, + tokenAddress: protocol.collateral.tradingToken, + }), + ]); - const gasStationDeficit = - eoaUsdceBalance >= MIN_GAS_STATION_USDCE_BALANCE_RAW + const gasTokenDeficit = + eoaPusdBalance >= MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW ? 0n - : MIN_GAS_STATION_USDCE_BALANCE_RAW - eoaUsdceBalance; + : MIN_PUSD_BALANCE_FOR_CLAIM_GAS_RAW - eoaPusdBalance; const transactions = compileClaimTransactions({ protocol, @@ -136,14 +146,14 @@ export async function planClaim({ positions, safeAddress, missingRequirements, - safeUsdceBalance, - gasStationDeficit, + safeLegacyUsdceBalance, + gasTokenDeficit, }); return { - gasStationDeficit, - safeUsdceBalance, - eoaUsdceBalance, + gasTokenDeficit, + safeLegacyUsdceBalance, + eoaPusdBalance, missingRequirements, transactions, }; @@ -155,22 +165,22 @@ function compileClaimTransactions({ positions, safeAddress, missingRequirements, - safeUsdceBalance, - gasStationDeficit, + safeLegacyUsdceBalance, + gasTokenDeficit, }: { protocol?: PolymarketV2ProtocolDefinition; signer: Signer; positions: PredictPosition[]; safeAddress: string; missingRequirements: V2AllowanceRequirement[]; - safeUsdceBalance: bigint; - gasStationDeficit: bigint; + safeLegacyUsdceBalance: bigint; + gasTokenDeficit: bigint; }): SafeTransaction[] { const transactions = compileAllowanceMaintenanceTransactions({ protocol, safeAddress, missingRequirements, - usdceBalance: safeUsdceBalance, + usdceBalance: safeLegacyUsdceBalance, }); transactions.push( @@ -180,14 +190,16 @@ function compileClaimTransactions({ }), ); - const unwrapTransaction = buildUnwrapTransaction({ - recipientAddress: signer.address, - amount: gasStationDeficit, - protocol, - }); - - if (unwrapTransaction) { - transactions.push(unwrapTransaction); + if (gasTokenDeficit > 0n) { + transactions.push({ + to: protocol.collateral.tradingToken, + data: encodeErc20Transfer({ + to: signer.address, + value: gasTokenDeficit, + }), + operation: OperationType.Call, + value: '0', + }); } return transactions; @@ -198,17 +210,20 @@ export async function buildClaimTransaction({ positions, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance, }: { signer: Signer; positions: PredictPosition[]; safeAddress: string; protocol?: PolymarketV2ProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }) { const plan = await planClaim({ signer, positions, safeAddress, protocol, + safeLegacyUsdceBalance, }); return buildSignedSafeExecution({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/core.ts b/app/components/UI/Predict/providers/polymarket/preflight/core.ts index 4c1718a6613e..db9e5d805819 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/core.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/core.ts @@ -5,7 +5,7 @@ import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from '../protocol/definitions'; -import { encodeUnwrap, encodeWrap } from '../protocol/orderCodec'; +import { encodeWrap } from '../protocol/orderCodec'; import { OperationType, type SafeTransaction } from '../safe/types'; import { aggregateTransaction, @@ -61,7 +61,7 @@ export function buildWrapTransaction({ amount: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction | undefined { - if (amount <= 0n || protocol.collateral.onrampAddress === undefined) { + if (amount <= 0n) { return undefined; } @@ -77,29 +77,18 @@ export function buildWrapTransaction({ }; } -export function buildUnwrapTransaction({ - recipientAddress, - amount, - protocol = POLYMARKET_V2_PROTOCOL, +function isLegacySweepRequirement({ + requirement, + protocol, }: { - recipientAddress: string; - amount: bigint; - protocol?: PolymarketProtocolDefinition; -}): SafeTransaction | undefined { - if (amount <= 0n || protocol.collateral.offrampAddress === undefined) { - return undefined; - } - - return { - to: protocol.collateral.offrampAddress, - data: encodeUnwrap({ - asset: protocol.collateral.legacyUsdceToken, - to: recipientAddress, - amount, - }), - operation: OperationType.Call, - value: '0', - }; + requirement: V2AllowanceRequirement; + protocol: PolymarketProtocolDefinition; +}): boolean { + return ( + requirement.type === 'erc20-allowance' && + requirement.tokenAddress === protocol.collateral.legacyUsdceToken && + requirement.spender === protocol.collateral.onrampAddress + ); } export function compileAllowanceMaintenanceTransactions({ @@ -113,7 +102,11 @@ export function compileAllowanceMaintenanceTransactions({ usdceBalance: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction[] { - const transactions = compileRequirementTransactions(missingRequirements); + const requirements = missingRequirements.filter( + (requirement) => + usdceBalance > 0n || !isLegacySweepRequirement({ requirement, protocol }), + ); + const transactions = compileRequirementTransactions(requirements); const wrapTransaction = buildWrapTransaction({ safeAddress, amount: usdceBalance, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts index b053b1cfb3b2..c266170b3330 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts @@ -11,10 +11,14 @@ import { getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface DepositMaintenancePlan { - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; preExistingSafeUsdceBalance: bigint; transactions: SafeTransaction[]; } @@ -22,20 +26,26 @@ export interface DepositMaintenancePlan { export async function planDepositMaintenance({ safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + preExistingSafeUsdceBalance: providedPreExistingSafeUsdceBalance, }: { safeAddress: string; protocol?: PolymarketProtocolDefinition; + preExistingSafeUsdceBalance?: bigint; }): Promise { - const [missingRequirements, preExistingSafeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const preExistingSafeUsdceBalance = + providedPreExistingSafeUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); + })); + const requirements = + preExistingSafeUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { missingRequirements, @@ -57,7 +67,7 @@ function compileDepositMaintenanceTransactions({ }: { protocol?: PolymarketProtocolDefinition; safeAddress: string; - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; preExistingSafeUsdceBalance: bigint; }): SafeTransaction[] { return compileAllowanceMaintenanceTransactions({ @@ -72,12 +82,18 @@ export async function buildDepositMaintenanceTransaction({ signer, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + preExistingSafeUsdceBalance, }: { signer: Signer; safeAddress: string; protocol?: PolymarketProtocolDefinition; + preExistingSafeUsdceBalance?: bigint; }) { - const plan = await planDepositMaintenance({ safeAddress, protocol }); + const plan = await planDepositMaintenance({ + safeAddress, + protocol, + preExistingSafeUsdceBalance, + }); return buildSignedSafeExecutionIfNeeded({ signer, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/trade.ts b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts index b3e8358b5809..0b9ecf88a5ec 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/trade.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts @@ -11,10 +11,14 @@ import { getRawTokenBalance, } from './core'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface TradePreflightPlan { - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; safeUsdceBalance: bigint; transactions: SafeTransaction[]; } @@ -22,20 +26,26 @@ export interface TradePreflightPlan { export async function planTradePreflight({ safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeUsdceBalance: providedSafeUsdceBalance, }: { safeAddress: string; protocol?: PolymarketProtocolDefinition; + safeUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const safeUsdceBalance = + providedSafeUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); + })); + const requirements = + safeUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { missingRequirements, @@ -57,7 +67,7 @@ export function compileTradePreflightTransactions({ }: { protocol?: PolymarketProtocolDefinition; safeAddress: string; - missingRequirements: ReturnType; + missingRequirements: V2AllowanceRequirement[]; safeUsdceBalance: bigint; }): SafeTransaction[] { return compileAllowanceMaintenanceTransactions({ @@ -72,14 +82,17 @@ export async function buildTradeAllowancesTx({ signer, safeAddress, protocol = POLYMARKET_V2_PROTOCOL, + safeUsdceBalance, }: { signer: Signer; safeAddress: string; protocol?: PolymarketProtocolDefinition; + safeUsdceBalance?: bigint; }): Promise<{ to: string; data: string } | undefined> { const plan = await planTradePreflight({ safeAddress, protocol, + safeUsdceBalance, }); const signedExecution = await buildSignedSafeExecutionIfNeeded({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts index 08e53ad3f6c1..2c6ff66433cb 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts @@ -1,12 +1,26 @@ import { PERMIT2_ADDRESS } from '../safe/constants'; import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, +} from './v2AllowanceRequirements'; describe('v2 allowance requirements', () => { + it('returns active v2 requirements without the legacy sweep requirement', () => { + const requirements = getActiveV2AllowanceRequirements(); + + expect(requirements).toHaveLength(8); + expect(requirements).not.toContainEqual({ + type: 'erc20-allowance', + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + spender: POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + }); + }); + it('returns the canonical requirement list in deterministic order', () => { const requirements = getCanonicalV2AllowanceRequirements(); - expect(requirements).toHaveLength(10); + expect(requirements).toHaveLength(9); expect(requirements[0]).toEqual({ type: 'erc20-allowance', tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, @@ -18,10 +32,6 @@ describe('v2 allowance requirements', () => { type: 'erc20-allowance', spender: PERMIT2_ADDRESS, }), - expect.objectContaining({ - type: 'erc20-allowance', - spender: POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - }), expect.objectContaining({ type: 'erc1155-operator', operator: POLYMARKET_V2_PROTOCOL.contracts.exchange, diff --git a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts index 9989616e6357..b83b75387774 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts @@ -48,23 +48,27 @@ function buildErc1155OperatorRequirements({ })); } -export function getCanonicalV2AllowanceRequirements( +export function getLegacySweepAllowanceRequirements( protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, ): V2AllowanceRequirement[] { - const { collateral, contracts } = protocol; - - if (!collateral.onrampAddress || !collateral.offrampAddress) { - throw new Error( - 'Polymarket CLOB v2 collateral ramp addresses are required', - ); - } + const { collateral } = protocol; return [ + // Temporary legacy Safe USDC.e -> pUSD sweep support. TODO: remove after one release. { type: 'erc20-allowance', tokenAddress: collateral.legacyUsdceToken, spender: collateral.onrampAddress, }, + ]; +} + +export function getActiveV2AllowanceRequirements( + protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, +): V2AllowanceRequirement[] { + const { collateral, contracts } = protocol; + + return [ ...buildErc20AllowanceRequirements({ tokenAddress: collateral.tradingToken, spenders: [ @@ -73,7 +77,6 @@ export function getCanonicalV2AllowanceRequirements( contracts.negRiskExchange, contracts.negRiskAdapter, PERMIT2_ADDRESS, - collateral.offrampAddress, ], }), ...buildErc1155OperatorRequirements({ @@ -86,3 +89,12 @@ export function getCanonicalV2AllowanceRequirements( }), ]; } + +export function getCanonicalV2AllowanceRequirements( + protocol: PolymarketProtocolDefinition = POLYMARKET_V2_PROTOCOL, +): V2AllowanceRequirement[] { + return [ + ...getLegacySweepAllowanceRequirements(protocol), + ...getActiveV2AllowanceRequirements(protocol), + ]; +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts index 82c2635e5add..b2c236b9dc63 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts @@ -1,20 +1,12 @@ -jest.mock('./core', () => ({ - buildSignedSafeExecution: jest.fn(), - buildUnwrapTransaction: jest.fn(({ amount, protocol, recipientAddress }) => { - if (amount === 0n || !protocol?.collateral.offrampAddress) { - return undefined; - } - - return { - to: protocol.collateral.offrampAddress, - data: '0xunwrap', - operation: 0, - value: '0', - recipientAddress, - }; - }), - getRawTokenBalance: jest.fn(), -})); +jest.mock('./core', () => { + const actual = jest.requireActual('./core'); + + return { + ...actual, + buildSignedSafeExecution: jest.fn(), + getRawTokenBalance: jest.fn(), + }; +}); jest.mock('./inspectMissingRequirements', () => ({ inspectMissingRequirements: jest.fn().mockResolvedValue([]), @@ -24,21 +16,22 @@ jest.mock('./compileRequirementTransactions', () => ({ compileRequirementTransactions: jest.fn(() => []), })); -jest.mock('../protocol/orderCodec', () => ({ - encodeUnwrap: jest.fn(() => '0xunwrap'), -})); - jest.mock('../utils', () => ({ encodeErc20Transfer: jest.fn(() => '0xtransfer'), })); import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; import { getRawTokenBalance } from './core'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; import { planWithdraw } from './withdraw'; const mockGetRawTokenBalance = getRawTokenBalance as jest.MockedFunction< typeof getRawTokenBalance >; +const mockInspectMissingRequirements = + inspectMissingRequirements as jest.MockedFunction< + typeof inspectMissingRequirements + >; const signer = { address: '0x1111111111111111111111111111111111111111', @@ -51,69 +44,37 @@ describe('planWithdraw', () => { jest.clearAllMocks(); }); - it('does not read Safe pUSD when the Safe already has enough USDC.e', async () => { + it('sweeps legacy Safe USDC.e state and transfers pUSD directly', async () => { mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); const plan = await planWithdraw({ signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', protocol: POLYMARKET_V2_PROTOCOL, }); - expect(plan.deficit).toBe(0n); + expect(plan.safeLegacyUsdceBalance).toBe(1_000_000n); expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(1); expect(mockGetRawTokenBalance).toHaveBeenCalledWith({ address: '0x9999999999999999999999999999999999999999', tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, }); - }); - - it('allows fallback withdraw when Safe pUSD covers the exact deficit', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(500_000n) - .mockResolvedValueOnce(500_000n); - - const plan = await planWithdraw({ - signer, - safeAddress: '0x9999999999999999999999999999999999999999', - requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', - protocol: POLYMARKET_V2_PROTOCOL, - }); - - expect(plan.deficit).toBe(500_000n); - expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(2); - expect(mockGetRawTokenBalance.mock.calls[1]?.[0]).toEqual({ + expect(mockInspectMissingRequirements).toHaveBeenCalledWith({ address: '0x9999999999999999999999999999999999999999', - tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + requirements: expect.arrayContaining([ + expect.objectContaining({ + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + spender: POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + }), + expect.objectContaining({ + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + }), + ]), }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); - - it('throws when Safe pUSD is below the exact deficit', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(500_000n) - .mockResolvedValueOnce(499_999n); - - await expect( - planWithdraw({ - signer, - safeAddress: '0x9999999999999999999999999999999999999999', - requestedAmountRaw: 1_000_000n, - mode: 'usdce-deficit-unwrap', - protocol: POLYMARKET_V2_PROTOCOL, - }), - ).rejects.toThrow('Insufficient Safe pUSD balance for fallback withdraw'); - - expect(mockGetRawTokenBalance).toHaveBeenCalledTimes(2); - expect(mockGetRawTokenBalance.mock.calls[1]?.[0]).toEqual({ - address: '0x9999999999999999999999999999999999999999', - tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, - }); - }); }); diff --git a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts index fff3cc1be12f..7345ad8742c6 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts @@ -3,24 +3,25 @@ import type { Signer } from '../../types'; import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, - type WithdrawExecutionMode, } from '../protocol/definitions'; import { OperationType, type SafeTransaction } from '../safe/types'; import { encodeErc20Transfer } from '../utils'; import { buildSignedSafeExecution, - buildUnwrapTransaction, + compileAllowanceMaintenanceTransactions, getRawTokenBalance, } from './core'; -import { compileRequirementTransactions } from './compileRequirementTransactions'; import { inspectMissingRequirements } from './inspectMissingRequirements'; -import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; +import { + getActiveV2AllowanceRequirements, + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; export interface WithdrawPlan { requestedAmountRaw: bigint; - safeUsdceBalance: bigint; - deficit: bigint; - missingRequirements: ReturnType; + safeLegacyUsdceBalance: bigint; + missingRequirements: V2AllowanceRequirement[]; transactions: SafeTransaction[]; } @@ -28,54 +29,40 @@ export async function planWithdraw({ signer, safeAddress, requestedAmountRaw, - mode, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance: providedSafeLegacyUsdceBalance, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - mode: WithdrawExecutionMode; protocol?: PolymarketProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }): Promise { - const [missingRequirements, safeUsdceBalance] = await Promise.all([ - inspectMissingRequirements({ - address: safeAddress, - requirements: getCanonicalV2AllowanceRequirements(protocol), - }), - getRawTokenBalance({ + const safeLegacyUsdceBalance = + providedSafeLegacyUsdceBalance ?? + (await getRawTokenBalance({ address: safeAddress, tokenAddress: protocol.collateral.legacyUsdceToken, - }), - ]); - - const deficit = - mode === 'usdce-deficit-unwrap' && requestedAmountRaw > safeUsdceBalance - ? requestedAmountRaw - safeUsdceBalance - : 0n; - - if (mode === 'usdce-deficit-unwrap' && deficit > 0n) { - const safePusdBalance = await getRawTokenBalance({ - address: safeAddress, - tokenAddress: protocol.collateral.tradingToken, - }); - - if (safePusdBalance < deficit) { - throw new Error('Insufficient Safe pUSD balance for fallback withdraw'); - } - } + })); + const requirements = + safeLegacyUsdceBalance > 0n + ? getCanonicalV2AllowanceRequirements(protocol) + : getActiveV2AllowanceRequirements(protocol); + const missingRequirements = await inspectMissingRequirements({ + address: safeAddress, + requirements, + }); return { requestedAmountRaw, - safeUsdceBalance, - deficit, + safeLegacyUsdceBalance, missingRequirements, transactions: compileWithdrawTransactions({ signer, safeAddress, requestedAmountRaw, - deficit, missingRequirements, - mode, + safeLegacyUsdceBalance, protocol, }), }; @@ -83,49 +70,28 @@ export async function planWithdraw({ function compileWithdrawTransactions({ signer, - safeAddress, requestedAmountRaw, - deficit, + safeAddress, missingRequirements, - mode, + safeLegacyUsdceBalance, protocol = POLYMARKET_V2_PROTOCOL, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - deficit: bigint; - missingRequirements: ReturnType; - mode: WithdrawExecutionMode; + missingRequirements: V2AllowanceRequirement[]; + safeLegacyUsdceBalance: bigint; protocol?: PolymarketProtocolDefinition; }): SafeTransaction[] { - const transactions = compileRequirementTransactions(missingRequirements); - - if (mode === 'pusd-transfer') { - transactions.push({ - to: protocol.collateral.tradingToken, - data: encodeErc20Transfer({ - to: signer.address, - value: requestedAmountRaw, - }), - operation: OperationType.Call, - value: '0', - }); - - return transactions; - } - - const unwrapTransaction = buildUnwrapTransaction({ - recipientAddress: safeAddress, - amount: deficit, + const transactions = compileAllowanceMaintenanceTransactions({ protocol, + safeAddress, + missingRequirements, + usdceBalance: safeLegacyUsdceBalance, }); - if (unwrapTransaction) { - transactions.push(unwrapTransaction); - } - transactions.push({ - to: protocol.collateral.legacyUsdceToken, + to: protocol.collateral.tradingToken, data: encodeErc20Transfer({ to: signer.address, value: requestedAmountRaw, @@ -141,21 +107,21 @@ export async function buildWithdrawTransaction({ signer, safeAddress, requestedAmountRaw, - mode, protocol = POLYMARKET_V2_PROTOCOL, + safeLegacyUsdceBalance, }: { signer: Signer; safeAddress: string; requestedAmountRaw: bigint; - mode: WithdrawExecutionMode; protocol?: PolymarketProtocolDefinition; + safeLegacyUsdceBalance?: bigint; }) { const plan = await planWithdraw({ signer, safeAddress, requestedAmountRaw, - mode, protocol, + safeLegacyUsdceBalance, }); return buildSignedSafeExecution({ diff --git a/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts index be681f4c8d54..98e68da4c9d8 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts @@ -1,6 +1,6 @@ import { parseUnits } from 'ethers/lib/utils'; import { PredictPositionStatus, type PredictPosition } from '../../../types'; -import { MIN_COLLATERAL_BALANCE_FOR_CLAIM } from '../constants'; +import { MIN_PUSD_BALANCE_FOR_CLAIM_GAS } from '../constants'; import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; import { planClaim, getClaimRequirements } from './claim'; import { getRawTokenBalance } from './core'; @@ -67,7 +67,7 @@ const signer = { }; const gasStationThresholdRaw = BigInt( - parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), + parseUnits(MIN_PUSD_BALANCE_FOR_CLAIM_GAS.toString(), 6).toString(), ); describe('preflight workflow planners', () => { @@ -109,7 +109,20 @@ describe('preflight workflow planners', () => { ); }); - it('builds claim transactions as repairs, wrap, adapter claim, then exact-deficit unwrap', async () => { + it('builds deposit maintenance allowance repairs even without legacy balance', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(0n); + + const plan = await planDepositMaintenance({ + protocol: POLYMARKET_V2_PROTOCOL, + safeAddress: '0x1111111111111111111111111111111111111111', + }); + + expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ + '0x1000000000000000000000000000000000000000', + ]); + }); + + it('builds claim transactions as repairs, wrap, adapter claim, then exact pUSD gas transfer', async () => { mockGetRawTokenBalance.mockResolvedValueOnce(10n).mockResolvedValueOnce(0n); const plan = await planClaim({ @@ -119,12 +132,12 @@ describe('preflight workflow planners', () => { safeAddress: '0x9999999999999999999999999999999999999999', }); - expect(plan.gasStationDeficit).toBe(gasStationThresholdRaw); + expect(plan.gasTokenDeficit).toBe(gasStationThresholdRaw); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ '0x1000000000000000000000000000000000000000', POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, POLYMARKET_V2_PROTOCOL.claim.standardTarget, - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); @@ -181,35 +194,31 @@ describe('preflight workflow planners', () => { ); }); - it('builds withdraw fallback as repairs, optional unwrap, then usdce transfer', async () => { - mockGetRawTokenBalance - .mockResolvedValueOnce(1_000_000n) - .mockResolvedValueOnce(1_000_000n); + it('builds withdraw as repairs, wrap, then pUSD transfer', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); const plan = await planWithdraw({ protocol: POLYMARKET_V2_PROTOCOL, signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: BigInt(parseUnits('2', 6).toString()), - mode: 'usdce-deficit-unwrap', }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ '0x1000000000000000000000000000000000000000', - POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, ]); }); - it('builds withdraw preferred mode as repairs followed by pusd transfer', async () => { - mockGetRawTokenBalance.mockResolvedValueOnce(1_000_000n); + it('builds withdraw allowance repairs even without legacy balance', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(0n); const plan = await planWithdraw({ protocol: POLYMARKET_V2_PROTOCOL, signer, safeAddress: '0x9999999999999999999999999999999999999999', requestedAmountRaw: BigInt(parseUnits('2', 6).toString()), - mode: 'pusd-transfer', }); expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts index 7e820cc1571f..18e894e84b43 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts @@ -2,17 +2,16 @@ import { CTF_COLLATERAL_ADAPTER_ADDRESS, DEFAULT_CLOB_BASE_URL, HASH_ZERO_BYTES32, - LEGACY_V2_CLOB_BASE_URL, + MATIC_CONTRACTS_V2, NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, + USDC_E_ADDRESS, } from '../constants'; import Logger from '../../../../../../util/Logger'; import { - POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL, getClobV2BuilderCode, getProtocolDepositTokenAddress, getProtocolWithdrawTokenAddress, - resolvePolymarketProtocol, } from './definitions'; describe('polymarket protocol definitions', () => { @@ -37,38 +36,35 @@ describe('polymarket protocol definitions', () => { process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; }); - it('resolves v1 when predictClobV2 is disabled', () => { - expect(resolvePolymarketProtocol({ predictClobV2Enabled: false })).toBe( - POLYMARKET_V1_PROTOCOL, + it('defines CLOB v2 as the only protocol', () => { + expect(POLYMARKET_V2_PROTOCOL).toEqual( + expect.objectContaining({ + key: 'v2', + contracts: MATIC_CONTRACTS_V2, + transport: { + clobBaseUrl: DEFAULT_CLOB_BASE_URL, + clobVersionHeader: '2', + }, + workflow: { + depositMode: 'pusd-transfer', + withdrawMode: 'pusd-transfer', + }, + }), ); }); - it('resolves v2 when predictClobV2 is enabled', () => { - expect(resolvePolymarketProtocol({ predictClobV2Enabled: true })).toBe( - POLYMARKET_V2_PROTOCOL, + it('keeps legacy USDC.e only as sweep collateral state', () => { + expect(POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken).toBe( + USDC_E_ADDRESS, ); - }); - - it('defaults the v2 protocol to the canonical CLOB host', () => { - expect(POLYMARKET_V2_PROTOCOL.transport.clobBaseUrl).toBe( - DEFAULT_CLOB_BASE_URL, + expect(POLYMARKET_V2_PROTOCOL.collateral.tradingToken).toBe( + MATIC_CONTRACTS_V2.collateral, ); - }); - - it('resolves a temporary v2 CLOB host override from feature flags', () => { - expect( - resolvePolymarketProtocol({ - predictClobV2Enabled: true, - predictClobV2ClobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }), - ).toEqual( - expect.objectContaining({ - key: 'v2', - transport: expect.objectContaining({ - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - clobVersionHeader: '2', - }), - }), + expect(POLYMARKET_V2_PROTOCOL.collateral.claimToken).toBe( + MATIC_CONTRACTS_V2.collateral, + ); + expect(POLYMARKET_V2_PROTOCOL.collateral.feeAuthorizationToken).toBe( + MATIC_CONTRACTS_V2.collateral, ); }); @@ -100,28 +96,19 @@ describe('polymarket protocol definitions', () => { ); }); - it('routes v2 claims through the collateral adapters', () => { + it('routes claims through the collateral adapters', () => { expect(POLYMARKET_V2_PROTOCOL.claim).toEqual({ standardTarget: CTF_COLLATERAL_ADAPTER_ADDRESS, negRiskTarget: NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, }); }); - it('returns the configured deposit token address for each protocol', () => { - expect(getProtocolDepositTokenAddress(POLYMARKET_V1_PROTOCOL)).toBe( - POLYMARKET_V1_PROTOCOL.collateral.legacyUsdceToken, - ); + it('returns pUSD for deposit and withdraw token addresses', () => { expect(getProtocolDepositTokenAddress(POLYMARKET_V2_PROTOCOL)).toBe( - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, - ); - }); - - it('returns the configured withdraw token address for each protocol', () => { - expect(getProtocolWithdrawTokenAddress(POLYMARKET_V1_PROTOCOL)).toBe( - POLYMARKET_V1_PROTOCOL.collateral.legacyUsdceToken, + MATIC_CONTRACTS_V2.collateral, ); expect(getProtocolWithdrawTokenAddress(POLYMARKET_V2_PROTOCOL)).toBe( - POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + MATIC_CONTRACTS_V2.collateral, ); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts index c653350e3e82..978885a7d36c 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts @@ -1,11 +1,8 @@ import type { ContractConfig } from '../types'; -import type { PredictFeatureFlags } from '../../../types/flags'; import { HASH_ZERO_BYTES32, - MATIC_CONTRACTS, MATIC_CONTRACTS_V2, DEFAULT_CLOB_BASE_URL, - COLLATERAL_OFFRAMP_ADDRESS, COLLATERAL_ONRAMP_ADDRESS, CTF_COLLATERAL_ADAPTER_ADDRESS, NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, @@ -13,32 +10,31 @@ import { } from '../constants'; import Logger from '../../../../../../util/Logger'; -export type PolymarketProtocolKey = 'v1' | 'v2'; -export type DepositExecutionMode = 'usdce-transfer' | 'pusd-transfer'; -export type WithdrawExecutionMode = - | 'usdce-transfer' - | 'usdce-deficit-unwrap' - | 'pusd-transfer'; +export type PolymarketProtocolKey = 'v2'; +export type DepositExecutionMode = 'pusd-transfer'; +export type WithdrawExecutionMode = 'pusd-transfer'; interface BasePolymarketProtocolDefinition { key: PolymarketProtocolKey; contracts: ContractConfig; collateral: { + /** + * Legacy Safe USDC.e is hidden from user-facing flows and only used for the + * one-release opportunistic sweep into pUSD. TODO: remove after sweep window. + */ legacyUsdceToken: string; tradingToken: string; claimToken: string; feeAuthorizationToken: string; - balanceTokens: string[]; - onrampAddress?: string; - offrampAddress?: string; + onrampAddress: string; }; order: { - domainVersion: '1' | '2'; + domainVersion: '2'; metadata: string; - getBuilderCode?: () => string; + getBuilderCode: () => string; }; transport: { - clobVersionHeader?: '2'; + clobVersionHeader: '2'; clobBaseUrl: string; }; workflow: { @@ -71,37 +67,6 @@ export function getClobV2BuilderCode(): string { return HASH_ZERO_BYTES32; } -export const POLYMARKET_V1_PROTOCOL = { - key: 'v1', - contracts: MATIC_CONTRACTS, - collateral: { - legacyUsdceToken: MATIC_CONTRACTS.collateral, - tradingToken: MATIC_CONTRACTS.collateral, - claimToken: MATIC_CONTRACTS.collateral, - feeAuthorizationToken: MATIC_CONTRACTS.collateral, - balanceTokens: [MATIC_CONTRACTS.collateral], - onrampAddress: undefined, - offrampAddress: undefined, - }, - order: { - domainVersion: '1', - metadata: HASH_ZERO_BYTES32, - getBuilderCode: undefined, - }, - transport: { - clobVersionHeader: undefined, - clobBaseUrl: DEFAULT_CLOB_BASE_URL, - }, - workflow: { - depositMode: 'usdce-transfer', - withdrawMode: 'usdce-transfer', - }, - claim: { - standardTarget: MATIC_CONTRACTS.conditionalTokens, - negRiskTarget: MATIC_CONTRACTS.negRiskAdapter, - }, -} satisfies BasePolymarketProtocolDefinition; - export const POLYMARKET_V2_PROTOCOL = { key: 'v2', contracts: MATIC_CONTRACTS_V2, @@ -110,9 +75,7 @@ export const POLYMARKET_V2_PROTOCOL = { tradingToken: MATIC_CONTRACTS_V2.collateral, claimToken: MATIC_CONTRACTS_V2.collateral, feeAuthorizationToken: MATIC_CONTRACTS_V2.collateral, - balanceTokens: [USDC_E_ADDRESS, MATIC_CONTRACTS_V2.collateral], onrampAddress: COLLATERAL_ONRAMP_ADDRESS, - offrampAddress: COLLATERAL_OFFRAMP_ADDRESS, }, order: { domainVersion: '2', @@ -124,8 +87,8 @@ export const POLYMARKET_V2_PROTOCOL = { clobBaseUrl: DEFAULT_CLOB_BASE_URL, }, workflow: { - depositMode: 'usdce-transfer', - withdrawMode: 'usdce-deficit-unwrap', + depositMode: 'pusd-transfer', + withdrawMode: 'pusd-transfer', }, claim: { standardTarget: CTF_COLLATERAL_ADAPTER_ADDRESS, @@ -133,61 +96,16 @@ export const POLYMARKET_V2_PROTOCOL = { }, } satisfies BasePolymarketProtocolDefinition; -export type PolymarketProtocolDefinition = - | typeof POLYMARKET_V1_PROTOCOL - | typeof POLYMARKET_V2_PROTOCOL; +export type PolymarketProtocolDefinition = typeof POLYMARKET_V2_PROTOCOL; export function getProtocolDepositTokenAddress( protocol: PolymarketProtocolDefinition, ): string { - const depositMode = protocol.workflow.depositMode as DepositExecutionMode; - - switch (depositMode) { - case 'pusd-transfer': - return protocol.collateral.tradingToken; - case 'usdce-transfer': - default: - return protocol.collateral.legacyUsdceToken; - } + return protocol.collateral.tradingToken; } export function getProtocolWithdrawTokenAddress( protocol: PolymarketProtocolDefinition, ): string { - const withdrawMode = protocol.workflow.withdrawMode as WithdrawExecutionMode; - - switch (withdrawMode) { - case 'pusd-transfer': - return protocol.collateral.tradingToken; - case 'usdce-transfer': - case 'usdce-deficit-unwrap': - default: - return protocol.collateral.legacyUsdceToken; - } -} - -export function resolvePolymarketProtocol( - featureFlags: Pick< - PredictFeatureFlags, - 'predictClobV2Enabled' | 'predictClobV2ClobBaseUrl' - >, -): PolymarketProtocolDefinition { - if (!featureFlags.predictClobV2Enabled) { - return POLYMARKET_V1_PROTOCOL; - } - - const clobBaseUrl = - featureFlags.predictClobV2ClobBaseUrl ?? DEFAULT_CLOB_BASE_URL; - - if (clobBaseUrl === POLYMARKET_V2_PROTOCOL.transport.clobBaseUrl) { - return POLYMARKET_V2_PROTOCOL; - } - - return { - ...POLYMARKET_V2_PROTOCOL, - transport: { - ...POLYMARKET_V2_PROTOCOL.transport, - clobBaseUrl, - }, - }; + return protocol.collateral.tradingToken; } diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts index 6fb226b1ef33..7bd4f2fb5f85 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts @@ -1,9 +1,8 @@ import { Side, type OrderPreview } from '../../../types'; import { OrderType } from '../types'; -import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; +import { POLYMARKET_V2_PROTOCOL } from './definitions'; import { buildProtocolUnsignedOrder, - encodeUnwrap, encodeWrap, getPreviewFeeRateBpsForProtocol, getProtocolOrderTypedData, @@ -28,7 +27,7 @@ const preview: OrderPreview = { }; describe('polymarket protocol order codec', () => { - const protocolV2 = { + const protocol = { ...POLYMARKET_V2_PROTOCOL, order: { ...POLYMARKET_V2_PROTOCOL.order, @@ -37,25 +36,9 @@ describe('polymarket protocol order codec', () => { }, }; - it('builds a v1 order with v1-only fields', () => { - const order = buildProtocolUnsignedOrder({ - protocol: POLYMARKET_V1_PROTOCOL, - preview, - makerAddress: '0x1111111111111111111111111111111111111111', - signerAddress: '0x2222222222222222222222222222222222222222', - nowInSeconds: 123, - }); - - expect(order).toHaveProperty('taker'); - expect(order).toHaveProperty('nonce', '0'); - expect(order).toHaveProperty('feeRateBps', '77'); - expect(order).not.toHaveProperty('metadata'); - expect(order).not.toHaveProperty('builder'); - }); - it('builds a v2 order with timestamp, metadata, and builder', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -92,7 +75,7 @@ describe('polymarket protocol order codec', () => { it('builds v2 typed data with domain version 2 and bytes32 fields', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -100,17 +83,17 @@ describe('polymarket protocol order codec', () => { }); const typedData = getProtocolOrderTypedData({ - protocol: protocolV2, + protocol, order, verifyingContract: getProtocolVerifyingContract({ - protocol: protocolV2, + protocol, negRisk: true, }), }); expect(typedData.domain.version).toBe('2'); expect(typedData.domain.verifyingContract).toBe( - protocolV2.contracts.negRiskExchange, + protocol.contracts.negRiskExchange, ); expect(typedData.types.Order).toEqual( expect.arrayContaining([ @@ -122,7 +105,7 @@ describe('polymarket protocol order codec', () => { it('serializes signed orders into the relayer body shape', () => { const order = buildProtocolUnsignedOrder({ - protocol: protocolV2, + protocol, preview, makerAddress: '0x1111111111111111111111111111111111111111', signerAddress: '0x2222222222222222222222222222222222222222', @@ -152,34 +135,14 @@ describe('polymarket protocol order codec', () => { ); }); - it('forces preview fee rate to zero under v2', () => { - expect( - getPreviewFeeRateBpsForProtocol({ - protocol: protocolV2, - preview, - }), - ).toBe('0'); - - expect( - getPreviewFeeRateBpsForProtocol({ - protocol: POLYMARKET_V1_PROTOCOL, - preview, - }), - ).toBe('77'); + it('forces preview fee rate to zero', () => { + expect(getPreviewFeeRateBpsForProtocol()).toBe('0'); }); - it('encodes wrap and unwrap calls', () => { + it('encodes wrap calls used by the legacy USDC.e sweep', () => { expect( encodeWrap({ - asset: protocolV2.collateral.legacyUsdceToken, - to: '0x1111111111111111111111111111111111111111', - amount: 42n, - }), - ).toMatch(/^0x[0-9a-f]+$/u); - - expect( - encodeUnwrap({ - asset: protocolV2.collateral.legacyUsdceToken, + asset: protocol.collateral.legacyUsdceToken, to: '0x1111111111111111111111111111111111111111', amount: 42n, }), diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts index aad09dba6b53..5f77591c9dfb 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts @@ -7,9 +7,7 @@ import { ROUNDING_CONFIG, } from '../constants'; import { - type ClobOrderObject, - type OrderData, - OrderType, + type OrderType, SignatureType, type TickSize, UtilsSide, @@ -17,14 +15,7 @@ import { import { generateSalt, roundOrderAmount } from '../utils'; import type { PolymarketProtocolDefinition } from './definitions'; -export type V1ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v1' } ->; -export type V2ProtocolDefinition = Extract< - PolymarketProtocolDefinition, - { key: 'v2' } ->; +export type ProtocolDefinition = PolymarketProtocolDefinition; export interface OrderDataV2 { maker: string; @@ -54,19 +45,10 @@ export interface ClobOrderObjectV2 { orderType: OrderType; } -export type ProtocolUnsignedOrderV1 = OrderData & { salt: string }; -export type ProtocolUnsignedOrderV2 = OrderDataV2 & { salt: string }; -export type ProtocolUnsignedOrder = - | ProtocolUnsignedOrderV1 - | ProtocolUnsignedOrderV2; -export type ProtocolSignedOrderV1 = ProtocolUnsignedOrderV1 & { - signature: string; -}; -export type ProtocolSignedOrderV2 = SignedOrderV2; -export type ProtocolSignedOrder = ProtocolSignedOrderV1 | ProtocolSignedOrderV2; -export type ProtocolRelayerOrder = ClobOrderObject | ClobOrderObjectV2; +export type ProtocolUnsignedOrder = OrderDataV2 & { salt: string }; +export type ProtocolSignedOrder = SignedOrderV2; +export type ProtocolRelayerOrder = ClobOrderObjectV2; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const ORDER_PRIMARY_TYPE = 'Order'; const ORDER_DOMAIN_NAME = 'Polymarket CTF Exchange'; const ORDER_DOMAIN_TYPES = [ @@ -91,41 +73,21 @@ function buildProtocolOrderDomain({ }; } -function getProtocolOrderTypes(protocol: PolymarketProtocolDefinition) { - if (protocol.key === 'v2') { - return { - EIP712Domain: ORDER_DOMAIN_TYPES, - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - { name: 'timestamp', type: 'uint256' }, - { name: 'metadata', type: 'bytes32' }, - { name: 'builder', type: 'bytes32' }, - ], - }; - } - +function getProtocolOrderTypes() { return { EIP712Domain: ORDER_DOMAIN_TYPES, Order: [ { name: 'salt', type: 'uint256' }, { name: 'maker', type: 'address' }, { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, { name: 'tokenId', type: 'uint256' }, { name: 'makerAmount', type: 'uint256' }, { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, { name: 'side', type: 'uint8' }, { name: 'signatureType', type: 'uint8' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'metadata', type: 'bytes32' }, + { name: 'builder', type: 'bytes32' }, ], }; } @@ -153,32 +115,6 @@ function getTakerAmountWithSlippage(preview: OrderPreview): string { ).toString(); } -export function buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress, - nowInSeconds, -}: { - protocol: V1ProtocolDefinition; - preview: OrderPreview; - makerAddress: string; - signerAddress: string; - nowInSeconds?: number; -}): ProtocolUnsignedOrderV1; -export function buildProtocolUnsignedOrder({ - protocol, - preview, - makerAddress, - signerAddress, - nowInSeconds, -}: { - protocol: V2ProtocolDefinition; - preview: OrderPreview; - makerAddress: string; - signerAddress: string; - nowInSeconds?: number; -}): ProtocolUnsignedOrderV2; export function buildProtocolUnsignedOrder({ protocol, preview, @@ -193,8 +129,7 @@ export function buildProtocolUnsignedOrder({ nowInSeconds?: number; }): ProtocolUnsignedOrder { // NOTE: Field order matters for EIP-712 signing. Do NOT use object spread - // (e.g. `...baseOrder`) to build these return objects — it causes fields like - // `taker` (v1) to land in the wrong position, resulting in an "invalid API" error. + // (e.g. `...baseOrder`) to build the return object. const salt = generateSalt(); const maker = makerAddress; const signer = signerAddress; @@ -206,41 +141,23 @@ export function buildProtocolUnsignedOrder({ const takerAmount = getTakerAmountWithSlippage(preview); const side = preview.side === Side.BUY ? UtilsSide.BUY : UtilsSide.SELL; const signatureType = SignatureType.POLY_GNOSIS_SAFE; + const builder = protocol.order.getBuilderCode(); - if (protocol.key === 'v2') { - const builder = protocol.order.getBuilderCode?.(); - - if (!builder) { - throw new Error('Missing Polymarket CLOB v2 builder code'); - } - - return { - salt, - maker, - signer, - tokenId, - makerAmount, - takerAmount, - expiration: '0', - timestamp: `${nowInSeconds}`, - metadata: protocol.order.metadata, - builder, - side, - signatureType, - }; + if (!builder) { + throw new Error('Missing Polymarket CLOB v2 builder code'); } return { salt, maker, signer, - taker: ZERO_ADDRESS, tokenId, makerAmount, takerAmount, expiration: '0', - nonce: '0', - feeRateBps: preview.feeRateBps ?? '0', + timestamp: `${nowInSeconds}`, + metadata: protocol.order.metadata, + builder, side, signatureType, }; @@ -276,33 +193,11 @@ export function getProtocolOrderTypedData({ verifyingContract, chainId, }), - types: getProtocolOrderTypes(protocol), + types: getProtocolOrderTypes(), message: order, }; } -export function serializeProtocolRelayerOrder({ - signedOrder, - owner, - orderType, - side, -}: { - signedOrder: ProtocolSignedOrderV1; - owner: string; - orderType: OrderType; - side: Side; -}): ClobOrderObject; -export function serializeProtocolRelayerOrder({ - signedOrder, - owner, - orderType, - side, -}: { - signedOrder: ProtocolSignedOrderV2; - owner: string; - orderType: OrderType; - side: Side; -}): ClobOrderObjectV2; export function serializeProtocolRelayerOrder({ signedOrder, owner, @@ -314,39 +209,19 @@ export function serializeProtocolRelayerOrder({ orderType: OrderType; side: Side; }): ProtocolRelayerOrder { - const order = { - ...signedOrder, - side, - salt: parseInt(signedOrder.salt), - }; - - if ('builder' in signedOrder) { - return { - order: order as ClobOrderObjectV2['order'], - owner, - orderType, - }; - } - return { - order: order as ClobOrderObject['order'], + order: { + ...signedOrder, + side, + salt: parseInt(signedOrder.salt), + }, owner, orderType, }; } -export function getPreviewFeeRateBpsForProtocol({ - protocol, - preview, -}: { - protocol: PolymarketProtocolDefinition; - preview: OrderPreview; -}): string { - if (protocol.key === 'v2') { - return '0'; - } - - return preview.feeRateBps ?? '0'; +export function getPreviewFeeRateBpsForProtocol(): string { + return '0'; } export function encodeWrap({ @@ -362,17 +237,3 @@ export function encodeWrap({ 'function wrap(address _asset, address _to, uint256 _amount)', ]).encodeFunctionData('wrap', [asset, to, amount]) as Hex; } - -export function encodeUnwrap({ - asset, - to, - amount, -}: { - asset: string; - to: string; - amount: bigint | string; -}): Hex { - return new Interface([ - 'function unwrap(address _asset, address _to, uint256 _amount)', - ]).encodeFunctionData('unwrap', [asset, to, amount]) as Hex; -} diff --git a/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts index 8da8799707c2..8ad8e0ad188c 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts @@ -1,7 +1,7 @@ import type { ClobHeaders } from '../types'; import type { ProtocolRelayerOrder } from './orderCodec'; +import { POLYMARKET_V2_PROTOCOL } from './definitions'; import { submitProtocolClobOrder } from './transport'; -import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; jest.mock('../utils', () => ({ getPolymarketEndpoints: jest.fn(() => ({ @@ -34,30 +34,7 @@ describe('polymarket protocol transport', () => { jest.clearAllMocks(); }); - it('submits orders without the v2 routing header for v1', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ - success: true, - }), - }); - - await submitProtocolClobOrder({ - protocol: POLYMARKET_V1_PROTOCOL, - headers, - clobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - expect.objectContaining({ - headers: expect.not.objectContaining({ 'X-Clob-Version': '2' }), - }), - ); - }); - - it('adds the v2 routing header for v2', async () => { + it('adds the CLOB v2 routing header', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, diff --git a/app/components/UI/Predict/providers/polymarket/protocol/transport.ts b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts index 4e8df360b477..f99ed436963f 100644 --- a/app/components/UI/Predict/providers/polymarket/protocol/transport.ts +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts @@ -1,10 +1,7 @@ import type { Result } from '../../../types'; import type { ClobHeaders, OrderResponse } from '../types'; import { getPolymarketEndpoints } from '../utils'; -import type { - Permit2FeeAuthorization, - SafeFeeAuthorization, -} from '../safe/types'; +import type { Permit2FeeAuthorization } from '../safe/types'; import type { PolymarketProtocolDefinition } from './definitions'; import type { ProtocolRelayerOrder } from './orderCodec'; @@ -29,7 +26,7 @@ export async function submitProtocolClobOrder({ protocol: Pick; headers: ClobHeaders; clobOrder: ProtocolRelayerOrder; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; + feeAuthorization?: Permit2FeeAuthorization; executor?: string; allowancesTx?: { to: string; data: string }; }): Promise> { @@ -37,9 +34,7 @@ export async function submitProtocolClobOrder({ const url = `${CLOB_RELAYER}/order`; const requestHeaders = normalizeRelayerHeaders(headers); - if (protocol.transport.clobVersionHeader) { - requestHeaders['X-Clob-Version'] = protocol.transport.clobVersionHeader; - } + requestHeaders['X-Clob-Version'] = protocol.transport.clobVersionHeader; const body = { ...clobOrder, diff --git a/app/components/UI/Predict/providers/polymarket/safe/constants.ts b/app/components/UI/Predict/providers/polymarket/safe/constants.ts index a7c626616226..d32e6483ad64 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/constants.ts @@ -1,5 +1,3 @@ -import { MATIC_CONTRACTS } from '../constants'; - export const SAFE_FACTORY_NAME = 'Polymarket Contract Proxy Factory'; export const SAFE_FACTORY_ADDRESS = @@ -17,19 +15,6 @@ export const DOMAIN_SEPARATOR_TYPEHASH = '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218'; export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; -export const usdcSpenders = [ - MATIC_CONTRACTS.conditionalTokens, // Conditional Tokens Framework - MATIC_CONTRACTS.exchange, // CTF Exchange - MATIC_CONTRACTS.negRiskExchange, // Neg Risk CTF Exchange - MATIC_CONTRACTS.negRiskAdapter, -]; - -export const outcomeTokenSpenders = [ - MATIC_CONTRACTS.exchange, // CTF Exchange - MATIC_CONTRACTS.negRiskExchange, // Neg Risk Exchange - MATIC_CONTRACTS.negRiskAdapter, // Neg Risk Adapter -]; - export const MASTER_COPY_ADDRESS = '0xE51abdf814f8854941b9Fe8e3A4F65CAB4e7A4a8'; // Example Gnosis Safe mastercopy // You must use the SAME proxy creation code used in the factory diff --git a/app/components/UI/Predict/providers/polymarket/safe/types.ts b/app/components/UI/Predict/providers/polymarket/safe/types.ts index 60ff992776ac..cfd50dc640f1 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/types.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/types.ts @@ -16,14 +16,6 @@ export interface SplitSignature { v: string; } -export interface SafeFeeAuthorization { - type: 'safe-transaction'; - authorization: { - tx: SafeTransaction; // Safe transaction - sig: string; // Signature of the Safe transaction - }; -} - export interface Permit2FeeAuthorization { type: 'safe-permit2'; authorization: { diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts index 60945e53d64c..25b661b5626a 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -1,71 +1,15 @@ import { Interface } from 'ethers/lib/utils'; -import Engine from '../../../../../../core/Engine'; -import { - MATIC_CONTRACTS, - POLYGON_MAINNET_CHAIN_ID, - POLYMARKET_PROVIDER_ID, -} from '../constants'; -import { - PERMIT2_ADDRESS, - SAFE_FACTORY_ADDRESS, - SAFE_MULTISEND_ADDRESS, - usdcSpenders, -} from './constants'; +import { MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID } from '../constants'; +import { SAFE_FACTORY_ADDRESS, SAFE_MULTISEND_ADDRESS } from './constants'; import { + aggregateTransaction, computeProxyAddress, createPermit2FeeAuthorization, - createSafeFeeAuthorization, - getPermit2Nonce, getDeployProxyWalletTypedData, - encodeCreateProxy, - getDeployProxyWalletTransaction, - checkProxyWalletDeployed, - encodeMultisend, - createSafeMultisendTransaction, - aggregateTransaction, - createAllowancesSafeTransaction, - hasAllowances, - hasPermit2Allowance, - createClaimSafeTransaction, - getSafeTransactionCallData, - getProxyWalletAllowancesTransaction, - getClaimTransaction, - getWithdrawTransactionCallData, - getSafeUsdcAmount, - getSafeUsdcAmountRaw, + getSafeTransferAmount, + getSafeTransferAmountRaw, } from './utils'; -import { OperationType } from './types'; -import { Signer } from '../../types'; -import { numberToHex } from '@metamask/utils'; -import EthQuery from '@metamask/eth-query'; -import { query } from '@metamask/controller-utils'; -import { PredictPosition, PredictPositionStatus } from '../../../types'; -import { isSmartContractAddress } from '../../../../../../util/transactions'; -import { getAllowance, getIsApprovedForAll } from '../utils'; - -jest.mock('@metamask/transaction-controller', () => ({ - TransactionType: { - cancel: 'cancel', - contractInteraction: 'contractInteraction', - deployContract: 'deployContract', - incoming: 'incoming', - personalSign: 'personalSign', - retry: 'retry', - sign: 'sign', - signTypedData: 'signTypedData', - simpleSend: 'simpleSend', - smart: 'smart', - swap: 'swap', - swapAndSend: 'swapAndSend', - swapApproval: 'swapApproval', - tokenMethodApprove: 'tokenMethodApprove', - tokenMethodIncreaseAllowance: 'tokenMethodIncreaseAllowance', - tokenMethodSetApprovalForAll: 'tokenMethodSetApprovalForAll', - tokenMethodTransfer: 'tokenMethodTransfer', - tokenMethodTransferFrom: 'tokenMethodTransferFrom', - tokenMethodSafeTransferFrom: 'tokenMethodSafeTransferFrom', - }, -})); +import { OperationType, type SafeTransaction } from './types'; jest.mock('../../../../../../core/Engine', () => ({ context: { @@ -73,1518 +17,118 @@ jest.mock('../../../../../../core/Engine', () => ({ findNetworkClientIdByChainId: jest.fn(), getNetworkClientById: jest.fn(), }, - KeyringController: { - signPersonalMessage: jest.fn(), - }, }, })); -jest.mock('@metamask/controller-utils', () => ({ - query: jest.fn(), -})); - -jest.mock('@metamask/eth-query'); - -jest.mock('../../../../../../util/transactions', () => ({ - isSmartContractAddress: jest.fn(), -})); - -jest.mock('../utils', () => ({ - encodeApprove: jest.fn(() => '0x095ea7b3000000000000000000000000'), - encodeErc1155Approve: jest.fn(() => '0xa22cb465000000000000000000000000'), - encodeErc20Transfer: jest.fn(() => '0xa9059cbb000000000000000000000000'), - encodeClaim: jest.fn(() => '0x4e71d92d000000000000000000000000'), - getAllowance: jest.fn(), - getIsApprovedForAll: jest.fn(), - getContractConfig: jest.fn(() => ({ - conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', - negRiskAdapter: '0xC5d563A36AE78145C45a50134d48A1215220f80a', - })), -})); +const signer = { + address: '0x1111111111111111111111111111111111111111', + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), +}; -const mockFindNetworkClientIdByChainId = Engine.context.NetworkController - .findNetworkClientIdByChainId as jest.Mock; -const mockGetNetworkClientById = Engine.context.NetworkController - .getNetworkClientById as jest.Mock; -const mockSignPersonalMessage = Engine.context.KeyringController - .signPersonalMessage as jest.Mock; -const mockSignTypedMessage = jest.fn(); -const mockQuery = query as jest.Mock; -const mockIsSmartContractAddress = - isSmartContractAddress as jest.MockedFunction; -const mockGetAllowance = getAllowance as jest.MockedFunction< - typeof getAllowance ->; -const mockGetIsApprovedForAll = getIsApprovedForAll as jest.MockedFunction< - typeof getIsApprovedForAll ->; - -const TEST_ADDRESS = '0x1234567890123456789012345678901234567890' as const; -const TEST_SAFE_ADDRESS = '0x9999999999999999999999999999999999999999' as const; -const TEST_TO_ADDRESS = '0x100c7b833bbd604a77890783439bbb9d65e31de7' as const; - -function buildSigner({ - address = TEST_ADDRESS, - signPersonalMessage = mockSignPersonalMessage, - signTypedMessage = mockSignTypedMessage, -}: Partial = {}): Signer { - return { - address, - signPersonalMessage, - signTypedMessage, - }; -} - -function mockNetworkController() { - const mockProvider = {}; - mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); - mockGetNetworkClientById.mockReturnValue({ - provider: mockProvider, - }); - return mockProvider; -} - -function setupMocksForFeeAuth() { - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); -} +const validSignature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b`; describe('safe utils', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('computeProxyAddress', () => { - it('computes proxy address from signer address', () => { - const signer = buildSigner(); - - const proxyAddress = computeProxyAddress(signer.address); - - expect(proxyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(typeof proxyAddress).toBe('string'); - }); - - it('returns properly formatted address', () => { - const testAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - - const proxyAddress = computeProxyAddress(testAddress); - - expect(proxyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); - }); - - it('returns deterministic address for same input', () => { - const testAddress = '0x1234567890123456789012345678901234567890'; - - const proxyAddress1 = computeProxyAddress(testAddress); - const proxyAddress2 = computeProxyAddress(testAddress); - - expect(proxyAddress1).toBe(proxyAddress2); - }); - - it('returns different addresses for different inputs', () => { - const address1 = '0x1234567890123456789012345678901234567890'; - const address2 = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - - const proxyAddress1 = computeProxyAddress(address1); - const proxyAddress2 = computeProxyAddress(address2); - - expect(proxyAddress1).not.toBe(proxyAddress2); - }); - - it('computes address using CREATE2', () => { - const testAddress = '0x1234567890123456789012345678901234567890'; - - const proxyAddress = computeProxyAddress(testAddress); - - expect(proxyAddress).toBeTruthy(); - expect(proxyAddress.length).toBe(42); + signer.signPersonalMessage.mockResolvedValue(validSignature); + jest.spyOn(global.crypto, 'getRandomValues').mockImplementation((array) => { + if (array instanceof Uint32Array) { + array[0] = 7; + } + return array; }); }); - describe('createSafeFeeAuthorization', () => { - const testParams = { - signer: buildSigner(), - safeAddress: TEST_SAFE_ADDRESS, - amount: BigInt(1000000), - to: TEST_TO_ADDRESS, - }; - - it('creates fee authorization with correct structure', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - }); - - it('encodes ERC20 transfer correctly', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization({ - ...testParams, - amount: BigInt(500000), - }); - - const expectedTransferData = new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [TEST_TO_ADDRESS, BigInt(500000)]); - expect(feeAuth.authorization.tx.data).toBe(expectedTransferData); - }); - - it('sets operation type to Call', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization({ - ...testParams, - amount: BigInt(250000), - }); - - expect(feeAuth.authorization.tx.operation).toBe(OperationType.Call); - }); - - it('uses MATIC_CONTRACTS.collateral as token address', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.tx.to).toBe(MATIC_CONTRACTS.collateral); - }); - - it('signs the Safe transaction', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('returns SafeFeeAuthorization type', async () => { - setupMocksForFeeAuth(); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.tx.value).toBe('0'); - expect(typeof feeAuth.authorization.sig).toBe('string'); - }); - - it('calls Safe contract for nonce', async () => { - setupMocksForFeeAuth(); - - await createSafeFeeAuthorization(testParams); - - expect(mockQuery).toHaveBeenCalledWith( - expect.any(EthQuery), - 'call', - expect.arrayContaining([ - expect.objectContaining({ - to: TEST_SAFE_ADDRESS, - }), - ]), - ); - }); - - it('handles undeployed Safe contract (nonce returns 0x)', async () => { - mockNetworkController(); - mockQuery - .mockResolvedValueOnce('0x') - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth.authorization.tx).toBeDefined(); - }); - - it('handles signature v value adjustment for 0 and 1', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value adjustment for 27 and 28', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901b', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth.authorization.sig).toBeTruthy(); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('throws error for invalid signature v value', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899ff', - ); - - await expect(createSafeFeeAuthorization(testParams)).rejects.toThrow( - 'Invalid signature', - ); - }); - - it('handles signature v value 0 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 1 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 27 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901b', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles signature v value 28 correctly', async () => { - setupMocksForFeeAuth(); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889901c', - ); - - const feeAuth = await createSafeFeeAuthorization(testParams); - - expect(feeAuth).toHaveProperty('type', 'safe-transaction'); - expect(feeAuth).toHaveProperty('authorization'); - expect(feeAuth.authorization).toHaveProperty('tx'); - expect(feeAuth.authorization).toHaveProperty('sig'); - expect(feeAuth.authorization.sig).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('getPermit2Nonce', () => { - it('returns a numeric string', async () => { - const nonce = await getPermit2Nonce(); - - expect(nonce).toMatch(/^\d+$/); - }); - - it('generates nonce from crypto.getRandomValues', async () => { - const spy = jest.spyOn(global.crypto, 'getRandomValues'); - - await getPermit2Nonce(); - - expect(spy).toHaveBeenCalledWith(expect.any(Uint32Array)); - spy.mockRestore(); - }); - }); - - describe('hasPermit2Allowance', () => { - it('returns true when Permit2 allowance is greater than zero', async () => { - mockGetAllowance.mockResolvedValueOnce(1n); - - const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalledWith({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: TEST_SAFE_ADDRESS, - spender: PERMIT2_ADDRESS, - }); - }); - - it('returns false when Permit2 allowance is zero', async () => { - mockGetAllowance.mockResolvedValueOnce(0n); - - const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); - - expect(result).toBe(false); - }); - }); - - describe('createPermit2FeeAuthorization', () => { - it('creates safe-permit2 authorization payload', async () => { - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const authorization = await createPermit2FeeAuthorization({ - safeAddress: TEST_SAFE_ADDRESS, - signer: buildSigner(), - amount: 1_000_000n, - spender: TEST_TO_ADDRESS, - }); - - expect(authorization.type).toBe('safe-permit2'); - expect(authorization.authorization.permit.permitted.token).toBe( - MATIC_CONTRACTS.collateral, - ); - expect(authorization.authorization.permit.permitted.amount).toBe( - '1000000', - ); - expect(authorization.authorization.permit.nonce).toMatch(/^\d+$/); - expect(authorization.authorization.spender).toBe(TEST_TO_ADDRESS); - expect(authorization.authorization.signature).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('getDeployProxyWalletTypedData', () => { - it('returns correct typed data structure', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData).toHaveProperty('domain'); - expect(typedData).toHaveProperty('types'); - expect(typedData).toHaveProperty('message'); - expect(typedData).toHaveProperty('primaryType', 'CreateProxy'); - }); - - it('uses correct domain values', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData.domain.name).toBeDefined(); - expect(typedData.domain.chainId).toBe( - numberToHex(POLYGON_MAINNET_CHAIN_ID), - ); - expect(typedData.domain.verifyingContract).toBe(SAFE_FACTORY_ADDRESS); - }); - - it('includes CreateProxy type definition', async () => { - const typedData = await getDeployProxyWalletTypedData(); - - expect(typedData.types.CreateProxy).toBeDefined(); - expect(typedData.types.CreateProxy).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'paymentToken', type: 'address' }), - expect.objectContaining({ name: 'payment', type: 'uint256' }), - expect.objectContaining({ name: 'paymentReceiver', type: 'address' }), - ]), - ); - }); - }); - - describe('encodeCreateProxy', () => { - it('encodes createProxy function call', () => { - const result = encodeCreateProxy({ - paymentToken: '0x0000000000000000000000000000000000000000', - payment: '0', - paymentReceiver: '0x0000000000000000000000000000000000000000', - createSig: { - v: 27, - r: '0x' + 'a'.repeat(64), - s: '0x' + 'b'.repeat(64), - }, - }); - - expect(result).toMatch(/^0x[a-f0-9]+$/); - expect(typeof result).toBe('string'); - }); - }); - - describe('getDeployProxyWalletTransaction', () => { - it('returns transaction with correct structure', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - const tx = await getDeployProxyWalletTransaction({ signer }); - - expect(tx).toHaveProperty('params'); - expect(tx?.params).toHaveProperty('to', SAFE_FACTORY_ADDRESS); - expect(tx?.params).toHaveProperty('data'); - expect(tx?.params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('calls signTypedMessage with correct parameters', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - await getDeployProxyWalletTransaction({ signer }); - - expect(mockSignTypedMessage).toHaveBeenCalled(); - }); - - it('throws error when signing fails', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockRejectedValue(new Error('Signature rejected')); - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => { - // Mock implementation to suppress console output - }); - - await expect(getDeployProxyWalletTransaction({ signer })).rejects.toThrow( - 'Failed to generate deploy proxy wallet transaction: Signature rejected', - ); - - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); - - it('throws error with "Unknown error" when non-Error is thrown', async () => { - const signer = buildSigner(); - mockSignTypedMessage.mockRejectedValue('string error'); - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => { - // Mock implementation to suppress console output - }); - - await expect(getDeployProxyWalletTransaction({ signer })).rejects.toThrow( - 'Failed to generate deploy proxy wallet transaction: Unknown error', - ); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('checkProxyWalletDeployed', () => { - it('returns true when contract is deployed', async () => { - mockIsSmartContractAddress.mockResolvedValue(true); - - const isDeployed = await checkProxyWalletDeployed({ - address: TEST_SAFE_ADDRESS, - networkClientId: 'polygon', - }); - - expect(isDeployed).toBe(true); - expect(mockIsSmartContractAddress).toHaveBeenCalledWith( - TEST_SAFE_ADDRESS, - numberToHex(POLYGON_MAINNET_CHAIN_ID), - 'polygon', - ); - }); - - it('returns false when contract is not deployed', async () => { - mockIsSmartContractAddress.mockResolvedValue(false); - - const isDeployed = await checkProxyWalletDeployed({ - address: TEST_SAFE_ADDRESS, - networkClientId: 'polygon', - }); - - expect(isDeployed).toBe(false); - }); - }); - - describe('encodeMultisend', () => { - it('encodes single transaction', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const encoded = encodeMultisend({ txns }); - - expect(encoded).toMatch(/^0x[a-f0-9]+$/); - expect(typeof encoded).toBe('string'); - }); - - it('encodes multiple transactions', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - { - to: TEST_SAFE_ADDRESS, - value: '100', - data: '0xabcd', - operation: OperationType.DelegateCall, - }, - ]; - - const encoded = encodeMultisend({ txns }); - - expect(encoded).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('createSafeMultisendTransaction', () => { - it('creates multisend transaction with correct structure', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const multisendTx = createSafeMultisendTransaction(txns); - - expect(multisendTx.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(multisendTx.value).toBe('0'); - expect(multisendTx.operation).toBe(OperationType.DelegateCall); - expect(multisendTx.data).toMatch(/^0x[a-f0-9]+$/); - }); - }); - - describe('aggregateTransaction', () => { - it('returns single transaction when only one provided', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - ]; - - const result = aggregateTransaction(txns); - - expect(result).toEqual(txns[0]); - }); - - it('returns multisend transaction when multiple provided', () => { - const txns = [ - { - to: TEST_TO_ADDRESS, - value: '0', - data: '0x1234', - operation: OperationType.Call, - }, - { - to: TEST_SAFE_ADDRESS, - value: '100', - data: '0xabcd', - operation: OperationType.Call, - }, - ]; - - const result = aggregateTransaction(txns); - - expect(result.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(result.operation).toBe(OperationType.DelegateCall); - }); + afterEach(() => { + jest.restoreAllMocks(); }); - describe('createAllowancesSafeTransaction', () => { - it('creates transaction with approvals', () => { - const safeTxn = createAllowancesSafeTransaction(); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('value'); - expect(safeTxn).toHaveProperty('data'); - expect(safeTxn).toHaveProperty('operation'); - }); - - it('includes USDC approvals and outcome token approvals', () => { - const safeTxn = createAllowancesSafeTransaction(); - - expect(safeTxn.data).toBeDefined(); - expect(typeof safeTxn.data).toBe('string'); - }); - - it('includes extra USDC spenders when provided', () => { - const defaultSafeTxn = createAllowancesSafeTransaction(); - const safeTxnWithExtra = createAllowancesSafeTransaction({ - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - expect(safeTxnWithExtra.data).toBeDefined(); - expect(safeTxnWithExtra.data.length).toBeGreaterThan( - defaultSafeTxn.data.length, - ); - }); + it('computes a deterministic proxy address', () => { + expect(computeProxyAddress(signer.address)).toMatch(/^0x[0-9a-fA-F]{40}$/u); + expect(computeProxyAddress(signer.address)).toBe( + computeProxyAddress(signer.address), + ); }); - describe('hasAllowances', () => { - it('returns true when all allowances are set', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalled(); - expect(mockGetIsApprovedForAll).toHaveBeenCalledWith( - expect.objectContaining({ - tokenAddress: MATIC_CONTRACTS.conditionalTokens, - }), - ); - }); - - it('returns false when some allowances are zero', async () => { - mockGetAllowance.mockResolvedValueOnce(0n).mockResolvedValueOnce(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(false); - }); - - it('returns false when some approvals are not set', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); - - const result = await hasAllowances({ address: TEST_ADDRESS }); - - expect(result).toBe(false); - }); - - it('checks allowances for extra USDC spenders', async () => { - mockGetAllowance.mockResolvedValue(100n); - mockGetIsApprovedForAll.mockResolvedValue(true); + it('builds deploy proxy typed data for Polygon', () => { + const typedData = getDeployProxyWalletTypedData(); - const result = await hasAllowances({ - address: TEST_ADDRESS, - extraUsdcSpenders: [PERMIT2_ADDRESS], - }); - - expect(result).toBe(true); - expect(mockGetAllowance).toHaveBeenCalledWith( - expect.objectContaining({ spender: PERMIT2_ADDRESS }), - ); - expect(mockGetAllowance).toHaveBeenCalledTimes(usdcSpenders.length + 1); + expect(typedData.domain).toEqual({ + name: 'Polymarket Contract Proxy Factory', + chainId: `0x${POLYGON_MAINNET_CHAIN_ID.toString(16)}`, + verifyingContract: SAFE_FACTORY_ADDRESS, }); + expect(typedData.primaryType).toBe('CreateProxy'); }); - describe('createClaimSafeTransaction', () => { - const mockPosition: PredictPosition = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'YES', - outcomeTokenId: 'token-1', - title: 'Test Market', - icon: 'icon.png', - amount: 100, - price: 0.5, - status: PredictPositionStatus.REDEEMABLE, - size: 100, - outcomeIndex: 0, - realizedPnl: 50, - percentPnl: 20, - cashPnl: 50, - initialValue: 100, - avgPrice: 0.5, - currentValue: 150, - endDate: '2025-01-01', - claimable: true, - }; - - it('creates claim transaction for single position', () => { - const safeTxn = createClaimSafeTransaction([mockPosition]); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('value'); - expect(safeTxn).toHaveProperty('data'); - expect(safeTxn).toHaveProperty('operation'); - }); - - it('creates claim transaction for multiple positions', () => { - const positions = [ - mockPosition, - { ...mockPosition, id: 'position-2', outcomeIndex: 1 }, - ]; - - const safeTxn = createClaimSafeTransaction(positions); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - }); - - it('handles negRisk positions', () => { - const negRiskPosition = { ...mockPosition, negRisk: true }; - - const safeTxn = createClaimSafeTransaction([negRiskPosition]); - - expect(safeTxn.to).toBeDefined(); - expect(safeTxn.data).toBeDefined(); + it('creates pUSD Permit2 fee authorization by default', async () => { + const authorization = await createPermit2FeeAuthorization({ + safeAddress: '0x9999999999999999999999999999999999999999', + signer, + amount: 123n, + spender: '0x2222222222222222222222222222222222222222', }); - it('creates claim transaction without transfer when includeTransfer is not provided', () => { - const safeTxn = createClaimSafeTransaction([mockPosition]); - - expect(safeTxn).toHaveProperty('to'); - expect(safeTxn).toHaveProperty('data'); - }); - - it('includes transfer transaction when includeTransfer address is provided', () => { - const includeTransfer = { address: TEST_ADDRESS }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - expect(safeTxn.data).toBeDefined(); - }); - - it('creates multisend transaction with transfer for single position when includeTransfer is provided', () => { - const includeTransfer = { address: TEST_ADDRESS }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); - expect(safeTxn.operation).toBe(OperationType.DelegateCall); - }); - - it('includes transfer with correct recipient address', () => { - const recipientAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const includeTransfer = { address: recipientAddress }; - - const safeTxn = createClaimSafeTransaction( - [mockPosition], - includeTransfer, - ); - - expect(safeTxn).toBeDefined(); - expect(safeTxn.data).toBeDefined(); + expect(authorization.type).toBe('safe-permit2'); + expect(authorization.authorization.permit.permitted).toEqual({ + token: MATIC_CONTRACTS_V2.collateral, + amount: '123', }); + expect(authorization.authorization.spender).toBe( + '0x2222222222222222222222222222222222222222', + ); + expect(authorization.authorization.signature).toMatch(/^0x[0-9a-f]+$/u); }); - describe('getSafeTransactionCallData', () => { - it('generates call data for safe transaction execution', async () => { - // Given a Safe transaction and signer - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, - value: '0', + it('aggregates multiple transactions into a multisend delegatecall', () => { + const transactions: SafeTransaction[] = [ + { + to: '0x1111111111111111111111111111111111111111', data: '0x1234', operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - // When generating call data - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then call data is returned with correct format - expect(callData).toMatch(/^0x[a-f0-9]+$/); - expect(mockQuery).toHaveBeenCalled(); - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('handles overrides parameter', async () => { - // Given overrides are provided - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, value: '0', - data: '0x1234', - operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - const overrides = { gasLimit: '100000' }; - - // When generating call data with overrides - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - overrides, - }); - - // Then call data is generated successfully - expect(callData).toMatch(/^0x[a-f0-9]+$/); - }); - - it('encodes execTransaction function call', async () => { - // Given a transaction to execute - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, - value: '100', - data: '0xabcdef', + }, + { + to: '0x2222222222222222222222222222222222222222', + data: '0xabcd', operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); - - // When generating call data - const callData = await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then the call data contains execTransaction encoding - expect(callData).toBeDefined(); - expect(typeof callData).toBe('string'); - expect(callData.length).toBeGreaterThan(10); - }); - - it('queries nonce from Safe contract', async () => { - // Given a Safe address - const signer = buildSigner(); - const mockTxn = { - to: TEST_TO_ADDRESS, value: '0', - data: '0x1234', - operation: OperationType.Call, - }; - - setupMocksForFeeAuth(); + }, + ]; - // When generating call data - await getSafeTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - txn: mockTxn, - }); - - // Then nonce is queried from contract - const nonceCall = mockQuery.mock.calls.find( - (call) => call[2][0].to === TEST_SAFE_ADDRESS, - ); - expect(nonceCall).toBeDefined(); - }); - }); - - describe('getProxyWalletAllowancesTransaction', () => { - it('generates transaction for setting allowances', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction is returned with correct structure - expect(tx).toHaveProperty('params'); - expect(tx?.params).toHaveProperty('to'); - expect(tx?.params).toHaveProperty('data'); - expect(tx?.params.to).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(tx?.params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('uses computed proxy address for transaction', async () => { - // Given a signer with specific address - const signer = buildSigner({ - address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - }); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction uses the computed proxy address - const expectedProxyAddress = computeProxyAddress(signer.address); - expect(tx?.params.to).toBe(expectedProxyAddress); - }); - - it('includes allowances for USDC and outcome tokens', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000009999999999999999999999999999999999999999', - ) - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - const tx = await getProxyWalletAllowancesTransaction({ signer }); - - // Then transaction data includes allowance settings - expect(tx?.params.data).toBeDefined(); - expect(tx?.params.data.length).toBeGreaterThan(10); - }); - - it('signs the transaction for execution', async () => { - // Given a signer - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000009999999999999999999999999999999999999999', - ) - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockResolvedValue( - '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', - ); - - // When generating allowances transaction - await getProxyWalletAllowancesTransaction({ signer }); - - // Then signer's signPersonalMessage is called - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('throws error when signing fails', async () => { - const signer = buildSigner(); - - mockNetworkController(); - mockQuery - .mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - .mockResolvedValueOnce( - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ); - mockSignPersonalMessage.mockRejectedValueOnce( - new Error('User rejected signing'), - ); - - await expect( - getProxyWalletAllowancesTransaction({ signer }), - ).rejects.toThrow( - 'Failed to generate proxy wallet allowances transaction: User rejected signing', - ); - }); + expect(aggregateTransaction(transactions)).toEqual( + expect.objectContaining({ + to: SAFE_MULTISEND_ADDRESS, + operation: OperationType.DelegateCall, + value: '0', + }), + ); }); - describe('getClaimTransaction', () => { - const mockPosition: PredictPosition = { - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'YES', - outcomeTokenId: 'token-1', - title: 'Test Market', - icon: 'icon.png', - amount: 100, - price: 0.5, - status: PredictPositionStatus.REDEEMABLE, - size: 100, - outcomeIndex: 0, - realizedPnl: 50, - percentPnl: 20, - cashPnl: 50, - initialValue: 100, - avgPrice: 0.5, - currentValue: 150, - endDate: '2025-01-01', - claimable: true, + it('keeps a single transaction unwrapped during aggregation', () => { + const transaction: SafeTransaction = { + to: '0x1111111111111111111111111111111111111111', + data: '0x1234', + operation: OperationType.Call, + value: '0', }; - it('generates claim transaction for positions', async () => { - // Given a signer and positions to claim - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then transaction is returned with correct structure - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('to', TEST_SAFE_ADDRESS); - expect(txs[0].params).toHaveProperty('data'); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('handles multiple positions in one transaction', async () => { - // Given multiple positions to claim - const signer = buildSigner(); - const positions = [ - mockPosition, - { ...mockPosition, id: 'position-2', outcomeIndex: 1 }, - ]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then single transaction is returned with all claims - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('to'); - expect(txs[0].params).toHaveProperty('data'); - }); - - it('signs the claim transaction', async () => { - // Given a signer and positions - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - // When generating claim transaction - await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then signer's signPersonalMessage is called - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('uses provided Safe address', async () => { - // Given a specific Safe address - const signer = buildSigner(); - const positions = [mockPosition]; - const customSafeAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: customSafeAddress, - }); - - // Then transaction is sent to the provided Safe address - expect(txs[0].params.to).toBe(customSafeAddress); - }); - - it('creates transaction for negRisk positions', async () => { - // Given a negRisk position - const signer = buildSigner(); - const negRiskPosition = { ...mockPosition, negRisk: true }; - - setupMocksForFeeAuth(); - - // When generating claim transaction - const txs = await getClaimTransaction({ - signer, - positions: [negRiskPosition], - safeAddress: TEST_SAFE_ADDRESS, - }); - - // Then transaction is generated successfully - expect(txs).toBeDefined(); - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('generates claim transaction without transfer when includeTransferTransaction is false', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: false, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - }); - - it('generates claim transaction without transfer when includeTransferTransaction is undefined', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - }); - - it('includes transfer transaction when includeTransferTransaction is true', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(Array.isArray(txs)).toBe(true); - expect(txs).toHaveLength(1); - expect(txs[0]).toHaveProperty('params'); - expect(txs[0].params).toHaveProperty('data'); - }); - - it('uses signer address for transfer when includeTransferTransaction is true', async () => { - const signerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const signer = buildSigner({ address: signerAddress }); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - const txs = await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(txs).toBeDefined(); - expect(Array.isArray(txs)).toBe(true); - expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); - }); - - it('signs claim transaction with transfer when includeTransferTransaction is true', async () => { - const signer = buildSigner(); - const positions = [mockPosition]; - - setupMocksForFeeAuth(); - - await getClaimTransaction({ - signer, - positions, - safeAddress: TEST_SAFE_ADDRESS, - includeTransferTransaction: true, - }); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - }); - - describe('getWithdrawTransactionCallData', () => { - it('generates call data for withdraw transaction', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toMatch(/^0x[a-f0-9]+$/); - expect(typeof callData).toBe('string'); - }); - - it('uses MATIC collateral contract address', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toBeDefined(); - expect(mockQuery).toHaveBeenCalled(); - }); - - it('creates Call operation type transaction', async () => { - const signer = buildSigner(); - const data = '0x1234567890abcdef'; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(callData).toBeTruthy(); - expect(callData.length).toBeGreaterThan(10); - }); - - it('signs the withdraw transaction', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - expect(mockSignPersonalMessage).toHaveBeenCalled(); - }); - - it('queries nonce from Safe contract', async () => { - const signer = buildSigner(); - const data = `0xa9059cbb${'0'.repeat(128)}`; - - setupMocksForFeeAuth(); - - await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: data as `0x${string}`, - }); - - const nonceCall = mockQuery.mock.calls.find( - (call) => call[2][0].to === TEST_SAFE_ADDRESS, - ); - expect(nonceCall).toBeDefined(); - }); - - it('handles custom data parameter', async () => { - const signer = buildSigner(); - const customData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7000000000000000000000000000000000000000000000000000000000000007b'; - - setupMocksForFeeAuth(); - - const callData = await getWithdrawTransactionCallData({ - signer, - safeAddress: TEST_SAFE_ADDRESS, - data: customData as `0x${string}`, - }); - - expect(callData).toMatch(/^0x[a-f0-9]+$/); - }); + expect(aggregateTransaction([transaction])).toBe(transaction); }); - describe('getSafeUsdcAmountRaw', () => { - it('decodes the raw ERC20 amount without a float round-trip', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000186a00'; + it('decodes ERC20 transfer amounts from Safe editable calldata', () => { + const calldata = new Interface([ + 'function transfer(address to, uint256 value)', + ]).encodeFunctionData('transfer', [signer.address, 1_500_000]); - const amount = getSafeUsdcAmountRaw(data); - - expect(amount).toBe(1600000n); - }); + expect(getSafeTransferAmountRaw(calldata)).toBe(1_500_000n); + expect(getSafeTransferAmount(calldata)).toBe(1.5); }); - describe('getSafeUsdcAmount', () => { - it('decodes USDC amount from ERC20 transfer data', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(10); - }); - - it('returns zero for transfer with zero amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000000'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(0); - }); - - it('decodes small fractional USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000001'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(0.000001); - }); - - it('decodes large USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000077359400'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(2000); - }); - - it('rounds to 6 decimal places for USDC precision', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(10); - expect(amount.toString().split('.')[1]?.length || 0).toBeLessThanOrEqual( - 6, - ); - }); - - it('throws error for non-ERC20 transfer data', () => { - const invalidData = - '0x12345678000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000989680'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Not an ERC20 transfer call', - ); - }); - - it('throws error for data without transfer selector', () => { - const invalidData = '0x000000000000000000000000100c7b833bbd604a77'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Not an ERC20 transfer call', - ); - }); - - it('throws error for invalid encoded amount', () => { - const invalidData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG'; - - expect(() => getSafeUsdcAmount(invalidData)).toThrow( - 'Invalid encoded amount in calldata', - ); - }); - - it('throws error for unreasonably large USDC amount', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; - - expect(() => getSafeUsdcAmount(data)).toThrow( - 'Decoded USDC amount is invalid or too large', - ); - }); - - it('handles 1.5 USDC amount correctly', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000186a00'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(1.6); - }); - - it('decodes medium-sized USDC amounts', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000002faf080'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(50); - }); - - it('validates non-negative amounts', () => { - const validData = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000000064'; - - const amount = getSafeUsdcAmount(validData); - - expect(amount).toBeGreaterThanOrEqual(0); - }); - - it('handles exact 1 USDC', () => { - const data = - '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de700000000000000000000000000000000000000000000000000000000000f4240'; - - const amount = getSafeUsdcAmount(data); - - expect(amount).toBe(1); - }); + it('rejects non-transfer calldata when decoding amounts', () => { + expect(() => getSafeTransferAmountRaw('0x12345678')).toThrow( + 'Not an ERC20 transfer call', + ); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index 9fc9639a1d0e..419353cd0893 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -12,38 +12,23 @@ import { hexlify, Interface, keccak256, - parseUnits, solidityPack, splitSignature, } from 'ethers/lib/utils'; -import { PredictPosition } from '../../..'; import { PREDICT_CONSTANTS } from '../../../constants/errors'; import Engine from '../../../../../../core/Engine'; import Logger, { type LoggerErrorOptions } from '../../../../../../util/Logger'; import { isSmartContractAddress } from '../../../../../../util/transactions'; import { Signer } from '../../types'; import { - COLLATERAL_TOKEN_DECIMALS, - CONDITIONAL_TOKEN_DECIMALS, - MATIC_CONTRACTS, - MIN_COLLATERAL_BALANCE_FOR_CLAIM, + MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, } from '../constants'; -import { - encodeApprove, - encodeClaim, - encodeErc1155Approve, - encodeErc20Transfer, - getAllowance, - getContractConfig, - getIsApprovedForAll, -} from '../utils'; import { multisendAbi, safeAbi } from './abi'; import { DOMAIN_SEPARATOR_TYPEHASH, MASTER_COPY_ADDRESS, - outcomeTokenSpenders, PERMIT2_ADDRESS, PROXY_CREATION_CODE, SAFE_FACTORY_ADDRESS, @@ -51,18 +36,14 @@ import { SAFE_MSG_TYPEHASH, SAFE_MULTISEND_ADDRESS, SAFE_TX_TYPEHASH, - usdcSpenders, } from './constants'; import { OperationType, Permit2FeeAuthorization, - SafeFeeAuthorization, SafeTransaction, SplitSignature, } from './types'; -const MIN_VALID_HEX_DATA_LENGTH = 10; - function joinHexData(hexData: string[]): string { return `0x${hexData .map((hex) => { @@ -261,79 +242,12 @@ const getTransactionHash = ({ ) as Hex; }; -const signSafetransaction = async ( - safeAddress: Hex, - safeTx: SafeTransaction, - signer: Signer, -) => { - const nonce = await getNonce({ safeAddress }); - - const txHash = getTransactionHash({ - safeAddress, - to: safeTx.to, - value: safeTx.value, - data: safeTx.data, - operation: safeTx.operation, - nonce, - }); - - const rsvSignature = await signTransactionHash(signer, txHash); - const packedSig = abiEncodePacked( - { type: 'uint256', value: rsvSignature.r }, - { type: 'uint256', value: rsvSignature.s }, - { type: 'uint8', value: rsvSignature.v }, - ); - - return packedSig; -}; - -/** - * Creates a SafeFeeAuthorization for a given safe address, signer, amount, and to address - * @param safeAddress Safe address - * @param signer Signer - * @param amount Amount to transfer - * @param to payee address - * @returns SafeFeeAuthorization - */ -export const createSafeFeeAuthorization = async ({ - safeAddress, - signer, - amount, - to, -}: { - safeAddress: Hex; - signer: Signer; - amount: bigint; - to: Hex; -}): Promise => { - const erc20transfer = new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [to, amount]); - - const tx = { - to: MATIC_CONTRACTS.collateral, - operation: OperationType.Call, - data: erc20transfer, - value: '0', - }; - - const sig = await signSafetransaction(safeAddress, tx, signer); - - return { - type: 'safe-transaction', - authorization: { - tx, - sig, - }, - }; -}; - export const createPermit2FeeAuthorization = async ({ safeAddress, signer, amount, spender, - tokenAddress = MATIC_CONTRACTS.collateral, + tokenAddress = MATIC_CONTRACTS_V2.collateral, }: { safeAddress: Hex; signer: Signer; @@ -584,41 +498,6 @@ export const aggregateTransaction = ( return transaction; }; -export const createAllowancesSafeTransaction = (options?: { - extraUsdcSpenders?: string[]; -}) => { - const safeTxns: SafeTransaction[] = []; - const allUsdcSpenders = [ - ...usdcSpenders, - ...(options?.extraUsdcSpenders ?? []), - ]; - - for (const spender of allUsdcSpenders) { - safeTxns.push({ - to: MATIC_CONTRACTS.collateral, - data: encodeApprove({ - spender, - amount: ethers.constants.MaxUint256.toBigInt(), - }), - operation: OperationType.Call, - value: '0', - }); - } - - for (const spender of outcomeTokenSpenders) { - safeTxns.push({ - to: MATIC_CONTRACTS.conditionalTokens, - data: encodeErc1155Approve({ spender, approved: true }), - operation: OperationType.Call, - value: '0', - }); - } - - const safeTxn = aggregateTransaction(safeTxns); - - return safeTxn; -}; - export const getSafeTransactionCallData = async ({ signer, safeAddress, @@ -682,214 +561,6 @@ export const getSafeTransactionCallData = async ({ return callData; }; -export const getProxyWalletAllowancesTransaction = async ({ - signer, - extraUsdcSpenders, -}: { - signer: Signer; - extraUsdcSpenders?: string[]; -}) => { - try { - const safeAddress = computeProxyAddress(signer.address); - const safeTxn = createAllowancesSafeTransaction({ extraUsdcSpenders }); - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - - if (!callData || callData.length < MIN_VALID_HEX_DATA_LENGTH) { - throw new Error( - `Invalid call data generated: ${callData?.length ?? 0} bytes, minimum ${MIN_VALID_HEX_DATA_LENGTH} required`, - ); - } - - return { - params: { - to: safeAddress as Hex, - data: callData as Hex, - }, - type: TransactionType.contractInteraction, - }; - } catch (error) { - const errorContext: LoggerErrorOptions = { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - provider: POLYMARKET_PROVIDER_ID, - }, - context: { - name: 'safeUtils', - data: { - method: 'getProxyWalletAllowancesTransaction', - }, - }, - }; - Logger.error(error as Error, errorContext); - - throw new Error( - `Failed to generate proxy wallet allowances transaction: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ); - } -}; - -export const hasAllowances = async ({ - address, - extraUsdcSpenders = [], -}: { - address: string; - extraUsdcSpenders?: string[]; -}) => { - const allowanceCalls = []; - const isApprovedForAllCalls = []; - const allUsdcSpenders = [...usdcSpenders, ...extraUsdcSpenders]; - for (const spender of allUsdcSpenders) { - allowanceCalls.push( - getAllowance({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: address, - spender, - }), - ); - } - for (const spender of outcomeTokenSpenders) { - isApprovedForAllCalls.push( - getIsApprovedForAll({ - tokenAddress: MATIC_CONTRACTS.conditionalTokens, - owner: address, - operator: spender, - }), - ); - } - const allowanceResults = await Promise.all(allowanceCalls); - const isApprovedForAllResults = await Promise.all(isApprovedForAllCalls); - return ( - allowanceResults.every((allowance) => allowance > 0) && - isApprovedForAllResults.every((isApproved) => isApproved) - ); -}; - -export const hasPermit2Allowance = async ({ - address, -}: { - address: string; -}): Promise => { - const allowance = await getAllowance({ - tokenAddress: MATIC_CONTRACTS.collateral, - owner: address, - spender: PERMIT2_ADDRESS, - }); - return allowance > 0; -}; - -export const createClaimSafeTransaction = ( - positions: PredictPosition[], - includeTransfer?: { - address: string; - }, -) => { - const safeTxns: SafeTransaction[] = []; - const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - - for (const position of positions) { - const amounts: bigint[] = [0n, 0n]; - amounts[position.outcomeIndex] = BigInt( - parseUnits( - position.size.toString(), - CONDITIONAL_TOKEN_DECIMALS, - ).toString(), - ); - const negRisk = !!position.negRisk; - - const to = ( - negRisk ? contractConfig.negRiskAdapter : contractConfig.conditionalTokens - ) as Hex; - const callData = encodeClaim(position.outcomeId, negRisk, amounts); - safeTxns.push({ - to, - data: callData, - operation: OperationType.Call, - value: '0', - }); - } - - if (includeTransfer) { - safeTxns.push({ - to: MATIC_CONTRACTS.collateral, - data: encodeErc20Transfer({ - to: includeTransfer.address, - value: parseUnits( - MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), - COLLATERAL_TOKEN_DECIMALS, - ).toBigInt(), - }), - operation: OperationType.Call, - value: '0', - }); - } - - const safeTxn = aggregateTransaction(safeTxns); - - return safeTxn; -}; - -export const getClaimTransaction = async ({ - signer, - positions, - safeAddress, - includeTransferTransaction, -}: { - signer: Signer; - positions: PredictPosition[]; - safeAddress: string; - includeTransferTransaction?: boolean; -}) => { - const includeTransfer = includeTransferTransaction - ? { address: signer.address } - : undefined; - const safeTxn = createClaimSafeTransaction(positions, includeTransfer); - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - return [ - { - params: { - to: safeAddress as Hex, - data: callData as Hex, - }, - type: TransactionType.predictClaim, - }, - ]; -}; - -export const getWithdrawTransactionCallData = async ({ - signer, - safeAddress, - data, -}: { - signer: Signer; - safeAddress: string; - data: Hex; -}) => { - const safeTxn: SafeTransaction = { - to: MATIC_CONTRACTS.collateral, - data, - operation: OperationType.Call, - value: '0', - }; - - const callData = await getSafeTransactionCallData({ - signer, - safeAddress, - txn: safeTxn, - }); - - return callData as Hex; -}; - /* * Computes the proxy address for a given user address * @param userAddress User address @@ -909,11 +580,11 @@ export function computeProxyAddress(userAddress: string): Hex { } /** - * Decodes USDC amount from ERC20 transfer calldata + * Decodes token amount from ERC20 transfer calldata. * @param data ERC20 transfer calldata (0xa9059cbb...) - * @returns USDC amount in decimal format (e.g., 1.5 for 1.5 USDC) + * @returns Raw token amount. */ -export function getSafeUsdcAmountRaw(data: string): bigint { +export function getSafeTransferAmountRaw(data: string): bigint { if (!data.startsWith('0xa9059cbb')) { throw new Error('Not an ERC20 transfer call'); } @@ -940,24 +611,28 @@ export function getSafeUsdcAmountRaw(data: string): bigint { } const rawAmount = amount.toBigInt(); - const maxReasonableRawAmount = parseUnits('100000000000', 6).toBigInt(); + const maxReasonableRawAmount = ethers.utils + .parseUnits('100000000000', 6) + .toBigInt(); if (rawAmount > maxReasonableRawAmount) { throw new Error( - `Decoded USDC amount is invalid or too large: ${rawAmount.toString()}`, + `Decoded token amount is invalid or too large: ${rawAmount.toString()}`, ); } if (rawAmount < 0n) { - throw new Error(`Decoded USDC amount is negative: ${rawAmount.toString()}`); + throw new Error( + `Decoded token amount is negative: ${rawAmount.toString()}`, + ); } return rawAmount; } -export function getSafeUsdcAmount(data: string): number { - const rawAmount = getSafeUsdcAmountRaw(data); - const usdcValue = parseFloat(ethers.utils.formatUnits(rawAmount, 6)); +export function getSafeTransferAmount(data: string): number { + const rawAmount = getSafeTransferAmountRaw(data); + const tokenValue = parseFloat(ethers.utils.formatUnits(rawAmount, 6)); - return Math.round(usdcValue * 1e6) / 1e6; + return Math.round(tokenValue * 1e6) / 1e6; } diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 6b860a9f17fa..a06b84a6379c 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -1,5 +1,4 @@ import { PredictGamePeriod, Side } from '../../types'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; export interface PolymarketPosition { conditionId: string; @@ -29,85 +28,6 @@ export enum UtilsSide { SELL, } -export interface OrderData { - /** - * Maker of the order, i.e the source of funds for the order - */ - maker: string; - - /** - * Address of the order taker. The zero address is used to indicate a public order - */ - taker: string; - - /** - * Token Id of the CTF ERC1155 asset to be bought or sold. - * If BUY, this is the tokenId of the asset to be bought, i.e the makerAssetId - * If SELL, this is the tokenId of the asset to be sold, i.e the takerAssetId - */ - tokenId: string; - - /** - * Maker amount, i.e the max amount of tokens to be sold - */ - makerAmount: string; - - /** - * Taker amount, i.e the minimum amount of tokens to be received - */ - takerAmount: string; - - /** - * The side of the order, BUY or SELL - */ - side: UtilsSide; - - /** - * Fee rate, in basis points, charged to the order maker, charged on proceeds - */ - feeRateBps: string; - - /** - * Nonce used for onchain cancellations - */ - nonce: string; - - /** - * Signer of the order. Optional, if it is not present the signer is the maker of the order. - */ - signer?: string; - - /** - * Timestamp after which the order is expired. - * Optional, if it is not present the value is '0' (no expiration) - */ - expiration?: string; - - /** - * Signature type used by the Order. Default value 'EOA' - */ - signatureType?: SignatureType; -} - -/** - * SignedOrder - * - * Based on the response from buildMarketOrderCreationArgs, which returns - * OrderData combined with a generated salt. A SignedOrder augments that - * structure with the EIP-712 signature string produced by the signer. - */ -export type SignedOrder = (OrderData & { salt: string }) & { - signature: string; -}; - -export interface ClobOrderObject { - order: Omit & { - side: Side; - salt: number; - }; - owner: string; - orderType: OrderType; -} // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type ClobHeaders = { POLY_ADDRESS: string; @@ -117,12 +37,6 @@ export type ClobHeaders = { POLY_PASSPHRASE: string; }; -export interface PolymarketOffchainTradeParams { - clobOrder: ClobOrderObject; - headers: ClobHeaders; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; -} - // Polymarket API response types export interface PolymarketApiMarket { conditionId: string; @@ -334,12 +248,6 @@ export interface TickSizeResponse { minimum_tick_size: TickSize; } -export interface ClobOrderParams { - owner: string; - order: ClobOrderObject; - orderType: OrderType; -} - export interface OrderSummary { price: string; size: string; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 1b33bc149484..158fd0f00ae2 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -1,5565 +1,249 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { query } from '@metamask/controller-utils'; +import EthQuery from '@metamask/eth-query'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import Engine from '../../../../../core/Engine'; -import { - PredictCategory, - PredictMarketGame, - PredictOutcome, - PredictPositionStatus, - Side, - PredictActivityBuy, - PredictActivitySell, - PredictActivityEntry, -} from '../../types'; +import { Side } from '../../types'; import { PREDICT_ERROR_CODES } from '../../constants/errors'; -import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import { - ClobAuthDomain, DEFAULT_CLOB_BASE_URL, - EIP712Domain, - HASH_ZERO_BYTES32, - LEGACY_V2_CLOB_BASE_URL, - MATIC_CONTRACTS, - MSG_TO_SIGN, + MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID, - POLYMARKET_PROVIDER_ID, } from './constants'; -import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; -import { - ApiKeyCreds, - ClobHeaders, - ClobOrderObject, - L2HeaderArgs, - OrderData, - OrderResponse, - OrderType, - PolymarketApiEvent, - PolymarketApiMarket, - PolymarketPosition, - SignatureType, - UtilsSide, -} from './types'; -import { GetMarketsParams } from '../types'; import { - buildOutcomeGroups, - buildPolyHmacSignature, - calculateFees, createApiKey, deriveApiKey, - encodeApprove, - encodeClaim, - encodeRedeemNegRiskPositions, - encodeRedeemPositions, - generateSalt, + getAllowance, getContractConfig, - getL1Headers, - getL2Headers, - getMarketsFromPolymarketApi, - getParsedMarketsFromPolymarketApi, + getIsApprovedForAll, getOrderBook, - getFeeRateBps, - getOrderTypedData, - getPolymarketEndpoints, - getPredictPositionStatus, - GROUP_ORDER, - parsePolymarketEvents, - parsePolymarketPositions, - parsePolymarketActivity, - priceValid, - SPORTS_MARKET_TYPE_TO_GROUP, - submitClobOrder, - decimalPlaces, - roundNormal, - roundDown, - roundUp, - roundOrderAmount, + getRawBalance, previewOrder, - getAllowanceCalls, - fetchCarouselFromPolymarketApi, - isSpreadMarket, - sortGameMarkets, - sortMarketsByField, - sortMarkets, - parsePolymarketMarket, - fetchChildEventsFromGammaApi, - mergeChildEventsIntoParent, } from './utils'; -// Mock external dependencies +const mockSignTypedMessage = jest.fn(); + +jest.mock('@metamask/controller-utils', () => ({ + query: jest.fn(), +})); + +jest.mock('@metamask/eth-query', () => + jest.fn().mockImplementation(() => ({})), +); + jest.mock('../../../../../core/Engine', () => ({ context: { KeyringController: { - signTypedMessage: jest.fn(), + signTypedMessage: (...args: unknown[]) => mockSignTypedMessage(...args), + }, + NetworkController: { + findNetworkClientIdByChainId: jest.fn(), + getNetworkClientById: jest.fn(), }, }, })); -jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ - log: jest.fn(), -})); - -// Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch; - -// Mock crypto -Object.defineProperty(global, 'crypto', { - value: { - createHmac: jest.fn(), - } as any, - writable: true, -}); +const mockQuery = jest.mocked(query); +const mockEthQuery = jest.mocked(EthQuery); +const mockFindNetworkClientIdByChainId = jest.mocked( + Engine.context.NetworkController.findNetworkClientIdByChainId, +); +const mockGetNetworkClientById = jest.mocked( + Engine.context.NetworkController.getNetworkClientById, +); + +const apiKeyCreds = { + apiKey: 'api-key', + secret: 'secret', + passphrase: 'passphrase', +}; + +const orderBook = { + market: 'market-1', + asset_id: 'token-1', + hash: 'hash', + timestamp: new Date('2026-01-01T00:00:00.000Z').toISOString(), + asks: [{ price: '0.50', size: '100' }], + bids: [{ price: '0.49', size: '100' }], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, +}; describe('polymarket utils', () => { - const mockAddress = '0x1234567890123456789012345678901234567890'; - const mockApiKey: ApiKeyCreds = { - apiKey: 'test-api-key', - secret: 'test-secret', - passphrase: 'test-passphrase', - }; - beforeEach(() => { jest.clearAllMocks(); - mockFetch.mockReset(); + mockSignTypedMessage.mockResolvedValue('0xsig'); + mockFindNetworkClientIdByChainId.mockReturnValue('test-network-client-id'); + mockGetNetworkClientById.mockReturnValue({ + provider: {}, + } as ReturnType< + typeof Engine.context.NetworkController.getNetworkClientById + >); + }); - // Setup default fetch mock to prevent unhandled rejections + it('creates API keys against the canonical CLOB host', async () => { mockFetch.mockResolvedValue({ + status: 200, ok: true, - json: jest.fn().mockResolvedValue({}), - } as any); - - // Setup default mock implementations - ( - Engine.context.KeyringController.signTypedMessage as jest.Mock - ).mockResolvedValue('mock-signature'); - (global.crypto as any).createHmac.mockReturnValue({ - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('mock-digest-base64'), - }); - }); - - describe('getPolymarketEndpoints', () => { - it('return production endpoints', () => { - const endpoints = getPolymarketEndpoints(); - expect(endpoints).toEqual({ - GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', - CLOB_ENDPOINT: DEFAULT_CLOB_BASE_URL, - CRYPTO_PRICE_ENDPOINT: 'https://polymarket.com/api/crypto/crypto-price', - DATA_API_ENDPOINT: 'https://data-api.polymarket.com', - GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', - HOMEPAGE_CAROUSEL_ENDPOINT: - 'https://polymarket.com/api/homepage/carousel', - CLOB_RELAYER: 'https://predict.api.cx.metamask.io', - }); - }); - }); - - describe('getL1Headers', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('generate correct L1 headers', async () => { - const expectedHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'mock-signature', - POLY_TIMESTAMP: '1704067200', - POLY_NONCE: '0', - }; - - const headers = await getL1Headers({ address: mockAddress }); - - expect(headers).toEqual(expectedHeaders); - expect( - Engine.context.KeyringController.signTypedMessage, - ).toHaveBeenCalledWith( - { - data: { - domain: { - name: 'ClobAuthDomain', - version: '1', - chainId: POLYGON_MAINNET_CHAIN_ID, - }, - types: { - EIP712Domain, - ...ClobAuthDomain, - }, - message: { - address: mockAddress, - timestamp: '1704067200', - nonce: 0, - message: MSG_TO_SIGN, - }, - primaryType: 'ClobAuth', - }, - from: mockAddress, - }, - SignTypedDataVersion.V4, - ); - }); - - it('handle signing errors', async () => { - const error = new Error('Signing failed'); - ( - Engine.context.KeyringController.signTypedMessage as jest.Mock - ).mockRejectedValue(error); - - await expect(getL1Headers({ address: mockAddress })).rejects.toThrow( - 'Signing failed', - ); - }); - }); - - describe('buildPolyHmacSignature', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('build HMAC signature without body', async () => { - const secret = 'test-secret'; - const timestamp = 1704067200; - const method = 'GET'; - const requestPath = '/test'; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const signature = await buildPolyHmacSignature( - secret, - timestamp, - method, - requestPath, - ); - - expect((global.crypto as any).createHmac).toHaveBeenCalledWith( - 'sha256', - Buffer.from(secret, 'base64'), - ); - expect(mockHmac.update).toHaveBeenCalledWith('1704067200GET/test'); - expect(mockHmac.digest).toHaveBeenCalledWith('base64'); - expect(signature).toBe('test-signature_'); // + -> -, / -> _ - }); - - it('build HMAC signature with body', async () => { - const secret = 'test-secret'; - const timestamp = 1704067200; - const method = 'POST'; - const requestPath = '/test'; - const body = '{"test": "data"}'; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const signature = await buildPolyHmacSignature( - secret, - timestamp, - method, - requestPath, - body, - ); - - expect(mockHmac.update).toHaveBeenCalledWith( - '1704067200POST/test{"test": "data"}', - ); - expect(signature).toBe('test-signature_'); - }); - - it('handle empty secret', async () => { - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - await buildPolyHmacSignature('', 1704067200, 'GET', '/test'); - - expect((global.crypto as any).createHmac).toHaveBeenCalledWith( - 'sha256', - Buffer.from('', 'base64'), - ); - }); - }); - - describe('getL2Headers', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('generate correct L2 headers', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'POST', - requestPath: '/order', - body: '{"test": "data"}', - }; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - const headers = await getL2Headers({ - l2HeaderArgs, - address: mockAddress, - apiKey: mockApiKey, - }); - - expect(headers).toEqual({ - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }); - }); - - it('use provided timestamp', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'GET', - requestPath: '/markets', - }; - const customTimestamp = 1704067300; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); - - await getL2Headers({ - l2HeaderArgs, - timestamp: customTimestamp, - address: mockAddress, - apiKey: mockApiKey, - }); - - expect(mockHmac.update).toHaveBeenCalledWith( - `${customTimestamp}GET/markets`, - ); + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - it('handle undefined apiKey gracefully', async () => { - const l2HeaderArgs: L2HeaderArgs = { - method: 'GET', - requestPath: '/markets', - }; - - const mockHmac = { - update: jest.fn().mockReturnThis(), - digest: jest.fn().mockReturnValue('test+signature/'), - }; - (global.crypto as any).createHmac.mockReturnValue(mockHmac); + await expect( + createApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await getL2Headers({ - l2HeaderArgs, - address: mockAddress, - apiKey: undefined as any, - }); - - expect(mockHmac.update).toHaveBeenCalledWith('1704067200GET/markets'); - expect(mockHmac.digest).toHaveBeenCalledWith('base64'); - }); + expect(mockSignTypedMessage).toHaveBeenCalledWith( + expect.any(Object), + SignTypedDataVersion.V4, + ); + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, + expect.objectContaining({ method: 'POST' }), + ); }); - describe('deriveApiKey', () => { - it('derive API key successfully', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await deriveApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/auth/derive-api-key', - { - method: 'GET', - headers: expect.objectContaining({ - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'mock-signature', - }), - }, - ); - }); - - it('defaults v2 API key derivation to the canonical CLOB endpoint', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - await deriveApiKey({ address: mockAddress, clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ - method: 'GET', - }), - ); - }); - - it('uses the temporary v2 CLOB host override when provided', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - mockFetch.mockResolvedValue(mockResponse); - - await deriveApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ - method: 'GET', - }), - ); + it('derives API keys against the canonical CLOB host', async () => { + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); + await expect( + deriveApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await expect(deriveApiKey({ address: mockAddress })).rejects.toThrow( - 'Network error', - ); - }); + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, + expect.objectContaining({ method: 'GET' }), + ); }); - describe('createApiKey', () => { - it('create API key successfully', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await createApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/auth/api-key', - { - method: 'POST', - headers: expect.objectContaining({ - POLY_ADDRESS: mockAddress, - }), - body: '', - }, - ); - }); - - it('defaults v2 API key creation to the canonical CLOB endpoint', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - await createApiKey({ address: mockAddress, clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ - method: 'POST', - body: '', - }), - ); - }); - - it('uses the temporary v2 CLOB host override for API key creation when provided', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - status: 200, - }; - mockFetch.mockResolvedValue(mockResponse); - - await createApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ - method: 'POST', - body: '', - }), - ); - }); - - it('derive API key when creation returns 400', async () => { - const createResponse = { - ok: false, - json: jest.fn().mockResolvedValue({}), + it('falls back to deriving an API key when creation returns 400', async () => { + mockFetch + .mockResolvedValueOnce({ status: 400, - }; - const deriveResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - - mockFetch - .mockResolvedValueOnce(createResponse) - .mockResolvedValueOnce(deriveResponse); - - const result = await createApiKey({ address: mockAddress }); - - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('derives from the provided v2 CLOB host when v2 creation returns 400', async () => { - const createResponse = { ok: false, - json: jest.fn().mockResolvedValue({}), - status: 400, - }; - const deriveResponse = { + json: jest.fn(), + }) + .mockResolvedValueOnce({ + status: 200, ok: true, - json: jest.fn().mockResolvedValue(mockApiKey), - }; - - mockFetch - .mockResolvedValueOnce(createResponse) - .mockResolvedValueOnce(deriveResponse); - - const result = await createApiKey({ - address: mockAddress, - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, + json: jest.fn().mockResolvedValue(apiKeyCreds), }); - expect(result).toEqual(mockApiKey); - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - `${LEGACY_V2_CLOB_BASE_URL}/auth/api-key`, - expect.objectContaining({ method: 'POST' }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - `${LEGACY_V2_CLOB_BASE_URL}/auth/derive-api-key`, - expect.objectContaining({ method: 'GET' }), - ); - }); - - it('handle creation errors', async () => { - const error = new Error('Creation failed'); - mockFetch.mockRejectedValue(error); + await expect( + createApiKey({ address: '0x1111111111111111111111111111111111111111' }), + ).resolves.toEqual(apiKeyCreds); - await expect(createApiKey({ address: mockAddress })).rejects.toThrow( - 'Creation failed', - ); - }); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + `${DEFAULT_CLOB_BASE_URL}/auth/api-key`, + expect.objectContaining({ method: 'POST' }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + `${DEFAULT_CLOB_BASE_URL}/auth/derive-api-key`, + expect.objectContaining({ method: 'GET' }), + ); }); - describe('priceValid', () => { - it('return true for valid prices', () => { - expect(priceValid(0.5, '0.1')).toBe(true); - expect(priceValid(0.6, '0.01')).toBe(true); - expect(priceValid(0.05, '0.001')).toBe(true); - expect(priceValid(0.9, '0.1')).toBe(true); // Upper bound for tickSize 0.1 + it('fetches order books from the canonical CLOB host', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(orderBook), }); - it('return false for invalid prices', () => { - expect(priceValid(0.05, '0.1')).toBe(false); // Below minimum tick - expect(priceValid(0.95, '0.1')).toBe(false); // Above 1 - minimum tick (0.9) - expect(priceValid(1.5, '0.1')).toBe(false); // Above 1 - expect(priceValid(-0.1, '0.1')).toBe(false); // Negative - }); + await expect(getOrderBook({ tokenId: 'token-1' })).resolves.toEqual( + orderBook, + ); - it.each([ - ['0.1', 0.6], - ['0.01', 0.55], - ['0.001', 0.544], - ['0.0001', 0.5444], - ] as const)( - 'should validate tick size %s correctly', - (tickSize, validPrice) => { - expect(priceValid(validPrice, tickSize)).toBe(true); - expect(priceValid(parseFloat(tickSize) - 0.001, tickSize)).toBe(false); // Well below minimum - expect(priceValid(1 - parseFloat(tickSize) + 0.001, tickSize)).toBe( - false, - ); // Well above maximum - }, + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_CLOB_BASE_URL}/book?token_id=token-1`, + { method: 'GET' }, ); }); - describe('getOrderBook', () => { - it('fetch order book successfully', async () => { - const mockOrderBook = { - bids: [ - { price: '0.4', size: '100' }, - { price: '0.45', size: '200' }, - ], - asks: [ - { price: '0.6', size: '150' }, - { price: '0.55', size: '100' }, - ], - }; - - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - const result = await getOrderBook({ tokenId: 'test-token' }); - - expect(result).toEqual(mockOrderBook); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/book?token_id=test-token', - { method: 'GET' }, - ); - }); - - it('defaults the v2 order book to the canonical CLOB endpoint', async () => { - const mockOrderBook = { - bids: [], - asks: [], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - await getOrderBook({ tokenId: 'test-token', clobVersion: 'v2' }); - - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/book?token_id=test-token`, - { method: 'GET' }, - ); - }); - - it('uses the temporary v2 CLOB host override for order book reads when provided', async () => { - const mockOrderBook = { - bids: [], - asks: [], - }; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockOrderBook), - }; - mockFetch.mockResolvedValue(mockResponse); - - await getOrderBook({ - tokenId: 'test-token', - clobVersion: 'v2', - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/book?token_id=test-token`, - { method: 'GET' }, - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - 'Network error', - ); - }); - - it('throws PREVIEW_NO_ORDER_BOOK error when orderbook does not exist', async () => { - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue({ - error: 'No orderbook exists for the requested token id', - }), - }; - mockFetch.mockResolvedValue(mockResponse); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK, - ); + it('maps missing order book errors to the Predict preview error code', async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue({ + error: 'No orderbook exists for the requested token id', + }), }); - it('throws error message from response when response is not ok', async () => { - const mockResponse = { - ok: false, - json: jest.fn().mockResolvedValue({ error: 'Custom error message' }), - }; - mockFetch.mockResolvedValue(mockResponse); - - await expect(getOrderBook({ tokenId: 'test-token' })).rejects.toThrow( - 'Custom error message', - ); - }); + await expect(getOrderBook({ tokenId: 'token-1' })).rejects.toThrow( + PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK, + ); }); - describe('getFeeRateBps', () => { - it('returns fee rate from CLOB fee-rate endpoint', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ base_fee: 30 }), - }); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('30'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/fee-rate?token_id=test-token', - { method: 'GET' }, - ); - }); - - it('returns zero fee rate when fee-rate endpoint responds with error', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: jest - .fn() - .mockResolvedValue({ error: 'fee rate not found for market' }), - }); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('0'); - }); - - it('returns zero fee rate when fee-rate endpoint throws', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - const result = await getFeeRateBps({ tokenId: 'test-token' }); - - expect(result).toBe('0'); + it('previews orders using CLOB v2 order books and zero fee-rate bps', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(orderBook), }); - }); - describe('generateSalt', () => { - it('generate a valid hex salt', () => { - const salt = generateSalt(); - expect(typeof salt).toBe('string'); - expect(salt.startsWith('0x')).toBe(true); - expect(salt.length).toBeGreaterThan(2); - // Should be a valid hex number - expect(() => parseInt(salt.slice(2), 16)).not.toThrow(); + const preview = await previewOrder({ + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 10, }); - it('generate different salts on multiple calls', () => { - const salt1 = generateSalt(); - const salt2 = generateSalt(); - expect(salt1).not.toBe(salt2); - }); + expect(preview).toEqual( + expect.objectContaining({ + marketId: 'market-1', + outcomeTokenId: 'token-1', + feeRateBps: '0', + negRisk: false, + }), + ); }); - describe('getContractConfig', () => { - it('return Polygon mainnet contracts', () => { - const config = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - expect(config).toEqual(MATIC_CONTRACTS); - }); - - it('throw error for unsupported chain', () => { - expect(() => getContractConfig(999)).toThrow( - 'MetaMask Predict is only supported on Polygon mainnet', - ); - }); + it('returns the v2 contract config for Polygon', () => { + expect(getContractConfig(POLYGON_MAINNET_CHAIN_ID)).toBe( + MATIC_CONTRACTS_V2, + ); }); - describe('getOrderTypedData', () => { - const orderData: OrderData & { salt: string } = { - salt: '12345', - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: UtilsSide.BUY, - signatureType: SignatureType.EOA, - }; + it('treats empty balance results as zero', async () => { + mockQuery.mockResolvedValue('0x'); - it('generate correct typed data structure', () => { - const result = getOrderTypedData({ - order: orderData, - chainId: POLYGON_MAINNET_CHAIN_ID, - verifyingContract: '0x1234567890123456789012345678901234567890', - }); + await expect( + getRawBalance({ + address: '0x1111111111111111111111111111111111111111', + tokenAddress: '0x2222222222222222222222222222222222222222', + }), + ).resolves.toBe(0n); - expect(result.primaryType).toBe('Order'); - expect(result.domain).toEqual({ - name: 'Polymarket CTF Exchange', - version: '1', - chainId: POLYGON_MAINNET_CHAIN_ID, - verifyingContract: '0x1234567890123456789012345678901234567890', - }); - expect(result.types).toEqual({ - EIP712Domain: [ - ...EIP712Domain, - { name: 'verifyingContract', type: 'address' }, - ], - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - ], - }); - expect(result.message).toEqual(orderData); - }); + expect(mockEthQuery).toHaveBeenCalled(); }); - describe('encodeApprove', () => { - it('encode approve function call correctly', () => { - const spender = '0x1234567890123456789012345678901234567890'; - const amount = BigInt(1000000); + it('treats empty allowance results as zero', async () => { + mockQuery.mockResolvedValue('0x'); - const result = encodeApprove({ spender, amount }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle string amounts', () => { - const spender = '0x1234567890123456789012345678901234567890'; - const amount = '1000000'; - - const result = encodeApprove({ spender, amount }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); + await expect( + getAllowance({ + tokenAddress: '0x2222222222222222222222222222222222222222', + owner: '0x1111111111111111111111111111111111111111', + spender: '0x3333333333333333333333333333333333333333', + }), + ).resolves.toBe(0n); }); - describe('submitClobOrder', () => { - const mockHeaders: ClobHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }; - - const mockClobOrder: ClobOrderObject = { - order: { - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: Side.BUY, - signatureType: SignatureType.EOA, - signature: 'mock-signature', - salt: 12345, - }, - owner: mockAddress, - orderType: OrderType.FOK, - }; - - const mockOrderResponse: OrderResponse = { - errorMsg: '', - makingAmount: '100000000', - orderID: 'order-123', - status: 'success', - success: true, - takingAmount: '50000000', - transactionsHashes: [], - }; - - beforeEach(() => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockOrderResponse), - }); - }); - - it('submit CLOB order successfully', async () => { - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: true, - response: mockOrderResponse, - }); - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Failed to submit CLOB order: Network error', - }); - }); - - it('includes feeAuthorization in request body when provided', async () => { - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ ...mockClobOrder, feeAuthorization }), - }, - ); - }); - - it('omits feeAuthorization when undefined', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('serializes feeAuthorization correctly to JSON', async () => { - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0x1234567890123456789012345678901234567890', - operation: 0, - data: '0xabcdef', - value: '100', - }, - sig: '0xdeadbeef', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody).toHaveProperty('feeAuthorization'); - expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); - }); - - it('uses CLOB_RELAYER endpoint when feeAuthorization is not provided for BUY orders', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...mockClobOrder, - feeAuthorization: undefined, - }), - }, - ); - }); - - it('uses CLOB_RELAYER endpoint for SELL orders with feeAuthorization', async () => { - const sellClobOrder: ClobOrderObject = { - ...mockClobOrder, - order: { - ...mockClobOrder.order, - side: Side.SELL, - }, - }; - - const feeAuthorization = { - type: 'safe-transaction' as const, - authorization: { - tx: { - to: '0xCollateralAddress', - operation: 0, - data: '0xdata', - value: '0', - }, - sig: '0xsig', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: sellClobOrder, - feeAuthorization, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://predict.api.cx.metamask.io/order', - { - method: 'POST', - headers: { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - 'POLY-ADDRESS': mockAddress, - 'POLY-SIGNATURE': 'test-signature_', - 'POLY-TIMESTAMP': '1704067200', - 'POLY-API-KEY': 'test-api-key', - 'POLY-PASSPHRASE': 'test-passphrase', - }, - body: JSON.stringify({ - ...sellClobOrder, - feeAuthorization, - }), - }, - ); - }); - - it('includes executor in request body when provided', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - executor: '0x1111111111111111111111111111111111111111', - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.executor).toBe( - '0x1111111111111111111111111111111111111111', - ); - }); - - it('supports Permit2 fee authorization payload in request body', async () => { - const feeAuthorization = { - type: 'safe-permit2' as const, - authorization: { - permit: { - permitted: { - token: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - amount: '1000000', - }, - nonce: '0', - deadline: '1700000000', - }, - spender: '0x1111111111111111111111111111111111111111', - signature: '0xabc', - }, - }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - feeAuthorization, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); - }); - - it('includes allowancesTx in request body when provided', async () => { - const allowancesTx = { to: '0xSafeAddress', data: '0xallowanceData' }; - - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - allowancesTx, - }); + it('treats empty approval results as false', async () => { + mockQuery.mockResolvedValue('0x'); - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody.allowancesTx).toEqual(allowancesTx); - }); - - it('omits allowancesTx from request body when not provided', async () => { - await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - const callArgs = mockFetch.mock.calls[0]; - const bodyString = callArgs[1].body; - const parsedBody = JSON.parse(bodyString); - - expect(parsedBody).not.toHaveProperty('allowancesTx'); - }); - }); - - describe('parsePolymarketEvents', () => { - const mockCategory: PredictCategory = 'trending'; - - const mockEvent: PolymarketApiEvent = { - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [{ id: '1', slug: 'test', title: 'Test', recurrence: 'daily' }], - markets: [ - { - conditionId: 'market-1', - question: 'Will it rain?', - // Event description matches markets' descriptions (as per Polymarket's team) - description: 'A test event', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - }; - - it('parse events correctly', () => { - const result = parsePolymarketEvents([mockEvent], mockCategory); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - id: 'event-1', - slug: 'test-event', - providerId: POLYMARKET_PROVIDER_ID, - title: 'Test Event', - description: 'A test event', - image: 'https://example.com/icon.png', - status: 'open', - recurrence: 'daily', - series: { - id: '1', - slug: 'test', - title: 'Test', - recurrence: 'daily', - }, - endDate: undefined, - game: undefined, - category: mockCategory, - tags: [], - outcomes: [ - { - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: 'Will it rain?', - description: 'A test event', - image: 'https://example.com/market-icon.png', - groupItemTitle: 'Weather', - groupItemThreshold: undefined, - status: 'open', - volume: 1000, - liquidity: 500, - resolutionStatus: 'unresolved', - tokens: [ - { - id: 'token-1', - title: 'Yes', - price: 0.6, - }, - { - id: 'token-2', - title: 'No', - price: 0.4, - }, - ], - sportsMarketType: undefined, - negRisk: true, - tickSize: '0.01', - resolvedBy: '0x0000000000000000000000000000000000000000', - }, - ], - liquidity: 1000000, - volume: 1000000, - }); - }); - - it('handle closed events', () => { - const closedEvent = { - ...mockEvent, - closed: true, - markets: [ - { - ...mockEvent.markets[0], - closed: true, - }, - ], - }; - const result = parsePolymarketEvents([closedEvent], mockCategory); - - expect(result[0].status).toBe('closed'); - expect(result[0].outcomes[0].status).toBe('closed'); - }); - - it('handle null clobTokenIds', () => { - const eventWithNullTokens = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - clobTokenIds: '[]', - outcomes: '[]', - outcomePrices: '[]', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithNullTokens], mockCategory); - - expect(result[0].outcomes[0].tokens).toEqual([]); - }); - - it('use market image when icon is not available', () => { - const eventWithoutIcon = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - icon: '', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithoutIcon], mockCategory); - - expect(result[0].outcomes[0].image).toBe(''); - }); - - it('filter out inactive markets', () => { - const eventWithInactiveMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-1', - active: true, - }, - { - ...mockEvent.markets[0], - conditionId: 'market-2', - active: false, - }, - { - ...mockEvent.markets[0], - conditionId: 'market-3', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithInactiveMarkets], - mockCategory, - ); - - expect(result[0].outcomes).toHaveLength(2); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-1', - 'market-3', - ]); - }); - - it('sorts markets by price in descending order when sortMarketsBy is price', () => { - const eventWithMultipleMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-low-price', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-high-price', - outcomePrices: '["0.8", "0.2"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-medium-price', - outcomePrices: '["0.5", "0.5"]', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithMultipleMarkets], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(3); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-high-price', - 'market-medium-price', - 'market-low-price', - ]); - }); - - it('handles markets with null outcomePrices in sorting when sortMarketsBy is price', () => { - const eventWithNullPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.6", "0.4"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-without-price', - outcomePrices: null as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithNullPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.6 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-without-price'); - }); - - it('handles markets with undefined outcomePrices in sorting when sortMarketsBy is price', () => { - const eventWithUndefinedPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-without-price', - outcomePrices: undefined as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithUndefinedPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.3 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-without-price'); - }); - - it('handles markets with empty outcomePrices string in sorting when sortMarketsBy is price', () => { - const eventWithEmptyPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-with-price', - outcomePrices: '["0.4", "0.6"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-with-empty-price', - outcomePrices: '', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithEmptyPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(2); - // Market with price comes first (0.4 > 0) - expect(result[0].outcomes[0].id).toBe('market-with-price'); - expect(result[0].outcomes[1].id).toBe('market-with-empty-price'); - }); - - it('include resolvedBy field in outcome', () => { - const eventWithResolvedBy = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - resolvedBy: '0x1234567890123456789012345678901234567890', - }, - ], - }; - - const result = parsePolymarketEvents([eventWithResolvedBy], mockCategory); - - expect(result[0].outcomes[0].resolvedBy).toBe( - '0x1234567890123456789012345678901234567890', - ); - }); - - it('handle undefined resolvedBy field', () => { - const eventWithoutResolvedBy = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - resolvedBy: undefined as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithoutResolvedBy], - mockCategory, - ); - - expect(result[0].outcomes[0].resolvedBy).toBeUndefined(); - }); - - it('handles complex sorting with mixed price scenarios when sortMarketsBy is price', () => { - const eventWithComplexPrices = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-zero', - outcomePrices: '["0", "1"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-high', - outcomePrices: '["0.9", "0.1"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-medium', - outcomePrices: '["0.5", "0.5"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-null', - outcomePrices: null as any, - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithComplexPrices], - mockCategory, - 'price', - ); - - expect(result[0].outcomes).toHaveLength(4); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-high', // 0.9 - 'market-medium', // 0.5 - 'market-zero', // 0 - 'market-null', // 0 (default) - ]); - }); - - it('preserves market order when no sortMarketsBy is provided for non-sport events', () => { - const eventWithMultipleMarkets = { - ...mockEvent, - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'market-first', - outcomePrices: '["0.3", "0.7"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-second', - outcomePrices: '["0.8", "0.2"]', - }, - { - ...mockEvent.markets[0], - conditionId: 'market-third', - outcomePrices: '["0.5", "0.5"]', - }, - ], - }; - - const result = parsePolymarketEvents( - [eventWithMultipleMarkets], - mockCategory, - ); - - expect(result[0].outcomes).toHaveLength(3); - expect(result[0].outcomes.map((outcome) => outcome.id)).toEqual([ - 'market-first', - 'market-second', - 'market-third', - ]); - }); - - it('populates outcomeGroups for sport event when extendedSportsMarketsLeagues includes league', () => { - const sportEvent: PolymarketApiEvent = { - id: 'nfl-game-1', - slug: 'nfl-sea-den-2025-01-15', - title: 'Seahawks vs. Broncos', - description: 'NFL game', - icon: 'https://example.com/icon.png', - closed: false, - tags: [ - { id: '1', label: 'Sports', slug: 'sports' }, - { id: '2', label: 'Games', slug: 'games' }, - { id: '3', label: 'NFL', slug: 'nfl' }, - ], - series: [], - markets: [ - { - ...mockEvent.markets[0], - conditionId: 'moneyline-1', - sportsMarketType: 'moneyline', - }, - ], - liquidity: 50000, - volume: 100000, - gameId: 'game-123', - }; - const mockTeamLookup = jest.fn((league: string, abbreviation: string) => { - const teams: Record< - string, - Record< - string, - { - id: string; - name: string; - logo: string; - abbreviation: string; - color: string; - alias: string; - } - > - > = { - nfl: { - sea: { - id: 'sea', - name: 'Seahawks', - logo: '', - abbreviation: 'sea', - color: TEST_HEX_COLORS.TEAM_SEA, - alias: 'Seahawks', - }, - den: { - id: 'den', - name: 'Broncos', - logo: '', - abbreviation: 'den', - color: TEST_HEX_COLORS.TEAM_DEN, - alias: 'Broncos', - }, - }, - }; - return teams[league]?.[abbreviation]; - }); - - const resultWithLeague = parsePolymarketEvents([sportEvent], { - category: 'sports', - teamLookup: mockTeamLookup, - extendedSportsMarketsLeagues: ['nfl'], - }); - - expect(resultWithLeague[0].outcomeGroups).toBeDefined(); - expect(Array.isArray(resultWithLeague[0].outcomeGroups)).toBe(true); - - const resultWithoutLeague = parsePolymarketEvents([sportEvent], { - category: 'sports', - teamLookup: mockTeamLookup, - extendedSportsMarketsLeagues: [], - }); - - expect(resultWithoutLeague[0].outcomeGroups).toBeUndefined(); - }); - }); - - describe('isSpreadMarket', () => { - it('returns true when sportsMarketType contains spread', () => { - const spreadMarket: PolymarketApiMarket = { - conditionId: 'spread-market', - question: 'Spread market?', - description: 'A spread market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A -3.5', - sportsMarketType: 'spreads', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(spreadMarket); - - expect(result).toBe(true); - }); - - it('returns true when sportsMarketType is spread (case insensitive)', () => { - const spreadMarket: PolymarketApiMarket = { - conditionId: 'spread-market', - question: 'Spread market?', - description: 'A spread market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A -3.5', - sportsMarketType: 'Spreads', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(spreadMarket); - - expect(result).toBe(true); - }); - - it('returns false when sportsMarketType is moneyline', () => { - const moneylineMarket: PolymarketApiMarket = { - conditionId: 'moneyline-market', - question: 'Moneyline market?', - description: 'A moneyline market', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A', - sportsMarketType: 'moneyline', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(moneylineMarket); - - expect(result).toBe(false); - }); - - it('returns false when sportsMarketType is undefined', () => { - const marketWithoutType: PolymarketApiMarket = { - conditionId: 'market-no-type', - question: 'Market?', - description: 'A market without type', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Team A', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }; - - const result = isSpreadMarket(marketWithoutType); - - expect(result).toBe(false); - }); - }); - - describe('sortGameMarkets', () => { - const createSportMarket = ( - id: string, - sportsMarketType: string, - liquidity: number, - volume: number, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - sportsMarketType, - status: 'open', - volumeNum: volume, - liquidity, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('groups markets by sportsMarketType with moneyline first, spreads second, totals third', () => { - const markets = [ - createSportMarket('totals-1', 'totals', 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - createSportMarket('spreads-1', 'spreads', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'spreads-1', - 'totals-1', - ]); - }); - - it('sorts alphabetically for unknown market types', () => { - const markets = [ - createSportMarket('zebra-1', 'zebra', 100, 100), - createSportMarket('alpha-1', 'alpha', 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'alpha-1', - 'zebra-1', - ]); - }); - - it('sorts markets within same group by liquidity + volume descending', () => { - const markets = [ - createSportMarket('moneyline-low', 'moneyline', 100, 100), // score: 200 - createSportMarket('moneyline-high', 'moneyline', 500, 500), // score: 1000 - createSportMarket('moneyline-medium', 'moneyline', 300, 200), // score: 500 - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-high', - 'moneyline-medium', - 'moneyline-low', - ]); - }); - - it('handles markets with undefined sportsMarketType as other', () => { - const markets = [ - createSportMarket('other-1', undefined as any, 100, 100), - createSportMarket('moneyline-1', 'moneyline', 100, 100), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'other-1', - ]); - }); - - it('maintains group ordering with multiple markets per group', () => { - const markets = [ - createSportMarket('totals-low', 'totals', 50, 50), - createSportMarket('spreads-high', 'spreads', 500, 500), - createSportMarket('moneyline-low', 'moneyline', 100, 100), - createSportMarket('totals-high', 'totals', 300, 300), - createSportMarket('spreads-low', 'spreads', 100, 100), - createSportMarket('moneyline-high', 'moneyline', 400, 400), - ]; - - const result = sortGameMarkets(markets); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-high', - 'moneyline-low', - 'spreads-high', - 'spreads-low', - 'totals-high', - 'totals-low', - ]); - }); - }); - - describe('sortMarketsByField', () => { - const createMarketForSorting = ( - id: string, - price: string, - threshold?: number, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - groupItemThreshold: threshold, - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: price, - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('sorts by price descending', () => { - const markets = [ - createMarketForSorting('low', '["0.3", "0.7"]'), - createMarketForSorting('high', '["0.9", "0.1"]'), - createMarketForSorting('medium', '["0.5", "0.5"]'), - ]; - - const result = sortMarketsByField(markets, 'price'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'high', - 'medium', - 'low', - ]); - }); - - it('sorts by groupItemThreshold ascending', () => { - const markets = [ - createMarketForSorting('high', '["0.5", "0.5"]', 100), - createMarketForSorting('low', '["0.5", "0.5"]', 10), - createMarketForSorting('medium', '["0.5", "0.5"]', 50), - ]; - - const result = sortMarketsByField(markets, 'ascending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'low', - 'medium', - 'high', - ]); - }); - - it('sorts by groupItemThreshold descending', () => { - const markets = [ - createMarketForSorting('high', '["0.5", "0.5"]', 100), - createMarketForSorting('low', '["0.5", "0.5"]', 10), - createMarketForSorting('medium', '["0.5", "0.5"]', 50), - ]; - - const result = sortMarketsByField(markets, 'descending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'high', - 'medium', - 'low', - ]); - }); - - it('handles undefined groupItemThreshold as 0 for ascending', () => { - const markets = [ - createMarketForSorting('with-threshold', '["0.5", "0.5"]', 50), - createMarketForSorting('without-threshold', '["0.5", "0.5"]'), - ]; - - const result = sortMarketsByField(markets, 'ascending'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'without-threshold', - 'with-threshold', - ]); - }); - - it('handles null outcomePrices as 0 for price sorting', () => { - const markets = [ - createMarketForSorting('with-price', '["0.6", "0.4"]'), - { - ...createMarketForSorting('null-price', ''), - outcomePrices: null as any, - }, - ]; - - const result = sortMarketsByField(markets, 'price'); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'with-price', - 'null-price', - ]); - }); - }); - - describe('sortMarkets', () => { - const createEvent = ( - tags: { id: string; label: string; slug: string }[], - markets: PolymarketApiMarket[], - sortBy?: 'price' | 'ascending' | 'descending', - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags, - series: [], - markets, - liquidity: 1000, - volume: 5000, - sortBy, - }); - - const createMarket = ( - id: string, - price: string, - liquidity: number, - volume: number, - sportsMarketType?: string, - ): PolymarketApiMarket => ({ - conditionId: id, - question: `Market ${id}?`, - description: `Description ${id}`, - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: `Group ${id}`, - sportsMarketType, - status: 'open', - volumeNum: volume, - liquidity, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: price, - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }); - - it('uses sortBy parameter when provided', () => { - const markets = [ - createMarket('low', '["0.3", "0.7"]', 100, 100), - createMarket('high', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price' }); - - expect(result.map((m) => m.conditionId)).toEqual(['high', 'low']); - }); - - it('uses sortGameMarkets for game events when no sortBy parameter', () => { - const markets = [ - createMarket('totals-1', '["0.5", "0.5"]', 100, 100, 'totals'), - createMarket('moneyline-1', '["0.5", "0.5"]', 100, 100, 'moneyline'), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, isGameEvent: true }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-1', - 'totals-1', - ]); - }); - - it('uses event.sortBy for non-game events when no sortBy parameter', () => { - const markets = [ - createMarket('low', '["0.3", "0.7"]', 100, 100), - createMarket('high', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets, 'price'); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual(['high', 'low']); - }); - - it('returns markets unchanged when no sorting specified for non-game events', () => { - const markets = [ - createMarket('first', '["0.3", "0.7"]', 100, 100), - createMarket('second', '["0.9", "0.1"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual(['first', 'second']); - }); - - it('does not apply game sorting when isGameEvent is not set even with sport tags', () => { - const markets = [ - createMarket('totals-1', '["0.5", "0.5"]', 100, 100, 'totals'), - createMarket('moneyline-1', '["0.5", "0.5"]', 100, 100, 'moneyline'), - ]; - const sportTags = [{ id: '1', label: 'Sports', slug: 'sports' }]; - const event = createEvent(sportTags, markets); - - const result = sortMarkets({ event }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'totals-1', - 'moneyline-1', - ]); - }); - - it('prioritizes game event sorting over sortBy parameter', () => { - const markets = [ - createMarket('totals-high-price', '["0.9", "0.1"]', 100, 100, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.3", "0.7"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price', isGameEvent: true }); - - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'totals-high-price', - ]); - }); - - it('places moneyline first for game events even when moneyline has lower price', () => { - const markets = [ - createMarket( - 'spreads-high-price', - '["0.9", "0.1"]', - 200, - 200, - 'spreads', - ), - createMarket('totals-mid-price', '["0.7", "0.3"]', 150, 150, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.3", "0.7"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price', isGameEvent: true }); - - // Moneyline first despite having the lowest price - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'spreads-high-price', - 'totals-mid-price', - ]); - }); - - it('uses sortBy parameter for non-game events when sortBy is provided', () => { - const markets = [ - createMarket('low-price', '["0.2", "0.8"]', 100, 100), - createMarket('high-price', '["0.8", "0.2"]', 100, 100), - ]; - const event = createEvent([], markets); - - const result = sortMarkets({ event, sortBy: 'price' }); - - // Non-game events still respect sortBy - expect(result.map((m) => m.conditionId)).toEqual([ - 'high-price', - 'low-price', - ]); - }); - - it('ignores event.sortBy for game events', () => { - const markets = [ - createMarket('totals-high-price', '["0.9", "0.1"]', 100, 100, 'totals'), - createMarket( - 'moneyline-low-price', - '["0.2", "0.8"]', - 100, - 100, - 'moneyline', - ), - ]; - const event = createEvent([], markets, 'price'); - - const result = sortMarkets({ event, isGameEvent: true }); - - // Game sorting wins over event.sortBy - expect(result.map((m) => m.conditionId)).toEqual([ - 'moneyline-low-price', - 'totals-high-price', - ]); - }); - }); - - describe('parsePolymarketMarket', () => { - const createMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Weather', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - closed: false, - active: true, - resolvedBy: '0x123', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createTestEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000, - volume: 5000, - ...overrides, - }); - - it('parses market to PredictOutcome correctly', () => { - const market = createMarket(); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result).toEqual({ - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: 'Will it rain?', - description: 'Weather prediction', - image: 'https://example.com/icon.png', - groupItemTitle: 'Weather', - groupItemThreshold: undefined, - status: 'open', - volume: 1000, - liquidity: 500, - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.6 }, - { id: 'token-2', title: 'No', price: 0.4 }, - ], - sportsMarketType: undefined, - negRisk: false, - tickSize: '0.01', - resolvedBy: '0x123', - resolutionStatus: 'unresolved', - }); - }); - - it('uses image when icon is not available', () => { - const market = createMarket({ icon: undefined as any }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.image).toBe('https://example.com/image.png'); - }); - - it('returns closed status for closed markets', () => { - const market = createMarket({ closed: true }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.status).toBe('closed'); - }); - - it('formats spread market groupItemTitle by removing dash', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - groupItemTitle: 'Team A -3.5', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.groupItemTitle).toBe('Team A 3.5'); - }); - - it('formats spread market groupItemTitle preserving dashes in team names', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - groupItemTitle: 'FC-Dallas -3.5', - }); - const event = createTestEvent({ title: 'FC-Dallas vs. St.-Louis' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.groupItemTitle).toBe('FC-Dallas 3.5'); - }); - - it('formats spread market outcome titles with line values', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Team A", "Team B"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - // Team A comes first (from event title split) - expect(result.tokens[0].title).toBe('Team A -3.5'); - expect(result.tokens[1].title).toBe('Team B +3.5'); - }); - - it('handles spread markets without line value', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - outcomes: '["Team A", "Team B"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - expect(result.tokens[0].title).toBe('Team A'); - expect(result.tokens[1].title).toBe('Team B'); - }); - - it('handles undefined volumeNum as 0', () => { - const market = createMarket({ volumeNum: undefined as any }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event); - - expect(result.volume).toBe(0); - }); - - it('sorts spread market outcome tokens with teamA first', () => { - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - clobTokenIds: '["token-b", "token-a"]', - outcomes: '["Team B", "Team A"]', - outcomePrices: '["0.4", "0.6"]', - }); - const event = createTestEvent({ title: 'Team A vs. Team B' }); - - const result = parsePolymarketMarket(market, event); - - // Team A should be sorted first based on event title - expect(result.tokens[0].title).toBe('Team A +3.5'); - expect(result.tokens[1].title).toBe('Team B -3.5'); - }); - - describe('with game (shortTitle generation)', () => { - const createGameData = (): PredictMarketGame => ({ - id: 'game-1', - homeTeam: { - id: 'home-1', - name: 'Denver Broncos', - abbreviation: 'DEN', - color: TEST_HEX_COLORS.TEAM_DEN, - alias: 'Broncos', - logo: 'https://example.com/den.png', - }, - awayTeam: { - id: 'away-1', - name: 'Seattle Seahawks', - abbreviation: 'SEA', - color: TEST_HEX_COLORS.TEAM_SEA, - alias: 'Seahawks', - logo: 'https://example.com/sea.png', - }, - startTime: '2024-12-31T20:00:00Z', - status: 'scheduled' as const, - league: 'nfl' as const, - elapsed: null, - period: null, - score: null, - }); - - it('adds team abbreviation shortTitles for moneyline markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds team abbreviation shortTitles using alias match', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Broncos", "Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ title: 'Broncos vs. Seahawks' }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds spread shortTitles with signed line values', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.55", "0.45"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN -3.5'); - expect(result.tokens[1].shortTitle).toBe('SEA +3.5'); - }); - - it('returns abbreviation only for spread markets without line', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: undefined as any, - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.5", "0.5"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('adds O/U shortTitles for over/under markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'totals', - groupItemTitle: 'O/U 45.5', - line: 45.5, - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.52", "0.48"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('O 45.5'); - expect(result.tokens[1].shortTitle).toBe('U 45.5'); - }); - - it('maps Yes/No to Over/Under titles for O/U markets', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'totals', - groupItemTitle: 'O/U 45.5', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.52", "0.48"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].title).toBe('Over'); - expect(result.tokens[1].title).toBe('Under'); - }); - - it('omits shortTitle when outcome name does not match any team', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Unknown Team", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Unknown Team vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('resolves negRisk moneyline shortTitles from groupItemTitle', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Denver Broncos', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('resolves negRisk moneyline shortTitles with mixed-case market type', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'Moneyline', - groupItemTitle: 'Denver Broncos', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].title).toBe('Denver Broncos'); - expect(result.tokens[0].shortTitle).toBe('DEN'); - expect(result.tokens[1].shortTitle).toBe('SEA'); - }); - - it('skips negRisk shortTitles for draw markets', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Draw', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.1", "0.9"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('skips negRisk shortTitles when groupItemTitle does not match a team', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Some Other Option', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.3", "0.7"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('resolves negRisk shortTitles for away team groupItemTitle', () => { - const game = createGameData(); - const market = createMarket({ - negRisk: true, - sportsMarketType: 'moneyline', - groupItemTitle: 'Seattle Seahawks', - outcomes: '["Yes", "No"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.4", "0.6"]', - }); - const event = createTestEvent(); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBe('SEA'); - expect(result.tokens[1].shortTitle).toBe('DEN'); - }); - - it('skips shortTitle generation when game is not provided', () => { - const market = createMarket({ - sportsMarketType: 'moneyline', - outcomes: '["Denver Broncos", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.6", "0.4"]', - }); - const event = createTestEvent({ - title: 'Denver Broncos vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBeUndefined(); - }); - - it('omits shortTitle for spread outcome when name does not match', () => { - const game = createGameData(); - const market = createMarket({ - sportsMarketType: 'spreads', - line: 3.5, - outcomes: '["Unknown", "Seattle Seahawks"]', - clobTokenIds: '["token-1", "token-2"]', - outcomePrices: '["0.5", "0.5"]', - }); - const event = createTestEvent({ - title: 'Unknown vs. Seattle Seahawks', - }); - - const result = parsePolymarketMarket(market, event, game); - - expect(result.tokens[0].shortTitle).toBeUndefined(); - expect(result.tokens[1].shortTitle).toBe('SEA +3.5'); - }); - }); - }); - - describe('parsePolymarketPositions', () => { - const createPosition = ( - id: string, - index: number, - props: Partial, - ): PolymarketPosition => ({ - asset: `position-${id}`, - conditionId: 'condition-1', - icon: `https://example.com/icon${id}.png`, - title: `Position ${id}`, - slug: `position-${id}`, - size: 100, - eventId: 'event-1', - outcome: 'Yes', - outcomeIndex: index, - cashPnl: 10, - curPrice: 0.6, - currentValue: 60, - percentPnl: 5, - realizedPnl: 0, - initialValue: 50, - avgPrice: 0.5, - redeemable: false, - negativeRisk: false, - endDate: '2024-12-31', - ...props, - }); - - const mockPositions: PolymarketPosition[] = [ - createPosition('1', 0, {}), - createPosition('2', 1, { - size: 50, - outcome: 'No', - cashPnl: -5, - curPrice: 0.4, - currentValue: 20, - percentPnl: -10, - initialValue: 25, - redeemable: true, - }), - createPosition('3', 2, { - size: 75, - outcome: 'Maybe', - cashPnl: 15, - curPrice: 0.8, - percentPnl: 20, - avgPrice: 0.67, - redeemable: true, - }), - ]; - - const mockMarketResponse: Partial[] = [ - { - conditionId: 'condition-1', - events: [ - { - id: 'event-1', - slug: 'slug-1', - title: 'Mock Event', - description: 'Mock Description', - icon: 'mock-icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000000, - volume: 1000000, - }, - ], - }, - ]; - - beforeEach(() => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockMarketResponse), - }); - }); - - it('parse positions correctly and enrich with market data', async () => { - const result = await parsePolymarketPositions({ - positions: mockPositions, - }); - - expect(result[0]).toEqual({ - id: 'position-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'Yes', - outcomeTokenId: 'position-1', - outcomeIndex: 0, - negRisk: false, - amount: 100, - price: 0.6, - status: 'open', - realizedPnl: 0, - percentPnl: 5, - cashPnl: 10, - initialValue: 50, - avgPrice: 0.5, - endDate: '2024-12-31', - title: 'Position 1', - icon: 'https://example.com/icon1.png', - size: 100, - claimable: false, - currentValue: 60, - }); - - expect(result[1]).toEqual({ - id: 'position-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'No', - outcomeTokenId: 'position-2', - outcomeIndex: 1, - negRisk: false, - amount: 50, - price: 0.4, - status: 'lost', - realizedPnl: 0, - percentPnl: -10, - cashPnl: -5, - initialValue: 25, - avgPrice: 0.5, - endDate: '2024-12-31', - title: 'Position 2', - icon: 'https://example.com/icon2.png', - size: 50, - claimable: true, - currentValue: 20, - }); - - expect(result[2]).toEqual({ - id: 'position-3', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - outcomeId: 'condition-1', - outcome: 'Maybe', - outcomeTokenId: 'position-3', - outcomeIndex: 2, - negRisk: false, - amount: 75, - price: 0.8, - status: 'won', - realizedPnl: 0, - percentPnl: 20, - cashPnl: 15, - initialValue: 50, - avgPrice: 0.67, - endDate: '2024-12-31', - title: 'Position 3', - icon: 'https://example.com/icon3.png', - size: 75, - claimable: true, - currentValue: 60, - }); - }); - - it('handle empty positions array', async () => { - const result = await parsePolymarketPositions({ positions: [] }); - expect(result).toEqual([]); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - describe('negRisk outcome label resolution', () => { - it('non-negRisk position outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: false, - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position without eventSlug outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: undefined, - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position with non-draw-capable league eventSlug outcome stays as original', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'politics-election-2024', - slug: 'politics-election-2024-candidate-a', - outcome: 'Yes', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Yes'); - }); - - it('negRisk position with UCL eventSlug and draw suffix resolves to Draw', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-draw', - outcome: 'Draw', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('Draw'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation with teamLookup resolves to team name', async () => { - const mockTeamLookup = jest.fn( - (league: string, abbreviation: string) => { - if (league === 'ucl' && abbreviation === 'mci') { - return { - id: 'team-1', - name: 'Manchester City', - logo: 'https://example.com/mci.png', - abbreviation: 'mci', - color: 'team-blue', - alias: 'City', - }; - } - return undefined; - }, - ); - - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-mci', - outcome: 'Manchester City', - }), - ]; - - const result = await parsePolymarketPositions({ - positions, - teamLookup: mockTeamLookup, - }); - - expect(result[0].outcome).toBe('Manchester City'); - expect(mockTeamLookup).toHaveBeenCalledWith('ucl', 'mci'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation without teamLookup resolves to uppercase abbreviation', async () => { - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-mci', - outcome: 'MCI', - }), - ]; - - const result = await parsePolymarketPositions({ positions }); - - expect(result[0].outcome).toBe('MCI'); - }); - - it('negRisk position with UCL eventSlug and team abbreviation with teamLookup returning undefined resolves to uppercase abbreviation', async () => { - const mockTeamLookup = jest.fn(() => undefined); - - const positions = [ - createPosition('1', 0, { - negativeRisk: true, - eventSlug: 'ucl-final-2024', - slug: 'ucl-final-2024-xyz', - outcome: 'XYZ', - }), - ]; - - const result = await parsePolymarketPositions({ - positions, - teamLookup: mockTeamLookup, - }); - - expect(result[0].outcome).toBe('XYZ'); - }); - }); - }); - - describe('getPredictPositionStatus', () => { - it.each([ - { claimable: false, cashPnl: 10, expected: PredictPositionStatus.OPEN }, - { claimable: false, cashPnl: -5, expected: PredictPositionStatus.OPEN }, - { claimable: true, cashPnl: 15, expected: PredictPositionStatus.WON }, - { claimable: true, cashPnl: 0, expected: PredictPositionStatus.LOST }, - { claimable: true, cashPnl: -5, expected: PredictPositionStatus.LOST }, - ])( - 'returns $expected when claimable=$claimable and cashPnl=$cashPnl', - ({ claimable, cashPnl, expected }) => { - const result = getPredictPositionStatus({ claimable, cashPnl }); - expect(result).toBe(expected); - }, - ); - }); - - describe('getParsedMarketsFromPolymarketApi', () => { - const mockEvent: PolymarketApiEvent = { - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [{ id: '1', slug: 'test', title: 'Test', recurrence: 'daily' }], - markets: [ - { - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - }; - - it('fetch markets without search parameters', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const result = await getParsedMarketsFromPolymarketApi(); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('event-1'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr', - ); - }); - - it('fetch markets with search query', async () => { - const mockResponse = { - events: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'weather', - limit: 10, - offset: 5, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('event-1'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/public-search?q=weather&type=events&events_status=active&sort=volume_24hr&presets=EventsTitle&limit_per_type=10&page=1', - ); - }); - - it('returns empty array when search results omit markets', async () => { - const eventWithoutMarkets = { - ...mockEvent, - markets: undefined, - } as unknown as PolymarketApiEvent; - - const mockResponse = { - events: [eventWithoutMarkets], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'nhl', - limit: 10, - offset: 0, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toEqual([]); - }); - - it('returns empty tags when search results omit tags', async () => { - const eventWithoutTags = { - ...mockEvent, - tags: undefined, - } as unknown as PolymarketApiEvent; - - const mockResponse = { - events: [eventWithoutTags], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - q: 'nhl', - limit: 10, - offset: 0, - }; - - const result = await getParsedMarketsFromPolymarketApi(params); - - expect(result).toHaveLength(1); - expect(result[0].tags).toEqual([]); - }); - - it('handle different categories', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'crypto', - limit: 5, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=5&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&tag_slug=crypto&order=volume24hr', - ); - }); - - it('return empty array for invalid response', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - }); - - const result = await getParsedMarketsFromPolymarketApi(); - - expect(result).toEqual([]); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect(getParsedMarketsFromPolymarketApi()).rejects.toThrow( - 'Network error', - ); - }); - - describe('hot tab with customQueryParams', () => { - it('uses only limit, offset, and customQueryParams when category is hot', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - customQueryParams: 'tag_id=149&tag_id=100995&order=volume24hr', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&offset=0&tag_id=149&tag_id=100995&order=volume24hr', - ); - }); - - it('falls back to default params when hot tab has no customQueryParams', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr', - ); - }); - - it('does not apply default filters for hot tab with customQueryParams', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'hot', - customQueryParams: 'tag_id=198', - limit: 10, - offset: 20, - }; - - await getParsedMarketsFromPolymarketApi(params); - - const callUrl = mockFetch.mock.calls[0][0] as string; - - expect(callUrl).not.toContain('active=true'); - expect(callUrl).not.toContain('archived=false'); - expect(callUrl).not.toContain('closed=false'); - expect(callUrl).not.toContain('liquidity_min'); - expect(callUrl).not.toContain('volume_min'); - expect(callUrl).toContain('limit=10'); - expect(callUrl).toContain('offset=20'); - expect(callUrl).toContain('tag_id=198'); - }); - - it('appends customQueryParams to standard category pagination queries', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'trending', - customQueryParams: 'tag_id=149', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&order=volume24hr&tag_id=149', - ); - }); - - it('appends customQueryParams for sports category', async () => { - const mockResponse = { - data: [mockEvent], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const params: GetMarketsParams = { - category: 'sports', - customQueryParams: 'tag_id=10', - limit: 20, - offset: 0, - }; - - await getParsedMarketsFromPolymarketApi(params); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events/pagination?limit=20&active=true&archived=false&closed=false&ascending=false&offset=0&liquidity_min=10000&volume_min=10000&tag_slug=sports&order=volume24hr&tag_id=10', - ); - }); - }); - }); - - describe('getMarketsFromPolymarketApi', () => { - const mockMarket: PolymarketApiMarket = { - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Weather', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }; - - it('fetch single market successfully', async () => { - const mockResponse = [mockMarket]; - - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const result = await getMarketsFromPolymarketApi({ - conditionIds: ['market-1'], - }); - - expect(result).toEqual(mockResponse); - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/markets?condition_ids=market-1', - ); - }); - - it('handle fetch errors', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValue(error); - - await expect( - getMarketsFromPolymarketApi({ conditionIds: ['market-1'] }), - ).rejects.toThrow('Network error'); - }); - }); - - describe('encodeRedeemPositions', () => { - it('encode redeem positions function call correctly', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [1, 2]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle different index sets', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [1, 2, 3, 4]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle bigint amounts', () => { - const collateralToken = '0x1234567890123456789012345678901234567890'; - const parentCollectionId = HASH_ZERO_BYTES32; - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const indexSets = [BigInt(1), BigInt(2)]; - - const result = encodeRedeemPositions({ - collateralToken, - parentCollectionId, - conditionId, - indexSets, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('encodeRedeemNegRiskPositions', () => { - it('encode redeem neg risk positions function call correctly', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = [100, 200]; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('handle bigint amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = [BigInt(100), BigInt(200)]; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle string amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const amounts = ['100', '200']; - - const result = encodeRedeemNegRiskPositions({ - conditionId, - amounts, - }); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('encodeClaim', () => { - it('encode claim for non-negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = false; - - const result = encodeClaim(conditionId, negRisk); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - // Should be a valid hex string - expect(() => parseInt(result.slice(2), 16)).not.toThrow(); - }); - - it('encode claim for negRisk positions with amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = [100, 200]; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('throw error for negRisk positions without amounts', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - - expect(() => encodeClaim(conditionId, negRisk)).toThrow( - 'amounts parameter is required when negRisk is true', - ); - }); - - it('handle bigint amounts for negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = [BigInt(100), BigInt(200)]; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - - it('handle string amounts for negRisk positions', () => { - const conditionId = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; - const negRisk = true; - const amounts = ['100', '200']; - - const result = encodeClaim(conditionId, negRisk, amounts); - - expect(typeof result).toBe('string'); - expect(result.startsWith('0x')).toBe(true); - }); - }); - - describe('calculateFees', () => { - const feeCollection = DEFAULT_FEE_COLLECTION_FLAG; - const totalFeePercentage = - (feeCollection.metamaskFee + feeCollection.providerFee) * 100; - - beforeEach(() => { - // Mock the Gamma API response for market details - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - id: 'market-1', - tags: [], - }), - }); - }); - - it('calculates fee using feeCollection config', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 1, - }; - - const fees = await calculateFees(params); - - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - expect(fees.executors).toEqual(feeCollection.executors ?? []); - expect(fees.permit2Enabled).toBe(feeCollection.permit2Enabled ?? false); - }); - - it('calculates fees correctly for various amounts', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 1, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBeGreaterThanOrEqual(0); - expect(fees.metamaskFee).toBeGreaterThanOrEqual(0); - expect(fees.totalFee).toBeGreaterThanOrEqual(0); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('handles large amounts correctly', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('handles small amounts correctly', async () => { - const params = { - feeCollection, - marketId: 'market-1', - userBetAmount: 0.25, - }; - - const fees = await calculateFees(params); - - expect(typeof fees.providerFee).toBe('number'); - expect(typeof fees.metamaskFee).toBe('number'); - expect(typeof fees.totalFee).toBe('number'); - const expectedMetamaskFee = - params.userBetAmount * feeCollection.metamaskFee; - const expectedProviderFee = - params.userBetAmount * feeCollection.providerFee; - const expectedTotal = expectedMetamaskFee + expectedProviderFee; - expect(fees.totalFee).toBe(expectedTotal); - expect(fees.providerFee).toBe(expectedProviderFee); - expect(fees.metamaskFee).toBe(expectedMetamaskFee); - expect(fees.totalFeePercentage).toBe(totalFeePercentage); - expect(fees.collector).toBe(feeCollection.collector); - }); - - it('returns zero fees when feeCollection is not provided', async () => { - const params = { - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBe(0); - expect(fees.metamaskFee).toBe(0); - expect(fees.totalFee).toBe(0); - expect(fees.totalFeePercentage).toBe(0); - expect(fees.collector).toBe('0x0'); - expect(fees.executors).toEqual([]); - expect(fees.permit2Enabled).toBe(false); - }); - - it('waives fees for markets in waiveList', async () => { - // Mock market with a tag that's in the waiveList - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ - id: 'market-with-waived-fees', - tags: [{ slug: 'middle-east' }], - }), - }); - - const feeCollectionWithWaiveList = { - ...feeCollection, - waiveList: ['middle-east'], - }; - - const params = { - feeCollection: feeCollectionWithWaiveList, - marketId: 'market-with-waived-fees', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.providerFee).toBe(0); - expect(fees.metamaskFee).toBe(0); - expect(fees.totalFee).toBe(0); - expect(fees.totalFeePercentage).toBe(0); - expect(fees.collector).toBe('0x0'); - expect(fees.executors).toEqual([]); - expect(fees.permit2Enabled).toBe(false); - }); - - it('returns executors and permit2Enabled from feeCollection config', async () => { - const params = { - feeCollection: { - ...feeCollection, - executors: ['0x1111111111111111111111111111111111111111'], - permit2Enabled: true, - }, - marketId: 'market-1', - userBetAmount: 100, - }; - - const fees = await calculateFees(params); - - expect(fees.executors).toEqual([ - '0x1111111111111111111111111111111111111111', - ]); - expect(fees.permit2Enabled).toBe(true); - }); - }); - - describe('submitClobOrder error handling', () => { - const mockHeaders: ClobHeaders = { - POLY_ADDRESS: mockAddress, - POLY_SIGNATURE: 'test-signature_', - POLY_TIMESTAMP: '1704067200', - POLY_API_KEY: 'test-api-key', - POLY_PASSPHRASE: 'test-passphrase', - }; - - const mockClobOrder: ClobOrderObject = { - order: { - maker: mockAddress, - signer: mockAddress, - taker: '0x0000000000000000000000000000000000000000', - tokenId: 'test-token', - makerAmount: '100000000', - takerAmount: '50000000', - expiration: '0', - nonce: '0', - feeRateBps: '0', - side: Side.BUY, - signatureType: SignatureType.EOA, - signature: 'mock-signature', - salt: 12345, - }, - owner: mockAddress, - orderType: OrderType.FOK, - }; - - it('handle 403 geoblock response with specific error message', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: jest.fn().mockResolvedValue({}), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'You are unable to access this provider.', - }); - }); - - it('handle non-403 error with JSON error message', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: jest.fn().mockResolvedValue({ - errorMsg: 'Invalid order parameters', - }), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Invalid order parameters', - }); - }); - - it('handle non-403 error without JSON error field, use statusText', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - json: jest.fn().mockResolvedValue({}), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Internal Server Error', - }); - }); - - it('handle non-JSON error response (HTML body)', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 502, - statusText: 'Bad Gateway', - json: jest.fn().mockRejectedValue(new Error('Unexpected token <')), - }); - - const result = await submitClobOrder({ - headers: mockHeaders, - clobOrder: mockClobOrder, - }); - - expect(result).toEqual({ - success: false, - error: 'Bad Gateway', - }); - }); - }); - - describe('parsePolymarketActivity', () => { - // Type guard helpers for better type safety - const isBuyEntry = ( - entry: PredictActivityEntry, - ): entry is PredictActivityBuy => entry.type === 'buy'; - - const isSellEntry = ( - entry: PredictActivityEntry, - ): entry is PredictActivitySell => entry.type === 'sell'; - - it('returns empty array for non-array input', () => { - // @ts-expect-error testing invalid input - expect(parsePolymarketActivity(null)).toEqual([]); - // @ts-expect-error testing invalid input - expect(parsePolymarketActivity(undefined)).toEqual([]); - }); - - it('maps TRADE BUY to buy entries', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'BUY' as const, - timestamp: 1000, - usdcSize: 12.34, - price: 0.56, - conditionId: 'cid-1', - outcomeIndex: 0, - title: 'Market A', - outcome: 'Yes' as const, - icon: 'https://a.png', - transactionHash: '0xhash1', - }, - ]; - const result = parsePolymarketActivity(input); - const activity = result[0]; - const entry = activity.entry; - expect(entry.type).toBe('buy'); - expect(isBuyEntry(entry)).toBe(true); - if (isBuyEntry(entry)) { - expect(entry.price).toBe(0.56); - expect(entry.amount).toBe(12.34); - } - expect(activity.outcome).toBe('Yes'); - expect(activity.title).toBe('Market A'); - expect(activity.icon).toBe('https://a.png'); - }); - - it('maps TRADE SELL to sell entries', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'SELL' as const, - timestamp: 2000, - usdcSize: 9.99, - price: 0.12, - conditionId: 'cid-2', - outcomeIndex: 1, - title: 'Market B', - outcome: 'No' as const, - icon: 'https://b.png', - transactionHash: '0xhash2', - }, - ]; - const result = parsePolymarketActivity(input); - const entry = result[0].entry; - expect(entry.type).toBe('sell'); - expect(isSellEntry(entry)).toBe(true); - if (isSellEntry(entry)) { - expect(entry.price).toBe(0.12); - expect(entry.amount).toBe(9.99); - expect(entry.outcomeId).toBe('cid-2'); - } - }); - - it('maps REDEEM with payout to claimWinnings entries', () => { - const input = [ - { - type: 'REDEEM' as const, - side: '' as const, - timestamp: 3000, - usdcSize: 1.23, // Winning claim with actual payout - price: 0, - conditionId: '', - outcomeIndex: 0, - title: 'Market C', - outcome: '' as const, - icon: '', - transactionHash: '0xhash3', - }, - ]; - const result = parsePolymarketActivity(input); - expect(result).toHaveLength(1); - expect(result[0].entry.type).toBe('claimWinnings'); - expect(result[0].entry.amount).toBe(1.23); - expect(result[0].id).toBe('0xhash3'); - }); - - it('generates fallback id and timestamp when missing', () => { - const input = [ - { - type: 'TRADE' as const, - side: 'BUY' as const, - timestamp: 0, - usdcSize: 0, - price: 0, - conditionId: '', - outcomeIndex: 0, - title: '', - outcome: '' as const, - icon: '', - transactionHash: '', - }, - ]; - const result = parsePolymarketActivity(input); - expect(result[0].id).toBeDefined(); - expect(typeof result[0].entry.timestamp).toBe('number'); - }); - }); - - describe('decimalPlaces', () => { - it('returns 0 for integers', () => { - expect(decimalPlaces(5)).toBe(0); - expect(decimalPlaces(100)).toBe(0); - expect(decimalPlaces(0)).toBe(0); - }); - - it('returns correct decimal places for decimals', () => { - expect(decimalPlaces(1.5)).toBe(1); - expect(decimalPlaces(0.123)).toBe(3); - expect(decimalPlaces(3.14159)).toBe(5); - }); - - it('returns 0 for numbers without decimal part', () => { - expect(decimalPlaces(10.0)).toBe(0); - }); - }); - - describe('roundNormal', () => { - it('rounds numbers to specified decimals', () => { - expect(roundNormal(1.235, 2)).toBe(1.24); - expect(roundNormal(1.234, 2)).toBe(1.23); - expect(roundNormal(1.5, 0)).toBe(2); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundNormal(1.5, 2)).toBe(1.5); - expect(roundNormal(1, 2)).toBe(1); - }); - - it('handles zero decimals', () => { - expect(roundNormal(1.6, 0)).toBe(2); - expect(roundNormal(1.4, 0)).toBe(1); - }); - }); - - describe('roundDown', () => { - it('rounds down to specified decimals', () => { - expect(roundDown(1.239, 2)).toBe(1.23); - expect(roundDown(1.999, 2)).toBe(1.99); - expect(roundDown(1.5, 0)).toBe(1); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundDown(1.5, 2)).toBe(1.5); - expect(roundDown(1, 2)).toBe(1); - }); - - it('handles edge cases', () => { - expect(roundDown(0.999, 2)).toBe(0.99); - expect(roundDown(100.123456, 3)).toBe(100.123); - }); - }); - - describe('roundUp', () => { - it('rounds up to specified decimals', () => { - expect(roundUp(1.231, 2)).toBe(1.24); - expect(roundUp(1.001, 2)).toBe(1.01); - expect(roundUp(1.5, 0)).toBe(2); - }); - - it('returns same number if already at or below target decimals', () => { - expect(roundUp(1.5, 2)).toBe(1.5); - expect(roundUp(1, 2)).toBe(1); - }); - - it('handles edge cases', () => { - expect(roundUp(0.001, 2)).toBe(0.01); - expect(roundUp(100.123456, 3)).toBe(100.124); - }); - }); - - describe('roundOrderAmount', () => { - it('returns same amount if decimal places are within limit', () => { - expect(roundOrderAmount({ amount: 1.5, decimals: 2 })).toBe(1.5); - expect(roundOrderAmount({ amount: 10.25, decimals: 2 })).toBe(10.25); - expect(roundOrderAmount({ amount: 5, decimals: 2 })).toBe(5); - }); - - it('rounds down amount if it exceeds decimals after rounding up', () => { - expect(roundOrderAmount({ amount: 1.235, decimals: 2 })).toBe(1.23); - expect(roundOrderAmount({ amount: 10.999, decimals: 2 })).toBe(10.99); - }); - - it('rounds down when amount has more decimals than target', () => { - expect(roundOrderAmount({ amount: 1.001, decimals: 2 })).toBe(1); - expect(roundOrderAmount({ amount: 0.0001, decimals: 2 })).toBe(0); - expect(roundOrderAmount({ amount: 1.0001, decimals: 2 })).toBe(1); - }); - - it('handles zero decimals', () => { - expect(roundOrderAmount({ amount: 1.5, decimals: 0 })).toBe(1); - expect(roundOrderAmount({ amount: 1.999, decimals: 0 })).toBe(1); - expect(roundOrderAmount({ amount: 5, decimals: 0 })).toBe(5); - }); - - it('handles large decimal precision', () => { - expect(roundOrderAmount({ amount: 1.123456789, decimals: 6 })).toBe( - 1.123456, - ); - expect(roundOrderAmount({ amount: 0.123456789, decimals: 5 })).toBe( - 0.12345, - ); - }); - - it('handles edge case with very small amounts', () => { - expect(roundOrderAmount({ amount: 0.00001, decimals: 2 })).toBe(0); - expect(roundOrderAmount({ amount: 0.000001, decimals: 4 })).toBe(0); - expect(roundOrderAmount({ amount: 0.123456, decimals: 4 })).toBe(0.1234); - }); - - it('handles edge case with large amounts', () => { - expect(roundOrderAmount({ amount: 1000.123456, decimals: 2 })).toBe( - 1000.12, - ); - expect(roundOrderAmount({ amount: 99999.999999, decimals: 3 })).toBe( - 99999.999, - ); - }); - - it('applies roundUp with extra decimals then roundDown if needed', () => { - const amount = 1.12345678; - const decimals = 2; - const result = roundOrderAmount({ amount, decimals }); - expect(result).toBe(1.12); - expect(decimalPlaces(result)).toBeLessThanOrEqual(decimals); - }); - - it('rounds up when amount can fit exactly into target decimals', () => { - expect(roundOrderAmount({ amount: 1.2345, decimals: 2 })).toBe(1.23); - expect(roundOrderAmount({ amount: 10.1234567, decimals: 4 })).toBe( - 10.1234, - ); - }); - - it('handles negative amounts', () => { - expect(roundOrderAmount({ amount: -1.235, decimals: 2 })).toBe(-1.24); - expect(roundOrderAmount({ amount: -10.999, decimals: 2 })).toBe(-11); - }); - - it('handles amounts that round up to exceed decimals', () => { - expect(roundOrderAmount({ amount: 1.996, decimals: 2 })).toBe(1.99); - expect(roundOrderAmount({ amount: 0.999999, decimals: 2 })).toBe(0.99); - }); - }); - - describe('previewOrder', () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it('previews BUY order successfully', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [ - { price: '0.50', size: '100' }, - { price: '0.51', size: '50' }, - ], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ base_fee: 30 }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }); - - expect(result.side).toBe(Side.BUY); - expect(result.marketId).toBe('market-1'); - expect(result.sharePrice).toBeGreaterThan(0); - expect(result.maxAmountSpent).toBeGreaterThan(0); - expect(result.slippage).toBeDefined(); - expect(result.feeRateBps).toBe('30'); - }); - - it('previews SELL order successfully', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [ - { price: '0.50', size: '100' }, - { price: '0.49', size: '50' }, - ], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ base_fee: 15 }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 50, - }); - - expect(result.side).toBe(Side.SELL); - expect(result.marketId).toBe('market-1'); - expect(result.sharePrice).toBeGreaterThan(0); - expect(result.fees).toBeUndefined(); - expect(result.feeRateBps).toBe('15'); - }); - - it('uses the v2 order book endpoint and zero fee rate for v2 previews', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [ - { price: '0.50', size: '100' }, - { price: '0.51', size: '50' }, - ], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - isV2: true, - }); - - expect(result.feeRateBps).toBe('0'); - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - `${DEFAULT_CLOB_BASE_URL}/book?token_id=token-1`, - { method: 'GET' }, - ); - }); - - it('uses the provided v2 CLOB host override during preview', async () => { - const mockOrderBook = { - min_order_size: '5', - tick_size: '0.01', - timestamp: '2025-02-08T00:00:00.000Z', - neg_risk: false, - asks: [{ price: '0.50', size: '100' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - isV2: true, - clobBaseUrl: LEGACY_V2_CLOB_BASE_URL, - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${LEGACY_V2_CLOB_BASE_URL}/book?token_id=token-1`, - { method: 'GET' }, - ); - }); - - it('throws error when orderbook is not available', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => null, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_BOOK'); - }); - - it('throws error for BUY when no asks available', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_MATCH_BUY'); - }); - - it('throws error for SELL when no bids available', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - await expect( - previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 50, - }), - ).rejects.toThrow('PREDICT_PREVIEW_NO_ORDER_MATCH_SELL'); - }); - - it('includes fees for BUY orders', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [{ price: '0.50', size: '200' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }); - - expect(result.fees).toBeDefined(); - expect(result.fees?.totalFee).toBeGreaterThanOrEqual(0); - expect(result.fees?.metamaskFee).toBeGreaterThanOrEqual(0); - expect(result.fees?.providerFee).toBeGreaterThanOrEqual(0); - }); - - it('does not include fees for SELL orders', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: false, - asks: [], - bids: [{ price: '0.50', size: '200' }], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.SELL, - size: 100, - }); - - expect(result.fees).toBeUndefined(); - }); - - it('handles negRisk markets', async () => { - const mockOrderBook = { - timestamp: '2024-01-01T00:00:00Z', - tick_size: '0.01', - min_order_size: '1', - neg_risk: true, - asks: [{ price: '0.50', size: '200' }], - bids: [], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockOrderBook, - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tags: [] }), - }); - - const result = await previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }); - - expect(result.negRisk).toBe(true); - }); - }); - - describe('fetchCarouselFromPolymarketApi', () => { - const carouselEndpoint = 'https://polymarket.com/api/homepage/carousel'; - - const createCarouselItem = (overrides = {}) => ({ - event: { - id: 'event-1', - slug: 'event-1', - title: 'Event 1', - description: 'event description', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [ - { - conditionId: 'market-1', - question: 'Question?', - description: 'market description', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Option group', - status: 'open', - volumeNum: 100, - liquidity: 100, - negRisk: false, - clobTokenIds: ['1', '2'], - outcomes: ['Yes', 'No'], - outcomePrices: ['0.6', '0.4'], - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 100, - volume: 200, - }, - type: 'sports', - shortName: 'S', - options: [], - ...overrides, - }); - - it('fetches from the carousel endpoint and returns items', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(mockFetch).toHaveBeenCalledWith(carouselEndpoint); - expect(result).toHaveLength(1); - expect(result[0].event.id).toBe('event-1'); - }); - - it('returns empty array when response is not an array', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ items: [createCarouselItem()] }), - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result).toEqual([]); - }); - - it('throws when response is not ok', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: async () => ({}), - }); - - await expect(fetchCarouselFromPolymarketApi()).rejects.toThrow( - 'Failed to fetch carousel data', - ); - }); - - it('normalizes array-type outcomes to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomes).toBe('["Yes","No"]'); - }); - - it('normalizes array-type outcomePrices to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomePrices).toBe('["0.6","0.4"]'); - }); - - it('normalizes array-type clobTokenIds to JSON strings', async () => { - const responseItems = [createCarouselItem()]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].clobTokenIds).toBe('["1","2"]'); - }); - - it('leaves string-type fields unchanged', async () => { - const responseItems = [ - createCarouselItem({ - event: { - ...createCarouselItem().event, - markets: [ - { - ...createCarouselItem().event.markets[0], - outcomes: '["Yes","No"]', - outcomePrices: '["0.6","0.4"]', - clobTokenIds: '["1","2"]', - }, - ], - }, - }), - ]; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => responseItems, - }); - - const result = await fetchCarouselFromPolymarketApi(); - - expect(result[0].event.markets[0].outcomes).toBe('["Yes","No"]'); - expect(result[0].event.markets[0].outcomePrices).toBe('["0.6","0.4"]'); - expect(result[0].event.markets[0].clobTokenIds).toBe('["1","2"]'); - }); - }); - - describe('getAllowanceCalls', () => { - it('returns array of allowance transaction calls', () => { - const calls = getAllowanceCalls({ address: mockAddress }); - - expect(Array.isArray(calls)).toBe(true); - expect(calls.length).toBeGreaterThan(0); - calls.forEach((call) => { - expect(call).toHaveProperty('data'); - expect(call).toHaveProperty('to'); - expect(call).toHaveProperty('chainId'); - expect(call).toHaveProperty('from'); - expect(call).toHaveProperty('value'); - expect(call.from).toBe(mockAddress); - }); - }); - - it('includes all necessary approval calls', () => { - const calls = getAllowanceCalls({ address: mockAddress }); - expect(calls.length).toBe(6); - }); - }); - - describe('buildOutcomeGroups', () => { - const createMockPolymarketApiMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'condition-default', - question: 'Market?', - description: 'Description', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Group', - status: 'open', - volumeNum: 100, - liquidity: 100, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.5", "0.5"]', - closed: false, - active: true, - resolvedBy: '', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createMockOutcome = ( - id: string, - overrides?: Partial, - ): PredictOutcome => ({ - id, - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'event-1', - title: `Market ${id}`, - description: `Description ${id}`, - image: 'https://example.com/icon.png', - groupItemTitle: `Group ${id}`, - status: 'open', - volume: 100, - liquidity: 100, - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.5 }, - { id: 'token-2', title: 'No', price: 0.5 }, - ], - negRisk: false, - tickSize: '0.01', - ...overrides, - }); - - it('groups mixed sport event into game-lines, first-half, and touchdowns', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - }), - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(3); - expect(result.map((g) => g.key)).toEqual([ - 'game_lines', - 'first_half', - 'touchdowns', - ]); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - expect(result[1].outcomes.map((o) => o.id)).toEqual(['fhs-1']); - expect(result[1].subgroups).toBeUndefined(); - expect(result[2].outcomes.map((o) => o.id)).toEqual(['at-1']); - expect(result[2].subgroups).toBeUndefined(); - }); - - it('groups all standard market types into single game-lines group', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - expect(result[0].subgroups?.[0].outcomes.map((o) => o.id)).toEqual([ - 'ml-1', - ]); - expect(result[0].subgroups?.[1].outcomes.map((o) => o.id)).toEqual([ - 'sp-1', - ]); - expect(result[0].subgroups?.[2].outcomes.map((o) => o.id)).toEqual([ - 'to-1', - ]); - }); - - it('falls back unknown sportsMarketType to game-lines', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'unknown-1', - sportsMarketType: 'some_new_type', - }), - ]; - const outcomes = [ - createMockOutcome('unknown-1', { - sportsMarketType: 'some_new_type', - }), - ]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - }); - - it('falls back undefined sportsMarketType to game-lines', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'undef-1', - sportsMarketType: undefined, - }), - ]; - const outcomes = [createMockOutcome('undef-1')]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - }); - - it('groups single mapped type into standalone group', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - }), - ]; - const outcomes = [ - createMockOutcome('fhs-1', { - sportsMarketType: 'first_half_spreads', - }), - ]; - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('first_half'); - }); - - it('returns empty array for empty inputs', () => { - const result = buildOutcomeGroups([]); - - expect(result).toEqual([]); - }); - - it('sorts game-lines subgroups by sportsMarketType priority', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 10, - volumeNum: 10, - }), - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 500, - volumeNum: 500, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 200, - volumeNum: 200, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - 'totals', - ]); - }); - - it('orders groups by GROUP_ORDER priority with unknown keys at end', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result.map((g) => g.key)).toEqual([ - 'game_lines', - 'first_half', - 'touchdowns', - ]); - expect(GROUP_ORDER.indexOf('game_lines')).toBeLessThan( - GROUP_ORDER.indexOf('first_half'), - ); - expect(GROUP_ORDER.indexOf('first_half')).toBeLessThan( - GROUP_ORDER.indexOf('touchdowns'), - ); - expect(SPORTS_MARKET_TYPE_TO_GROUP.first_half_spreads).toBe('first_half'); - expect(SPORTS_MARKET_TYPE_TO_GROUP.anytime_touchdowns).toBe('touchdowns'); - }); - - it('tiebreaks game-lines outcomes by liquidity+volume when sportsMarketType priority is equal', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'sp-low', - sportsMarketType: 'spreads', - liquidity: 50, - volumeNum: 50, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-high', - sportsMarketType: 'spreads', - liquidity: 500, - volumeNum: 500, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].outcomes.map((o) => o.id)).toEqual([ - 'sp-high', - 'sp-low', - ]); - }); - - it('sorts first-half subgroups by normalized sportsMarketType priority (moneyline, spreads, totals)', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'fht-1', - sportsMarketType: 'first_half_totals', - liquidity: 500, - volumeNum: 500, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhm-1', - sportsMarketType: 'first_half_moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'fhs-1', - sportsMarketType: 'first_half_spreads', - liquidity: 300, - volumeNum: 300, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('first_half'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'first_half_moneyline', - 'first_half_spreads', - 'first_half_totals', - ]); - }); - - it('creates subgroups for touchdowns group with anytime and first types', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'at-1', - sportsMarketType: 'anytime_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'ft-1', - sportsMarketType: 'first_touchdowns', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('touchdowns'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups).toHaveLength(2); - expect(result[0].subgroups?.map((s) => s.key)).toEqual( - expect.arrayContaining(['anytime_touchdowns', 'first_touchdowns']), - ); - }); - - it('keeps single-type group flat without subgroups (points)', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'pts-1', - sportsMarketType: 'points', - liquidity: 200, - volumeNum: 200, - }), - createMockPolymarketApiMarket({ - conditionId: 'pts-2', - sportsMarketType: 'points', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('points'); - expect(result[0].outcomes).toHaveLength(2); - expect(result[0].outcomes.map((o) => o.id)).toEqual(['pts-1', 'pts-2']); - expect(result[0].subgroups).toBeUndefined(); - }); - - it('creates subgroups for game-lines with moneyline and spreads only', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toEqual([]); - expect(result[0].subgroups).toHaveLength(2); - expect(result[0].subgroups?.map((s) => s.key)).toEqual([ - 'moneyline', - 'spreads', - ]); - }); - - it('keeps game-lines flat when only moneyline exists', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('game_lines'); - expect(result[0].outcomes).toHaveLength(1); - expect(result[0].outcomes[0].id).toBe('ml-1'); - expect(result[0].subgroups).toBeUndefined(); - }); - - it('sorts outcomes by liquidity+volume within each subgroup', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-low', - sportsMarketType: 'spreads', - liquidity: 50, - volumeNum: 50, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-high', - sportsMarketType: 'spreads', - liquidity: 500, - volumeNum: 500, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const spreadsSubgroup = result[0].subgroups?.find( - (s) => s.key === 'spreads', - ); - expect(spreadsSubgroup?.outcomes.map((o) => o.id)).toEqual([ - 'sp-high', - 'sp-low', - ]); - }); - - it('mixed event produces subgrouped and flat groups', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'to-1', - sportsMarketType: 'totals', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'pts-1', - sportsMarketType: 'points', - liquidity: 100, - volumeNum: 100, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const gameLines = result.find((g) => g.key === 'game_lines'); - const points = result.find((g) => g.key === 'points'); - expect(gameLines?.subgroups).toHaveLength(3); - expect(gameLines?.outcomes).toEqual([]); - expect(points?.outcomes).toHaveLength(1); - expect(points?.subgroups).toBeUndefined(); - }); - - it('multiple spread thresholds within spreads subgroup', () => { - const markets = [ - createMockPolymarketApiMarket({ - conditionId: 'ml-1', - sportsMarketType: 'moneyline', - liquidity: 100, - volumeNum: 100, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-1', - sportsMarketType: 'spreads', - liquidity: 300, - volumeNum: 300, - groupItemThreshold: 3.5, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-2', - sportsMarketType: 'spreads', - liquidity: 200, - volumeNum: 200, - groupItemThreshold: 7.5, - }), - createMockPolymarketApiMarket({ - conditionId: 'sp-3', - sportsMarketType: 'spreads', - liquidity: 100, - volumeNum: 100, - groupItemThreshold: 10.5, - }), - ]; - const outcomes = markets.map((m) => - createMockOutcome(m.conditionId, { - sportsMarketType: m.sportsMarketType, - volume: m.volumeNum ?? 100, - liquidity: m.liquidity ?? 100, - }), - ); - - const result = buildOutcomeGroups(outcomes); - - const spreadsSubgroup = result[0].subgroups?.find( - (s) => s.key === 'spreads', - ); - expect(spreadsSubgroup?.outcomes).toHaveLength(3); - expect(spreadsSubgroup?.outcomes.map((o) => o.id)).toEqual([ - 'sp-1', - 'sp-2', - 'sp-3', - ]); - }); - }); - - describe('parsePolymarketMarket - sportsMarketType mapping', () => { - const createMarketForSportsType = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'market-1', - question: 'Will it rain?', - description: 'Weather prediction', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Weather', - status: 'open', - volumeNum: 1000, - liquidity: 500, - negRisk: false, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - closed: false, - active: true, - resolvedBy: '0x123', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const createEventForSportsType = (): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - tags: [], - series: [], - markets: [], - liquidity: 1000, - volume: 5000, - }); - - it('parsePolymarketMarket maps sportsMarketType from raw market', () => { - const market = createMarketForSportsType({ - sportsMarketType: 'spreads', - }); - const event = createEventForSportsType(); - - const result = parsePolymarketMarket(market, event); - - expect(result.sportsMarketType).toBe('spreads'); - }); - - it('parsePolymarketMarket maps undefined when raw market has no sportsMarketType', () => { - const market = createMarketForSportsType(); - const event = createEventForSportsType(); - - const result = parsePolymarketMarket(market, event); - - expect(result.sportsMarketType).toBeUndefined(); - }); - }); - - describe('parsePolymarketEvents - series metadata', () => { - const mockCategory: PredictCategory = 'trending'; - - const createMockEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'series-event-1', - slug: 'series-event', - title: 'Series Event', - description: 'Series event description', - icon: 'https://example.com/series-icon.png', - closed: false, - tags: [], - series: [], - markets: [ - { - conditionId: 'series-market-1', - question: 'Will BTC move up?', - description: 'Series event description', - icon: 'https://example.com/market-icon.png', - image: 'https://example.com/market-image.png', - groupItemTitle: 'Crypto', - closed: false, - volumeNum: 1000, - liquidity: 500, - clobTokenIds: '["token-1", "token-2"]', - outcomes: '["Yes", "No"]', - outcomePrices: '["0.6", "0.4"]', - negRisk: true, - orderPriceMinTickSize: 0.01, - status: 'open', - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - umaResolutionStatus: 'unresolved', - }, - ], - liquidity: 1000000, - volume: 1000000, - ...overrides, - }); - - it('maps the first series item onto the parsed market', () => { - const series = { - id: '10684', - slug: 'btc-up-or-down-5m', - title: 'BTC Up or Down 5m', - recurrence: '5m', - }; - const event = createMockEvent({ series: [series] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toEqual(series); - }); - - it('omits series when the event series array is empty', () => { - const event = createMockEvent({ series: [] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toBeUndefined(); - }); - - it('uses the first series item when multiple series are present', () => { - const firstSeries = { - id: '10684', - slug: 'btc-up-or-down-5m', - title: 'BTC Up or Down 5m', - recurrence: '5m', - }; - const secondSeries = { - id: '10685', - slug: 'eth-up-or-down-15m', - title: 'ETH Up or Down 15m', - recurrence: '15m', - }; - const event = createMockEvent({ series: [firstSeries, secondSeries] }); - - const result = parsePolymarketEvents([event], mockCategory); - - expect(result[0].series).toEqual(firstSeries); - }); - }); - - describe('fetchChildEventsFromGammaApi', () => { - const buildMockApiEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'event-1', - slug: 'test-event', - title: 'Test Event', - description: 'A test event', - icon: 'https://example.com/icon.png', - closed: false, - series: [], - markets: [], - tags: [], - liquidity: 500000, - volume: 1000000, - ...overrides, - }); - - it('returns array of events on success', async () => { - const events = [ - buildMockApiEvent({ id: 'parent-1', title: 'Parent' }), - buildMockApiEvent({ id: 'child-1', title: 'Child' }), - ]; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(events), - }); - - const result = await fetchChildEventsFromGammaApi({ - parentEventId: 'parent-1', - }); - - expect(result).toEqual(events); - expect(result).toHaveLength(2); - }); - - it('throws on non-ok response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: jest.fn(), - }); - - await expect( - fetchChildEventsFromGammaApi({ parentEventId: 'parent-1' }), - ).rejects.toThrow('Failed to fetch child events'); - }); - - it('calls correct URL with parent_event_id and include_children params', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue([]), - }); - - await fetchChildEventsFromGammaApi({ parentEventId: 'abc-123' }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://gamma-api.polymarket.com/events?parent_event_id=abc-123&include_children=true', - ); - }); - }); - - describe('mergeChildEventsIntoParent', () => { - const buildMarket = ( - overrides: Partial = {}, - ): PolymarketApiMarket => ({ - conditionId: 'cond-default', - question: 'Default question?', - description: 'Default description', - icon: 'https://example.com/icon.png', - image: 'https://example.com/image.png', - groupItemTitle: 'Default', - status: 'open', - volumeNum: 100, - liquidity: 50, - negRisk: false, - clobTokenIds: '["tok-a","tok-b"]', - outcomes: '["Yes","No"]', - outcomePrices: '["0.5","0.5"]', - closed: false, - active: true, - resolvedBy: '0x0000000000000000000000000000000000000000', - orderPriceMinTickSize: 0.01, - umaResolutionStatus: 'unresolved', - ...overrides, - }); - - const buildEvent = ( - overrides: Partial = {}, - ): PolymarketApiEvent => ({ - id: 'evt-default', - slug: 'default-event', - title: 'Default Event', - description: 'Default description', - icon: 'https://example.com/icon.png', - closed: false, - series: [], - markets: [], - tags: [], - liquidity: 500000, - volume: 1000000, - ...overrides, - }); - - it('merges parent and children markets into single event', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const childMarket1 = buildMarket({ conditionId: 'child-mkt-1' }); - const childMarket2 = buildMarket({ conditionId: 'child-mkt-2' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const child1 = buildEvent({ - id: 'child-1', - markets: [childMarket1], - }); - const child2 = buildEvent({ - id: 'child-2', - markets: [childMarket2], - }); - - const result = mergeChildEventsIntoParent([parent, child1, child2]); - - expect(result.markets).toHaveLength(3); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - expect(result.markets[1].conditionId).toBe('child-mkt-1'); - expect(result.markets[2].conditionId).toBe('child-mkt-2'); - }); - - it('returns parent as-is when no children', () => { - const parentMarket = buildMarket({ conditionId: 'solo-mkt' }); - const parent = buildEvent({ - id: 'solo-parent', - title: 'Solo Parent', - markets: [parentMarket], - }); - - const result = mergeChildEventsIntoParent([parent]); - - expect(result).toBe(parent); - expect(result.markets).toHaveLength(1); - expect(result.markets[0].conditionId).toBe('solo-mkt'); - }); - - it('throws on empty array', () => { - expect(() => mergeChildEventsIntoParent([])).toThrow( - 'No events to merge', - ); - }); - - it('preserves parent metadata (id, slug, title)', () => { - const parent = buildEvent({ - id: 'parent-id', - slug: 'parent-slug', - title: 'Parent Title', - markets: [buildMarket()], - }); - const child = buildEvent({ - id: 'child-id', - slug: 'child-slug', - title: 'Child Title', - markets: [buildMarket({ conditionId: 'child-cond' })], - }); - - const result = mergeChildEventsIntoParent([parent, child]); - - expect(result.id).toBe('parent-id'); - expect(result.slug).toBe('parent-slug'); - expect(result.title).toBe('Parent Title'); - }); - - it('handles children with empty markets arrays', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const childNoMarkets = buildEvent({ - id: 'child-empty', - markets: [], - }); - - const result = mergeChildEventsIntoParent([parent, childNoMarkets]); - - expect(result.markets).toHaveLength(1); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - }); - - it('identifies parent by missing parentEventId when parent is not first', () => { - const childMarket = buildMarket({ conditionId: 'child-mkt' }); - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const child = buildEvent({ - id: 'child-1', - parentEventId: 'parent-1', - markets: [childMarket], - }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - - const result = mergeChildEventsIntoParent([child, parent]); - - expect(result.id).toBe('parent-1'); - expect(result.markets).toHaveLength(2); - expect(result.markets[0].conditionId).toBe('parent-mkt'); - expect(result.markets[1].conditionId).toBe('child-mkt'); - }); - - it('does not duplicate parent markets', () => { - const parentMarket = buildMarket({ conditionId: 'parent-mkt' }); - const childMarket = buildMarket({ conditionId: 'child-mkt' }); - const parent = buildEvent({ - id: 'parent-1', - markets: [parentMarket], - }); - const child = buildEvent({ - id: 'child-1', - markets: [childMarket], - }); - - const result = mergeChildEventsIntoParent([parent, child]); - - const parentMarketCount = result.markets.filter( - (m) => m.conditionId === 'parent-mkt', - ).length; - expect(parentMarketCount).toBe(1); - expect(result.markets).toHaveLength(2); - }); + await expect( + getIsApprovedForAll({ + tokenAddress: '0x2222222222222222222222222222222222222222', + owner: '0x1111111111111111111111111111111111111111', + operator: '0x3333333333333333333333333333333333333333', + }), + ).resolves.toBe(false); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 826713d1b297..08c7a18fc696 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -16,7 +16,6 @@ import { type PredictMarket, type PredictPosition, PredictActivity, - Result, PredictOutcome, PredictOutcomeGroup, PredictOutcomeToken, @@ -48,7 +47,7 @@ import { GROUP_ORDER, SPORTS_MARKET_TYPE_PRIORITIES, HASH_ZERO_BYTES32, - MATIC_CONTRACTS, + MATIC_CONTRACTS_V2, MSG_TO_SIGN, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, @@ -57,16 +56,12 @@ import { SLIPPAGE_SELL, SPORTS_MARKET_TYPE_TO_GROUP, } from './constants'; -import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { ApiKeyCreds, ClobHeaders, - ClobOrderObject, COLLATERAL_TOKEN_DECIMALS, ContractConfig, L2HeaderArgs, - OrderData, - OrderResponse, OrderSummary, PolymarketApiEvent, PolymarketApiActivity, @@ -203,39 +198,17 @@ export const getL2Headers = async ({ return headers; }; -function getClobEndpoint({ - clobVersion = 'v1', - clobBaseUrl, -}: { - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}): string { +function getClobEndpoint(): string { const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - if (clobVersion === 'v2') { - return clobBaseUrl ?? CLOB_ENDPOINT; - } - return CLOB_ENDPOINT; } -export const deriveApiKey = async ({ - address, - clobVersion = 'v1', - clobBaseUrl, -}: { - address: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const deriveApiKey = async ({ address }: { address: string }) => { const headers = await getL1Headers({ address }); - const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/derive-api-key`, - { - method: 'GET', - headers, - }, - ); + const response = await fetch(`${getClobEndpoint()}/auth/derive-api-key`, { + method: 'GET', + headers, + }); if (!response.ok) { throw new Error('Failed to derive API key'); } @@ -243,26 +216,15 @@ export const deriveApiKey = async ({ return apiKeyRaw as ApiKeyCreds; }; -export const createApiKey = async ({ - address, - clobVersion = 'v1', - clobBaseUrl, -}: { - address: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const createApiKey = async ({ address }: { address: string }) => { const headers = await getL1Headers({ address }); - const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/api-key`, - { - method: 'POST', - headers, - body: '', - }, - ); + const response = await fetch(`${getClobEndpoint()}/auth/api-key`, { + method: 'POST', + headers, + body: '', + }); if (response.status === 400) { - return await deriveApiKey({ address, clobVersion, clobBaseUrl }); + return await deriveApiKey({ address }); } const apiKeyRaw = await response.json(); return apiKeyRaw as ApiKeyCreds; @@ -271,17 +233,9 @@ export const createApiKey = async ({ export const priceValid = (price: number, tickSize: TickSize): boolean => price >= parseFloat(tickSize) && price <= 1 - parseFloat(tickSize); -export const getOrderBook = async ({ - tokenId, - clobVersion = 'v1', - clobBaseUrl, -}: { - tokenId: string; - clobVersion?: 'v1' | 'v2'; - clobBaseUrl?: string; -}) => { +export const getOrderBook = async ({ tokenId }: { tokenId: string }) => { const response = await fetch( - `${getClobEndpoint({ clobVersion, clobBaseUrl })}/book?token_id=${tokenId}`, + `${getClobEndpoint()}/book?token_id=${tokenId}`, { method: 'GET', }, @@ -299,121 +253,18 @@ export const getOrderBook = async ({ return responseData; }; -interface FeeRateResponse { - base_fee?: number; -} - -const DEFAULT_FEE_RATE_BPS = '0'; - -export const getFeeRateBps = async ({ - tokenId, -}: { - tokenId: string; -}): Promise => { - const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - try { - const response = await fetch( - `${CLOB_ENDPOINT}/fee-rate?token_id=${tokenId}`, - { - method: 'GET', - }, - ); - - if (!response.ok) { - let errorMessage = `Request failed with status ${response.status}`; - const responseData = (await response.json().catch(() => undefined)) as - | { error?: string } - | undefined; - if (responseData?.error) { - errorMessage = responseData.error; - } - - DevLogger.log('Polymarket fee-rate request failed, using zero fee', { - tokenId, - status: response.status, - errorMessage, - }); - return DEFAULT_FEE_RATE_BPS; - } - - const responseData = (await response.json()) as FeeRateResponse; - const baseFee = responseData.base_fee; - if ( - typeof baseFee !== 'number' || - !Number.isFinite(baseFee) || - baseFee < 0 - ) { - DevLogger.log('Polymarket fee-rate response invalid, using zero fee', { - tokenId, - baseFee, - }); - return DEFAULT_FEE_RATE_BPS; - } - - return Math.round(baseFee).toString(); - } catch (error) { - DevLogger.log('Polymarket fee-rate request threw, using zero fee', { - tokenId, - error, - }); - return DEFAULT_FEE_RATE_BPS; - } -}; - export const generateSalt = (): Hex => `0x${BigInt(Math.floor(Math.random() * 1000000)).toString(16)}`; export const getContractConfig = (chainID: number): ContractConfig => { switch (chainID) { case POLYGON_MAINNET_CHAIN_ID: - return MATIC_CONTRACTS; + return MATIC_CONTRACTS_V2; default: - throw new Error( - 'MetaMask Predict is only supported on Polygon mainnet and Amoy testnet', - ); + throw new Error('MetaMask Predict is only supported on Polygon mainnet'); } }; -export const getOrderTypedData = ({ - order, - chainId, - verifyingContract, -}: { - order: OrderData & { salt: string }; - chainId: number; - verifyingContract: string; -}) => ({ - primaryType: 'Order', - domain: { - name: 'Polymarket CTF Exchange', - version: '1', - chainId, - verifyingContract, - }, - types: { - EIP712Domain: [ - ...EIP712Domain, - { name: 'verifyingContract', type: 'address' }, - ], - Order: [ - { name: 'salt', type: 'uint256' }, - { name: 'maker', type: 'address' }, - { name: 'signer', type: 'address' }, - { name: 'taker', type: 'address' }, - { name: 'tokenId', type: 'uint256' }, - { name: 'makerAmount', type: 'uint256' }, - { name: 'takerAmount', type: 'uint256' }, - { name: 'expiration', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'feeRateBps', type: 'uint256' }, - { name: 'side', type: 'uint8' }, - { name: 'signatureType', type: 'uint8' }, - ], - }, - message: order, -}); - export const encodeApprove = ({ spender, amount, @@ -451,82 +302,6 @@ function replaceAll(s: string, search: string, replace: string) { return s.split(search).join(replace); } -export const submitClobOrder = async ({ - headers, - clobOrder, - feeAuthorization, - executor, - allowancesTx, -}: { - headers: ClobHeaders; - clobOrder: ClobOrderObject; - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; - executor?: string; - allowancesTx?: { to: string; data: string }; -}): Promise> => { - const { CLOB_RELAYER } = getPolymarketEndpoints(); - const url = `${CLOB_RELAYER}/order`; - const body: ClobOrderObject & { - feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; - executor?: string; - allowancesTx?: { to: string; data: string }; - } = { - ...clobOrder, - feeAuthorization, - ...(executor && { executor }), - ...(allowancesTx && { allowancesTx }), - }; - - // For our relayer, we need to replace the underscores with dashes - // since underscores are not standardly allowed in headers - headers = { - ...headers, - ...Object.entries(headers) - .map(([key, value]) => ({ - [key.replace(/_/g, '-')]: value, - })) - .reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }; - - try { - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); - - if (response.status === 403) { - return { - success: false, - error: 'You are unable to access this provider.', - }; - } - - let responseData; - try { - responseData = (await response.json()) as OrderResponse; - } catch (error) { - responseData = undefined; - } - - if (!response.ok || !responseData || responseData?.success === false) { - const error = responseData?.errorMsg ?? response.statusText; - return { - success: false, - error, - }; - } - - return { success: true, response: responseData }; - } catch (error) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - return { - success: false, - error: `Failed to submit CLOB order: ${msg}`, - }; - } -}; - const normalizeSportsMarketType = (type: string): string => { const lower = type.toLowerCase(); if (lower.startsWith('first_half_')) { @@ -1666,6 +1441,14 @@ export const getAllowanceCalls = (params: { address: string }) => { return calls; }; +const parseNumericRpcResult = (res: string): bigint => { + if (res === '0x') { + return 0n; + } + + return BigInt(res); +}; + export const getAllowance = async ({ tokenAddress, owner, @@ -1696,8 +1479,8 @@ export const getAllowance = async ({ }, ]); - // Decode the result - const allowance = BigInt(res); + // Treat empty hex responses as zero to avoid breaking on sparse/mock RPCs. + const allowance = parseNumericRpcResult(res); return allowance; }; @@ -1732,7 +1515,7 @@ export const getIsApprovedForAll = async ({ ]); // Decode the result - convert hex to boolean - const isApproved = BigInt(res) !== 0n; + const isApproved = parseNumericRpcResult(res) !== 0n; return isApproved; }; @@ -1783,7 +1566,7 @@ export const getRawBalance = async ({ }, ]); - return BigInt(res); + return parseNumericRpcResult(res); }; export const getBalance = async ({ @@ -1939,27 +1722,15 @@ export const roundOrderAmount = ({ export const previewOrder = async ( params: Omit & { feeCollection?: PredictFeeCollection; - isV2?: boolean; - clobBaseUrl?: string; }, ): Promise => { - const { - marketId, - outcomeId, - outcomeTokenId, - side, - size, - feeCollection, - isV2, - clobBaseUrl, - } = params; + const { marketId, outcomeId, outcomeTokenId, side, size, feeCollection } = + params; const [book, feeRateBps] = await Promise.all([ getOrderBook({ tokenId: outcomeTokenId, - clobVersion: isV2 ? 'v2' : 'v1', - clobBaseUrl: isV2 ? clobBaseUrl : undefined, }), - isV2 ? Promise.resolve('0') : getFeeRateBps({ tokenId: outcomeTokenId }), + Promise.resolve('0'), ]); if (!book) { throw new Error(PREDICT_ERROR_CODES.PREVIEW_NO_ORDER_BOOK); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 316c2ca7796e..3e203e2ce181 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,7 +1,6 @@ import { selectExtendedSportsMarketsLeagues, selectPredictBottomSheetEnabledFlag, - selectPredictClobV2EnabledFlag, selectPredictEnabledFlag, selectPredictFakOrdersEnabledFlag, selectPredictFeaturedCarouselEnabledFlag, @@ -1260,83 +1259,6 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictClobV2EnabledFlag', () => { - it('returns true when flag is enabled and version requirement is met', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(true); - }); - - it('returns false when flag is disabled', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(false); - }); - - it('returns false when app version is below minimum required version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '99.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictClobV2EnabledFlag(state); - - expect(result).toBe(false); - }); - - it('returns false when remote feature flags are empty', () => { - const result = selectPredictClobV2EnabledFlag(mockedEmptyFlagsState); - - expect(result).toBe(false); - }); - }); - describe('selectExtendedSportsMarketsLeagues', () => { it('returns leagues when flag is enabled and version check passes', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index e86fe2463916..1d11080d8923 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -147,11 +147,6 @@ export const selectPredictUpDownEnabledFlag = createSelector( (flags) => flags.predictUpDownEnabled, ); -export const selectPredictClobV2EnabledFlag = createSelector( - selectPredictFeatureFlags, - (flags) => flags.predictClobV2Enabled, -); - export const selectPredictFeaturedCarouselEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index d1738a446aa8..dd8ff6632d85 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -23,10 +23,6 @@ export interface PredictExtendedSportsMarketsFlag leagues: string[]; } -export type PredictClobV2Flag = VersionGatedFeatureFlag; - -export type PredictClobV2UseLegacyClobHostFlag = VersionGatedFeatureFlag; - export interface PredictFeatureFlags { feeCollection: PredictFeeCollection; liveSportsLeagues: string[]; @@ -35,8 +31,6 @@ export interface PredictFeatureFlags { fakOrdersEnabled: boolean; predictWithAnyTokenEnabled: boolean; predictUpDownEnabled: boolean; - predictClobV2Enabled: boolean; - predictClobV2ClobBaseUrl?: string; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 001f3832640b..f7805821337d 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -614,7 +614,6 @@ export interface PreviewOrderParams { export interface AccountState { address: Hex; isDeployed: boolean; - hasAllowances: boolean; } export interface GeoBlockResponse { diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index 55dc7afac71d..f624a78b99a5 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -4,7 +4,6 @@ import { DEFAULT_FEE_COLLECTION_FLAG, DEFAULT_MARKET_HIGHLIGHTS_FLAG, } from '../constants/flags'; -import { LEGACY_V2_CLOB_BASE_URL } from '../providers/polymarket/constants'; import { resolvePredictFeatureFlags } from './resolvePredictFeatureFlags'; jest.mock('../../../../util/remoteFeatureFlag', () => ({ @@ -32,8 +31,6 @@ describe('resolvePredictFeatureFlags', () => { fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, predictUpDownEnabled: false, - predictClobV2Enabled: false, - predictClobV2ClobBaseUrl: undefined, }); }); @@ -188,129 +185,6 @@ describe('resolvePredictFeatureFlags', () => { expect(result.fakOrdersEnabled).toBe(true); expect(result.predictWithAnyTokenEnabled).toBe(false); - expect(result.predictClobV2Enabled).toBe(false); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - describe('predictClobV2Enabled', () => { - const mockEnabledVersionGatedFlags = () => { - mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => - Boolean( - flag && - typeof flag === 'object' && - 'enabled' in flag && - (flag as { enabled: boolean }).enabled, - ), - ); - }; - - it('returns false when flag is missing', () => { - const result = resolvePredictFeatureFlags({}); - - expect(result.predictClobV2Enabled).toBe(false); - }); - - it('returns true when flag is enabled and version validation passes', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('uses the temporary v2 CLOB host when the legacy-host flag is also enabled', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBe(LEGACY_V2_CLOB_BASE_URL); - }); - - it('keeps the canonical v2 CLOB host when the legacy-host flag is disabled', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('ignores the legacy-host flag when predictClobV2 is disabled or version-gated off', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(false); - expect(result.predictClobV2ClobBaseUrl).toBeUndefined(); - }); - - it('supports enabling v2 locally while the internal legacy-host flag remains remote', () => { - mockEnabledVersionGatedFlags(); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictClobV2: { - enabled: false, - minimumVersion: '1.0.0', - }, - predictClobV2UseLegacyClobHost: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - localOverrides: { - predictClobV2: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictClobV2Enabled).toBe(true); - expect(result.predictClobV2ClobBaseUrl).toBe(LEGACY_V2_CLOB_BASE_URL); - }); }); describe('extendedSportsMarketsLeagues', () => { diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts index 533d8ed9398d..07d12182ade7 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts @@ -16,7 +16,6 @@ import { PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; -import { LEGACY_V2_CLOB_BASE_URL } from '../providers/polymarket/constants'; import { unwrapRemoteFeatureFlag } from './flags'; export interface RawFeatureFlags { @@ -32,32 +31,6 @@ function resolveVersionGatedBooleanFlag(flag: unknown): boolean { ); } -function resolvePredictClobV2Flag({ - predictClobV2Flag, - predictClobV2UseLegacyClobHostFlag, -}: { - predictClobV2Flag: unknown; - predictClobV2UseLegacyClobHostFlag: unknown; -}): { - enabled: boolean; - clobBaseUrl?: string; -} { - const enabled = resolveVersionGatedBooleanFlag(predictClobV2Flag); - - if (!enabled) { - return { enabled: false, clobBaseUrl: undefined }; - } - - return { - enabled: true, - clobBaseUrl: resolveVersionGatedBooleanFlag( - predictClobV2UseLegacyClobHostFlag, - ) - ? LEGACY_V2_CLOB_BASE_URL - : undefined, - }; -} - /** * Resolves the Predict feature flags used by both the controller and selectors. * Local overrides take precedence over remote values when both are present. @@ -118,10 +91,6 @@ export function resolvePredictFeatureFlags( const predictUpDownEnabled = resolveVersionGatedBooleanFlag( flags.predictUpDown, ); - const predictClobV2 = resolvePredictClobV2Flag({ - predictClobV2Flag: flags.predictClobV2, - predictClobV2UseLegacyClobHostFlag: flags.predictClobV2UseLegacyClobHost, - }); return { feeCollection, @@ -131,7 +100,5 @@ export function resolvePredictFeatureFlags( fakOrdersEnabled, predictWithAnyTokenEnabled, predictUpDownEnabled, - predictClobV2Enabled: predictClobV2.enabled, - predictClobV2ClobBaseUrl: predictClobV2.clobBaseUrl, }; } diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx index 687731d98e29..747385940d27 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx @@ -79,11 +79,11 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ })); jest.mock('../../../../../../Views/confirmations/constants/predict', () => ({ - POLYGON_USDCE: { - address: '0xUSDCe', + POLYGON_PUSD: { + address: '0xPUSD', decimals: 6, - name: 'USDC.e', - symbol: 'USDC.e', + name: 'Polymarket USD', + symbol: 'pUSD', }, })); @@ -139,12 +139,12 @@ describe('PredictPayWithRow', () => { expect(screen.getByTestId('token-icon-0xToken-0x89')).toBeOnTheScreen(); }); - it('renders TokenIcon with POLYGON_USDCE when predict balance selected', () => { + it('renders TokenIcon with POLYGON_PUSD when predict balance selected', () => { mockIsPredictBalanceSelected = true; renderWithProvider(); - expect(screen.getByTestId('token-icon-0xUSDCe-0x89')).toBeOnTheScreen(); + expect(screen.getByTestId('token-icon-0xPUSD-0x89')).toBeOnTheScreen(); }); it('does not render TokenIcon when payToken has no address', () => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx index 540e0ff16666..929956cc78b2 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -27,7 +27,7 @@ import { TokenIconVariant, } from '../../../../../../Views/confirmations/components/token-icon'; import { isHardwareAccount } from '../../../../../../../util/address'; -import { POLYGON_USDCE } from '../../../../../../Views/confirmations/constants/predict'; +import { POLYGON_PUSD } from '../../../../../../Views/confirmations/constants/predict'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; import { PREDICT_BALANCE_CHAIN_ID } from '../../../../constants/transactions'; import { usePredictDefaultPaymentToken } from '../../hooks/usePredictDefaultPaymentToken'; @@ -74,7 +74,7 @@ export function PredictPayWithRow({ ? 'Predict balance' : (selectedPaymentToken?.symbol ?? payToken?.symbol ?? ''); const tokenIconAddress = showPredictBalance - ? POLYGON_USDCE.address + ? POLYGON_PUSD.address : (payToken?.address as Hex | undefined); const tokenIconChainId = showPredictBalance ? PREDICT_BALANCE_CHAIN_ID diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts index 8b01944fde38..f9eb6fe2e3da 100644 --- a/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts +++ b/tests/api-mocking/mock-responses/polymarket/polymarket-mocks.ts @@ -966,23 +966,41 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( // Safe Factory call - return proxy wallet address result = MOCK_RPC_RESPONSES.SAFE_FACTORY_RESULT; } else if ( - toAddress?.toLowerCase() === USDC_CONTRACT_ADDRESS.toLowerCase() + toAddress?.toLowerCase() === POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase() ) { - // USDC contract call - check function selector + // pUSD contract call (post-CLOB-v1 migration: Predict balance lives in pUSD). + // Return the current global balance for balanceOf so the displayed Predict + // balance comes from pUSD, matching production state for v2 users. if (callData?.toLowerCase()?.startsWith('0x70a08231')) { // balanceOf(address) selector - return current global balance result = currentUSDCBalance; } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { - // allowance(address,address) selector - return max allowance (uint256 max) - // This indicates full allowance is granted + // allowance(address,address) selector - max allowance + result = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + } else { + result = currentUSDCBalance; + } + } else if ( + toAddress?.toLowerCase() === USDC_CONTRACT_ADDRESS.toLowerCase() + ) { + // Legacy Safe USDC.e contract call. Post-migration this balance is 0 + // so deposit/withdraw/claim/trade flows do not append the legacy sweep + // maintenance transactions during E2E. Allowances stay maxed so any + // unrelated reads still see the wallet as fully approved. + if (callData?.toLowerCase()?.startsWith('0x70a08231')) { + // balanceOf(address) selector - ABI-encoded 0 (no legacy balance to sweep) + result = MOCK_RPC_RESPONSES.ZERO_UINT256_RESULT; + } else if (callData?.toLowerCase()?.startsWith('0xdd62ed3e')) { + // allowance(address,address) selector - max allowance result = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; } else if (callData?.toLowerCase()?.startsWith('0x6352211e')) { - // ownerOf(uint256) selector - return owner of the token + // ownerOf(uint256) selector result = '0x'; } else { - // Other USDC contract calls - return current global balance as fallback - result = currentUSDCBalance; + // Other legacy USDC.e contract calls default to ABI-encoded zero. + result = MOCK_RPC_RESPONSES.ZERO_UINT256_RESULT; } } else if ( toAddress?.toLowerCase() === MULTICALL_CONTRACT_ADDRESS.toLowerCase() @@ -1012,7 +1030,9 @@ export const POLYMARKET_USDC_BALANCE_MOCKS = async ( result = MOCK_RPC_RESPONSES.EMPTY_RESULT; } } else if (body?.method === 'eth_blockNumber') { - // Return current block number (dynamically updated to invalidate cache) + // Auto-advance so PendingTransactionTracker detects new blocks and + // polls eth_getTransactionReceipt, allowing relay deposits to confirm. + currentBlockNumber++; result = `0x${currentBlockNumber.toString(16)}`; } else if (body?.method === 'eth_getBalance') { result = MOCK_RPC_RESPONSES.ETH_BALANCE_RESULT; @@ -1475,7 +1495,7 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( return false; } - // Parse body to ensure this is a USDC balance call + // Parse body to ensure this is a pUSD balance call try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; @@ -1485,9 +1505,9 @@ export const POLYMARKET_UPDATE_USDC_BALANCE_MOCKS = async ( const toAddress = body?.params?.[0]?.to?.toLowerCase(); const callData = body?.params?.[0]?.data; const isMatch = - toAddress === USDC_CONTRACT_ADDRESS.toLowerCase() && + toAddress === POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase() && callData?.toLowerCase()?.startsWith('0x70a08231'); - // Only match USDC balanceOf calls + // Only match pUSD balanceOf calls (post-CLOB-v1 Predict balance source) return isMatch; } catch (error) { return false; @@ -1546,15 +1566,23 @@ export const POLYMARKET_POST_CASH_OUT_MOCKS = async (mockServer: Mockttp) => { // Verify the request matches cash-out order structure // Only check consistent fields - allow variable values for dynamic fields (salt, tokenId, amounts, signature, owner) + // CLOB v2 SELL order shape — see + // app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts + // (`buildProtocolUnsignedOrder`/`serializeProtocolRelayerOrder`). + // v2 orders have `timestamp`, `metadata`, `builder`; v1-only fields + // (`taker`, `nonce`, `feeRateBps`) were removed when CLOB v1 support + // was dropped, so this matcher must not check them. return ( order && (body.orderType === 'FOK' || body.orderType === 'FAK') && order.maker?.toLowerCase() === PROXY_WALLET_ADDRESS.toLowerCase() && order.signer?.toLowerCase() === USER_WALLET_ADDRESS.toLowerCase() && - order.taker === '0x0000000000000000000000000000000000000000' && order.expiration === '0' && - order.nonce === '0' && - typeof order.feeRateBps === 'string' && + typeof order.timestamp === 'string' && + typeof order.metadata === 'string' && + order.metadata.startsWith('0x') && + typeof order.builder === 'string' && + order.builder.startsWith('0x') && order.side === 'SELL' && order.signatureType === 2 && typeof order.salt === 'number' && @@ -1878,12 +1906,14 @@ export const POLYMARKET_WITHDRAW_BALANCE_LOAD_MOCKS = async ( try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; - const isUSDCBalanceCall = + // Match pUSD balanceOf calls — post-CLOB-v1 migration the Predict + // balance lives in pUSD, so withdraw flow refreshes target pUSD. + const isPusdBalanceCall = body?.method === 'eth_call' && body?.params?.[0]?.to?.toLowerCase() === - USDC_CONTRACT_ADDRESS.toLowerCase(); + POLYGON_PUSD_TOKEN_ADDRESS.toLowerCase(); - return isUSDCBalanceCall; + return isPusdBalanceCall; } catch (error) { return false; } diff --git a/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts b/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts index 7cb1b5dfde0d..90ed7c1309b8 100644 --- a/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts +++ b/tests/api-mocking/mock-responses/polymarket/polymarket-rpc-response.ts @@ -25,6 +25,9 @@ export const MOCK_RPC_RESPONSES = { // Post-claim USDC balance (48.16 USDC = 48,160,000 = 0x2de0300) POST_CLAIM_USDC_BALANCE_RESULT: POST_CLAIM_USDC_BALANCE_WEI, + ZERO_UINT256_RESULT: + '0x0000000000000000000000000000000000000000000000000000000000000000', + EMPTY_RESULT: '0x', // Mock approval result (true) diff --git a/tests/smoke/confirmations/transactions/transaction-pay.spec.ts b/tests/smoke/confirmations/transactions/transaction-pay.spec.ts index c8bf1c3a8cd6..e92fd0ccb28e 100644 --- a/tests/smoke/confirmations/transactions/transaction-pay.spec.ts +++ b/tests/smoke/confirmations/transactions/transaction-pay.spec.ts @@ -25,7 +25,10 @@ import ActivitiesView from '../../../page-objects/Transactions/ActivitiesView'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; describe(SmokeConfirmations('Transaction Pay'), () => { - it('deposits to predict balance', async () => { + // TODO: Re-enable once Predict deposit activity is stable again after the + // CLOB v2 migration work. + // eslint-disable-next-line jest/no-disabled-tests -- temporarily disabling a flaky Predict deposit activity assertion + it.skip('deposits to predict balance', async () => { await withFixtures( { fixture: new FixtureBuilder() From ba4c8810df1e56d20efdc52f932fbce54b3f99fb Mon Sep 17 00:00:00 2001 From: Andy Bridges Date: Wed, 6 May 2026 19:24:24 +0100 Subject: [PATCH 15/28] chore: remove unused ContractBox temp components (#29816) ## **Description** Removes the unused temporary ContractBox and ContractBoxBase components from `app/component-library/components-temp/Contracts`. The prior audit found no production imports, and a follow-up reference search confirms no remaining references. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: temporary component cleanup Scenario: ContractBox temp components are removed Given the repository is on this branch When searching for ContractBox and ContractBoxBase references Then no app, test, or Storybook references are found ``` ## **Screenshots/Recordings** N/A. Code cleanup only. ### **Before** N/A ### **After** N/A ## **Validation** - `rg -n "ContractBox|ContractBoxBase|CONTRACT_BOX|components-temp/Contracts" app tests .storybook --glob '!node_modules/**'` returned no matches. - `yarn lint:tsc` was run and failed on existing unrelated type errors in confirmations, SocialLeaderboard tests, bridge messenger types, multichain assets rates messenger types, and test account utilities. ## **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). - [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 - [ ] 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 - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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. --- .../ContractBox/ContractBox.constants.ts | 26 ----- .../ContractBox/ContractBox.styles.ts | 16 --- .../ContractBox/ContractBox.test.tsx | 40 -------- .../Contracts/ContractBox/ContractBox.tsx | 33 ------- .../ContractBox/ContractBox.types.ts | 3 - .../Contracts/ContractBox/index.ts | 1 - .../ContractBoxBase.constants.ts | 4 - .../ContractBoxBase/ContractBoxBase.styles.ts | 38 ------- .../ContractBoxBase/ContractBoxBase.test.tsx | 49 --------- .../ContractBoxBase/ContractBoxBase.tsx | 99 ------------------- .../ContractBoxBase/ContractBoxBase.types.ts | 32 ------ .../Contracts/ContractBoxBase/index.ts | 1 - 12 files changed, 342 deletions(-) delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBox/index.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.types.ts delete mode 100644 app/component-library/components-temp/Contracts/ContractBoxBase/index.ts diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts deleted file mode 100644 index 2aadf9828161..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable no-console */ -import { ImageSourcePropType } from 'react-native'; - -const imageSource = - 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880'; - -export const CONTRACT_PET_NAME = 'DAI'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_LOCAL_IMAGE: ImageSourcePropType = { - uri: imageSource, -}; - -export const CONTRACT_COPY_ADDRESS = () => { - console.log('copy address'); -}; - -export const CONTRACT_EXPORT_ADDRESS = () => { - console.log('export address'); -}; - -export const CONTRACT_ON_PRESS = () => { - console.log('contract pressed'); -}; - -export const HAS_BLOCK_EXPLORER = true; -export const TOKEN_SYMBOL = 'D'; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts deleted file mode 100644 index 46ff512773fb..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; - -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - }, -}); - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx deleted file mode 100644 index d4acbe3aea94..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import TEST_ADDRESS from '../../../../constants/address'; -import ContractBox from './ContractBox'; -import { - CONTRACT_BOX_TEST_ID, - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_EXPORT_ADDRESS, - CONTRACT_ON_PRESS, -} from './ContractBox.constants'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('ContractBox', () => { - it('should render ContractBox', () => { - renderWithProvider( - , - { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }, - ); - expect(screen.getAllByTestId(CONTRACT_BOX_TEST_ID).length).toBeGreaterThan( - 0, - ); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx deleted file mode 100644 index f06f4e6a1766..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import Card from '../../../components/Cards/Card'; -import ContractBoxBase from '../ContractBoxBase'; -import styles from './ContractBox.styles'; -import { View } from 'react-native'; -import { ContractBoxProps } from './ContractBox.types'; -import { CONTRACT_BOX_TEST_ID } from './ContractBox.constants'; - -const ContractBox = ({ - contractAddress, - contractPetName, - contractLocalImage, - onExportAddress, - onCopyAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxProps) => ( - - - - - -); - -export default ContractBox; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts deleted file mode 100644 index c7e39064c398..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ContractBoxBaseProps } from '../ContractBoxBase/ContractBoxBase.types'; - -export type ContractBoxProps = ContractBoxBaseProps; diff --git a/app/component-library/components-temp/Contracts/ContractBox/index.ts b/app/component-library/components-temp/Contracts/ContractBox/index.ts deleted file mode 100644 index bd87bb9fbfb8..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ContractBox'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts deleted file mode 100644 index 23a62de58a3f..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const EXPORT_ICON_TEST_ID = 'export-icon'; -export const COPY_ICON_TEST_ID = 'copy-icon'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_BOX_NO_PET_NAME_TEST_ID = 'contract-box-no-pet-name'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts deleted file mode 100644 index 8d164eb6a7a2..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../util/theme/models'; -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - return StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - flex: 1, - }, - rowContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - imageContainer: { - marginRight: 16, - }, - icon: { - paddingHorizontal: 6, - }, - iconContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - header: { - color: theme.colors.info.default, - }, - }); -}; - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx deleted file mode 100644 index 58e1e9532d36..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import ContractBoxBase from './ContractBoxBase'; -import TEST_ADDRESS from '../../../../constants/address'; -import { - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_ON_PRESS, -} from '../ContractBox/ContractBox.constants'; -import { CONTRACT_BOX_NO_PET_NAME_TEST_ID } from './ContractBoxBase.constants'; -import { ContractBoxBaseProps } from './ContractBoxBase.types'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('Component ContractBoxBase', () => { - let props: ContractBoxBaseProps; - - beforeEach(() => { - props = { - contractAddress: TEST_ADDRESS, - contractPetName: CONTRACT_PET_NAME, - contractLocalImage: CONTRACT_LOCAL_IMAGE, - onCopyAddress: CONTRACT_COPY_ADDRESS, - onContractPress: CONTRACT_ON_PRESS, - }; - }); - - const renderComponent = () => - renderWithProvider(, { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }); - - it('should render correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toBeDefined(); - }); - - it('renders the no-pet-name element when contractPetName is undefined', () => { - props.contractPetName = undefined; - renderComponent(); - expect(screen.getByTestId(CONTRACT_BOX_NO_PET_NAME_TEST_ID)).toBeTruthy(); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx deleted file mode 100644 index 5ba78777e381..000000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// Third party depencies -import React from 'react'; -import { View, Pressable } from 'react-native'; - -// External dependencies. -import Avatar, { - AvatarSize, - AvatarVariant, -} from '../../../components/Avatars/Avatar'; -import Text, { TextVariant } from '../../../components/Texts/Text'; -import { formatAddress } from '../../../../util/address'; -import Icon, { IconName, IconSize } from '../../../components/Icons/Icon'; -import { useStyles } from '../../../hooks'; -import Button, { ButtonVariants } from '../../../components/Buttons/Button'; -import Identicon from '../../../../components/UI/Identicon'; - -// Internal dependencies. -import { ContractBoxBaseProps, IconViewProps } from './ContractBoxBase.types'; -import styleSheet from './ContractBoxBase.styles'; -import { - EXPORT_ICON_TEST_ID, - COPY_ICON_TEST_ID, - CONTRACT_BOX_TEST_ID, - CONTRACT_BOX_NO_PET_NAME_TEST_ID, -} from './ContractBoxBase.constants'; - -const ContractBoxBase = ({ - contractAddress, - contractLocalImage, - contractPetName, - onCopyAddress, - onExportAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxBaseProps) => { - const formattedAddress = formatAddress(contractAddress, 'short'); - const { - styles, - theme: { colors }, - } = useStyles(styleSheet, {}); - - const renderIconView = ({ onPress, name, size, testID }: IconViewProps) => ( - - - - ); - - return ( - - - - {contractLocalImage ? ( - - ) : ( - - )} - - {contractPetName ? ( - - - {contractPetName} - - {formattedAddress} - - ) : ( - - - - ); -}; + {strings('money.footer.add_money')} + + +); export default MoneyFooter; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx index e6957fb16039..2b7a7ebcc97f 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx @@ -2,34 +2,27 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import MoneyHeader from './MoneyHeader'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; - -const noop = jest.fn(); +import { strings } from '../../../../../../locales/i18n'; describe('MoneyHeader', () => { - it('renders the back and menu buttons', () => { - const { getByTestId } = render( - , - ); + it('renders the menu button', () => { + const { getByTestId } = render(); - expect(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)).toBeOnTheScreen(); expect(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)).toBeOnTheScreen(); }); - it('calls onBackPress when the back button is pressed', () => { - const mockOnBackPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)); + it('renders the Money title alongside the menu button', () => { + const { getByTestId } = render(); - expect(mockOnBackPress).toHaveBeenCalledTimes(1); + expect(getByTestId(MoneyHeaderTestIds.TITLE)).toHaveTextContent( + strings('money.title'), + ); }); it('calls onMenuPress when the menu button is pressed', () => { const mockOnMenuPress = jest.fn(); const { getByTestId } = render( - , + , ); fireEvent.press(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)); diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts index 29abd63b1cd3..e41dc9605c5b 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts @@ -1,5 +1,5 @@ export const MoneyHeaderTestIds = { CONTAINER: 'money-header-container', - BACK_BUTTON: 'money-header-back-button', + TITLE: 'money-header-title', MENU_BUTTON: 'money-header-menu-button', } as const; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx index da1cb440f54f..f63fd31d16c7 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx @@ -1,26 +1,25 @@ import React from 'react'; -import { HeaderStandard, IconName } from '@metamask/design-system-react-native'; +import { + HeaderBase, + HeaderBaseVariant, + IconName, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; interface MoneyHeaderProps { - /** - * Handler for the back/navigation button - */ - onBackPress: () => void; /** * Handler for the options menu button */ onMenuPress: () => void; } -const MoneyHeader = ({ onBackPress, onMenuPress }: MoneyHeaderProps) => ( - ( + ( testID: MoneyHeaderTestIds.MENU_BUTTON, }, ]} - /> + > + {strings('money.title')} + ); export default MoneyHeader; diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index e2ebe150a2da..71d5b7db40d7 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -24,6 +24,7 @@ const MoneyScreenStack = () => { return ( Date: Wed, 6 May 2026 23:01:34 +0200 Subject: [PATCH 23/28] feat(MUSD-431, MUSD-752): add Money balance card to wallet home (#29724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a new **Money balance card** to the wallet home screen, slotted between the wallet action bar (Buy/Swap/Send/Receive) and the Carousel/Tokens block. The card surfaces the user's Money Account balance, current vault APY, and a direct CTA to add funds — replacing the chevron-style "Money" section pattern described in the Money Home Review (April 30, 2026) for users on the new wallet home. The card has two visual states sharing one component: - **Empty** (MUSD-431) — balance `$0.00`, **Secondary** "Add" button. - **Funded** (MUSD-752) — live `totalFiatFormatted` from `useMoneyAccountBalance`, **Secondary** "Add" button. Switches as soon as `totalFiatRaw > 0`. Other behaviour: - Tap card body → navigates to Money home (`Routes.MONEY.ROOT`) - Tap "Add" → `Routes.MONEY.MODALS.ADD_MONEY_SHEET` (same flow as the existing Money home Add pill) - Tap info icon → new `MoneyBalanceInfoSheet` modal (registered alongside the existing APY/Earnings info sheets) - Skeleton placeholders while balance/APY are loading - Render-gated by `selectMoneyHomeScreenEnabledFlag && isHomepageSectionsV1Enabled` The legacy `MoneyAccountHomeRow` and `CashSection` are intentionally untouched in this PR — they continue to serve users without the Money home flag. ## **Changelog** CHANGELOG entry: Added a new Money balance card to the wallet home screen showing the user's Money Account balance, vault APY, and a quick action to add funds. ## **Related issues** Fixes: MUSD-431, MUSD-752 ## **Manual testing steps** ```gherkin Feature: Money balance card on wallet home Scenario: user with no Money balance sees the empty state Given the Money home feature flag is enabled And the homepage sections v1 flag is enabled And the user has no Money Account balance When the user opens the wallet home screen Then the Money balance card is visible between the action bar and the tokens And the balance shows "$0.00" And the "Add" button uses the Secondary variant And the "4% APY" tag is visible Scenario: user with a Money balance sees the funded state Given the Money home feature flag is enabled And the homepage sections v1 flag is enabled And the user has a Money Account balance greater than $0.00 When the user opens the wallet home screen Then the Money balance card shows the live USD balance And the "Add" button uses the Secondary variant Scenario: user taps the Money balance card body Given the Money balance card is visible When the user taps anywhere on the card outside of the Add button or info icon Then the app navigates to the Money home screen Scenario: user taps the info icon on the Money balance card Given the Money balance card is visible When the user taps the info icon next to "Money balance" Then the Money balance info sheet opens Scenario: feature flag is off Given the Money home feature flag is disabled When the user opens the wallet home screen Then the Money balance card is not rendered ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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 - [ ] I've included tests if applicable - [ ] 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 - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Adds new wallet-home UI and navigation paths (including a new modal route) gated by feature flags; risk is mainly around routing/press handlers and empty-state logic but is localized and well-covered by tests. > > **Overview** > Adds a new `MoneyBalanceCard` on the wallet home header area (shown only when `selectMoneyHomeScreenEnabledFlag` is on) that displays Money balance + APY with loading skeletons, and provides CTAs to open the Add Money sheet, navigate to Money Home, or start the mUSD conversion education flow for new users. > > Introduces a new `MoneyBalanceInfoSheet` bottom-sheet modal and registers it in the Money modal stack via new route `Routes.MONEY.MODALS.MONEY_BALANCE_INFO_SHEET`, along with new i18n strings for the card label and sheet copy. > > Removes the legacy `MoneyAccountHomeRow` component/tests and updates `CashSection` so the entire Cash section is not rendered when the Money home flag is enabled; updates/extends unit tests (Wallet flag-gated rendering, Cash section null behavior, Money modal registration) accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dea40132d6057dcd00f7c4fb9cb0f7f917b9ff69. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyAccountHomeRow.test.tsx | 229 ----------- .../MoneyAccountHomeRow.testIds.ts | 10 - .../MoneyAccountHomeRow.tsx | 144 ------- .../components/MoneyAccountHomeRow/index.ts | 2 - .../MoneyBalanceCard.styles.ts | 13 + .../MoneyBalanceCard.test.tsx | 367 ++++++++++++++++++ .../MoneyBalanceCard.testIds.ts | 13 + .../MoneyBalanceCard/MoneyBalanceCard.tsx | 204 ++++++++++ .../components/MoneyBalanceCard/index.ts | 2 + .../MoneyBalanceInfoSheet.styles.ts | 14 + .../MoneyBalanceInfoSheet.test.tsx | 128 ++++++ .../MoneyBalanceInfoSheet.testIds.ts | 5 + .../MoneyBalanceInfoSheet.tsx | 56 +++ .../components/MoneyBalanceInfoSheet/index.ts | 2 + app/components/UI/Money/routes/index.test.tsx | 8 + app/components/UI/Money/routes/index.tsx | 6 + .../Sections/Cash/CashSection.test.tsx | 36 +- .../Homepage/Sections/Cash/CashSection.tsx | 16 +- app/components/Views/Wallet/index.test.tsx | 81 ++++ app/components/Views/Wallet/index.tsx | 3 + app/constants/navigation/Routes.ts | 1 + locales/languages/en.json | 6 + 22 files changed, 920 insertions(+), 426 deletions(-) delete mode 100644 app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.test.tsx delete mode 100644 app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.testIds.ts delete mode 100644 app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.tsx delete mode 100644 app/components/UI/Money/components/MoneyAccountHomeRow/index.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx create mode 100644 app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx create mode 100644 app/components/UI/Money/components/MoneyBalanceCard/index.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx create mode 100644 app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts create mode 100644 app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx create mode 100644 app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts diff --git a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.test.tsx b/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.test.tsx deleted file mode 100644 index 766e0a7c9147..000000000000 --- a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.test.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import MoneyAccountHomeRow from './MoneyAccountHomeRow'; -import { MoneyAccountHomeRowTestIds } from './MoneyAccountHomeRow.testIds'; -import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; - -const mockNavigate = jest.fn(); -const mockNavigateToCash = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualReactNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualReactNavigation, - useNavigation: () => ({ - navigate: mockNavigate, - }), - }; -}); - -jest.mock('../../../../Views/Homepage/Sections/Cash/useCashNavigation', () => ({ - useCashNavigation: () => ({ - navigateToCash: mockNavigateToCash, - }), -})); - -jest.mock('../../hooks/useMoneyAccountBalance', () => ({ - __esModule: true, - default: jest.fn(), -})); - -const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); - -const createBalanceMock = ( - overrides: Partial> = {}, -) => - ({ - totalFiatFormatted: '$1,000.00', - totalFiatRaw: '1000', - tokenTotal: undefined, - isAggregatedBalanceLoading: false, - apyDecimal: 0.04, - apyPercent: 4, - apyPercentFormatted: '4%', - vaultApyQuery: { - data: { apy: 0.04, timestamp: '2026-01-01T00:00:00Z' }, - isLoading: false, - }, - musdBalanceQuery: { - data: { balance: '1000000000' }, - isLoading: false, - }, - musdEquivalentBalanceQuery: { - data: { - musdEquivalentValue: '0', - musdSHFvdBalance: '0', - exchangeRate: '1000000', - }, - isLoading: false, - }, - musdFiatFormatted: '$1,000.00', - musdSHFvdFiatFormatted: '$0.00', - ...overrides, - }) as ReturnType; - -describe('MoneyAccountHomeRow', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseMoneyAccountBalance.mockReturnValue(createBalanceMock()); - }); - - describe('when balance is empty', () => { - beforeEach(() => { - mockUseMoneyAccountBalance.mockReturnValue( - createBalanceMock({ totalFiatRaw: undefined }), - ); - }); - - it('renders the empty container testID', () => { - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.EMPTY_CONTAINER), - ).toBeOnTheScreen(); - }); - - it('renders the "Get started" button', () => { - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.GET_STARTED_BUTTON), - ).toBeOnTheScreen(); - }); - - it('renders the APY tag with "Earn" prefix', () => { - const { getByTestId } = renderWithProvider(); - - expect(getByTestId(MoneyAccountHomeRowTestIds.APY_TAG)).toHaveTextContent( - strings('homepage.sections.cash_empty_state.earn_apy', { - percentage: 4, - }), - ); - }); - - it('navigates to Money home when "Get started" is pressed', () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press( - getByTestId(MoneyAccountHomeRowTestIds.GET_STARTED_BUTTON), - ); - - expect(mockNavigateToCash).toHaveBeenCalledTimes(1); - }); - - it('renders the empty container when totalFiatRaw is the string zero', () => { - mockUseMoneyAccountBalance.mockReturnValue( - createBalanceMock({ totalFiatRaw: '0', totalFiatFormatted: '$0.00' }), - ); - - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.EMPTY_CONTAINER), - ).toBeOnTheScreen(); - }); - }); - - describe('when balance is funded', () => { - it('renders the funded container testID', () => { - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.FUNDED_CONTAINER), - ).toBeOnTheScreen(); - }); - - it('renders the "Add" button', () => { - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.ADD_BUTTON), - ).toBeOnTheScreen(); - }); - - it('renders the balance from useMoneyAccountBalance', () => { - const { getByTestId } = renderWithProvider(); - - expect(getByTestId(MoneyAccountHomeRowTestIds.BALANCE)).toHaveTextContent( - '$1,000.00', - ); - }); - - it('renders the APY tag without "Earn" prefix', () => { - const { getByTestId } = renderWithProvider(); - - expect(getByTestId(MoneyAccountHomeRowTestIds.APY_TAG)).toHaveTextContent( - strings('homepage.sections.cash_filled_state.apy', { percentage: 4 }), - ); - }); - - it('opens the Add money sheet when "Add" is pressed', () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press(getByTestId(MoneyAccountHomeRowTestIds.ADD_BUTTON)); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); - }); - }); - - describe('loading states', () => { - it('renders balance skeleton when balance is loading', () => { - mockUseMoneyAccountBalance.mockReturnValue( - createBalanceMock({ isAggregatedBalanceLoading: true }), - ); - - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.BALANCE_SKELETON), - ).toBeOnTheScreen(); - expect( - queryByTestId(MoneyAccountHomeRowTestIds.BALANCE), - ).not.toBeOnTheScreen(); - }); - - it('renders APY skeleton when APY is loading', () => { - mockUseMoneyAccountBalance.mockReturnValue( - createBalanceMock({ - vaultApyQuery: { - data: undefined, - isLoading: true, - } as ReturnType['vaultApyQuery'], - }), - ); - - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAccountHomeRowTestIds.APY_TAG_SKELETON), - ).toBeOnTheScreen(); - expect( - queryByTestId(MoneyAccountHomeRowTestIds.APY_TAG), - ).not.toBeOnTheScreen(); - }); - - it('renders balance and APY values when data has loaded', () => { - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); - - expect(getByTestId(MoneyAccountHomeRowTestIds.BALANCE)).toBeOnTheScreen(); - expect(getByTestId(MoneyAccountHomeRowTestIds.APY_TAG)).toBeOnTheScreen(); - expect( - queryByTestId(MoneyAccountHomeRowTestIds.BALANCE_SKELETON), - ).not.toBeOnTheScreen(); - expect( - queryByTestId(MoneyAccountHomeRowTestIds.APY_TAG_SKELETON), - ).not.toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.testIds.ts b/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.testIds.ts deleted file mode 100644 index 5d97447ea2a5..000000000000 --- a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.testIds.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const MoneyAccountHomeRowTestIds = { - EMPTY_CONTAINER: 'money-account-home-row-empty-container', - FUNDED_CONTAINER: 'money-account-home-row-funded-container', - BALANCE: 'money-account-home-row-balance', - BALANCE_SKELETON: 'money-account-home-row-balance-skeleton', - APY_TAG: 'money-account-home-row-apy-tag', - APY_TAG_SKELETON: 'money-account-home-row-apy-tag-skeleton', - GET_STARTED_BUTTON: 'money-account-home-row-get-started-button', - ADD_BUTTON: 'money-account-home-row-add-button', -} as const; diff --git a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.tsx b/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.tsx deleted file mode 100644 index a8e0a175b24e..000000000000 --- a/app/components/UI/Money/components/MoneyAccountHomeRow/MoneyAccountHomeRow.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { useCallback } from 'react'; -import { Pressable } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - AvatarToken, - AvatarTokenSize, - Box, - Button, - ButtonSize, - ButtonVariant, - FontWeight, - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import { MUSD_TOKEN } from '../../../Earn/constants/musd'; -import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; -import { useCashNavigation } from '../../../../Views/Homepage/Sections/Cash/useCashNavigation'; -import { MoneyAccountHomeRowTestIds } from './MoneyAccountHomeRow.testIds'; - -const MoneyAccountHomeRow = () => { - const tw = useTailwind(); - const navigation = useNavigation(); - const { navigateToCash } = useCashNavigation(); - const { - totalFiatRaw, - totalFiatFormatted, - apyPercent, - isAggregatedBalanceLoading, - vaultApyQuery, - } = useMoneyAccountBalance(); - - const isEmpty = totalFiatRaw === undefined || totalFiatRaw === '0'; - - const apyLabel = isEmpty - ? strings('homepage.sections.cash_empty_state.earn_apy', { - percentage: apyPercent, - }) - : strings('homepage.sections.cash_filled_state.apy', { - percentage: apyPercent, - }); - - const handleGetStartedPress = useCallback(() => { - navigateToCash(); - }, [navigateToCash]); - - const handleAddPress = useCallback(() => { - navigation.navigate(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); - }, [navigation]); - - return ( - - tw.style( - 'flex-row items-center justify-between py-1', - pressed && 'opacity-80', - ) - } - > - - - - {isAggregatedBalanceLoading ? ( - - ) : ( - - {totalFiatFormatted} - - )} - {vaultApyQuery.isLoading ? ( - - ) : ( - - - {apyLabel} - - - )} - - - - {isEmpty ? ( - - ) : ( - - )} - - ); -}; - -export default MoneyAccountHomeRow; diff --git a/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts b/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts deleted file mode 100644 index 86e35c1e8230..000000000000 --- a/app/components/UI/Money/components/MoneyAccountHomeRow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './MoneyAccountHomeRow'; -export { MoneyAccountHomeRowTestIds } from './MoneyAccountHomeRow.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts new file mode 100644 index 000000000000..016c8f5decea --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.styles.ts @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + height: 82, + borderRadius: 12, + paddingHorizontal: 16, + marginHorizontal: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx new file mode 100644 index 000000000000..affcaedbd7ee --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -0,0 +1,367 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyBalanceCard from './MoneyBalanceCard'; +import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +jest.mock('../../hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../../reducers/user/selectors', () => ({ + __esModule: true, + selectMusdConversionEducationSeen: jest.fn(), +})); + +const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); +const mockSelectMusdConversionEducationSeen = jest.mocked( + selectMusdConversionEducationSeen, +); + +const createBalanceMock = ( + overrides: Partial> = {}, +) => + ({ + totalFiatFormatted: '$1,000.00', + totalFiatRaw: '1000', + tokenTotal: undefined, + isAggregatedBalanceLoading: false, + apyDecimal: 0.04, + apyPercent: 4, + apyPercentFormatted: '4%', + vaultApyQuery: { + data: { apy: 0.04, timestamp: '2026-01-01T00:00:00Z' }, + isLoading: false, + }, + musdBalanceQuery: { + data: { balance: '1000000000' }, + isLoading: false, + }, + musdEquivalentBalanceQuery: { + data: { + musdEquivalentValue: '0', + musdSHFvdBalance: '0', + exchangeRate: '1000000', + }, + isLoading: false, + }, + musdFiatFormatted: '$1,000.00', + musdSHFvdFiatFormatted: '$0.00', + ...overrides, + }) as ReturnType; + +describe('MoneyBalanceCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMoneyAccountBalance.mockReturnValue(createBalanceMock()); + mockSelectMusdConversionEducationSeen.mockReturnValue(true); + }); + + describe('when balance is empty (totalFiatRaw undefined)', () => { + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + }); + + it('renders the empty container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.EMPTY_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the balance as $0.00', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$0.00', + ); + }); + + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + + it('renders the label', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.LABEL)).toHaveTextContent( + strings('money.balance_card.label'), + ); + }); + + it('renders the empty container when totalFiatRaw is the string zero', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ totalFiatRaw: '0', totalFiatFormatted: '$0.00' }), + ); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.EMPTY_CONTAINER), + ).toBeOnTheScreen(); + }); + }); + + describe('when balance is empty and onboarding has not been seen', () => { + beforeEach(() => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + mockSelectMusdConversionEducationSeen.mockReturnValue(false); + }); + + it('renders the new-user container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.NEW_USER_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the Get started button', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), + ).toHaveTextContent( + strings('homepage.sections.cash_empty_state.get_started'), + ); + }); + + it('does not render the Add button', () => { + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('navigates to the conversion education flow when Get started is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.EARN.ROOT, { + screen: Routes.EARN.MUSD.CONVERSION_EDUCATION, + params: { + returnTo: { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }, + }, + }); + }); + }); + + describe('when balance is funded', () => { + it('renders the funded container testID', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceCardTestIds.FUNDED_CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the balance from useMoneyAccountBalance', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$1,000.00', + ); + }); + + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + + it('renders the APY tag', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toHaveTextContent( + strings('money.apy_label', { percentage: 4 }), + ); + }); + + it('falls back to $0.00 when totalFiatFormatted is undefined but totalFiatRaw is non-zero', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: '1000', + totalFiatFormatted: undefined, + }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$0.00', + ); + }); + }); + + describe('navigation', () => { + it('navigates to MONEY home when the card body is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.FUNDED_CONTAINER)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.ROOT, { + screen: Routes.MONEY.HOME, + }); + }); + + it('opens the Add money sheet when Add is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }); + + it('opens the Money balance info sheet when the info icon is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.INFO_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.MONEY_BALANCE_INFO_SHEET, + }); + }); + + it('opens the Add money sheet (and not the Money home) when Add is pressed in empty state', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: undefined, + totalFiatFormatted: undefined, + }), + ); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }); + }); + + describe('loading states', () => { + it('renders balance skeleton when balance is loading', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ isAggregatedBalanceLoading: true }), + ); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(MoneyBalanceCardTestIds.BALANCE_SKELETON), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.BALANCE), + ).not.toBeOnTheScreen(); + }); + + it('renders APY skeleton when APY is loading', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + vaultApyQuery: { + data: undefined, + isLoading: true, + } as ReturnType['vaultApyQuery'], + }), + ); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(MoneyBalanceCardTestIds.APY_TAG_SKELETON), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.APY_TAG), + ).not.toBeOnTheScreen(); + }); + + it('renders balance and APY values when data has loaded', () => { + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.BALANCE_SKELETON), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyBalanceCardTestIds.APY_TAG_SKELETON), + ).not.toBeOnTheScreen(); + }); + + it('renders the APY tag with 0 when apyPercent is undefined', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ apyPercent: undefined }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toHaveTextContent( + strings('money.apy_label', { percentage: 0 }), + ); + }); + }); + + describe('layout resilience', () => { + it('keeps the APY tag and Add button on screen when the balance is very long', () => { + mockUseMoneyAccountBalance.mockReturnValue( + createBalanceMock({ + totalFiatRaw: '999999999990', + totalFiatFormatted: '$999,999,999,999.99', + }), + ); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceCardTestIds.BALANCE)).toHaveTextContent( + '$999,999,999,999.99', + ); + expect(getByTestId(MoneyBalanceCardTestIds.APY_TAG)).toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts new file mode 100644 index 000000000000..6c9a3dac5167 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts @@ -0,0 +1,13 @@ +export const MoneyBalanceCardTestIds = { + NEW_USER_CONTAINER: 'money-balance-card-new-user-container', + EMPTY_CONTAINER: 'money-balance-card-empty-container', + FUNDED_CONTAINER: 'money-balance-card-funded-container', + LABEL: 'money-balance-card-label', + INFO_BUTTON: 'money-balance-card-info-button', + BALANCE: 'money-balance-card-balance', + BALANCE_SKELETON: 'money-balance-card-balance-skeleton', + APY_TAG: 'money-balance-card-apy-tag', + APY_TAG_SKELETON: 'money-balance-card-apy-tag-skeleton', + ADD_BUTTON: 'money-balance-card-add-button', + GET_STARTED_BUTTON: 'money-balance-card-get-started-button', +} as const; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx new file mode 100644 index 000000000000..57fbf86673fe --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -0,0 +1,204 @@ +import React, { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, + ButtonVariant, + FontWeight, + IconColor, + IconName, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useStyles } from '../../../../../component-library/hooks'; +import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import styleSheet from './MoneyBalanceCard.styles'; +import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; + +const EMPTY_BALANCE_DISPLAY = '$0.00'; + +const MoneyBalanceCard = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + const { + totalFiatRaw, + totalFiatFormatted, + apyPercent, + isAggregatedBalanceLoading, + vaultApyQuery, + } = useMoneyAccountBalance(); + const hasSeenOnboarding = useSelector(selectMusdConversionEducationSeen); + + const isEmpty = totalFiatRaw === undefined || totalFiatRaw === '0'; + const isNewUser = isEmpty && !hasSeenOnboarding; + + let balanceText: string; + let buttonVariant: ButtonVariant; + let buttonLabel: string; + let buttonTestId: string; + let containerTestId: string; + if (isNewUser) { + balanceText = EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Primary; + buttonLabel = strings('homepage.sections.cash_empty_state.get_started'); + buttonTestId = MoneyBalanceCardTestIds.GET_STARTED_BUTTON; + containerTestId = MoneyBalanceCardTestIds.NEW_USER_CONTAINER; + } else if (isEmpty) { + balanceText = EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Primary; + buttonLabel = strings('money.balance_card.add'); + buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; + containerTestId = MoneyBalanceCardTestIds.EMPTY_CONTAINER; + } else { + balanceText = totalFiatFormatted ?? EMPTY_BALANCE_DISPLAY; + buttonVariant = ButtonVariant.Secondary; + buttonLabel = strings('money.balance_card.add'); + buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; + containerTestId = MoneyBalanceCardTestIds.FUNDED_CONTAINER; + } + + const handleCardPress = useCallback(() => { + navigation.navigate(Routes.MONEY.ROOT, { + screen: Routes.MONEY.HOME, + }); + }, [navigation]); + + const handleAddPress = useCallback(() => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }, [navigation]); + + const handleGetStartedPress = useCallback(() => { + navigation.navigate(Routes.EARN.ROOT, { + screen: Routes.EARN.MUSD.CONVERSION_EDUCATION, + params: { + returnTo: { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }, + }, + }); + }, [navigation]); + + const handleButtonPress = isNewUser ? handleGetStartedPress : handleAddPress; + + const handleInfoPress = useCallback(() => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.MONEY_BALANCE_INFO_SHEET, + }); + }, [navigation]); + + return ( + [ + styles.container, + tw.style( + 'flex-row items-center justify-between bg-muted', + pressed && 'opacity-80', + ), + ]} + > + + + + {strings('money.balance_card.label')} + + + + + {isAggregatedBalanceLoading ? ( + + ) : ( + + {balanceText} + + )} + {vaultApyQuery.isLoading ? ( + + ) : ( + + + {strings('money.apy_label', { percentage: apyPercent ?? 0 })} + + + )} + + + + + + + ); +}; + +export default MoneyBalanceCard; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/index.ts b/app/components/UI/Money/components/MoneyBalanceCard/index.ts new file mode 100644 index 000000000000..f9961f83ad5d --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceCard'; +export { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts new file mode 100644 index 000000000000..83a15787e299 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + content: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 16, + backgroundColor: params.theme.colors.background.default, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx new file mode 100644 index 000000000000..1ce76bb0d7a9 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyBalanceInfoSheet from './MoneyBalanceInfoSheet'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); + + const MockBottomSheet = ReactActual.forwardRef( + ( + { + children, + testID, + goBack, + }: { + children: React.ReactNode; + testID?: string; + goBack?: () => void; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + onOpenBottomSheet: jest.fn(), + })); + return ReactActual.createElement( + View, + { testID }, + ReactActual.createElement( + Pressable, + { + testID: 'bottom-sheet-go-back', + onPress: goBack, + }, + ReactActual.createElement(RNText, {}, 'go-back'), + ), + children, + ); + }, + ); + + const MockBottomSheetHeader = ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'bottom-sheet-header' }, + ReactActual.createElement( + Pressable, + { testID: 'bottom-sheet-close-button', onPress: onClose }, + ReactActual.createElement(RNText, {}, 'close'), + ), + children, + ); + + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +describe('MoneyBalanceInfoSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceInfoSheetTestIds.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the sheet title', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.TITLE)).toHaveTextContent( + strings('money.balance_card.info_sheet_title'), + ); + }); + + it('renders the body copy', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.BODY)).toHaveTextContent( + strings('money.balance_card.info_sheet_body'), + ); + }); + + it('closes the sheet when the close button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-close-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('navigates back when the BottomSheet goBack handler is invoked', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-go-back')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts new file mode 100644 index 000000000000..11f47f9297d3 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts @@ -0,0 +1,5 @@ +export const MoneyBalanceInfoSheetTestIds = { + CONTAINER: 'money-balance-info-sheet-container', + TITLE: 'money-balance-info-sheet-title', + BODY: 'money-balance-info-sheet-body', +} as const; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx new file mode 100644 index 000000000000..3b3ec1a9841e --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx @@ -0,0 +1,56 @@ +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + BottomSheetHeader, + type BottomSheetRef, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './MoneyBalanceInfoSheet.styles'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; + +const MoneyBalanceInfoSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('money.balance_card.info_sheet_title')} + + + + + {strings('money.balance_card.info_sheet_body')} + + + + ); +}; + +export default MoneyBalanceInfoSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts new file mode 100644 index 000000000000..9bc437212a2f --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceInfoSheet'; +export { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; diff --git a/app/components/UI/Money/routes/index.test.tsx b/app/components/UI/Money/routes/index.test.tsx index ecccc38fa579..dd3ea38c2574 100644 --- a/app/components/UI/Money/routes/index.test.tsx +++ b/app/components/UI/Money/routes/index.test.tsx @@ -96,4 +96,12 @@ describe('MoneyModalStack', () => { expect(getByTestId('money-screen-MoneyAddMoneySheet')).toBeOnTheScreen(); }); + + it('registers the Money balance info sheet as a modal screen', () => { + const { getByTestId } = renderWithProvider(, { + theme: themeWithCustomBackground, + }); + + expect(getByTestId('money-screen-MoneyBalanceInfoSheet')).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index 71d5b7db40d7..26334a35bf1a 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -12,6 +12,7 @@ import MoneyMoreSheet from '../components/MoneyMoreSheet'; import MoneyTransferSheet from '../components/MoneyTransferSheet'; import MoneyApyInfoSheet from '../components/MoneyApyInfoSheet'; import MoneyEarningsInfoSheet from '../components/MoneyEarningsInfoSheet'; +import MoneyBalanceInfoSheet from '../components/MoneyBalanceInfoSheet'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -84,6 +85,11 @@ const MoneyModalStack = () => ( component={MoneyEarningsInfoSheet} options={{ headerShown: false }} /> + ); diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx index 3e51674603e0..4ce1725523e0 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx @@ -80,20 +80,6 @@ jest.mock('./CashGetMusdEmptyState', () => { }; }); -jest.mock('../../../../UI/Money/components/MoneyAccountHomeRow', () => { - const { Text } = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - __esModule: true, - default: () => - ReactActual.createElement( - Text, - { testID: 'money-account-home-row' }, - 'MoneyAccountHomeRow', - ), - }; -}); - describe('CashSection', () => { beforeEach(() => { jest.clearAllMocks(); @@ -159,32 +145,16 @@ describe('CashSection', () => { ); }); - it('renders MoneyAccountHomeRow when Money home screen flag is enabled', () => { - jest - .requireMock('../../../../UI/Money/selectors/featureFlags') - .selectMoneyHomeScreenEnabledFlag.mockReturnValue(true); - - renderWithProvider( - , - ); - - expect(screen.getByTestId('money-account-home-row')).toBeOnTheScreen(); - }); - - it('navigates to Money home screen when Money home screen flag is enabled', () => { + it('returns null when Money home screen flag is enabled', () => { jest .requireMock('../../../../UI/Money/selectors/featureFlags') .selectMoneyHomeScreenEnabledFlag.mockReturnValue(true); - renderWithProvider( + const { queryByText } = renderWithProvider( , ); - fireEvent.press(screen.getByText('Money')); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.ROOT, { - screen: Routes.MONEY.HOME, - }); + expect(queryByText('Money')).toBeNull(); }); it('shows Get mUSD empty state when user has no mUSD balance', () => { diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx index 28f7344993fe..d36cd07e78a6 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx @@ -19,7 +19,6 @@ import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selec import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; import { useMusdBalance } from '../../../../UI/Earn/hooks/useMusdBalance'; import { selectMoneyHomeScreenEnabledFlag } from '../../../../UI/Money/selectors/featureFlags'; -import MoneyAccountHomeRow from '../../../../UI/Money/components/MoneyAccountHomeRow'; import MusdAggregatedRow from './MusdAggregatedRow'; import { useCashNavigation } from './useCashNavigation'; @@ -49,7 +48,8 @@ const CashSection = forwardRef( const { hasMusdBalanceOnAnyChain } = useMusdBalance(); const { navigateToCash } = useCashNavigation(); - const isCashSectionEnabled = isMusdConversionEnabled && isGeoEligible; + const isCashSectionEnabled = + isMusdConversionEnabled && isGeoEligible && !isMoneyHomeEnabled; const { onLayout } = useHomeViewedEvent({ sectionRef: sectionViewRef, @@ -76,8 +76,12 @@ const CashSection = forwardRef( useImperativeHandle(ref, () => ({ refresh }), [refresh]); if (!isCashSectionEnabled) { + let reason = 'flag_off'; + if (isMusdConversionEnabled) { + reason = !isGeoEligible ? 'geo_ineligible' : 'money_home_on'; + } Logger.log( - `[CashSection] not rendered flag=${isMusdConversionEnabled} geo=${isGeoEligible} reason=${!isMusdConversionEnabled ? 'flag_off' : 'geo_ineligible'}`, + `[CashSection] not rendered flag=${isMusdConversionEnabled} geo=${isGeoEligible} moneyHome=${isMoneyHomeEnabled} reason=${reason}`, ); return null; } @@ -88,11 +92,7 @@ const CashSection = forwardRef( - {isMoneyHomeEnabled ? ( - - - - ) : !hasMusdBalanceOnAnyChain ? ( + {!hasMusdBalanceOnAnyChain ? ( diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 1a0faedde64e..db08fc54a8f7 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -91,6 +91,31 @@ jest.mock('../../../selectors/featureFlagController/homepage', () => ({ selectHomepageSectionsV1Enabled: jest.fn(() => mockHomepageSectionsEnabled), })); +// Control Money home screen feature flag per test (default false so existing tests are unaffected) +let mockMoneyHomeScreenEnabled = false; +jest.mock('../../UI/Money/selectors/featureFlags', () => ({ + selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), +})); + +// Mock MoneyBalanceCard so the integration test does not depend on its hooks/contexts. +jest.mock('../../UI/Money/components/MoneyBalanceCard', () => { + const ReactMock = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement(View, { + testID: 'money-balance-card-mock', + }), + }; +}); + +// Mock NetworkConnectionBanner so the Wallet view's render does not depend on +// Engine.lookupEnabledNetworks / NetworkController / controllerMessenger APIs. +// Without this, the banner hook throws during render and the ErrorBoundary +// swallows the failure, making negative-assert tests pass for the wrong reason. +jest.mock('../../UI/NetworkConnectionBanner', () => () => null); + // Control discovery tabs AB test variant per test (default control so existing tests are unaffected) let mockDiscoveryTabsVariantName = 'control'; jest.mock('../../../hooks', () => ({ @@ -1879,3 +1904,59 @@ describe('useHomeDeepLinkEffects', () => { assertCase(mocks); }); }); + +describe('MoneyBalanceCard slot', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = false; + }); + + it('renders the MoneyBalanceCard when both feature flags are enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockHomepageSectionsEnabled = true; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { getByTestId } = render(Wallet); + + expect(getByTestId('money-balance-card-mock')).toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when only the Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockHomepageSectionsEnabled = false; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when only the Homepage sections flag is enabled', () => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = true; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when both feature flags are disabled', () => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = false; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index ab5d51d24560..582e005ad6ab 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -56,6 +56,7 @@ import PickerAccount from '../../../component-library/components/Pickers/PickerA import AddressCopy from '../../UI/AddressCopy'; import CardButton from '../../UI/Card/components/CardButton'; import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import MoneyBalanceCard from '../../UI/Money/components/MoneyBalanceCard'; import { createAccountSelectorNavDetails } from '../AccountSelector'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; @@ -1381,6 +1382,7 @@ const Wallet = ({ receiveButtonActionID={WalletViewSelectorsIDs.WALLET_RECEIVE_BUTTON} /> {isCarouselBannersEnabled && } + {isMoneyHomeScreenEnabled && } ); @@ -1402,6 +1404,7 @@ const Wallet = ({ receiveButtonActionID={WalletViewSelectorsIDs.WALLET_RECEIVE_BUTTON} /> {isCarouselBannersEnabled && } + {isMoneyHomeScreenEnabled && } ); diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 0593e003de7a..c2ec31e80f80 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -435,6 +435,7 @@ const Routes = { TRANSFER_MONEY_SHEET: 'MoneyTransferSheet', APY_INFO_SHEET: 'MoneyApyInfoSheet', EARNINGS_INFO_SHEET: 'MoneyEarningsInfoSheet', + MONEY_BALANCE_INFO_SHEET: 'MoneyBalanceInfoSheet', }, }, FULL_SCREEN_CONFIRMATIONS: { diff --git a/locales/languages/en.json b/locales/languages/en.json index 4894a091aaad..5d16d9730ae2 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6556,6 +6556,12 @@ "musd_row": { "add": "Add" }, + "balance_card": { + "label": "Money balance", + "add": "Add", + "info_sheet_title": "Money Home", + "info_sheet_body": "Your dollar-backed balance, always available. Spend it, send it, or trade it anytime. We don't calculate this into your total account balance." + }, "potential_earnings": { "title": "Earn on your crypto", "description": "See how your money can grow over time by converting your crypto to mUSD.", From 8d9bcda3ba842abaf565a43a06c04ad5e6208d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 6 May 2026 22:04:19 +0100 Subject: [PATCH 24/28] chore: What's Happening UI/UX polish (#29782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Four small UI/UX fixes for the What's Happening feature: 1. Home carousel card date visibility 2. Detail-view header size 3. Expanded card height consistency 4. in Tokens row the Buy button switched to Trade button, and now users should always be directed to Perps trade. When a token asset has an hlPerpsMarket entry the row now shows a Trade button navigating to Perps market details; otherwise it falls back to the existing Buy/Ramp flow. Simulator Screenshot - iPhone 17 Pro - 2026-05-06
at 12 23 11 Simulator Screenshot - iPhone 17 Pro - 2026-05-06
at 12 23 02 Simulator Screenshot - iPhone 17 Pro - 2026-05-06
at 12 01 23 ## **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] > **Medium Risk** > Medium risk because it changes navigation behavior for token action buttons (routing some assets to Perps) and alters carousel/card layout rendering based on runtime measurement, which could affect visibility/scroll positioning across devices. > > **Overview** > Polishes What’s Happening UI by slightly increasing home carousel card heights, reducing card title lines to preserve footer/date visibility, and resizing the “View more” card to match. > > Updates the detail view header to a custom, smaller layout and makes the expanded-card carousel use a fixed measured `cardHeight`, gating card rendering and initial scroll positioning until layout is known; tests now simulate `onLayout` to validate rendering. > > Changes related-asset actions so token rows show **Trade** (navigating to Perps market details) when `hlPerpsMarket` is present, via new `useTradeNavigation`; `PerpsRow` is simplified to reuse the same hook, and `AssetRow` migrates to the design-system `Button` component. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7edb17219e5ee02ef52029314dcc3e192d262823. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../WhatsHappening/WhatsHappeningSection.tsx | 2 +- .../components/WhatsHappeningCard.tsx | 4 +- .../WhatsHappeningDetailView.test.tsx | 9 +- .../WhatsHappeningDetailView.tsx | 73 ++++++++++----- .../components/AssetRow.tsx | 15 +-- .../components/PerpsRow.tsx | 21 +---- .../components/TokenRow.test.tsx | 77 ++++++++++----- .../components/TokenRow.tsx | 16 +++- .../WhatsHappeningExpandedCard.test.tsx | 79 +++++++++++++--- .../components/WhatsHappeningExpandedCard.tsx | 93 ++++++++++--------- .../hooks/useTradeNavigation.ts | 39 ++++++++ 11 files changed, 287 insertions(+), 141 deletions(-) create mode 100644 app/components/Views/WhatsHappeningDetailView/hooks/useTradeNavigation.ts diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx index 1394aad43259..1fa9d6fdeca0 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx @@ -166,7 +166,7 @@ const WhatsHappeningSection = forwardRef< ))} diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx index 34ab48a84fbc..f8d8f42975d6 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -38,7 +38,7 @@ const WhatsHappeningCard: React.FC = ({ onPress={handlePress} activeOpacity={0.7} style={tw.style( - 'w-[280px] h-[248px] rounded-2xl bg-background-muted overflow-hidden p-4 justify-between gap-3', + 'w-[280px] h-[254px] rounded-2xl bg-background-muted overflow-hidden p-4 justify-between gap-3', )} > @@ -83,7 +83,7 @@ const WhatsHappeningCard: React.FC = ({ variant={TextVariant.BodyMd} fontWeight={FontWeight.Medium} color={TextColor.TextDefault} - numberOfLines={3} + numberOfLines={2} > {item.title} diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx index be5bc016e771..8da2db4bd1f3 100644 --- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx @@ -113,9 +113,12 @@ describe('WhatsHappeningDetailView', () => { refresh: mockRefresh, }); renderWithProvider(); - expect( - screen.getByTestId('whats-happening-detail-carousel'), - ).toBeOnTheScreen(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + expect(carousel).toBeOnTheScreen(); + // Simulate the carousel measuring its height so cards become visible + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); expect(screen.getByText(mockItem.title)).toBeOnTheScreen(); }); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx index 97a32e15dfab..562223eb544f 100644 --- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Dimensions, + LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, SafeAreaView, @@ -10,10 +11,14 @@ import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, + BoxAlignItems, + BoxFlexDirection, ButtonIcon, ButtonIconSize, - HeaderBase, + FontWeight, IconName, + Text, + TextVariant, } from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import { useWhatsHappening } from '../Homepage/Sections/WhatsHappening/hooks'; @@ -45,22 +50,33 @@ const WhatsHappeningDetailView = () => { const route = useRoute>(); - const { initialIndex = 0 } = route.params; + const initialIndex = route.params?.initialIndex ?? 0; const { items, isLoading, error, refresh } = useWhatsHappening(MAX_ITEMS_DISPLAYED); const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [cardHeight, setCardHeight] = useState(0); const scrollViewRef = useRef(null); + const handleCarouselLayout = useCallback((e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (height > 0) setCardHeight(height); + }, []); + useEffect(() => { - if (initialIndex > 0 && scrollViewRef.current && !isLoading) { + if ( + initialIndex > 0 && + cardHeight > 0 && + scrollViewRef.current && + !isLoading + ) { scrollViewRef.current.scrollTo({ x: initialIndex * SNAP_INTERVAL, animated: false, }); } - }, [initialIndex, isLoading]); + }, [initialIndex, isLoading, cardHeight]); const handleBackPress = useCallback(() => { navigation.goBack(); @@ -79,20 +95,24 @@ const WhatsHappeningDetailView = () => { return ( - - } - style={tw`p-4`} - twClassName="h-auto" + - {strings('homepage.sections.whats_happening')} - + + + + {strings('homepage.sections.whats_happening')} + + + + {isLoading ? ( @@ -125,17 +145,20 @@ const WhatsHappeningDetailView = () => { snapToInterval={SNAP_INTERVAL} snapToAlignment="start" style={tw`flex-1`} - contentContainerStyle={tw.style(`px-4 gap-3 items-stretch`)} + contentContainerStyle={tw.style('px-4 gap-3')} + onLayout={handleCarouselLayout} onMomentumScrollEnd={handleScrollEnd} testID="whats-happening-detail-carousel" > - {items.map((item) => ( - - ))} + {cardHeight > 0 && + items.map((item) => ( + + ))} diff --git a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx index 7fc2de6e5e73..381404af3946 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx @@ -6,8 +6,9 @@ import { BoxAlignItems, BoxFlexDirection, BoxJustifyContent, - ButtonBase, - ButtonBaseSize, + Button, + ButtonSize, + ButtonVariant, FontWeight, Text, TextColor, @@ -25,7 +26,7 @@ interface AssetRowProps { /** * Shared layout for a single asset row (logo + symbol + action button). - * Used by TokenRow (Buy) and PerpsRow (Trade); each wrapper supplies its + * Used by TokenRow (Buy/Trade) and PerpsRow (Trade); each wrapper supplies its * own hook logic and passes the resolved label and handler here. */ const AssetRow: React.FC = ({ @@ -66,14 +67,14 @@ const AssetRow: React.FC = ({ {asset.symbol} - {actionLabel} - + ); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx index 7bf753575041..6a25fd84b10d 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -1,11 +1,8 @@ -import React, { useCallback } from 'react'; -import { useNavigation, NavigationProp } from '@react-navigation/native'; -import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import React from 'react'; import type { RelatedAsset } from '@metamask/ai-controllers'; -import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; -import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; interface PerpsRowProps { asset: RelatedAsset; @@ -18,19 +15,7 @@ interface PerpsRowProps { * be called per-asset (hooks cannot be called inside a loop). */ const PerpsRow: React.FC = ({ asset }) => { - const navigation = useNavigation>(); - const hlPerpsMarket = asset.hlPerpsMarket?.[0]; - - const handleTrade = useCallback(() => { - if (!hlPerpsMarket) return; - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market: { symbol: hlPerpsMarket, name: asset.name }, - source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, - }, - }); - }, [navigation, hlPerpsMarket, asset.name]); + const { handleTrade } = useTradeNavigation(asset); return ( ({ useRampNavigation: () => ({ goToBuy: mockGoToBuy }), })); +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + jest.mock('../utils/getRelatedAssetImageSource', () => ({ getRelatedAssetImageSource: jest.fn(() => undefined), })); @@ -21,12 +31,12 @@ const btcAsset: RelatedAsset = { caip19: ['eip155:1/slip44:0'], }; -const perpsOnlyAsset: RelatedAsset = { - sourceAssetId: 'tsla', - symbol: 'TSLA', - name: 'Tesla', - caip19: [], - hlPerpsMarket: ['xyz:TSLA'], +const dualAsset: RelatedAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], }; describe('TokenRow', () => { @@ -34,27 +44,48 @@ describe('TokenRow', () => { jest.clearAllMocks(); }); - it('renders the asset symbol', () => { - renderWithProvider(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - }); + describe('when asset has only caip19 (no hlPerpsMarket)', () => { + it('renders the asset symbol', () => { + renderWithProvider(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); - it('renders the Buy button', () => { - renderWithProvider(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); - }); + it('renders the Buy button', () => { + renderWithProvider(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); - it('calls goToBuy with the first caip19 identifier on Buy press', () => { - renderWithProvider(); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: 'eip155:1/slip44:0', + it('calls goToBuy with the first caip19 identifier on Buy press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: 'eip155:1/slip44:0', + }); }); }); - it('calls goToBuy with assetId undefined when caip19 is empty', () => { - renderWithProvider(); - fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); + describe('when asset has hlPerpsMarket (dual asset)', () => { + it('renders the Trade button instead of Buy', () => { + renderWithProvider(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); + }); + + it('navigates to Perps market details on Trade press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'ETH', name: 'Ethereum' }, + }), + }); + }); + + it('does not call goToBuy when Trade is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx index de0076fd2ab2..26394755c5bc 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx @@ -3,6 +3,7 @@ import type { RelatedAsset } from '@metamask/ai-controllers'; import { strings } from '../../../../../locales/i18n'; import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; interface TokenRowProps { asset: RelatedAsset; @@ -10,18 +11,31 @@ interface TokenRowProps { /** * A single row in the Tokens section of the expanded What's Happening card. - * Displays the token logo, symbol, and a Buy button that navigates to the + * Shows a Trade button (navigating to Perps) when the asset has an + * `hlPerpsMarket` entry; otherwise falls back to a Buy button that opens the * Ramp buy flow. Extracted as its own component so hooks can be called * per-asset (hooks cannot be called inside a loop). */ const TokenRow: React.FC = ({ asset }) => { const { goToBuy } = useRampNavigation(); + const { handleTrade, canTrade } = useTradeNavigation(asset); const handleBuy = useCallback(() => { const assetId = asset.caip19?.[0]; goToBuy({ assetId }); }, [goToBuy, asset.caip19]); + if (canTrade) { + return ( + + ); + } + return ( { it('renders the title and description', () => { renderWithProvider( - , + , ); expect(screen.getByText(baseItem.title)).toBeOnTheScreen(); expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); @@ -86,7 +91,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders the impact badge for positive impact', () => { renderWithProvider( - , + , ); expect(screen.getByText('Bullish')).toBeOnTheScreen(); }); @@ -94,7 +103,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Neutral badge when impact is explicitly neutral', () => { const item = { ...baseItem, impact: 'neutral' as const }; renderWithProvider( - , + , ); expect(screen.getByText('Neutral')).toBeOnTheScreen(); }); @@ -102,7 +115,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render an impact badge when impact is undefined', () => { const item = { ...baseItem, impact: undefined }; renderWithProvider( - , + , ); expect(screen.queryByText('Neutral')).toBeNull(); expect(screen.queryByText('Bullish')).toBeNull(); @@ -112,7 +129,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Tokens section when assets have caip19', () => { const item = { ...baseItem, relatedAssets: [tokenAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); expect(screen.getByText('BTC')).toBeOnTheScreen(); @@ -122,7 +143,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render Tokens section when no assets have caip19', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.queryByText('Tokens')).toBeNull(); expect(screen.queryByText('Buy')).toBeNull(); @@ -131,7 +156,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders Perps section when assets have hlPerpsMarket', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Perps')).toBeOnTheScreen(); expect(screen.getByText('TSLA')).toBeOnTheScreen(); @@ -141,7 +170,11 @@ describe('WhatsHappeningExpandedCard', () => { it('does not render Perps section when no assets have hlPerpsMarket', () => { const item = { ...baseItem, relatedAssets: [tokenAsset] }; renderWithProvider( - , + , ); expect(screen.queryByText('Perps')).toBeNull(); expect(screen.queryByText('Trade')).toBeNull(); @@ -150,7 +183,11 @@ describe('WhatsHappeningExpandedCard', () => { it('renders both Tokens and Perps sections when there are separate token and perps-only assets', () => { const item = { ...baseItem, relatedAssets: [tokenAsset, perpsOnlyAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); expect(screen.getByText('Perps')).toBeOnTheScreen(); @@ -158,20 +195,28 @@ describe('WhatsHappeningExpandedCard', () => { expect(screen.getByText('Trade')).toBeOnTheScreen(); }); - it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section', () => { + it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section, shows Trade for the token row', () => { const item = { ...baseItem, relatedAssets: [dualAsset] }; renderWithProvider( - , + , ); expect(screen.getByText('Tokens')).toBeOnTheScreen(); - expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); expect(screen.queryByText('Perps')).toBeNull(); - expect(screen.queryByText('Trade')).toBeNull(); }); it('renders neither section when relatedAssets is empty', () => { renderWithProvider( - , + , ); expect(screen.queryByText('Tokens')).toBeNull(); expect(screen.queryByText('Perps')).toBeNull(); @@ -180,7 +225,11 @@ describe('WhatsHappeningExpandedCard', () => { it('Trade button navigates to PerpsMarketDetails', () => { const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; renderWithProvider( - , + , ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx index 044a65e72ebd..8359a15efcf9 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -31,11 +31,14 @@ import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet interface WhatsHappeningExpandedCardProps { item: WhatsHappeningItem; cardWidth: number; + /** Height of the carousel container — used to give every card the same fixed height. */ + cardHeight: number; } const WhatsHappeningExpandedCard: React.FC = ({ item, cardWidth, + cardHeight, }) => { const tw = useTailwind(); const [sourcesVisible, setSourcesVisible] = useState(false); @@ -61,17 +64,15 @@ const WhatsHappeningExpandedCard: React.FC = ({ }, [uniqueSources]); return ( - - - + {/* Card surface — fills the fixed height so all cards are the same size */} + + {/* Scrollable main content */} + - {/* Impact badge — only rendered when impact is explicitly set */} + {/* Impact badge */} {item.impact && ( @@ -99,7 +100,7 @@ const WhatsHappeningExpandedCard: React.FC = ({ )} - {/* Tokens section — only assets with a purchasable CAIP-19 identifier */} + {/* Tokens section */} {item.relatedAssets.some((asset) => asset.caip19?.length) && ( = ({ ))} )} + - {/* Sources trigger */} - {uniqueSources.length > 0 && ( - <> - + {/* Fixed sources footer — always pinned to the bottom of the card */} + {uniqueSources.length > 0 && ( + + - setSourcesVisible(true)} - accessibilityRole="button" - > - {({ pressed }) => ( + setSourcesVisible(true)} + accessibilityRole="button" + > + {({ pressed }) => ( + - - - {sourceLabel ? ( - - {sourceLabel} - - ) : null} - - - {item.date ? ( + + {sourceLabel ? ( - {formatRelativeTime(item.date, { nowLabel: 'now' })} + {sourceLabel} ) : null} - )} - - - )} - - + + {item.date ? ( + + {formatRelativeTime(item.date, { nowLabel: 'now' })} + + ) : null} + + )} + + + )} + {sourcesVisible && ( void; + /** True when the asset has at least one `hlPerpsMarket` entry. */ + canTrade: boolean; +} + +/** + * Provides a stable `handleTrade` callback and a `canTrade` flag for an asset. + * `handleTrade` is always a valid function — it is a no-op when the asset has + * no `hlPerpsMarket` entry. Use `canTrade` to decide whether to show a Trade + * button at all. + */ +const useTradeNavigation = (asset: RelatedAsset): UseTradeNavigationResult => { + const navigation = useNavigation>(); + const hlPerpsMarket = asset.hlPerpsMarket?.[0]; + + const handleTrade = useCallback(() => { + if (!hlPerpsMarket) return; + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: hlPerpsMarket, name: asset.name }, + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }, + }); + }, [navigation, hlPerpsMarket, asset.name]); + + return { handleTrade, canTrade: Boolean(hlPerpsMarket) }; +}; + +export default useTradeNavigation; From 33eaccfd7e77450175c124f1a2018eaf3d7de394 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Wed, 6 May 2026 17:10:24 -0400 Subject: [PATCH 25/28] feat: use account API v4 transactions (#29536) ## **Description** - Replaces confirmed EVM Activity transactions with data from accounts v4 API via React Query - Adds infinite pagination for confirmed EVM history - Keep local pending EVM transactions and existing non-EVM activity unchanged Note: Due to the API requesting a bearer token, there is a current bottleneck in that token retrieval, in particular in `AuthenticationController.getPrimaryEntropySourceId` ## **Changelog** CHANGELOG entry: feat: use accounts API v4 for transactions ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Accounts API v4 Transactions Scenario: user views EVM activity Given the wallet has EVM confirmed on-chain activity When user visits the Activity tab Then confirmed onchain transactions returned by the Accounts v4 API is displayed on screen. ``` ## **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** > Moderate risk because it rewires the Activity/UnifiedTransactionsView data source and filtering/deduping logic, which can change what transactions appear and when pagination/refresh occurs. > > **Overview** > **Confirmed EVM Activity now comes from the Accounts v4 API** via a new React Query `useTransactionsQuery` hook, while local pending EVM transactions continue to come from controller state and are merged/deduped with the API results. > > Adds a small transformation layer (`helpers/adapters` + `helpers/transformations`) to normalize API responses into `TransactionMeta`-compatible view models, filter out unwanted items (e.g. spam/incoming transfers/zero-value self-sends), and handle bridge-history matching/deduping (including case-insensitive hash matching). > > Updates `UnifiedTransactionsView` to support infinite scrolling pagination (prefetch near the end of confirmed EVM items), show initial/next-page loading indicators, and refresh both local polling and the query. Related selector additions (`selectLocalTransactions`, `selectRequiredTransactionHashes/Ids`) support filtering required child txs and nonce/hash collisions, and tests/smoke mocks were updated accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a3e6592c8ff5b9714bb0c09fd09bf11ef6e1b34b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Copilot --- .../MultichainBridgeTransactionListItem.tsx | 4 + .../MultichainTransactionListItem.tsx | 4 + app/components/UI/TransactionElement/index.js | 47 +- .../UnifiedTransactionsView.test.tsx | 448 ++++++++++++++---- .../UnifiedTransactionsView.tsx | 405 +++++++--------- .../helpers/adapters.test.ts | 172 +++++++ .../helpers/adapters.ts | 138 ++++++ .../helpers/transformations.test.ts | 255 ++++++++++ .../helpers/transformations.ts | 227 +++++++++ .../Views/UnifiedTransactionsView/types.ts | 32 ++ .../useTransactionsQuery.test.ts | 135 ++++++ .../useTransactionsQuery.ts | 40 ++ app/core/apiClient.test.ts | 56 +++ app/core/apiClient.ts | 13 + app/selectors/transactionController.test.ts | 211 +++++++++ app/selectors/transactionController.ts | 103 ++++ .../bridge/hooks/useBridgeTxHistoryData.ts | 16 +- .../useBridgeTxHistoryData.test.ts | 29 ++ app/util/string/index.test.ts | 24 + app/util/string/index.ts | 10 + .../wallet/incoming-transactions.spec.ts | 29 +- 21 files changed, 2051 insertions(+), 347 deletions(-) create mode 100644 app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts create mode 100644 app/components/Views/UnifiedTransactionsView/helpers/adapters.ts create mode 100644 app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts create mode 100644 app/components/Views/UnifiedTransactionsView/helpers/transformations.ts create mode 100644 app/components/Views/UnifiedTransactionsView/types.ts create mode 100644 app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts create mode 100644 app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts create mode 100644 app/core/apiClient.test.ts create mode 100644 app/core/apiClient.ts diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx index ab09e4229295..1469dbcb56d0 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx @@ -27,6 +27,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import { parseCaipAssetType } from '@metamask/utils'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -102,10 +103,13 @@ const MultichainBridgeTransactionListItem = ({ const networkImageSource = getNetworkImageSource({ chainId }); return ( } > diff --git a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx index 24b416ef2207..ce4454228a7d 100644 --- a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx +++ b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx @@ -21,6 +21,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import Routes from '../../../constants/navigation/Routes'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -91,10 +92,13 @@ const MultichainTransactionListItem = ({ return ( } > diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index ae7597c6e8e7..55bbda471ab5 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -49,6 +49,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { NetworkBadgeSource } from '../AssetOverview/Balance/Balance'; import { getFontFamily, @@ -101,6 +102,10 @@ const createStyles = (colors, typography) => width: 32, height: 32, }, + iconBadgePosition: { + bottom: -4, + right: -4, + }, importText: { color: colors.text.alternative, fontSize: 14, @@ -271,13 +276,13 @@ class TransactionElement extends PureComponent { mounted = false; componentDidMount = async () => { + this.mounted = true; const [transactionElement, transactionDetails] = await decodeTransaction({ ...this.props, swapsTransactions: this.props.swapsTransactions, assetSymbol: this.props.assetSymbol, ticker: this.props.ticker, }); - this.mounted = true; this.mounted && this.setState({ transactionElement, transactionDetails }); }; @@ -469,10 +474,13 @@ class TransactionElement extends PureComponent { return ( } > @@ -715,6 +723,35 @@ class TransactionElement extends PureComponent { ); }; + renderPendingElement = () => { + const { i, tx } = this.props; + const { colors, typography } = this.context || mockTheme; + const styles = createStyles(colors, typography); + + return ( + + + {this.renderTxTime()} + + + + + + + + ... + + + + + + ); + }; + render() { const { tx, selectedInternalAccount } = this.props; const { transactionElement, transactionDetails } = this.state; @@ -722,7 +759,7 @@ class TransactionElement extends PureComponent { const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); - if (!transactionElement || !transactionDetails) return null; + const isReady = Boolean(transactionElement && transactionDetails); const accountImportTime = selectedInternalAccount?.metadata.importTime; const { time } = tx; @@ -734,11 +771,13 @@ class TransactionElement extends PureComponent { style={ this.props.showBottomBorder ? styles.rowWithBorder : styles.row } - onPress={this.onPressItem} + onPress={isReady ? this.onPressItem : undefined} underlayColor={colors.background.alternative} activeOpacity={1} > - {this.renderTxElement(transactionElement)} + {isReady + ? this.renderTxElement(transactionElement) + : this.renderPendingElement()} {accountImportTime <= time && this.renderImportTime()} diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index 1013058594aa..7c72da882b12 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -1,16 +1,45 @@ import React, { ComponentType } from 'react'; import { RefreshControl } from 'react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; import { TransactionStatus } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import UnifiedTransactionsView from './UnifiedTransactionsView'; -import renderWithProvider from '../../../util/test/renderWithProvider'; +import _renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { updateIncomingTransactions } from '../../../util/transaction-controller'; import { useUnifiedTxActions } from './useUnifiedTxActions'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { selectTransactions } from './helpers/transformations'; // Type helper for UNSAFE_queryByType with mocked string components const asComponentType = (name: string) => name as unknown as ComponentType; +type TransactionsQueryData = ReturnType>; + +const emptyTransactionsQueryData: TransactionsQueryData = { + pageParams: [], + pages: [], +}; + +const createUseTransactionsQueryResult = ( + data: TransactionsQueryData = emptyTransactionsQueryData, +) => ({ + data, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + refetch: jest.fn().mockResolvedValue(undefined), +}); + +const mockUseTransactionsQuery = ( + data: TransactionsQueryData = emptyTransactionsQueryData, +) => { + (useTransactionsQuery as jest.Mock).mockReturnValue( + createUseTransactionsQueryResult(data), + ); +}; + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -77,6 +106,10 @@ jest.mock('./useUnifiedTxActions', () => ({ useUnifiedTxActions: jest.fn(() => mockDefaultUnifiedTxActionsReturn), })); +jest.mock('./useTransactionsQuery', () => ({ + useTransactionsQuery: jest.fn(() => createUseTransactionsQueryResult()), +})); + jest.mock('./useTransactionAutoScroll', () => ({ useTransactionAutoScroll: () => ({ handleScroll: jest.fn(), @@ -185,6 +218,33 @@ jest.mock( }), ); +const renderWithProvider = ( + component: React.ReactElement, + providerValues?: Parameters[1], + includeNavigationContainer?: Parameters[2], + includeFeatureFlagOverrideProvider?: Parameters< + typeof _renderWithProvider + >[3], +) => + _renderWithProvider( + + {component} + , + providerValues, + includeNavigationContainer, + includeFeatureFlagOverrideProvider, + ); + describe('UnifiedTransactionsView', () => { const initialState = { engine: { @@ -194,6 +254,7 @@ describe('UnifiedTransactionsView', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -319,6 +380,13 @@ describe('UnifiedTransactionsView', () => { }); describe('UnifiedTransactionsView with transactions', () => { + const ACTIVE_EVM_ADDRESS = '0x0000000000000000000000000000000000000abc'; + const BRIDGE_TX_HASH = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const OTHER_TX_HASH = + '0x1111111111111111111111111111111111111111111111111111111111111111'; + const BRIDGE_TX_ID = 'bridge-tx-id'; + const stateWithTransactions = { engine: { backgroundState: { @@ -344,8 +412,154 @@ describe('UnifiedTransactionsView with transactions', () => { }, }; + const stateWithConfirmedBridgeTransaction = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: { + ...backgroundState.AccountsController, + internalAccounts: { + accounts: { + 'evm-account-id': { + id: 'evm-account-id', + type: 'eip155:eoa' as const, + address: ACTIVE_EVM_ADDRESS, + options: {}, + methods: [], + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + }, + }, + }, + selectedAccount: 'evm-account-id', + }, + }, + TransactionController: { + ...backgroundState.TransactionController, + transactions: [ + { + id: BRIDGE_TX_ID, + chainId: '0x1' as const, + hash: BRIDGE_TX_HASH, + status: TransactionStatus.confirmed, + time: Date.now(), + txParams: { + from: ACTIVE_EVM_ADDRESS, + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + nonce: '0x1', + }, + }, + ], + }, + }, + }, + }; + + const createConfirmedEvmQueryData = ( + transactions: V1TransactionByHashResponse[] = [], + ) => + selectTransactions({ + address: ACTIVE_EVM_ADDRESS, + })({ + pageParams: [undefined], + pages: [ + { + data: transactions, + unprocessedNetworks: [], + pageInfo: { + count: transactions.length, + endCursor: undefined, + hasNextPage: false, + }, + }, + ], + }); + + const createConfirmedBridgeTransaction = (hash = BRIDGE_TX_HASH) => + createConfirmedEvmQueryData([ + { + accountId: `eip155:1:${ACTIVE_EVM_ADDRESS}`, + blockHash: '0xblock', + blockNumber: 1, + chainId: 1, + cumulativeGasUsed: 21000, + effectiveGasPrice: '1', + from: ACTIVE_EVM_ADDRESS, + gas: 21000, + gasPrice: '1', + gasUsed: 21000, + hash, + isError: false, + logs: [], + methodId: '0x', + nonce: 1, + readable: 'Transfer', + timestamp: '2026-04-29T19:28:41.000Z', + to: '0x1111111111111111111111111111111111111111', + transactionCategory: 'TRANSFER', + transactionType: 'SIMPLE_SEND', + value: '1', + valueTransfers: [], + } as V1TransactionByHashResponse, + ]); + + const bridgeHistory = { + [BRIDGE_TX_ID]: { + txMetaId: BRIDGE_TX_ID, + account: ACTIVE_EVM_ADDRESS, + quote: { + srcChainId: 1, + destChainId: 8453, + srcAsset: { + symbol: 'ETH', + chainId: 1, + decimals: 18, + address: 'native', + }, + destAsset: { + symbol: 'ETH', + chainId: 8453, + decimals: 18, + address: 'native', + }, + }, + status: { + srcChain: { + txHash: BRIDGE_TX_HASH, + chainId: 1, + amount: '1', + }, + destChain: { + txHash: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + chainId: 8453, + amount: '1', + }, + status: 'COMPLETE', + }, + estimatedProcessingTimeInSeconds: 60, + slippagePercentage: 0, + completionTime: Date.now(), + startTime: Date.now() - 60000, + }, + }; + + const getRenderedTransactionIds = ( + queryAllByType: ReturnType< + typeof renderWithProvider + >['UNSAFE_queryAllByType'], + ) => + queryAllByType(asComponentType('TransactionElement')).map( + ({ props }) => props.tx.id, + ); + + const apiBridgeTransactionId = `${BRIDGE_TX_HASH}-1`; + beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -366,6 +580,39 @@ describe('UnifiedTransactionsView with transactions', () => { ); expect(transactionElements.length).toBeGreaterThanOrEqual(0); }); + + it('uses the Accounts API bridge transaction when the source hash matches bridge history', () => { + mockUseTransactionsQuery(createConfirmedBridgeTransaction()); + mockSelectBridgeHistoryForAccount.mockReturnValue(bridgeHistory); + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithConfirmedBridgeTransaction, + }, + ); + + const transactionIds = getRenderedTransactionIds(UNSAFE_queryAllByType); + + expect(transactionIds).toContain(apiBridgeTransactionId); + expect(transactionIds).not.toContain(BRIDGE_TX_ID); + }); + + it('keeps the local bridge transaction when Accounts API only has a nonce collision', () => { + mockUseTransactionsQuery(createConfirmedBridgeTransaction(OTHER_TX_HASH)); + mockSelectBridgeHistoryForAccount.mockReturnValue(bridgeHistory); + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithConfirmedBridgeTransaction, + }, + ); + + const transactionIds = getRenderedTransactionIds(UNSAFE_queryAllByType); + + expect(transactionIds).toContain(BRIDGE_TX_ID); + }); }); describe('UnifiedTransactionsView - Speed up / Cancel modal', () => { @@ -377,6 +624,7 @@ describe('UnifiedTransactionsView - Speed up / Cancel modal', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -433,6 +681,7 @@ describe('UnifiedTransactionsView - refresh', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -454,135 +703,125 @@ describe('UnifiedTransactionsView - refresh', () => { }); describe('UnifiedTransactionsView - token poisoning protection', () => { - const { - buildTrustedAddressSet: mockBuildTrustedAddressSet, - filterByAddress: mockFilterByAddress, - isTransactionOnChains: mockIsTransactionOnChains, - } = jest.requireMock('../../../util/activity'); - const FRIEND_ADDRESS = '0x1234000000000000000000000000000000000001'; + const ACTIVE_EVM_ADDRESS = '0xabc'; const baseState = { engine: { backgroundState } }; + const createConfirmedEvmTransaction = ( + overrides: Partial = {}, + ) => + ({ + accountId: `eip155:1:${ACTIVE_EVM_ADDRESS}`, + blockHash: '0xblock', + blockNumber: 1, + chainId: 1, + cumulativeGasUsed: 21000, + effectiveGasPrice: '1', + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + gas: 21000, + gasPrice: '1', + gasUsed: 21000, + hash: '0xhash', + isError: false, + logs: [], + methodId: '0x', + nonce: 1, + readable: 'Transfer', + timestamp: '2026-04-29T19:28:41.000Z', + to: ACTIVE_EVM_ADDRESS, + transactionCategory: 'TRANSFER', + transactionType: 'SIMPLE_SEND', + value: '1', + valueTransfers: [], + ...overrides, + }) as V1TransactionByHashResponse; + + const createConfirmedEvmQueryData = ( + transactions: V1TransactionByHashResponse[] = [], + ) => + selectTransactions({ + address: ACTIVE_EVM_ADDRESS, + })({ + pageParams: [undefined], + pages: [ + { + data: transactions, + unprocessedNetworks: [], + pageInfo: { + count: transactions.length, + endCursor: undefined, + hasNextPage: false, + }, + }, + ], + }); + // State with a single incoming ERC-20 transfer from an unknown sender - const stateWithIncomingTransfer = { - engine: { - backgroundState: { - ...backgroundState, - TransactionController: { - ...backgroundState.TransactionController, - transactions: [ - { - id: 'tx-erc20', - chainId: '0x1' as const, - status: TransactionStatus.confirmed, - time: Date.now(), - isTransfer: true, - transferInformation: { - contractAddress: '0x3333333333333333333333333333333333333333', - decimals: 18, - symbol: 'TKN', - }, - txParams: { - from: '0x9999999999999999999999999999999999999999', - to: '0xabc', - value: '0x0', - nonce: '0x1', - }, - }, - ], + const stateWithIncomingTransfer = createConfirmedEvmQueryData([ + createConfirmedEvmTransaction({ + hash: '0xpoison-erc20', + transactionType: 'TOKEN_TRANSFER', + valueTransfers: [ + { + amount: '1', + contractAddress: '0x3333333333333333333333333333333333333333', + decimal: 18, + from: '0x9999999999999999999999999999999999999999', + name: 'Test Token', + symbol: 'TKN', + to: ACTIVE_EVM_ADDRESS, + transferType: 'ERC20', }, - }, - }, - }; + ], + }), + ]); + + const stateWithIncomingNativeTransfer = createConfirmedEvmQueryData([ + createConfirmedEvmTransaction({ + hash: '0xpoison-native', + valueTransfers: [ + { + amount: '1', + contractAddress: '', + decimal: 18, + from: '0x9999999999999999999999999999999999999999', + name: 'Ether', + symbol: 'ETH', + to: ACTIVE_EVM_ADDRESS, + transferType: 'NATIVE', + }, + ], + }), + ]); beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); - // Re-set implementations after any prior resetAllMocks() calls - (mockBuildTrustedAddressSet as jest.Mock).mockReturnValue( - new Set(), - ); - (mockFilterByAddress as jest.Mock).mockReturnValue(true); - // isTransactionOnChains gates the second chain filter at line 252 of the - // component; restore it so confirmed transactions aren't silently dropped - (mockIsTransactionOnChains as jest.Mock).mockReturnValue(true); - }); - - it('calls buildTrustedAddressSet on every render', () => { - renderWithProvider(, { state: baseState }); - - expect(mockBuildTrustedAddressSet).toHaveBeenCalled(); - }); - - it('calls buildTrustedAddressSet with the addressBook from state and an array of account addresses', () => { - const mockAddressBook = { - '0x1': { - [FRIEND_ADDRESS]: { - address: FRIEND_ADDRESS, - name: 'Friend', - chainId: '0x1' as Hex, - memo: '', - isEns: false, - }, - }, - }; - const stateWithAddressBook = { - engine: { - backgroundState: { - ...backgroundState, - AddressBookController: { addressBook: mockAddressBook }, - }, - }, - }; - - renderWithProvider(, { - state: stateWithAddressBook, - }); - - expect(mockBuildTrustedAddressSet).toHaveBeenCalledWith( - mockAddressBook, - expect.any(Array), - ); - }); - - it('passes a pre-built Set to filterByAddress (not the raw addressBook)', () => { - renderWithProvider(, { - state: stateWithIncomingTransfer, - }); - - expect(mockFilterByAddress).toHaveBeenCalled(); - (mockFilterByAddress as jest.Mock).mock.calls.forEach((args) => { - // arg[5] is trustedAddresses — must be a Set, not a plain object - expect(args[5]).toBeInstanceOf(Set); - // There is no arg[6]; the old addressBook + internalAccountAddresses - // params have been replaced by a single Set - expect(args[6]).toBeUndefined(); - }); }); it('hides incoming ERC-20 transfer when filterByAddress returns false (unknown sender)', () => { - (mockFilterByAddress as jest.Mock).mockReturnValue(false); + mockUseTransactionsQuery(stateWithIncomingTransfer); const { getByText } = renderWithProvider(, { - state: stateWithIncomingTransfer, + state: baseState, }); // Transaction is filtered out → data is empty → empty state is shown expect(getByText('You have no transactions')).toBeOnTheScreen(); }); - it('shows incoming ERC-20 transfer when filterByAddress returns true (trusted sender)', () => { - (mockFilterByAddress as jest.Mock).mockReturnValue(true); + it('hides incoming native transfer when sender is unknown', () => { + mockUseTransactionsQuery(stateWithIncomingNativeTransfer); - const { queryByText } = renderWithProvider(, { - state: stateWithIncomingTransfer, + const { getByText } = renderWithProvider(, { + state: baseState, }); - // Transaction passes filter → data is non-empty → empty state is absent - expect(queryByText('You have no transactions')).not.toBeOnTheScreen(); + expect(getByText('You have no transactions')).toBeOnTheScreen(); }); }); @@ -644,6 +883,7 @@ describe('UnifiedTransactionsView - cross-chain bridge visibility', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx index 6ee79da9efbc..aa917b3e7925 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx @@ -4,18 +4,19 @@ import { SmartTransaction } from '@metamask/smart-transactions-controller'; import { TransactionMeta } from '@metamask/transaction-controller'; import { numberToHex } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { + FlashList, + type FlashListRef, + type ViewToken, +} from '@shopify/flash-list'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { RefreshControl, View } from 'react-native'; +import { ActivityIndicator, RefreshControl, View } from 'react-native'; import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../locales/i18n'; import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { RPC } from '../../../constants/network'; -import { - selectSelectedInternalAccount, - selectInternalAccounts, -} from '../../../selectors/accountsController'; -import { selectAddressBook } from '../../../selectors/addressBookController'; +import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { selectCurrentCurrency } from '../../../selectors/currencyRateController'; import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain'; import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; @@ -27,20 +28,12 @@ import { selectEVMEnabledNetworks, selectNonEVMEnabledNetworks, } from '../../../selectors/networkEnablementController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectSortedEVMTransactionsForSelectedAccountGroup } from '../../../selectors/transactionController'; +import { selectLocalTransactions } from '../../../selectors/transactionController'; import { baseStyles } from '../../../styles/common'; -import { - filterByAddress, - isTransactionOnChains, - sortTransactions, - buildTrustedAddressSet, -} from '../../../util/activity'; import { areAddressesEqual, isHardwareAccount } from '../../../util/address'; import { getBlockExplorerAddressUrl } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { updateIncomingTransactions } from '../../../util/transaction-controller'; -import { addAccountTimeFlagFilter } from '../../../util/transactions'; import { useStyles } from '../../hooks/useStyles'; import PriceChartContext, { PriceChartProvider, @@ -49,7 +42,6 @@ import { useBridgeHistoryItemBySrcTxHash } from '../../UI/Bridge/hooks/useBridge import MultichainBridgeTransactionListItem from '../../UI/MultichainBridgeTransactionListItem'; import MultichainTransactionListItem from '../../UI/MultichainTransactionListItem'; import TransactionElement from '../../UI/TransactionElement'; -import { filterDuplicateOutgoingTransactions } from '../../UI/Transactions/utils'; import TransactionsFooter from '../../UI/Transactions/TransactionsFooter'; import MultichainTransactionsFooter from '../MultichainTransactionsView/MultichainTransactionsFooter'; import { getAddressUrl } from '../../../core/Multichain/utils'; @@ -64,30 +56,41 @@ import { TabEmptyState } from '../../../component-library/components-temp/TabEmp import { UnifiedTransactionsViewSelectorsIDs } from './UnifiedTransactionsView.testIds'; import { useMultichainActivityMaliciousTokenKeys } from '../../hooks/useMultichainActivityMaliciousTokenKeys/useMultichainActivityMaliciousTokenKeys'; import { filterMultichainTransactionsExcludingMaliciousTokenActivity } from '../../../util/multichain/multichainTransactionTokenScan'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { + type EvmTransaction, + TransactionKind, + type TransactionViewModel, + type UnifiedItem, +} from './types'; +import { + isBridgeHistoryForEvmTransaction, + mergeTransactionsByTime, +} from './helpers/transformations'; -type SmartTransactionWithId = SmartTransaction & { id: string }; -type EvmTransaction = TransactionMeta | SmartTransactionWithId; -type TransactionMetaWithImport = TransactionMeta & { - insertImportTime?: boolean; -}; +const confirmedEvmOverscan = 5; +const visibilityConfig = { itemVisiblePercentThreshold: 1 }; const getTransactionId = (tx: EvmTransaction) => tx.id; - -const isTransactionMetaLike = (tx: EvmTransaction): tx is TransactionMeta => - 'chainId' in tx && typeof tx.chainId === 'string'; +const isTransactionMetaLike = ( + tx: TransactionMeta | SmartTransaction, +): tx is EvmTransaction => 'id' in tx && typeof tx.id === 'string'; const getEvmTransactionTime = (tx: EvmTransaction) => tx.time ?? 0; const getEvmChainId = (tx: EvmTransaction) => tx.chainId; -enum TransactionKind { - Evm = 'evm', - NonEvm = 'nonEvm', -} +const generateKey = (item: UnifiedItem) => { + if (item.kind === TransactionKind.Evm) { + return getTransactionId(item.tx); + } + + if (item.kind === TransactionKind.ConfirmedEvm) { + return getTransactionId(item.tx.transactionMeta); + } -type UnifiedItem = - | { kind: TransactionKind.Evm; tx: TransactionMeta | SmartTransactionWithId } - | { kind: TransactionKind.NonEvm; tx: NonEvmTransaction }; + return String(item.tx.id ?? `${item.tx.chain}-${item.tx.timestamp ?? '0'}`); +}; interface UnifiedTransactionsViewProps { header?: React.ReactElement; @@ -103,12 +106,26 @@ const UnifiedTransactionsView = ({ }: UnifiedTransactionsViewProps) => { const navigation = useNavigation(); const { colors } = useTheme(); + const tw = useTailwind(); const { styles } = useStyles(styleSheet, {}); const { bridgeHistoryItemsBySrcTxHash } = useBridgeHistoryItemBySrcTxHash(); - const evmTransactions = useSelector( - selectSortedEVMTransactionsForSelectedAccountGroup, + const { + data: evmTransactions, + fetchNextPage, + hasNextPage, + isInitialLoading, + isFetchingNextPage, + refetch, + } = useTransactionsQuery(); + + const allConfirmedFiltered = useMemo( + () => evmTransactions?.pages.flatMap((page) => page.data) ?? [], + [evmTransactions], ); + + const submittedTxs = useSelector(selectLocalTransactions); + const nonEvmState = useSelector( selectNonEvmTransactionsForSelectedAccountGroup, ); @@ -121,12 +138,9 @@ const UnifiedTransactionsView = ({ // Inputs required to reproduce EVM filtering pipeline const selectedInternalAccount = useSelector(selectSelectedInternalAccount); - const tokens = useSelector(selectTokens); const selectedAccountGroupInternalAccounts = useSelector( selectSelectedAccountGroupInternalAccounts, ); - const selectedAccountGroupInternalAccountsAddresses = - selectedAccountGroupInternalAccounts.map((account) => account.address); const selectedAccountGroupEvmAddress = useMemo(() => { const evmAccount = selectedAccountGroupInternalAccounts.find( (account) => @@ -162,161 +176,62 @@ const UnifiedTransactionsView = ({ ); const bridgeHistory = useSelector(selectBridgeHistoryForAccount); - const addressBook = useSelector(selectAddressBook); - const internalAccounts = useSelector(selectInternalAccounts); - - const trustedAddresses = useMemo( - () => - buildTrustedAddressSet( - addressBook, - internalAccounts.map((account) => account.address), - ), - [addressBook, internalAccounts], - ); const unifiedTransactionSource = useMemo<{ - evmPendingItems: UnifiedItem[]; - evmConfirmedItems: UnifiedItem[]; + evmPendingTxs: EvmTransaction[]; + evmConfirmedTxs: TransactionViewModel[]; chainFilteredNonEvmTransactionsForSelectedChain: NonEvmTransaction[]; }>(() => { - // Build EVM submitted/confirmed with full filtering pipeline - let accountAddedTimeInsertPointFound = false; - const addedAccountTime = selectedInternalAccount?.metadata?.importTime; - const submittedTxs: EvmTransaction[] = []; - - const sortedTransactions = sortTransactions( - evmTransactions ?? [], - ) as EvmTransaction[]; - - const allTransactionsSorted = sortedTransactions.filter( - (tx, index, self) => { - const key = getTransactionId(tx); - return self.findIndex((_tx) => getTransactionId(_tx) === key) === index; - }, - ); - - const transactionMetaPool = allTransactionsSorted.filter( - isTransactionMetaLike, - ) as TransactionMeta[]; - - const allConfirmed = allTransactionsSorted.filter((tx) => { - if (!isTransactionMetaLike(tx)) { - const status = tx.status; - if ( - status === 'submitted' || - status === 'signed' || - status === 'unapproved' || - status === 'approved' || - status === 'pending' - ) { - submittedTxs.push(tx as SmartTransactionWithId); + const bridgeHistoryValues = Object.values(bridgeHistory ?? {}); + const submittedTxsFiltered = submittedTxs.filter( + (tx): tx is EvmTransaction => { + if (!isTransactionMetaLike(tx)) { + return false; } - return false; - } - const isReceivedOrSentTransaction = - selectedAccountGroupInternalAccountsAddresses.some((addr) => - filterByAddress( - tx, - tokens, - addr, - transactionMetaPool, - bridgeHistory, - trustedAddresses, - ), + const { chainId: _chainId, txParams } = tx; + const isBridgeTransaction = isBridgeHistoryForEvmTransaction( + tx, + bridgeHistoryValues, ); - if (!isReceivedOrSentTransaction) return false; - - const insertImportTime = addAccountTimeFlagFilter( - tx as unknown as object, - addedAccountTime as unknown as object, - accountAddedTimeInsertPointFound as unknown as object, - ); - const updatedTx = { ...tx, insertImportTime }; - if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true; - - // not sure if pending is a valid status for EVM transactions, but keeping - // it for now to avoid breaking changes - const status = tx.status as TransactionMeta['status'] | 'pending'; - switch (status) { - case 'submitted': - case 'signed': - case 'unapproved': - case 'approved': - case 'pending': - submittedTxs.push(updatedTx); - return false; - case 'confirmed': - break; - } - return isReceivedOrSentTransaction; - }) as TransactionMetaWithImport[]; - - // Network filtering for confirmed EVM txs - const allConfirmedFiltered: TransactionMetaWithImport[] = - allConfirmed.filter((tx) => - isTransactionOnChains(tx, enabledEVMChainIds, transactionMetaPool), - ); - // Deduplicate submitted by (address + chain + nonce) and drop if already confirmed - const seenSubmittedNonces = new Set(); - const submittedTxsFiltered = submittedTxs.filter( - ({ chainId: _chainId, txParams }) => { - const { from, nonce, actionId } = txParams || {}; - // Some txs don't have nonce, like intent based swaps + const hash = 'hash' in tx ? tx.hash : undefined; + const { from, nonce } = txParams || {}; const hasNonce = nonce !== undefined && nonce !== null; - if ( - !selectedAccountGroupInternalAccountsAddresses.some((addr) => - areAddressesEqual(from, addr), - ) - ) { - return false; - } - const dedupeKeyPrefix = `${_chainId}-${String(from).toLowerCase()}`; - const dedupeKey = hasNonce - ? `${dedupeKeyPrefix}-${nonce}` - : `${dedupeKeyPrefix}-${actionId}`; - if (seenSubmittedNonces.has(dedupeKey)) { - return false; - } - const alreadyConfirmed = allConfirmedFiltered.find( + const matchingConfirmedByHash = allConfirmedFiltered.some( + (confirmedTx) => + typeof hash === 'string' && + confirmedTx.hash.toLowerCase() === hash.toLowerCase() && + confirmedTx.hexChainId === _chainId, + ); + const matchingConfirmedByNonce = allConfirmedFiltered.some( (confirmedTx) => hasNonce && - confirmedTx.txParams?.nonce === nonce && - selectedAccountGroupInternalAccountsAddresses.some((addr) => - areAddressesEqual(confirmedTx.txParams?.from, addr), - ) && - confirmedTx.chainId === _chainId, + confirmedTx.nonce === nonce && + confirmedTx.hexChainId === _chainId && + Boolean(from) && + areAddressesEqual(confirmedTx.from, from), ); - if (alreadyConfirmed) { + if ( + matchingConfirmedByHash || + (!isBridgeTransaction && matchingConfirmedByNonce) + ) { return false; } - seenSubmittedNonces.add(dedupeKey); return true; }, ); - // Ensure insertImportTime appears at least once if applicable - if (!accountAddedTimeInsertPointFound && allConfirmedFiltered?.length) { - const lastIndex = allConfirmedFiltered.length - 1; - allConfirmedFiltered[lastIndex] = { - ...allConfirmedFiltered[lastIndex], - insertImportTime: true, - }; - } // EVM: pending/submitted first (desc), then confirmed (dedup outgoing) const evmPendingFirst = [...submittedTxsFiltered].sort( (a, b) => getEvmTransactionTime(b) - getEvmTransactionTime(a), ); - const evmConfirmedDeduped = - filterDuplicateOutgoingTransactions(allConfirmedFiltered); // Non-EVM: filter by enabled chains, also include bridge txs // whose destination chain is enabled (e.g. Solana→Optimism bridge // should appear when viewing Optimism activity) - const bridgeHistoryValues = Object.values(bridgeHistory ?? {}); const chainFilteredNonEvmTransactionsForSelectedChain = nonEvmTransactions .filter((tx) => { if (enabledNonEVMChainIds.includes(tx.chain)) return true; @@ -333,30 +248,18 @@ const UnifiedTransactionsView = ({ (tx, index, self) => index === self.findIndex((t) => t.id === tx.id), ); - const evmPendingItems: UnifiedItem[] = evmPendingFirst.map((tx) => ({ - kind: TransactionKind.Evm, - tx, - })); - const evmConfirmedItems: UnifiedItem[] = evmConfirmedDeduped.map((tx) => ({ - kind: TransactionKind.Evm, - tx, - })); - return { - evmPendingItems, - evmConfirmedItems, + evmPendingTxs: evmPendingFirst, + evmConfirmedTxs: allConfirmedFiltered, chainFilteredNonEvmTransactionsForSelectedChain, }; }, [ - evmTransactions, + allConfirmedFiltered, + submittedTxs, nonEvmTransactions, - selectedAccountGroupInternalAccountsAddresses, enabledEVMChainIds, enabledNonEVMChainIds, - selectedInternalAccount, - tokens, bridgeHistory, - trustedAddresses, ]); const { data, nonEvmTransactionsForSelectedChain } = useMemo<{ @@ -364,8 +267,8 @@ const UnifiedTransactionsView = ({ nonEvmTransactionsForSelectedChain: NonEvmTransaction[]; }>(() => { const { - evmPendingItems, - evmConfirmedItems, + evmPendingTxs, + evmConfirmedTxs, chainFilteredNonEvmTransactionsForSelectedChain, } = unifiedTransactionSource; @@ -375,28 +278,14 @@ const UnifiedTransactionsView = ({ maliciousTokenKeys, ); - const nonEvmItems: UnifiedItem[] = - filteredNonEvmTransactionsForSelectedChain.map((tx) => ({ - kind: TransactionKind.NonEvm, - tx, - })); - - const confirmedUnified = [...evmConfirmedItems, ...nonEvmItems].sort( - (a, b) => { - const ta = - a.kind === TransactionKind.Evm - ? getEvmTransactionTime(a.tx) - : (a.tx.timestamp ?? 0) * 1000; - const tb = - b.kind === TransactionKind.Evm - ? getEvmTransactionTime(b.tx) - : (b.tx.timestamp ?? 0) * 1000; - return tb - ta; - }, + const data = mergeTransactionsByTime( + evmPendingTxs, + evmConfirmedTxs, + filteredNonEvmTransactionsForSelectedChain, ); return { - data: [...evmPendingItems, ...confirmedUnified], + data, nonEvmTransactionsForSelectedChain: filteredNonEvmTransactionsForSelectedChain, }; @@ -531,6 +420,14 @@ const UnifiedTransactionsView = ({ }, [navigation, nonEvmExplorerUrl]); const footerComponent = useMemo(() => { + if (isFetchingNextPage) { + return ( + + + + ); + } + if (showEvmFooter) { return ( { setRefreshing(true); try { - await updateIncomingTransactions(); + await Promise.all([updateIncomingTransactions(), refetch()]); } finally { setRefreshing(false); } - }, []); + }, [refetch]); - const listRef = useRef>(null); + const lastConfirmedEvmIndex = useMemo(() => { + for (let index = data.length - 1; index >= 0; index -= 1) { + if (data[index].kind === TransactionKind.ConfirmedEvm) { + return index; + } + } - // Auto-scroll to top when new transactions are added - const { handleScroll } = useTransactionAutoScroll(data, listRef, { - keyExtractor: (item: UnifiedItem) => { - if (item.kind === TransactionKind.Evm) { - return getTransactionId(item.tx) ?? null; + return -1; + }, [data]); + + const lastConfirmedEvmKey = + lastConfirmedEvmIndex >= 0 + ? generateKey(data[lastConfirmedEvmIndex]) + : undefined; + + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if ( + !hasNextPage || + isFetchingNextPage || + !lastConfirmedEvmKey || + lastConfirmedEvmIndex < 0 + ) { + return; } - // For non-EVM (Solana, Bitcoin, Tron, etc.) - // Use same fallback as keyExtractor to ensure consistency - return String( - item.tx?.id ?? `${item.tx?.chain}-${item.tx?.timestamp ?? '0'}`, + + const prefetchIndex = Math.max( + lastConfirmedEvmIndex - confirmedEvmOverscan, + 0, + ); + const isNearPrefetchThreshold = viewableItems.some( + ({ index }) => typeof index === 'number' && index >= prefetchIndex, ); + + if (!isNearPrefetchThreshold) { + return; + } + + fetchNextPage(); }, + [ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + lastConfirmedEvmIndex, + lastConfirmedEvmKey, + ], + ); + const listRef = useRef>(null); + + // Auto-scroll to top when new transactions are added + const { handleScroll } = useTransactionAutoScroll(data, listRef, { + keyExtractor: generateKey, }); const renderEmptyList = () => ( @@ -617,6 +555,15 @@ const UnifiedTransactionsView = ({ ); + const renderInitialLoading = () => ( + + + + ); + + const shouldShowTransactionList = !isInitialLoading && data.length > 0; + const items = shouldShowTransactionList ? data : []; + const renderItem = ({ item, index, @@ -655,6 +602,21 @@ const UnifiedTransactionsView = ({ ); } + if (item.kind === TransactionKind.ConfirmedEvm) { + return ( + + ); + } + // Render non-EVM transactions const srcTxHash = item.tx.id; // id is unique for multichain tx const bridgeHistoryItem = bridgeHistoryItemsBySrcTxHash[srcTxHash]; @@ -688,19 +650,14 @@ const UnifiedTransactionsView = ({ {({ isChartBeingTouched }) => ( - listItem.kind === TransactionKind.Evm - ? getTransactionId(listItem.tx) - : String( - listItem.tx.id ?? - `${listItem.tx.chain}-${listItem.tx.timestamp ?? '0'}`, - ) - } + keyExtractor={generateKey} ListHeaderComponent={header} - ListEmptyComponent={renderEmptyList} + ListEmptyComponent={ + isInitialLoading ? renderInitialLoading : renderEmptyList + } ListFooterComponent={footerComponent} style={baseStyles.flexGrow} refreshControl={ @@ -712,6 +669,8 @@ const UnifiedTransactionsView = ({ /> } onScroll={handleScroll} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={visibilityConfig} scrollEventThrottle={16} scrollEnabled={!isChartBeingTouched} /> diff --git a/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts b/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts new file mode 100644 index 000000000000..88d2f1eaed79 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts @@ -0,0 +1,172 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + APPROVE_FUNCTION_SIGNATURE, + TRANSFER_FUNCTION_SIGNATURE, +} from '../../../../util/transactions'; +import { normalizeTransaction } from './adapters'; + +describe('normalizeTransaction', () => { + const address = '0x0000000000000000000000000000000000000001'; + const otherAddress = '0x0000000000000000000000000000000000000002'; + const contractAddress = '0x00000000000000000000000000000000000000aa'; + + const buildTransaction = ( + overrides: Partial = {}, + ): V1TransactionByHashResponse => + ({ + hash: '0xhash', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 100, + blockHash: '0xblock', + gas: 21000, + gasUsed: 21000, + gasPrice: '1000000000', + effectiveGasPrice: '1000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000', + to: otherAddress, + from: address, + methodId: '0x', + isError: false, + ...overrides, + }) as unknown as V1TransactionByHashResponse; + + it('normalizes a simple outgoing send', () => { + const meta = normalizeTransaction(address, buildTransaction()); + + expect(meta).toEqual( + expect.objectContaining({ + hash: '0xhash', + id: '0xhash-1', + chainId: '0x1', + status: TransactionStatus.confirmed, + type: TransactionType.simpleSend, + isTransfer: false, + networkClientId: '', + toSmartContract: false, + verifiedOnBlockchain: false, + blockNumber: '100', + time: Date.parse('2024-01-01T00:00:00Z'), + error: undefined, + transferInformation: undefined, + }), + ); + expect(meta.txParams).toEqual( + expect.objectContaining({ + chainId: '0x1', + from: address, + to: otherAddress, + value: '0x3e8', + gas: '0x5208', + gasPrice: '0x3b9aca00', + gasUsed: '0x5208', + nonce: '0x0', + }), + ); + }); + + it('marks the transaction as failed when isError is true', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ isError: true }), + ); + + expect(meta.status).toBe(TransactionStatus.failed); + expect(meta.error).toBeInstanceOf(Error); + expect(meta.error?.message).toBe('Transaction failed'); + }); + + it('marks an outgoing transaction with no `to` and calldata as deployContract', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + to: undefined as unknown as string, + methodId: '0xabcdef', + }), + ); + + expect(meta.type).toBe(TransactionType.deployContract); + }); + + it('classifies an incoming transaction', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ from: otherAddress, to: address }), + ); + + expect(meta.type).toBe(TransactionType.incoming); + }); + + it('detects ERC20 transfer method', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: TRANSFER_FUNCTION_SIGNATURE, + value: '0', + }), + ); + + expect(meta.type).toBe(TransactionType.tokenMethodTransfer); + }); + + it('detects ERC20 approve method', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: APPROVE_FUNCTION_SIGNATURE, + value: '0', + }), + ); + + expect(meta.type).toBe(TransactionType.tokenMethodApprove); + }); + + it('classifies a contract interaction when calldata has value', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: '0xdeadbeef', + value: '1000', + }), + ); + + expect(meta.type).toBe(TransactionType.contractInteraction); + }); + + it('extracts transfer information for an incoming token transfer and rewrites txParams', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + from: otherAddress, + to: contractAddress, + value: '0', + valueTransfers: [ + { + from: otherAddress, + to: address, + amount: '5000', + contractAddress, + decimal: 6, + symbol: 'USDC', + }, + ], + } as Partial), + ); + + expect(meta.isTransfer).toBe(true); + expect(meta.transferInformation).toEqual({ + amount: '5000', + contractAddress, + decimals: 6, + symbol: 'USDC', + }); + expect(meta.txParams.to).toBe(address); + expect(meta.txParams.value).toBe('0x1388'); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts b/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts new file mode 100644 index 000000000000..865a58c2361f --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts @@ -0,0 +1,138 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + type TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + APPROVE_FUNCTION_SIGNATURE, + INCREASE_ALLOWANCE_SIGNATURE, + NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE, + SET_APPROVAL_FOR_ALL_SIGNATURE, + TRANSFER_FROM_FUNCTION_SIGNATURE, + TRANSFER_FUNCTION_SIGNATURE, +} from '../../../../util/transactions'; +import { Hex } from 'viem'; +import { toHex } from '@metamask/controller-utils'; + +// Ported from transaction-controller +// - AccountsApiRemoteTransactionSource +// - determineTransactionType +function resolveTransactionMetaType( + transaction: V1TransactionByHashResponse, + isOutgoing: boolean, +) { + if (!isOutgoing) { + return TransactionType.incoming; + } + + const rawData = transaction.methodId?.toLowerCase(); + // Treat '0x' (empty calldata) the same as no methodId, since the API + // returns '0x' for simple ETH sends. + const data = rawData && rawData !== '0x' ? rawData : undefined; + + if (data && !transaction.to) { + return TransactionType.deployContract; + } + + const isContractAddress = Boolean(data?.length); + + if (!isContractAddress) { + return TransactionType.simpleSend; + } + + const hasValue = BigInt(transaction.value ?? '0') !== BigInt(0); + + if (hasValue) { + return TransactionType.contractInteraction; + } + + if (!data) { + return TransactionType.contractInteraction; + } + + switch (data) { + case APPROVE_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodApprove; + case SET_APPROVAL_FOR_ALL_SIGNATURE: + return TransactionType.tokenMethodSetApprovalForAll; + case TRANSFER_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransfer; + case TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransferFrom; + case NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodSafeTransferFrom; + case INCREASE_ALLOWANCE_SIGNATURE: + return TransactionType.tokenMethodIncreaseAllowance; + default: + return TransactionType.contractInteraction; + } +} + +// Ported from transaction-controller normalizeTransaction +export function normalizeTransaction( + address: string, + transaction: V1TransactionByHashResponse, +) { + const { from, hash, methodId } = transaction; + const normalizedAddress = address.toLowerCase(); + + const status = transaction.isError + ? TransactionStatus.failed + : TransactionStatus.confirmed; + + // Find token transfer that involves the current address + const valueTransfer = transaction.valueTransfers?.find( + (vt) => + (vt.to?.toLowerCase() === normalizedAddress || + vt.from?.toLowerCase() === normalizedAddress) && + vt.contractAddress, + ); + + const isIncomingTokenTransfer = + valueTransfer?.to?.toLowerCase() === normalizedAddress && + from.toLowerCase() !== normalizedAddress; + const isOutgoing = from.toLowerCase() === normalizedAddress; + + const transferInformation = valueTransfer + ? { + amount: valueTransfer.amount, + contractAddress: valueTransfer.contractAddress, + decimals: valueTransfer.decimal, + symbol: valueTransfer.symbol, + } + : undefined; + + const meta: TransactionMeta = { + blockNumber: String(transaction.blockNumber), + chainId: toHex(transaction.chainId), + error: transaction.isError ? new Error('Transaction failed') : undefined, + hash, + id: `${hash}-${transaction.chainId}`, + isTransfer: isIncomingTokenTransfer, + networkClientId: '', + status, + time: Date.parse(transaction.timestamp) || 0, + toSmartContract: false, + transferInformation, + txParams: { + chainId: toHex(transaction.chainId), + data: methodId as Hex, + from: from as Hex, + gas: toHex(transaction.gas), + gasPrice: toHex(transaction.gasPrice), + gasUsed: toHex(transaction.gasUsed), + nonce: toHex(transaction.nonce), + to: isIncomingTokenTransfer ? address : transaction.to, + value: toHex( + isIncomingTokenTransfer + ? (valueTransfer?.amount ?? transaction.value) + : transaction.value, + ), + }, + type: resolveTransactionMetaType(transaction, isOutgoing), + verifiedOnBlockchain: false, + }; + + return meta; +} diff --git a/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts b/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts new file mode 100644 index 000000000000..33169e9f3025 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts @@ -0,0 +1,255 @@ +import type { + V1TransactionByHashResponse, + V4MultiAccountTransactionsResponse, +} from '@metamask/core-backend'; +import type { InfiniteData } from '@tanstack/react-query'; +import { + isBridgeHistoryForEvmTransaction, + mergeTransactionsByTime, + selectTransactions, +} from './transformations'; +import { TransactionKind } from '../types'; + +describe('selectTransactions', () => { + const address = '0x0000000000000000000000000000000000000001'; + const otherAddress = '0x0000000000000000000000000000000000000002'; + + const buildTransaction = ( + overrides: Partial = {}, + ): V1TransactionByHashResponse => + ({ + hash: '0xhash', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 100, + blockHash: '0xblock', + gas: 21000, + gasUsed: 21000, + gasPrice: '1000000000', + effectiveGasPrice: '1000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000', + to: otherAddress, + from: address, + ...overrides, + }) as unknown as V1TransactionByHashResponse; + + const buildData = ( + transactions: V1TransactionByHashResponse[], + ): InfiniteData => + ({ + pages: [{ data: transactions } as V4MultiAccountTransactionsResponse], + pageParams: [undefined], + }) as InfiniteData; + + it('transforms transactions into view models with id and transactionMeta', () => { + const tx = buildTransaction(); + const result = selectTransactions({ address })(buildData([tx])); + + expect(result.pages).toHaveLength(1); + expect(result.pages[0].data).toHaveLength(1); + const [viewModel] = result.pages[0].data; + expect(viewModel.id).toBe('0xhash-1'); + expect(viewModel.hexChainId).toBe('0x1'); + expect(viewModel.transactionMeta).toBeDefined(); + expect(viewModel.hash).toBe('0xhash'); + }); + + it('filters out spam token transfers', () => { + const spam = buildTransaction({ + hash: '0xspam', + transactionType: 'SPAM_TOKEN_TRANSFER', + } as Partial); + const normal = buildTransaction({ hash: '0xnormal' }); + + const result = selectTransactions({ address })(buildData([spam, normal])); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xnormal'); + }); + + it('filters out transactions unrelated to the address', () => { + const unrelated = buildTransaction({ + hash: '0xunrelated', + from: '0x0000000000000000000000000000000000000003', + to: '0x0000000000000000000000000000000000000004', + }); + + const result = selectTransactions({ address })(buildData([unrelated])); + + expect(result.pages[0].data).toHaveLength(0); + }); + + it('filters out transactions with excluded hashes', () => { + const excluded = buildTransaction({ hash: '0xEXCLUDED' }); + const normal = buildTransaction({ hash: '0xnormal' }); + + const result = selectTransactions({ + address, + excludedTxHashes: new Set(['0xexcluded']), + })(buildData([excluded, normal])); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xnormal'); + }); + + it('filters incoming token transfers', () => { + const incomingTokenTransfer = buildTransaction({ + hash: '0xincoming-token', + from: otherAddress, + to: address, + valueTransfers: [ + { + contractAddress: '0x00000000000000000000000000000000000000aa', + from: otherAddress, + to: address, + }, + ], + } as Partial); + const outgoing = buildTransaction({ hash: '0xoutgoing' }); + + const result = selectTransactions({ address })( + buildData([incomingTokenTransfer, outgoing]), + ); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xoutgoing'); + }); + + it('filters incoming native transfers', () => { + const incomingNativeTransfer = buildTransaction({ + hash: '0xincoming-native', + from: otherAddress, + to: address, + valueTransfers: [ + { + from: otherAddress, + to: address, + }, + ], + } as Partial); + const outgoing = buildTransaction({ hash: '0xoutgoing' }); + + const result = selectTransactions({ address })( + buildData([incomingNativeTransfer, outgoing]), + ); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xoutgoing'); + }); + + it('filters zero-value self sends without calldata or transfers', () => { + const selfSend = buildTransaction({ + from: address, + to: address, + value: '0', + methodId: '0x', + valueTransfers: [], + }); + + const result = selectTransactions({ address })(buildData([selfSend])); + + expect(result.pages[0].data).toHaveLength(0); + }); +}); + +describe('isBridgeHistoryForEvmTransaction', () => { + it('matches bridge history by original transaction id', () => { + const tx = { + id: 'tx-id', + actionId: 'action-id', + }; + const bridgeHistoryValues = [ + { + txMetaId: 'different-id', + originalTransactionId: 'action-id', + }, + ]; + + const result = isBridgeHistoryForEvmTransaction( + tx as Parameters[0], + bridgeHistoryValues as Parameters< + typeof isBridgeHistoryForEvmTransaction + >[1], + ); + + expect(result).toBe(true); + }); + + it('matches bridge history by source hash', () => { + const tx = { + id: 'tx-id', + hash: '0xABC', + }; + const bridgeHistoryValues = [ + { + txMetaId: 'different-id', + status: { + srcChain: { + txHash: '0xabc', + }, + }, + }, + ]; + + const result = isBridgeHistoryForEvmTransaction( + tx as Parameters[0], + bridgeHistoryValues as Parameters< + typeof isBridgeHistoryForEvmTransaction + >[1], + ); + + expect(result).toBe(true); + }); +}); + +describe('mergeTransactionsByTime', () => { + it('sorts unified transactions by time and removes local transactions with confirmed hashes', () => { + const localDuplicate = { + id: 'local-duplicate', + hash: '0xDUPLICATE', + time: 300, + }; + const localUnique = { + id: 'local-unique', + hash: '0xlocal', + time: 200, + }; + const confirmedDuplicate = { + id: 'confirmed-duplicate', + hash: '0xduplicate', + time: 400, + }; + const nonEvm = { + id: 'non-evm', + timestamp: 1, + }; + + const result = mergeTransactionsByTime( + [localDuplicate, localUnique] as Parameters< + typeof mergeTransactionsByTime + >[0], + [confirmedDuplicate] as Parameters[1], + [nonEvm] as Parameters[2], + ); + + expect(result).toStrictEqual([ + { + kind: TransactionKind.NonEvm, + tx: nonEvm, + time: 1000, + }, + { + kind: TransactionKind.ConfirmedEvm, + tx: confirmedDuplicate, + time: 400, + }, + { + kind: TransactionKind.Evm, + tx: localUnique, + time: 200, + }, + ]); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts b/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts new file mode 100644 index 000000000000..5c9d1ad958a4 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts @@ -0,0 +1,227 @@ +import { + type V1TransactionByHashResponse, + type V4MultiAccountTransactionsResponse, +} from '@metamask/core-backend'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; +import type { InfiniteData } from '@tanstack/react-query'; +import { normalizeTransaction } from './adapters'; +import { + EvmTransaction, + TransactionKind, + TransactionViewModel, + UnifiedItem, +} from '../types'; +import { equalsIgnoreCase } from '../../../../util/string'; + +const excludedTransactionTypes = ['SPAM_TOKEN_TRANSFER']; + +const getOriginalTransactionId = (bridgeHistoryItem: BridgeHistoryItem) => + (bridgeHistoryItem as unknown as { originalTransactionId?: string }) + .originalTransactionId; + +export const isBridgeHistoryForEvmTransaction = ( + tx: EvmTransaction & { actionId?: string; hash?: string }, + bridgeHistoryValues: BridgeHistoryItem[], +) => + bridgeHistoryValues.some((bridgeHistoryItem) => { + const originalTransactionId = getOriginalTransactionId(bridgeHistoryItem); + + return ( + bridgeHistoryItem.txMetaId === tx.id || + bridgeHistoryItem.txMetaId === tx.actionId || + originalTransactionId === tx.id || + originalTransactionId === tx.actionId || + equalsIgnoreCase(bridgeHistoryItem.status?.srcChain?.txHash, tx.hash) + ); + }); + +function isIncomingTokenTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + return ( + transaction.valueTransfers?.some( + (transfer) => + Boolean(transfer.contractAddress) && + transfer.to?.toLowerCase() === address && + transaction.from?.toLowerCase() !== address, + ) ?? false + ); +} + +function isIncomingNativeTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + const normalizedAddress = address.toLowerCase(); + let hasOutgoingTransfer = false; + let hasIncomingNativeTransfer = false; + + for (const transfer of transaction.valueTransfers ?? []) { + if ( + !hasOutgoingTransfer && + transfer.from?.toLowerCase() === normalizedAddress + ) { + hasOutgoingTransfer = true; + } + + if ( + !hasIncomingNativeTransfer && + transfer.to?.toLowerCase() === normalizedAddress && + !transfer.contractAddress + ) { + hasIncomingNativeTransfer = true; + } + + if (hasOutgoingTransfer && hasIncomingNativeTransfer) { + break; + } + } + + return hasIncomingNativeTransfer && !hasOutgoingTransfer; +} + +function shouldSkipTransaction( + address: string, + transaction: V1TransactionByHashResponse, + excludedTxHashes?: Set, +) { + const rawFrom = transaction.from?.toLowerCase(); + const rawTo = transaction.to?.toLowerCase(); + const hash = transaction.hash?.toLowerCase(); + + if (hash && excludedTxHashes?.has(hash)) { + return true; + } + + if (rawFrom !== address && rawTo !== address) { + return true; + } + + // Filter out span token transfers + if (excludedTransactionTypes.includes(transaction.transactionType ?? '')) { + return true; + } + + // Filter out zero-value self-sends with no calldata and no transfers + if ( + rawFrom === address && + rawTo === address && + transaction.value === '0' && + !transaction.valueTransfers?.length && + (!transaction.methodId || transaction.methodId === '0x') + ) { + return true; + } + + // Filter out incoming native token transfers + if (isIncomingTokenTransfer(address, transaction)) { + return true; + } + + return rawFrom !== address && isIncomingNativeTransfer(address, transaction); +} + +function transformTransactions( + address: string, + transactions: V1TransactionByHashResponse[], + excludedTxHashes?: Set, +): TransactionViewModel[] { + const filteredTransactions = []; + + for (const tx of transactions) { + if (shouldSkipTransaction(address, tx, excludedTxHashes)) { + continue; + } + + filteredTransactions.push(tx); + } + + return filteredTransactions.map((tx) => { + const transactionMeta = normalizeTransaction(address, tx); + + return { + // Intent is to use the API response more directly + ...tx, + // But for now, we keep this until we can refactor the UI components + id: transactionMeta.id, + time: transactionMeta.time, + hexChainId: transactionMeta.chainId, + transactionMeta, + }; + }); +} + +export function selectTransactions({ + address, + excludedTxHashes, +}: { + address: string; + excludedTxHashes?: Set; +}) { + return (data: InfiniteData) => ({ + ...data, + pages: data.pages.map((page) => ({ + ...page, + data: transformTransactions(address, page.data, excludedTxHashes), + })), + }); +} + +const getEvmTime = (tx: EvmTransaction) => tx.time ?? 0; +const getNonEvmTime = (tx: NonEvmTransaction) => (tx.timestamp ?? 0) * 1000; +const getEvmHash = (tx: EvmTransaction) => + 'hash' in tx && typeof tx.hash === 'string' ? tx.hash.toLowerCase() : ''; + +// Merges local EVM, API-confirmed EVM and non-EVM transactions into one list +// sorted by time (newest first), deduplicated by hash (API-confirmed wins). +export function mergeTransactionsByTime( + evmLocalTransactions: EvmTransaction[], + evmConfirmedTransactions: TransactionViewModel[], + nonEvmTransactions: NonEvmTransaction[], +) { + const seenHashes = new Set(); + + const confirmedItems: UnifiedItem[] = []; + for (const tx of evmConfirmedTransactions) { + const hash = tx.hash?.toLowerCase(); + if (hash) { + if (seenHashes.has(hash)) { + continue; + } + seenHashes.add(hash); + } + confirmedItems.push({ + kind: TransactionKind.ConfirmedEvm, + tx, + time: tx.time ?? 0, + }); + } + + const localItems: UnifiedItem[] = []; + for (const tx of evmLocalTransactions) { + const hash = getEvmHash(tx); + if (hash) { + if (seenHashes.has(hash)) { + continue; + } + seenHashes.add(hash); + } + localItems.push({ + kind: TransactionKind.Evm, + tx, + time: getEvmTime(tx), + }); + } + + const nonEvmItems: UnifiedItem[] = nonEvmTransactions.map((tx) => ({ + kind: TransactionKind.NonEvm, + tx, + time: getNonEvmTime(tx), + })); + + return [...localItems, ...confirmedItems, ...nonEvmItems].sort( + (a, b) => b.time - a.time, + ); +} diff --git a/app/components/Views/UnifiedTransactionsView/types.ts b/app/components/Views/UnifiedTransactionsView/types.ts new file mode 100644 index 000000000000..ecaa717af7a4 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/types.ts @@ -0,0 +1,32 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { SmartTransaction } from '@metamask/smart-transactions-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; + +export type SmartTransactionWithId = SmartTransaction & { id: string }; + +export type EvmTransaction = TransactionMeta | SmartTransactionWithId; + +export type TransactionViewModel = V1TransactionByHashResponse & { + // Intent is to use the API response more directly + id: string; + time: number; + hexChainId: string; + // But for now, we keep this until we can refactor the UI components + transactionMeta: TransactionMeta; +}; + +export enum TransactionKind { + Evm = 'evm', + ConfirmedEvm = 'confirmed', + NonEvm = 'nonEvm', +} + +export type UnifiedItem = + | { kind: TransactionKind.Evm; tx: EvmTransaction; time: number } + | { + kind: TransactionKind.ConfirmedEvm; + tx: TransactionViewModel; + time: number; + } + | { kind: TransactionKind.NonEvm; tx: NonEvmTransaction; time: number }; diff --git a/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts new file mode 100644 index 000000000000..67b6543fb2c1 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts @@ -0,0 +1,135 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { apiClient } from '../../../core/apiClient'; +import { selectEvmAddress } from '../../../selectors/accountsController'; +import { selectEvmEnabledCaipNetworks } from '../../../selectors/networkEnablementController'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { MINUTE } from '../../../constants/time'; +import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; + +jest.mock('@tanstack/react-query', () => ({ + useInfiniteQuery: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../core/apiClient', () => ({ + apiClient: { + accounts: { + getV4MultiAccountTransactionsInfiniteQueryOptions: jest.fn(), + }, + }, +})); + +jest.mock('../../../selectors/accountsController', () => ({ + selectEvmAddress: jest.fn(), +})); + +jest.mock('../../../selectors/networkEnablementController', () => ({ + selectEvmEnabledCaipNetworks: jest.fn(), +})); + +jest.mock('../../../selectors/transactionController', () => ({ + selectRequiredTransactionHashes: jest.fn(), +})); + +const ADDRESS_MOCK = '0x1234567890123456789012345678901234567890'; +const NETWORKS_MOCK = ['eip155:1', 'eip155:137']; +const QUERY_OPTIONS_MOCK = { + queryKey: ['transactions'], + queryFn: jest.fn(), + getNextPageParam: jest.fn(), +}; + +describe('useTransactionsQuery', () => { + const useSelectorMock = jest.mocked(useSelector); + const useInfiniteQueryMock = jest.mocked(useInfiniteQuery); + const getQueryOptionsMock = jest.mocked( + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions, + ); + + function setupSelectors({ + evmAddress = ADDRESS_MOCK, + networks = NETWORKS_MOCK, + }: { + evmAddress?: string; + networks?: string[]; + } = {}) { + useSelectorMock.mockImplementation((selector) => { + if (selector === selectEvmAddress) { + return evmAddress; + } + if (selector === selectEvmEnabledCaipNetworks) { + return networks; + } + if (selector === selectRequiredTransactionHashes) { + return new Set(); + } + return undefined; + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getQueryOptionsMock.mockReturnValue( + QUERY_OPTIONS_MOCK as unknown as ReturnType, + ); + useInfiniteQueryMock.mockReturnValue({ + data: undefined, + } as unknown as ReturnType); + }); + + it('composes query options from the selected EVM account and networks', () => { + setupSelectors(); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith({ + accountAddresses: [`eip155:0:${ADDRESS_MOCK}`], + networks: NETWORKS_MOCK, + includeTxMetadata: true, + }); + }); + + it('delegates to useInfiniteQuery with selectFn, enabled, staleTime and retry', () => { + setupSelectors(); + + renderHook(() => useTransactionsQuery()); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + ...QUERY_OPTIONS_MOCK, + select: expect.any(Function), + enabled: true, + staleTime: 5 * MINUTE, + retry: false, + }), + ); + }); + + it('disables the query and sends no account addresses when there is no EVM address', () => { + setupSelectors({ evmAddress: '' }); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ accountAddresses: [] }), + ); + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('disables the query when there are no enabled networks', () => { + setupSelectors({ networks: [] }); + + renderHook(() => useTransactionsQuery()); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts new file mode 100644 index 000000000000..395df12accf6 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts @@ -0,0 +1,40 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { KnownCaipNamespace, toCaipAccountId } from '@metamask/utils'; +import { apiClient } from '../../../core/apiClient'; +import { selectEvmAddress } from '../../../selectors/accountsController'; +import { selectEvmEnabledCaipNetworks } from '../../../selectors/networkEnablementController'; +import { selectTransactions } from './helpers/transformations'; +import { MINUTE } from '../../../constants/time'; +import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; + +export const useTransactionsQuery = () => { + const evmAddress = useSelector(selectEvmAddress) || ''; + const networks = useSelector(selectEvmEnabledCaipNetworks); + const excludedTxHashes = useSelector(selectRequiredTransactionHashes); + const accountAddresses = evmAddress + ? [toCaipAccountId(KnownCaipNamespace.Eip155, '0', evmAddress)] + : []; + + const queryOptions = + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses, + networks, + includeTxMetadata: true, + }); + + const selectFn = useMemo( + () => selectTransactions({ address: evmAddress, excludedTxHashes }), + [evmAddress, excludedTxHashes], + ); + + // @ts-expect-error apiClient returns v5 types, repo still in v4 + return useInfiniteQuery({ + ...queryOptions, + select: selectFn, + enabled: accountAddresses.length > 0 && networks.length > 0, + staleTime: 5 * MINUTE, + retry: false, + }); +}; diff --git a/app/core/apiClient.test.ts b/app/core/apiClient.test.ts new file mode 100644 index 000000000000..423c37a1ff8f --- /dev/null +++ b/app/core/apiClient.test.ts @@ -0,0 +1,56 @@ +import { createApiPlatformClient } from '@metamask/core-backend'; +import Engine from './Engine'; +import './apiClient'; + +jest.mock('@metamask/core-backend', () => ({ + createApiPlatformClient: jest.fn(() => ({ accounts: {} })), +})); + +jest.mock('./Engine', () => ({ + __esModule: true, + default: { + context: { + AuthenticationController: { + getBearerToken: jest.fn(), + }, + }, + }, +})); + +const createApiPlatformClientMock = jest.mocked(createApiPlatformClient); +const getBearerTokenMock = jest.mocked( + Engine.context.AuthenticationController.getBearerToken, +); + +const [firstCallArgs] = createApiPlatformClientMock.mock.calls; +const getBearerToken = firstCallArgs[0].getBearerToken as () => Promise< + string | undefined +>; + +describe('apiClient', () => { + beforeEach(() => { + getBearerTokenMock.mockReset(); + }); + + it('creates the API platform client with the mobile product identifier', () => { + expect(createApiPlatformClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + clientProduct: 'metamask-mobile', + getBearerToken: expect.any(Function), + }), + ); + }); + + it('returns the bearer token from AuthenticationController', async () => { + getBearerTokenMock.mockResolvedValueOnce('bearer-token-mock'); + + await expect(getBearerToken()).resolves.toBe('bearer-token-mock'); + expect(getBearerTokenMock).toHaveBeenCalled(); + }); + + it('returns undefined when AuthenticationController throws', async () => { + getBearerTokenMock.mockRejectedValueOnce(new Error('boom')); + + await expect(getBearerToken()).resolves.toBeUndefined(); + }); +}); diff --git a/app/core/apiClient.ts b/app/core/apiClient.ts new file mode 100644 index 000000000000..e09744bd826a --- /dev/null +++ b/app/core/apiClient.ts @@ -0,0 +1,13 @@ +import { createApiPlatformClient } from '@metamask/core-backend'; +import Engine from './Engine'; + +export const apiClient = createApiPlatformClient({ + clientProduct: 'metamask-mobile', + getBearerToken: async () => { + try { + return await Engine.context.AuthenticationController.getBearerToken(); + } catch { + return undefined; + } + }, +}); diff --git a/app/selectors/transactionController.test.ts b/app/selectors/transactionController.test.ts index 2725e20d6d27..4684567699ce 100644 --- a/app/selectors/transactionController.test.ts +++ b/app/selectors/transactionController.test.ts @@ -4,9 +4,16 @@ import { TransactionType } from '@metamask/transaction-controller'; import { selectTransactions, selectLastWithdrawTokenByType, + selectLocalTransactions, selectNonReplacedTransactions, + selectRequiredTransactionIds, + selectRequiredTransactionHashes, + selectRequiredTransactions, selectSwapsTransactions, + selectTransactionBatchMetadataById, selectTransactionMetadataById, + selectTransactionsByBatchId, + selectTransactionsByIds, selectSortedTransactions, selectSortedEVMTransactionsForSelectedAccountGroup, } from './transactionController'; @@ -96,6 +103,138 @@ describe('TransactionController Selectors', () => { }); }); + describe('selectRequiredTransactionHashes', () => { + it('returns hashes for required child transactions', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child'], + }, + { + id: 'child', + hash: '0xABC', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactionHashes(state)).toStrictEqual( + new Set(['0xabc']), + ); + }); + }); + + describe('selectRequiredTransactionIds', () => { + it('returns required child transaction ids', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child-1', 'child-2'], + }, + { + id: 'child-1', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactionIds(state)).toStrictEqual( + new Set(['child-1', 'child-2']), + ); + }); + }); + + describe('selectRequiredTransactions', () => { + it('returns transactions referenced by required ids', () => { + const child = { + id: 'child', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'parent', + requiredTransactionIds: ['child'], + }, + child, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectRequiredTransactions(state)).toStrictEqual([child]); + }); + }); + + describe('selectLocalTransactions', () => { + it('filters required child transactions before nonce dedupe', () => { + const activeEvmAddress = '0x0000000000000000000000000000000000000001'; + const state = { + engine: { + backgroundState: { + AccountsController: { + internalAccounts: { + selectedAccount: 'account-1', + accounts: { + 'account-1': { + id: 'account-1', + address: activeEvmAddress, + type: 'eip155:eoa', + }, + }, + }, + }, + TransactionController: { + transactions: [ + { + id: 'child', + hash: '0xCHILD', + chainId: '0x1', + time: 200, + txParams: { + from: activeEvmAddress, + nonce: '0x1', + }, + }, + { + id: 'parent', + chainId: '0x1', + requiredTransactionIds: ['child'], + time: 100, + type: TransactionType.predictDeposit, + txParams: { + from: activeEvmAddress, + nonce: '0x1', + }, + }, + ], + }, + }, + }, + pendingSmartTransactionsForGroup: [], + } as unknown as RootState; + + expect(selectLocalTransactions(state)).toStrictEqual([ + expect.objectContaining({ id: 'parent' }), + ]); + }); + }); + describe('selectTransactionMetadataById', () => { it('returns the transaction matching the given id', () => { const transactions = [ @@ -138,6 +277,78 @@ describe('TransactionController Selectors', () => { }); }); + describe('selectTransactionBatchMetadataById', () => { + it('returns the transaction batch matching the given id', () => { + const batch = { + id: 'batch-id', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [], + transactionBatches: [batch], + }, + }, + }, + } as unknown as RootState; + + expect(selectTransactionBatchMetadataById(state, 'batch-id')).toBe(batch); + }); + }); + + describe('selectTransactionsByIds', () => { + it('returns matching transactions in requested id order', () => { + const first = { + id: 'first', + }; + const second = { + id: 'second', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [first, second], + }, + }, + }, + } as unknown as RootState; + + expect( + selectTransactionsByIds(state, ['second', 'missing', 'first']), + ).toStrictEqual([second, first]); + }); + }); + + describe('selectTransactionsByBatchId', () => { + it('returns transactions matching the batch id', () => { + const matchingTransaction = { + id: 'matching', + batchId: 'batch-id', + }; + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + matchingTransaction, + { + id: 'other', + batchId: 'other-batch-id', + }, + ], + }, + }, + }, + } as unknown as RootState; + + expect(selectTransactionsByBatchId(state, 'batch-id')).toStrictEqual([ + matchingTransaction, + ]); + }); + }); + describe('selectSortedTransactions', () => { it('merges non-replaced transactions and pending smart transactions and sorts them descending by time', () => { // Transactions with one replaced transaction and two non-replaced ones diff --git a/app/selectors/transactionController.ts b/app/selectors/transactionController.ts index 0c34e5e5c65a..fc0ba1b6abdc 100644 --- a/app/selectors/transactionController.ts +++ b/app/selectors/transactionController.ts @@ -5,17 +5,55 @@ import { selectPendingSmartTransactionsBySender, selectPendingSmartTransactionsForSelectedAccountGroup, } from './smartTransactionsController'; +import { selectEvmAddress } from './accountsController'; import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { SmartTransaction } from '@metamask/smart-transactions-controller'; +import { areAddressesEqual } from '../util/address'; interface MetaMaskPayToken { address: Hex; chainId: Hex; } +type LocalTransaction = TransactionMeta | SmartTransaction; + +// Extracted from UnifiedTransactionsView +function dedupeTransactions(transactions: LocalTransaction[]) { + const seenTransactions = new Set(); + + return transactions.filter((transaction) => { + const { chainId, txParams } = transaction; + const { from, nonce, actionId } = txParams || {}; + const hash = 'hash' in transaction ? transaction.hash : undefined; + const isBridgeTransaction = transaction.type === TransactionType.bridge; + const hasNonce = nonce !== undefined && nonce !== null; + + if (!from) { + return false; + } + + const dedupeKeyPrefix = `${chainId}-${String(from).toLowerCase()}`; + const dedupeKey = + isBridgeTransaction && hash + ? `${dedupeKeyPrefix}-bridge-${hash.toLowerCase()}` + : hasNonce + ? `${dedupeKeyPrefix}-${nonce}` + : `${dedupeKeyPrefix}-${actionId}`; + + // Keep only the first local transaction for each dedupe key + if (seenTransactions.has(dedupeKey)) { + return false; + } + + seenTransactions.add(dedupeKey); + return true; + }); +} + function getNestedTransactionTypes( transaction: TransactionMeta, ): TransactionType[] { @@ -55,6 +93,28 @@ const selectTransactionBatchesStrict = createSelector( (transactionControllerState) => transactionControllerState.transactionBatches, ); +export const selectRequiredTransactionIds = createSelector( + selectTransactionsStrict, + (transactions) => + new Set(transactions.flatMap((tx) => tx.requiredTransactionIds ?? [])), +); + +export const selectRequiredTransactions = createSelector( + [selectTransactionsStrict, selectRequiredTransactionIds], + (transactions, requiredTransactionIds) => + transactions.filter((tx) => requiredTransactionIds.has(tx.id)), +); + +export const selectRequiredTransactionHashes = createSelector( + selectRequiredTransactions, + (transactions) => + new Set( + transactions + .map((tx) => tx.hash?.toLowerCase()) + .filter((hash): hash is string => Boolean(hash)), + ), +); + export const selectTransactions = createDeepEqualSelector( selectTransactionsStrict, (transactions) => transactions, @@ -125,6 +185,49 @@ export const selectSortedEVMTransactionsForSelectedAccountGroup = ), ); +export const selectLocalTransactions = createDeepEqualSelector( + [ + selectNonReplacedTransactions, + selectPendingSmartTransactionsForSelectedAccountGroup, + selectEvmAddress, + selectRequiredTransactionIds, + ], + ( + nonReplacedTransactions, + pendingSmartTransactions, + activeEvmAddress, + requiredTransactionIds, + ) => { + const transactions = nonReplacedTransactions.filter((transaction) => { + if (requiredTransactionIds.has(transaction.id)) { + return false; + } + + const fromAddress = transaction.txParams?.from; + if (!fromAddress || !activeEvmAddress) { + return false; + } + + return areAddressesEqual(fromAddress, activeEvmAddress); + }); + + const pendingSmartTransactionsForActiveAddress = + pendingSmartTransactions.filter((transaction) => { + const fromAddress = transaction.txParams?.from; + if (!fromAddress || !activeEvmAddress) { + return false; + } + + return areAddressesEqual(fromAddress, activeEvmAddress); + }); + + return dedupeTransactions([ + ...transactions, + ...pendingSmartTransactionsForActiveAddress, + ]).sort((a, b) => (b?.time ?? 0) - (a?.time ?? 0)); + }, +); + export const selectSwapsTransactions = createSelector( selectTransactionControllerState, (transactionControllerState) => diff --git a/app/util/bridge/hooks/useBridgeTxHistoryData.ts b/app/util/bridge/hooks/useBridgeTxHistoryData.ts index ca514c7de25b..ac0fec55e98e 100644 --- a/app/util/bridge/hooks/useBridgeTxHistoryData.ts +++ b/app/util/bridge/hooks/useBridgeTxHistoryData.ts @@ -6,6 +6,7 @@ import { import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController'; import { Transaction } from '@metamask/keyring-api'; import { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import { equalsIgnoreCase } from '../../string'; export const FINAL_NON_CONFIRMED_STATUSES = [ TransactionStatus.failed, @@ -46,16 +47,23 @@ export function useBridgeTxHistoryData({ // If not found, try to find by originalTransactionId for intent transactions if (!bridgeHistoryItem && srcTxMetaId) { const matchingEntry = Object.entries(bridgeHistory).find( - ([_, historyItem]) => - (historyItem as unknown as { originalTransactionId: string }) + ([, historyItem]) => + (historyItem as { originalTransactionId?: string }) .originalTransactionId === srcTxMetaId, ); bridgeHistoryItem = matchingEntry ? matchingEntry[1] : undefined; } + + // Fallback for API-normalized transactions whose id differs from txMetaId + if (!bridgeHistoryItem && evmTxMeta.hash) { + bridgeHistoryItem = Object.values(bridgeHistory).find((item) => + equalsIgnoreCase(item.status.srcChain.txHash, evmTxMeta.hash), + ); + } } else if (multiChainTx) { const srcTxHash = multiChainTx?.id; - bridgeHistoryItem = Object.values(bridgeHistory).find( - (item) => item.status.srcChain.txHash === srcTxHash, + bridgeHistoryItem = Object.values(bridgeHistory).find((item) => + equalsIgnoreCase(item.status.srcChain.txHash, srcTxHash), ); } diff --git a/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts b/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts index 18b18f290207..628d0fc4d15b 100644 --- a/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts +++ b/app/util/bridge/hooks/useBridgeTxHistoryData/useBridgeTxHistoryData.test.ts @@ -105,6 +105,35 @@ describe('useBridgeTxHistoryData', () => { }); }); + it('should find bridge history item by EVM transaction hash', async () => { + const tx: TransactionMeta = { + id: 'api-normalized-transaction-id', + hash: mockTxHash, + status: TransactionStatus.confirmed, + chainId: mockChainId, + networkClientId: 'mainnet', + time: Date.now(), + txParams: { + to: '0x123', + from: '0x456', + value: '0x0', + data: '0x', + }, + }; + + const { result } = renderHookWithProvider( + () => useBridgeTxHistoryData({ evmTxMeta: tx }), + { + state: initialState, + }, + ); + + await waitFor(() => { + expect(result.current.bridgeTxHistoryItem?.txMetaId).toBe(mockTxId); + expect(result.current.isBridgeComplete).toBe(true); + }); + }); + it('should find bridge history item by multi-chain transaction hash', async () => { const multiChainTx: Transaction = { id: mockTxHash, diff --git a/app/util/string/index.test.ts b/app/util/string/index.test.ts index 99f46d7b33c8..42ca951e3d31 100644 --- a/app/util/string/index.test.ts +++ b/app/util/string/index.test.ts @@ -1,4 +1,5 @@ import { + equalsIgnoreCase, escapeSpecialUnicode, isArrayType, isSolidityType, @@ -23,6 +24,29 @@ describe('string utils', () => { }); }); + describe('equalsIgnoreCase', () => { + it('returns true for identical strings', () => { + expect(equalsIgnoreCase('hello', 'hello')).toBe(true); + }); + + it('returns true for strings differing only in case', () => { + expect(equalsIgnoreCase('Hello', 'hELLo')).toBe(true); + expect(equalsIgnoreCase('0xABC123', '0xabc123')).toBe(true); + }); + + it('returns false for different strings', () => { + expect(equalsIgnoreCase('hello', 'world')).toBe(false); + }); + + it('returns false when either value is nullish or empty', () => { + expect(equalsIgnoreCase(undefined, 'hello')).toBe(false); + expect(equalsIgnoreCase('hello', undefined)).toBe(false); + expect(equalsIgnoreCase(null, null)).toBe(false); + expect(equalsIgnoreCase('', 'hello')).toBe(false); + expect(equalsIgnoreCase('', '')).toBe(false); + }); + }); + describe('isArrayType', () => { [ ['uint256[]', true], diff --git a/app/util/string/index.ts b/app/util/string/index.ts index f797c1816765..360411ce0e95 100644 --- a/app/util/string/index.ts +++ b/app/util/string/index.ts @@ -27,6 +27,16 @@ export const stripMultipleNewlines = ( return str.replace(/\n+/g, '\n'); }; +export const equalsIgnoreCase = ( + a: string | undefined | null, + b: string | undefined | null, +) => { + if (!a || !b) { + return false; + } + return a.toLowerCase() === b.toLowerCase(); +}; + const solidityTypes = () => { const types = [ 'bool', diff --git a/tests/smoke/wallet/incoming-transactions.spec.ts b/tests/smoke/wallet/incoming-transactions.spec.ts index b5c46a494972..28bd9f8eb6af 100644 --- a/tests/smoke/wallet/incoming-transactions.spec.ts +++ b/tests/smoke/wallet/incoming-transactions.spec.ts @@ -104,16 +104,15 @@ function mockAccountsApi( transactions: Record[] = [], ): MockApiEndpoint { return { - urlEndpoint: new RegExp( - `^https://accounts\\.api\\.cx\\.metamask\\.io/v1/accounts/${DEFAULT_FIXTURE_ACCOUNT}/transactions\\?.*sortDirection=DESC`, - ), + urlEndpoint: + /^https:\/\/accounts\.api\.cx\.metamask\.io\/v4\/multiaccount\/transactions(\?.*)?$/, response: { data: transactions.length > 0 ? transactions : [RESPONSE_STANDARD_MOCK, RESPONSE_STANDARD_2_MOCK], pageInfo: { - count: 2, + count: transactions.length || 2, hasNextPage: false, }, }, @@ -126,12 +125,16 @@ function createAccountsTestSpecificMock( ): TestSpecificMock { return async (mockServer: Mockttp) => { const mock = mockAccountsApi(transactions); - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: mock.urlEndpoint, - response: mock.response, - responseCode: mock.responseCode, - }); + await setupMockRequest( + mockServer, + { + requestMethod: 'GET', + url: mock.urlEndpoint, + response: mock.response, + responseCode: mock.responseCode, + }, + 1000, + ); }; } @@ -184,7 +187,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { { fixture, restartDevice: true, - testSpecificMock: createAccountsTestSpecificMock(), + testSpecificMock: createAccountsTestSpecificMock([ + RESPONSE_STANDARD_MOCK, + ]), }, async () => { await loginToApp(); @@ -262,7 +267,7 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { ); }); - it('displays nothing if privacyMode is enabled', async () => { + it.skip('displays nothing if privacyMode is enabled', async () => { const fixture = new FixtureBuilder() .withAccountTreeController( EVM_ONLY_ACCOUNT_TREE as unknown as Partial, From 0b6a6a0fe44c8fc684805ee28475156e1264c425 Mon Sep 17 00:00:00 2001 From: Curtis David Date: Wed, 6 May 2026 17:11:51 -0400 Subject: [PATCH 26/28] test: 1/3 remove wdio folder dependencies (#29820) ## **Description** > Removes the WDIO `generateTestId` helper and updates many UI components to pass `testID` directly (dropping `Platform`-dependent spreading) across buttons, toggles, list items, and modals. > > Introduces/standardizes colocated `*.testIds.ts` selector constants for several screens/components (e.g., `Navbar`, `EthereumAddress`, `AccountSelector`, `BrowserTab` options, `PhishingModal`, `WebviewError`, `TermsAndConditions`, `ConnectQRHardware`) and updates unit tests/components to import selectors from the new `tests/selectors`/local testId modules instead of `wdio` paths. > ## **Changelog** CHANGELOG entry: ## **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** > Low risk refactor focused on test selectors: changes only how `testID`s are assigned and referenced, with minimal runtime impact beyond potential selector/name mismatches in tests/automation. > > **Overview** > Removes the WDIO `generateTestId` utility and updates multiple UI components to pass `testID` directly (dropping `Platform`-dependent spreading) across buttons, toggles, list items, and modals. > > Introduces/standardizes co-located `*.testIds.ts` selector constants (e.g., `Navbar`, `EthereumAddress`, `AccountSelector`, browser options, phishing/webview error modals, terms, QR hardware connect) and updates unit tests/components to import selectors from these modules (and from `tests/selectors`) instead of `wdio` paths. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7d696af86a879bb3e87e940070a5f98da74b4224. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../SheetActions/SheetActions.tsx | 5 +- .../AvatarNetwork/AvatarNetwork.constants.ts | 8 +--- .../AddToAddressBookWrapper.tsx | 13 ++---- app/components/UI/AssetElement/index.test.tsx | 2 +- app/components/UI/AssetElement/index.tsx | 7 ++- .../AssetActionButton/AssetActionButton.tsx | 5 +- .../components/TokenSelectorItem.test.tsx | 5 -- .../Bridge/components/TokenSelectorItem.tsx | 9 +--- .../ManageCardListItem/ManageCardListItem.tsx | 5 +- .../EthereumAddress.testIds.ts | 3 ++ app/components/UI/EthereumAddress/index.js | 7 ++- .../AccountSelector.testIds.ts | 5 ++ .../HardwareWallet/AccountSelector/index.tsx | 15 ++---- app/components/UI/Navbar/Navbar.testIds.ts | 4 ++ app/components/UI/Navbar/index.js | 7 +-- app/components/UI/NetworkInfo/index.tsx | 3 +- .../UI/PhishingModal/PhishingModal.testIds.ts | 3 ++ app/components/UI/PhishingModal/index.js | 6 +-- .../SecurityOptionToggle.tsx | 5 +- app/components/UI/SettingsDrawer/index.js | 9 +--- .../UI/SkipAccountSecurityModal/index.js | 3 +- .../TokenList/TokenListItem/TokenListItem.tsx | 7 ++- app/components/UI/UrlAutocomplete/Result.tsx | 2 +- .../UrlAutocomplete.testIds.ts | 1 + .../UI/UrlAutocomplete/index.test.tsx | 2 +- .../UI/WebviewError/WebviewError.testIds.ts | 5 ++ app/components/UI/WebviewError/index.js | 15 ++---- .../components/Options/Options.testIds.ts | 10 ++++ .../BrowserTab/components/Options/index.tsx | 23 +++------- .../ConnectQRHardware.testIds.ts | 3 ++ .../Instruction/index.test.tsx | 6 ++- .../ConnectQRHardware/Instruction/index.tsx | 7 ++- .../Views/ConnectQRHardware/index.test.tsx | 34 ++++++-------- .../Views/LedgerSelectAccount/index.test.tsx | 46 +++++++++++-------- .../BatchAccountBalanceSettings/index.tsx | 8 +--- .../SecuritySettings/SecuritySettings.tsx | 4 +- .../TermsAndConditions.testIds.ts | 3 ++ .../Views/TermsAndConditions/index.js | 7 ++- wdio/utils/generateTestId.js | 2 - 39 files changed, 145 insertions(+), 169 deletions(-) create mode 100644 app/components/UI/EthereumAddress/EthereumAddress.testIds.ts create mode 100644 app/components/UI/HardwareWallet/AccountSelector/AccountSelector.testIds.ts create mode 100644 app/components/UI/Navbar/Navbar.testIds.ts create mode 100644 app/components/UI/PhishingModal/PhishingModal.testIds.ts create mode 100644 app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts create mode 100644 app/components/UI/WebviewError/WebviewError.testIds.ts create mode 100644 app/components/Views/BrowserTab/components/Options/Options.testIds.ts create mode 100644 app/components/Views/ConnectQRHardware/ConnectQRHardware.testIds.ts create mode 100644 app/components/Views/TermsAndConditions/TermsAndConditions.testIds.ts delete mode 100644 wdio/utils/generateTestId.js diff --git a/app/component-library/components-temp/SheetActions/SheetActions.tsx b/app/component-library/components-temp/SheetActions/SheetActions.tsx index 6a6d0874b350..d99130d42c90 100644 --- a/app/component-library/components-temp/SheetActions/SheetActions.tsx +++ b/app/component-library/components-temp/SheetActions/SheetActions.tsx @@ -1,6 +1,6 @@ // Third party dependencies. import React, { useCallback } from 'react'; -import { Platform, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; @@ -10,7 +10,6 @@ import Button, { ButtonWidthTypes, } from '../../components/Buttons/Button'; import Loader from '../Loader'; -import generateTestId from '../../../../wdio/utils/generateTestId'; // Internal dependencies. import { SheetActionsProps } from './SheetActions.types'; @@ -46,7 +45,7 @@ const SheetActions = ({ actions }: SheetActionsProps) => { disabled={disabled || isLoading} style={buttonStyle} isDanger={isDanger} - {...generateTestId(Platform, testID)} + testID={testID} /> {isLoading && } diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts index 271a41f73498..ad2719b501a0 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts @@ -1,12 +1,11 @@ /* eslint-disable import-x/prefer-default-export */ // Third party dependencies. -import { ImageSourcePropType, Platform } from 'react-native'; +import { ImageSourcePropType } from 'react-native'; // External dependencies. import { AvatarSize } from '../../Avatar.types'; import { BrowserViewSelectorsIDs } from '../../../../../../components/Views/BrowserTab/BrowserView.testIds'; -import generateTestId from '../../../../../../../wdio/utils/generateTestId'; // Internal dependencies. import { AvatarNetworkProps } from './AvatarNetwork.types'; @@ -16,10 +15,7 @@ export const DEFAULT_AVATARNETWORK_SIZE = AvatarSize.Md; export const DEFAULT_AVATARNETWORK_ERROR_TEXT = '?'; // Test IDs -export const AVATARNETWORK_IMAGE_TESTID = generateTestId( - Platform, - BrowserViewSelectorsIDs.AVATAR_IMAGE, -).testID; +export const AVATARNETWORK_IMAGE_TESTID = BrowserViewSelectorsIDs.AVATAR_IMAGE; // Sample consts const SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE: ImageSourcePropType = { diff --git a/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx b/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx index 46cfc943afc6..42136583e366 100644 --- a/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx +++ b/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx @@ -1,8 +1,7 @@ import React, { ReactElement, useState } from 'react'; import { useSelector } from 'react-redux'; -import { View, Platform, TextInput, TouchableOpacity } from 'react-native'; +import { View, TextInput, TouchableOpacity } from 'react-native'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import { AddAddressModalSelectorsIDs } from './AddAddressModal.testIds'; import { strings } from '../../../../locales/i18n'; import Engine from '../../../core/Engine'; @@ -91,10 +90,7 @@ export const AddToAddressBookWrapper = ({ {strings('address_book.add_to_address_book')} @@ -117,10 +113,7 @@ export const AddToAddressBookWrapper = ({ numberOfLines={1} value={alias} keyboardAppearance={themeAppearance} - {...generateTestId( - Platform, - AddAddressModalSelectorsIDs.ENTER_ALIAS_INPUT, - )} + testID={AddAddressModalSelectorsIDs.ENTER_ALIAS_INPUT} /> diff --git a/app/components/UI/AssetElement/index.test.tsx b/app/components/UI/AssetElement/index.test.tsx index 5621cd0019fe..fe6fe281b406 100644 --- a/app/components/UI/AssetElement/index.test.tsx +++ b/app/components/UI/AssetElement/index.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import AssetElement from './'; -import { getAssetTestId } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { getAssetTestId } from '../../../../tests/selectors/Wallet/WalletView.selectors'; import { BALANCE_TEST_ID, SECONDARY_BALANCE_BUTTON_TEST_ID, diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index 02fab95a07be..200ae2946db3 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -1,14 +1,13 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; +import { TouchableOpacity, StyleSheet, View } from 'react-native'; import { TextVariant, TextColor, } from '../../../component-library/components/Texts/Text'; import SkeletonText from '../Ramp/Aggregator/components/SkeletonText'; import { TokenI } from '../Tokens/types'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { getAssetTestId } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { getAssetTestId } from '../../../../tests/selectors/Wallet/WalletView.selectors'; import SensitiveText, { SensitiveTextLength, } from '../../../component-library/components/Texts/SensitiveText'; @@ -113,7 +112,7 @@ const AssetElement: React.FC = ({ onPress={handleOnPress} onLongPress={handleOnLongPress} style={styles.itemWrapper} - {...generateTestId(Platform, getAssetTestId(asset.symbol))} + testID={getAssetTestId(asset.symbol)} > {children} diff --git a/app/components/UI/AssetOverview/AssetActionButton/AssetActionButton.tsx b/app/components/UI/AssetOverview/AssetActionButton/AssetActionButton.tsx index a7808efba37b..eae80152f570 100644 --- a/app/components/UI/AssetOverview/AssetActionButton/AssetActionButton.tsx +++ b/app/components/UI/AssetOverview/AssetActionButton/AssetActionButton.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { Platform, TouchableOpacity, View } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import FeatherIcon from 'react-native-vector-icons/Feather'; import Ionicon from 'react-native-vector-icons/Ionicons'; import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import generateTestId from '../../../../../wdio/utils/generateTestId'; import { useTheme } from '../../../../util/theme'; import Text from '../../../Base/Text'; import styleSheet from './AssetActionButton.styles'; @@ -84,7 +83,7 @@ const AssetActionButton = ({ return ( ({ }), })); -jest.mock('../../../../../wdio/utils/generateTestId', () => ({ - __esModule: true, - default: () => ({}), -})); - jest.mock( '../../../../component-library/components/Badges/BadgeWrapper', () => ({ diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.tsx index d63e7412d6db..b38b68dff988 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.tsx @@ -4,14 +4,12 @@ import { ImageSourcePropType, View, TouchableOpacity, - Platform, StyleProp, TextStyle, } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; -import { getAssetTestId } from '../../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; -import generateTestId from '../../../../../wdio/utils/generateTestId'; +import { getAssetTestId } from '../../../../../tests/selectors/Wallet/WalletView.selectors'; import TagBase, { TagSeverity, TagShape, @@ -334,10 +332,7 @@ export const TokenSelectorItem: React.FC = ({ key={token.address} onPress={() => onPress(token)} style={styles.itemWrapper} - {...generateTestId( - Platform, - getAssetTestId(`${token.chainId}-${token.symbol}`), - )} + testID={getAssetTestId(`${token.chainId}-${token.symbol}`)} > = ({ const styles = createStyles(colors, descriptionOrientation); return ( - + diff --git a/app/components/UI/EthereumAddress/EthereumAddress.testIds.ts b/app/components/UI/EthereumAddress/EthereumAddress.testIds.ts new file mode 100644 index 000000000000..ae003d447e43 --- /dev/null +++ b/app/components/UI/EthereumAddress/EthereumAddress.testIds.ts @@ -0,0 +1,3 @@ +export const EthereumAddressSelectorsIDs = { + ADDRESS_LABEL: 'wallet-account-address-label', +}; diff --git a/app/components/UI/EthereumAddress/index.js b/app/components/UI/EthereumAddress/index.js index 82b2ce368a88..cd2cd15e0e08 100644 --- a/app/components/UI/EthereumAddress/index.js +++ b/app/components/UI/EthereumAddress/index.js @@ -1,9 +1,8 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Platform, Text } from 'react-native'; +import { Text } from 'react-native'; import { formatAddress } from '../../../util/address'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { WALLET_ACCOUNT_ADDRESS_LABEL } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { EthereumAddressSelectorsIDs } from './EthereumAddress.testIds'; /** * View that renders an ethereum address @@ -56,7 +55,7 @@ class EthereumAddress extends PureComponent { {this.state.address} diff --git a/app/components/UI/HardwareWallet/AccountSelector/AccountSelector.testIds.ts b/app/components/UI/HardwareWallet/AccountSelector/AccountSelector.testIds.ts new file mode 100644 index 000000000000..606a2dfeeddd --- /dev/null +++ b/app/components/UI/HardwareWallet/AccountSelector/AccountSelector.testIds.ts @@ -0,0 +1,5 @@ +export const AccountSelectorSelectorsIDs = { + NEXT_BUTTON: 'account-selector-next-button', + PREVIOUS_BUTTON: 'account-selector-previous-button', + FORGET_BUTTON: 'account-selector-forget-button', +}; diff --git a/app/components/UI/HardwareWallet/AccountSelector/index.tsx b/app/components/UI/HardwareWallet/AccountSelector/index.tsx index 78a2114f6150..f693200af2e2 100644 --- a/app/components/UI/HardwareWallet/AccountSelector/index.tsx +++ b/app/components/UI/HardwareWallet/AccountSelector/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState, useMemo } from 'react'; -import { View, Text, FlatList, TouchableOpacity, Platform } from 'react-native'; +import { View, Text, FlatList, TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import CheckBox from '@react-native-community/checkbox'; import { useSelector } from 'react-redux'; @@ -13,12 +13,7 @@ import { createStyle } from './styles'; import AccountDetails from '../AccountDetails'; import StyledButton from '../../../UI/StyledButton'; import { selectProviderConfig } from '../../../../selectors/networkController'; -import generateTestId from '../../../../../wdio/utils/generateTestId'; -import { - ACCOUNT_SELECTOR_FORGET_BUTTON, - ACCOUNT_SELECTOR_NEXT_BUTTON, - ACCOUNT_SELECTOR_PREVIOUS_BUTTON, -} from '../../../../../wdio/screen-objects/testIDs/Components/AccountSelector.testIds'; +import { AccountSelectorSelectorsIDs } from './AccountSelector.testIds'; import { toFormattedAddress } from '../../../../util/address'; interface ISelectQRAccountsProps { @@ -122,7 +117,7 @@ const AccountSelector = (props: ISelectQRAccountsProps) => { @@ -132,7 +127,7 @@ const AccountSelector = (props: ISelectQRAccountsProps) => { {strings('account_selector.next')} @@ -153,7 +148,7 @@ const AccountSelector = (props: ISelectQRAccountsProps) => { type={'transparent-blue'} onPress={onForget} containerStyle={[styles.button]} - {...generateTestId(Platform, ACCOUNT_SELECTOR_FORGET_BUTTON)} + testID={AccountSelectorSelectorsIDs.FORGET_BUTTON} > {strings('account_selector.forget')} diff --git a/app/components/UI/Navbar/Navbar.testIds.ts b/app/components/UI/Navbar/Navbar.testIds.ts new file mode 100644 index 000000000000..503ce2f58629 --- /dev/null +++ b/app/components/UI/Navbar/Navbar.testIds.ts @@ -0,0 +1,4 @@ +export const NavbarSelectorsIDs = { + ANDROID_BACK_BUTTON: 'nav-android-back', + BACK_BUTTON_SIMPLE_WEBVIEW: 'back_button_simple_webview', +}; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index b639085f6dd5..7c671c31fc82 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -5,7 +5,6 @@ import ModalNavbarTitle from '../ModalNavbarTitle'; import { Alert, Image, - Platform, StyleSheet, Text, TouchableOpacity, @@ -21,9 +20,7 @@ import { analytics } from '../../../util/analytics/analytics'; import { Authentication } from '../../../core'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import Device from '../../../util/device'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { NAV_ANDROID_BACK_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/NetworksScreen.testids'; -import { BACK_BUTTON_SIMPLE_WEBVIEW } from '../../../../wdio/screen-objects/testIDs/Components/SimpleWebView.testIds'; +import { NavbarSelectorsIDs } from './Navbar.testIds'; import Routes from '../../../constants/navigation/Routes'; import { @@ -602,7 +599,7 @@ export function getClosableNavigationOptions( { type="confirm" onPress={onClose} containerStyle={styles.closeButton} - testID={NETWORK_EDUCATION_MODAL_CLOSE_BUTTON} + testID={NetworkEducationModalSelectorsIDs.CLOSE_BUTTON} > {strings('network_information.got_it')} diff --git a/app/components/UI/PhishingModal/PhishingModal.testIds.ts b/app/components/UI/PhishingModal/PhishingModal.testIds.ts new file mode 100644 index 000000000000..3346e5bb9521 --- /dev/null +++ b/app/components/UI/PhishingModal/PhishingModal.testIds.ts @@ -0,0 +1,3 @@ +export const PhishingModalSelectorsIDs = { + DETECTION_TITLE: 'ethereum-detection-title', +}; diff --git a/app/components/UI/PhishingModal/index.js b/app/components/UI/PhishingModal/index.js index 9cf64191756b..0e44773a8937 100644 --- a/app/components/UI/PhishingModal/index.js +++ b/app/components/UI/PhishingModal/index.js @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - Platform, Linking, TouchableOpacity, } from 'react-native'; @@ -13,8 +12,7 @@ import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import URL from 'url-parse'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { ETHEREUM_DETECTION_TITLE } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { PhishingModalSelectorsIDs } from './PhishingModal.testIds'; import Button from '../../../component-library/components/Buttons/Button/Button'; import { ButtonVariants, @@ -146,7 +144,7 @@ export default class PhishingModal extends PureComponent { {strings('phishing.site_might_be_harmful')} diff --git a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx index be8f03f8509c..85471e4f3b88 100644 --- a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx +++ b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Platform, Switch, View } from 'react-native'; +import { Switch, View } from 'react-native'; import { createStyles } from './styles'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Text, { TextColor, TextVariant, @@ -57,7 +56,7 @@ const SecurityOptionToggle = ({ style={styles.switch} ios_backgroundColor={colors.border.muted} disabled={disabled} - {...generateTestId(Platform, testId)} + testID={testId} /> diff --git a/app/components/UI/SettingsDrawer/index.js b/app/components/UI/SettingsDrawer/index.js index ba16c8ad17ce..57d57bf854c7 100644 --- a/app/components/UI/SettingsDrawer/index.js +++ b/app/components/UI/SettingsDrawer/index.js @@ -1,9 +1,8 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; import PropTypes from 'prop-types'; import { fontStyles } from '../../../styles/common'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Icon, { IconColor, IconName, @@ -54,10 +53,6 @@ const propTypes = { * Additional descriptive text about this option */ description: PropTypes.string, - /** - * Disable bottom border - */ - noBorder: PropTypes.bool, /** * Handler called when this drawer is pressed */ @@ -96,7 +91,7 @@ const SettingsDrawer = ({ const { colors } = useTheme(); const styles = createStyles(colors, titleColor); return ( - + diff --git a/app/components/UI/SkipAccountSecurityModal/index.js b/app/components/UI/SkipAccountSecurityModal/index.js index a46800d47a5a..77391bebb7b8 100644 --- a/app/components/UI/SkipAccountSecurityModal/index.js +++ b/app/components/UI/SkipAccountSecurityModal/index.js @@ -11,7 +11,6 @@ import Text, { } from '../../../component-library/components/Texts/Text'; import PropTypes from 'prop-types'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import { SkipAccountSecurityModalSelectorsIDs } from './SkipAccountSecurityModal.testIds'; import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet'; import Checkbox from '../../../component-library/components/Checkbox'; @@ -102,7 +101,7 @@ const SkipAccountSecurityModal = ({ route }) => { name={IconName.Danger} size={IconSize.Lg} style={styles.imageWarning} - {...generateTestId(Platform, 'skip-backup-warning')} + testID="skip-backup-warning" /> diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 8fe0f6230a84..0f6a3bd30dcf 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { CaipAssetType, Hex } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo } from 'react'; -import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import Badge, { BadgeVariant, @@ -76,8 +76,7 @@ import { } from '@metamask/assets-controllers'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; import { safeToChecksumAddress } from '../../../../../util/address'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; -import { getAssetTestId } from '../../../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { getAssetTestId } from '../../../../../../tests/selectors/Wallet/WalletView.selectors'; import SkeletonText from '../../../Ramp/Aggregator/components/SkeletonText'; import { TOKEN_BALANCE_LOADING, @@ -563,7 +562,7 @@ export const TokenListItem = React.memo( onLongPress?.(asset); }} style={styles.itemWrapper} - {...generateTestId(Platform, getAssetTestId(asset.symbol))} + testID={getAssetTestId(asset.symbol)} > {/* Column: 1 - Token logo */} `delete-favorite-${url}`; diff --git a/app/components/UI/UrlAutocomplete/index.test.tsx b/app/components/UI/UrlAutocomplete/index.test.tsx index 7419393ce9cb..3b43cee2db01 100644 --- a/app/components/UI/UrlAutocomplete/index.test.tsx +++ b/app/components/UI/UrlAutocomplete/index.test.tsx @@ -143,7 +143,7 @@ jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => ({ import React from 'react'; import UrlAutocomplete, { UrlAutocompleteRef } from './'; -import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; +import { deleteFavoriteTestId } from './UrlAutocomplete.testIds'; import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; import renderWithProvider, { DeepPartial, diff --git a/app/components/UI/WebviewError/WebviewError.testIds.ts b/app/components/UI/WebviewError/WebviewError.testIds.ts new file mode 100644 index 000000000000..dfb8b7774632 --- /dev/null +++ b/app/components/UI/WebviewError/WebviewError.testIds.ts @@ -0,0 +1,5 @@ +export const WebviewErrorSelectorsIDs = { + TITLE: 'error-page-title', + MESSAGE: 'error-page-message', + RETURN_BUTTON: 'error-page-return-button', +}; diff --git a/app/components/UI/WebviewError/index.js b/app/components/UI/WebviewError/index.js index a2d6d0004b3d..bdae4ab0fd28 100644 --- a/app/components/UI/WebviewError/index.js +++ b/app/components/UI/WebviewError/index.js @@ -1,16 +1,11 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Image, StyleSheet, View, Text, Platform } from 'react-native'; +import { Image, StyleSheet, View, Text } from 'react-native'; import StyledButton from '../StyledButton'; import { strings } from '../../../../locales/i18n'; import { fontStyles } from '../../../styles/common'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { - ERROR_PAGE_MESSAGE, - ERROR_PAGE_RETURN_BUTTON, - ERROR_PAGE_TITLE, -} from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { WebviewErrorSelectorsIDs } from './WebviewError.testIds'; const createStyles = (colors) => StyleSheet.create({ @@ -102,13 +97,13 @@ export default class WebviewError extends PureComponent { {strings('webview_error.title')} {strings('webview_error.message')} @@ -118,7 +113,7 @@ export default class WebviewError extends PureComponent { {strings('webview_error.return_home')} diff --git a/app/components/Views/BrowserTab/components/Options/Options.testIds.ts b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts new file mode 100644 index 000000000000..dac9ec7070ad --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts @@ -0,0 +1,10 @@ +export const BrowserOptionsSelectorsIDs = { + MENU: 'browser-options-menu', + ADD_FAVORITES: 'browser-options-menu-add-favorites', + OPEN_FAVORITES: 'browser-options-menu-open-favorites', + NEW_TAB: 'browser-options-menu-new-tab', + RELOAD: 'browser-options-menu-reload', + SHARE: 'browser-options-menu-share', + OPEN_IN_BROWSER: 'browser-options-menu-open-in-browser', + SWITCH_NETWORK: 'browser-options-switch-browser', +}; diff --git a/app/components/Views/BrowserTab/components/Options/index.tsx b/app/components/Views/BrowserTab/components/Options/index.tsx index 607a2cc9185e..664216f2ee27 100644 --- a/app/components/Views/BrowserTab/components/Options/index.tsx +++ b/app/components/Views/BrowserTab/components/Options/index.tsx @@ -1,27 +1,18 @@ import React, { MutableRefObject, useCallback } from 'react'; import { Linking, - Platform, Text, TouchableWithoutFeedback, View, ImageSourcePropType, } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; import Device from '../../../../../util/device'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './styles'; import Button from '../../../../UI/Button'; import { strings } from '../../../../../../locales/i18n'; -import { - ADD_FAVORITES_OPTION, - MENU_ID, - NEW_TAB_OPTION, - OPEN_IN_BROWSER_OPTION, - RELOAD_OPTION, - SHARE_OPTION, -} from '../../../../../../wdio/screen-objects/testIDs/BrowserScreen/OptionMenu.testIds'; +import { BrowserOptionsSelectorsIDs } from './Options.testIds'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -194,7 +185,7 @@ const Options = ({ {strings('browser.share')} @@ -216,7 +207,7 @@ const Options = ({ {strings('browser.reload')} @@ -235,7 +226,7 @@ const Options = ({ ? styles.optionsWrapperAndroid : styles.optionsWrapperIos, ]} - {...generateTestId(Platform, MENU_ID)} + testID={BrowserOptionsSelectorsIDs.MENU} > + + ); }; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx index 9917f800bcea..6f2633ce026f 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx @@ -197,7 +197,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('+$221.59'); @@ -239,7 +239,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); }); }); @@ -279,7 +279,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); }); }); @@ -318,7 +318,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('$0.00'); @@ -560,7 +560,7 @@ describe('AssetOverviewClaimBonus', () => { // (700 + 300) * 3% = 30.00 expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Claim $5.00 bonus'); @@ -586,7 +586,7 @@ describe('AssetOverviewClaimBonus', () => { // 500 * 3% = 15.00, "Accruing next bonus" because balance > 0 & no claim expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -614,7 +614,7 @@ describe('AssetOverviewClaimBonus', () => { // on Linea and always returned undefined, dropping Linea balances. expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$6.00'); + ).toHaveTextContent('$6.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -642,7 +642,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('No accruing bonus'); @@ -683,7 +683,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$4.50'); + ).toHaveTextContent('$4.50'); }); it('looks up mUSD on each chain using checksummed addresses', () => { diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx index 67903b1e659a..be7bddd408a1 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx @@ -129,8 +129,8 @@ const AssetOverviewClaimBonus: React.FC = ({ [balance], ); const formattedAnnualBonus = hasBalance - ? `+$${estimatedAnnualBonus.toFixed(2)}` - : '+$0.00'; + ? `$${estimatedAnnualBonus.toFixed(2)}` + : '$0.00'; // Lifetime bonus: white $0.00 until first claim, then green +$X. const hasLifetimeBonus = Number(lifetimeBonusClaimed) > 0; @@ -356,7 +356,7 @@ const AssetOverviewClaimBonus: React.FC = ({ {/* CTA Button */} - ); }; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx new file mode 100644 index 000000000000..2e80be9fb978 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyMusdEmptyBalanceRow from './MoneyMusdEmptyBalanceRow'; +import { MoneyMusdEmptyBalanceRowTestIds } from './MoneyMusdEmptyBalanceRow.testIds'; + +describe('MoneyMusdEmptyBalanceRow', () => { + it('renders the MetaMask USD name', () => { + const { getByText } = renderWithProvider(); + expect(getByText('MetaMask USD')).toBeOnTheScreen(); + }); + + it('renders the zero fiat balance', () => { + const { getByTestId } = renderWithProvider(); + expect( + getByTestId(MoneyMusdEmptyBalanceRowTestIds.FIAT_BALANCE), + ).toHaveTextContent('$0.00'); + }); + + it('renders the zero native balance', () => { + const { getByTestId } = renderWithProvider(); + expect( + getByTestId(MoneyMusdEmptyBalanceRowTestIds.NATIVE_BALANCE), + ).toHaveTextContent('0 mUSD'); + }); + + it('calls onPress when the row is tapped', () => { + const onPress = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + fireEvent.press(getByTestId(MoneyMusdEmptyBalanceRowTestIds.CONTAINER)); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not throw when tapped without an onPress handler', () => { + const { getByTestId } = renderWithProvider(); + expect(() => + fireEvent.press(getByTestId(MoneyMusdEmptyBalanceRowTestIds.CONTAINER)), + ).not.toThrow(); + }); +}); diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts new file mode 100644 index 000000000000..03882c7cab99 --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.testIds.ts @@ -0,0 +1,5 @@ +export const MoneyMusdEmptyBalanceRowTestIds = { + CONTAINER: 'money-musd-empty-balance-row-container', + FIAT_BALANCE: 'money-musd-empty-balance-row-fiat', + NATIVE_BALANCE: 'money-musd-empty-balance-row-native', +}; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx new file mode 100644 index 000000000000..6b5690c4049d --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/MoneyMusdEmptyBalanceRow.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { + AvatarToken, + AvatarTokenSize, + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { getNetworkImageSource } from '../../../../../util/networks'; +import { MUSD_TOKEN } from '../../../Earn/constants/musd'; +import { MoneyMusdEmptyBalanceRowTestIds } from './MoneyMusdEmptyBalanceRow.testIds'; +import type { ImageOrSvgSrc } from '@metamask/design-system-react-native/dist/components/temp-components/ImageOrSvg/ImageOrSvg.types.d.cts'; + +const styles = StyleSheet.create({ + badgeWrapper: { alignSelf: 'center' }, +}); + +interface MoneyMusdEmptyBalanceRowProps { + onPress?: () => void; +} + +const MoneyMusdEmptyBalanceRow = ({ + onPress, +}: MoneyMusdEmptyBalanceRowProps) => ( + + + + } + > + + + + + {MUSD_TOKEN.name} + + + + + $0.00 + + + {`0 ${MUSD_TOKEN.symbol}`} + + + + +); + +export default MoneyMusdEmptyBalanceRow; diff --git a/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts new file mode 100644 index 000000000000..eeab43f11e1b --- /dev/null +++ b/app/components/UI/Money/components/MoneyMusdEmptyBalanceRow/index.ts @@ -0,0 +1 @@ +export { default } from './MoneyMusdEmptyBalanceRow'; diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 7927c1018b21..231477334a1a 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -1,5 +1,6 @@ const EVENT_LOCATIONS = { MONEY_HUB: 'money_hub', + ASSET_DETAIL: 'asset_detail', }; const MONEY_HUB_STATES = { diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 2851f3f6246c..71d10b709cb9 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -44,8 +44,15 @@ import Balance from '../../AssetOverview/Balance'; import TokenDetails from '../../AssetOverview/TokenDetails'; import { TokenDetailsActions } from './TokenDetailsActions'; import AssetOverviewClaimBonus from '../../Earn/components/AssetOverviewClaimBonus'; +import MoneyConvertStablecoins from '../../Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins'; +import { MONEY_EVENTS_CONSTANTS } from '../../Money/constants/moneyEvents'; import { isTokenEligibleForMerklRewards } from '../../Earn/components/MerklRewards/hooks/useMerklRewards'; -import { selectMerklCampaignClaimingEnabledFlag } from '../../Earn/selectors/featureFlags'; +import { isMusdToken } from '../../Earn/constants/musd'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectMerklCampaignClaimingEnabledFlag, +} from '../../Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../Earn/hooks/useMusdConversionEligibility'; import PerpsDiscoveryBanner from '../../Perps/components/PerpsDiscoveryBanner'; import { isTokenTrustworthyForPerps } from '../../Perps/constants/perpsConfig'; import { selectTokenOverviewAdvancedChartEnabled } from '../../../../selectors/featureFlagController/tokenOverviewAdvancedChart'; @@ -341,6 +348,15 @@ const AssetOverviewContent: React.FC = ({ [isMerklClaimingEnabled, token.chainId, token.address], ); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); + const showMusdConvertSection = + isMusdToken(token.address) && + isMusdConversionFlowEnabled && + isMusdGeoEligible; + const securityConfig = useMemo( () => getResultTypeConfig(securityData?.resultType), [securityData?.resultType], @@ -748,6 +764,11 @@ const AssetOverviewContent: React.FC = ({ {isTokenEligibleForMerklClaim && ( )} + {showMusdConvertSection && ( + + )} { ///: BEGIN:ONLY_INCLUDE_IF(tron) tronNativeToken && ( diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index 584dc59f2293..84ab6d4a48de 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -47,6 +47,11 @@ interface TokenListProps { * refresh orchestrator (e.g. Money Hub). */ refreshControl?: React.ReactElement; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } const TokenListComponent = ({ @@ -60,6 +65,7 @@ const TokenListComponent = ({ isFullView = false, listFooterComponent, refreshControl, + hideSecondaryPriceRow = false, }: TokenListProps) => { const { colors } = useTheme(); const tw = useTailwind(); @@ -155,6 +161,7 @@ const TokenListComponent = ({ showPercentageChange={showPercentageChange} isFullView={isFullView} shouldShowTokenListItemCta={shouldShowTokenListItemCta} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ), [ @@ -164,6 +171,7 @@ const TokenListComponent = ({ showPercentageChange, isFullView, shouldShowTokenListItemCta, + hideSecondaryPriceRow, ], ); @@ -182,6 +190,7 @@ const TokenListComponent = ({ showPercentageChange={showPercentageChange} isFullView={isFullView} shouldShowTokenListItemCta={shouldShowTokenListItemCta} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ))} {shouldShowViewAllButton && ( diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 8b4d871efd11..5b5f1a5e7e2c 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1138,6 +1138,56 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }); }); + describe('hideSecondaryPriceRow (Money Hub compact mUSD layout)', () => { + const musdAsset = { + ...defaultAsset, + address: MUSD_TOKEN_ADDRESS, + symbol: 'mUSD', + name: 'MetaMask USD', + isNative: false, + balance: '1280.34', + balanceFiat: '$1,280.34', + }; + const musdKey: FlashListAssetKey = { + address: MUSD_TOKEN_ADDRESS, + chainId: '0x1', + isStaked: false, + }; + const renderCompact = (key: FlashListAssetKey) => + renderWithProvider( + , + ); + + it('renders compact mUSD layout and navigates on press', () => { + prepareMocks({ asset: musdAsset }); + const { getByText } = renderCompact(musdKey); + expect(getByText('MetaMask USD')).toBeOnTheScreen(); + expect(getByText('1280.34 mUSD')).toBeOnTheScreen(); + fireEvent.press(getByText('MetaMask USD')); + expect(mockNavigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ symbol: 'mUSD' }), + ); + }); + + it('does not affect non-mUSD rows', () => { + prepareMocks({ asset: defaultAsset }); + const { getByText } = renderCompact({ + address: '0x456', + chainId: '0x1', + isStaked: false, + }); + expect(getByText('Test Token')).toBeOnTheScreen(); + }); + }); + describe('mUSD Bonus Row', () => { const claimableAsset = { ...defaultAsset, @@ -1151,14 +1201,14 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isStaked: false, }; - it('shows green "3% bonus" on mUSD rows when conversion is enabled', () => { + it('does not render the "3% bonus" label on mUSD rows (MUSD-729)', () => { prepareMocks({ asset: claimableAsset, pricePercentChange1d: 5.0, isMusdConversionEnabled: true, }); - const { getByText, queryByText } = renderWithProvider( + const { queryByText, getByText } = renderWithProvider( { ); expect( - getByText( + queryByText( strings('earn.musd_conversion.percentage_bonus', { percentage: MUSD_CONVERSION_APY, }), ), - ).toBeOnTheScreen(); - expect(queryByText('+5.00%')).toBeNull(); - // Price rail must stay hidden on mUSD bonus rows per Figma. - expect(queryByText(/\u2022/)).toBeNull(); + ).toBeNull(); + // Without the bonus label or a Convert CTA, the row falls back to the + // standard percentage-change rail. + expect(getByText('+5.00%')).toBeOnTheScreen(); }); it('shows normal percentage when mUSD but conversion flow is disabled', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 0f6a3bd30dcf..1f1dd5850819 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -21,11 +21,7 @@ import { TokenI } from '../../types'; import { ScamWarningIcon } from './ScamWarningIcon/ScamWarningIcon'; import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { FlashListAssetKey } from '../TokenList'; -import { - selectIsMusdConversionFlowEnabledFlag, - selectStablecoinLendingEnabledFlag, -} from '../../../Earn/selectors/featureFlags'; -import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; +import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { selectAsset } from '../../../../../selectors/assets/assets-list'; import Tag from '../../../../../component-library/components/Tags/Tag'; @@ -90,6 +86,7 @@ import { } from '../../../AssetElement/index.constants'; import { Box, + BoxAlignItems, BoxFlexDirection, BoxJustifyContent, FontWeight, @@ -153,6 +150,11 @@ interface TokenListItemProps { showPercentageChange?: boolean; isFullView?: boolean; shouldShowTokenListItemCta: (asset?: TokenI) => boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } export const TokenListItem = React.memo( @@ -164,6 +166,7 @@ export const TokenListItem = React.memo( showPercentageChange = true, isFullView = false, shouldShowTokenListItemCta, + hideSecondaryPriceRow = false, }: TokenListItemProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); @@ -247,11 +250,6 @@ export const TokenListItem = React.memo( selectStablecoinLendingEnabledFlag, ); - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); - const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); - const { getEarnToken } = useEarnTokens(); const earnToken = getEarnToken(asset as TokenI); @@ -265,8 +263,6 @@ export const TokenListItem = React.memo( ); const isMusdAsset = !!asset && isMusdToken(asset.address); - const showMusdBonusRow = - isMusdAsset && isMusdConversionFlowEnabled && isMusdGeoEligible; const pricePercentChange1d = useTokenPricePercentageChange(asset); @@ -440,16 +436,6 @@ export const TokenListItem = React.memo( }); const secondaryBalanceDisplay = useMemo(() => { - if (showMusdBonusRow) { - return { - text: strings('earn.musd_conversion.percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - }), - color: CLTextColor.Success, - onPress: undefined, - }; - } - if (shouldShowConvertToMusdCta) { return { text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { @@ -492,7 +478,6 @@ export const TokenListItem = React.memo( return { text, color, onPress: undefined }; }, [ - showMusdBonusRow, shouldShowConvertToMusdCta, isStablecoinLendingEnabled, earnToken?.experience?.type, @@ -551,6 +536,68 @@ export const TokenListItem = React.memo( fiatBalanceDisplay = fiatBalance; } + // Money Hub compact mUSD layout: name vertically centered, fiat over + // native on the right, no price/24h-change row. + if (hideSecondaryPriceRow && isMusdAsset) { + return ( + onItemPress?.(asset)} + style={styles.itemWrapper} + testID={getAssetTestId(asset.symbol)} + > + + ) + } + > + + + + + {asset.name || asset.symbol} + + + + {fiatBalanceDisplay} + + + {tokenBalance} + + + + + ); + } + return ( { @@ -655,93 +702,64 @@ export const TokenListItem = React.memo( justifyContent={BoxJustifyContent.Between} twClassName="gap-2.5" > - {showMusdBonusRow ? ( - <> - - - {tokenBalance} - - - - + + {tokenPriceInFiat && !hideFiatForScamWarning + ? formatPriceWithSubscriptNotation( + tokenPriceInFiat, + currentCurrency, + ) + : '-'} + {' \u2022 '} + + + {hideFiatForScamWarning ? ( + + {'-'} + + ) : ( + - {secondaryBalanceDisplay.text} - - - ) : ( - <> - {/* Token price and percentage change */} - - - {tokenPriceInFiat && !hideFiatForScamWarning - ? formatPriceWithSubscriptNotation( - tokenPriceInFiat, - currentCurrency, - ) - : '-'} - {' \u2022 '} - - - {hideFiatForScamWarning ? ( - - {'-'} - - ) : ( - - - {secondaryBalanceDisplay.text || '-'} - - - )} - - - {/* Token balance */} - - {tokenBalance} + {secondaryBalanceDisplay.text || '-'} - - - )} + + )} + + + {/* Token balance */} + + + {tokenBalance} + + diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 9446ad1f1d17..8782c01dfbeb 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -69,6 +69,11 @@ interface TokensProps { * already handles its own loading state (e.g. CashTokensFullView). */ hideLoadingSkeleton?: boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } const Tokens = forwardRef( @@ -80,6 +85,7 @@ const Tokens = forwardRef( listFooterComponent, refreshControl, hideLoadingSkeleton = false, + hideSecondaryPriceRow = false, }, ref, ) => { @@ -271,6 +277,7 @@ const Tokens = forwardRef( isFullView={isFullView} listFooterComponent={listFooterComponent} refreshControl={refreshControl} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ); @@ -278,9 +285,9 @@ const Tokens = forwardRef( const cashEmptyDescription = showOnlyMusd && hasMusdBalanceOnAnyChainProp - ? strings('homepage.sections.cash_empty_description_network_filter') + ? strings('homepage.sections.money_empty_description_network_filter') : showOnlyMusd - ? strings('homepage.sections.cash_empty_description') + ? strings('homepage.sections.money_empty_description') : undefined; const emptyState = ( @@ -324,6 +331,7 @@ const Tokens = forwardRef( isGeoEligible, listFooterComponent, refreshControl, + hideSecondaryPriceRow, ]); return ( diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx index 2a957457ed3c..df3dd29c025d 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx @@ -4,13 +4,17 @@ import { InteractionManager } from 'react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; import CashTokensFullView from './CashTokensFullView'; import { useMerklBonusClaim } from '../../UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim'; +import { selectMoneyHubEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import { CashTokensFullViewTestIds } from './CashTokensFullView.testIds'; const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ goBack: mockGoBack, + navigate: mockNavigate, }), })); @@ -38,8 +42,12 @@ jest.mock('../../UI/Earn/hooks/useMusdConversion', () => ({ hasSeenConversionEducationScreen: true, }), })); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockUseMusdConversionTokens = jest.fn<{ tokens: any[] }, []>(() => ({ + tokens: [], +})); jest.mock('../../UI/Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: () => ({ tokens: [] }), + useMusdConversionTokens: () => mockUseMusdConversionTokens(), tokenFiatValue: () => 0, })); jest.mock('../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({ @@ -49,11 +57,13 @@ jest.mock('../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({ jest.mock( '../../UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins', () => { - const { View } = jest.requireActual('react-native'); + const { View, Text } = jest.requireActual('react-native'); return { __esModule: true, - default: (props: Record) => ( - + default: ({ location }: { location: string }) => ( + + {location} + ), }; }, @@ -98,6 +108,17 @@ jest.mock('../../Views/confirmations/hooks/useNetworkName', () => ({ jest.mock('../../UI/Money/selectors/featureFlags', () => ({ selectMoneyHubEnabledFlag: jest.fn(() => false), })); +jest.mock('../../UI/Money/components/MoneyMusdEmptyBalanceRow', () => { + const { Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onPress }: { onPress?: () => void }) => ( + + MetaMask USD empty + + ), + }; +}); jest.mock('./useCashTokensRefresh', () => ({ useCashTokensRefresh: () => ({ refreshing: false, onRefresh: jest.fn() }), })); @@ -215,4 +236,95 @@ describe('CashTokensFullView', () => { fireEvent.press(screen.getByTestId('cash-tokens-full-view-back-button')); expect(mockGoBack).toHaveBeenCalled(); }); + + describe('Money Hub enabled', () => { + beforeEach(() => { + (selectMoneyHubEnabledFlag as unknown as jest.Mock).mockReturnValue(true); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); + }); + + it('renders the empty Money Hub layout (heading, mUSD row, bonus + convert)', () => { + renderWithProvider(); + expect( + screen.getByTestId(CashTokensFullViewTestIds.HEADING), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('money-musd-empty-balance-row'), + ).toBeOnTheScreen(); + expect( + screen.queryByTestId('cash-get-musd-empty-state'), + ).not.toBeOnTheScreen(); + expect( + screen.getByTestId('asset-overview-claim-bonus'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('money-convert-stablecoins-container'), + ).toBeOnTheScreen(); + }); + + it('renders Your balance heading when user has mUSD', async () => { + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { '0x1': '1000' }, + }); + renderWithProvider(); + await waitFor(() => { + expect( + screen.getByTestId(CashTokensFullViewTestIds.HEADING), + ).toBeOnTheScreen(); + }); + }); + + it('press handlers wire to navigation and pass money_hub location to convert section', () => { + renderWithProvider(); + fireEvent.press(screen.getByTestId('money-musd-empty-balance-row')); + expect(mockNavigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ symbol: 'mUSD' }), + ); + expect( + screen.getByTestId('money-convert-stablecoins-location'), + ).toHaveTextContent('money_hub'); + }); + + it('renders Swap/Buy footer with no stablecoins; switches to Convert when stablecoins exist', () => { + const { rerender } = renderWithProvider(); + expect( + screen.getByTestId(CashTokensFullViewTestIds.SWAP_BUTTON), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(CashTokensFullViewTestIds.BUY_BUTTON), + ).toBeOnTheScreen(); + expect(() => { + fireEvent.press( + screen.getByTestId(CashTokensFullViewTestIds.SWAP_BUTTON), + ); + fireEvent.press( + screen.getByTestId(CashTokensFullViewTestIds.BUY_BUTTON), + ); + }).not.toThrow(); + + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [ + { + address: '0xabc', + chainId: '0x1', + symbol: 'USDC', + fiat: { balance: 100 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + rerender(); + expect( + screen.queryByTestId(CashTokensFullViewTestIds.SWAP_BUTTON), + ).not.toBeOnTheScreen(); + expect(() => + fireEvent.press(screen.getByText('Convert to mUSD')), + ).not.toThrow(); + }); + }); }); diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.testIds.ts b/app/components/Views/CashTokensFullView/CashTokensFullView.testIds.ts index 428b14883543..ee10b5a7e7c6 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.testIds.ts +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.testIds.ts @@ -2,4 +2,5 @@ export const CashTokensFullViewTestIds = { BACK_BUTTON: 'cash-tokens-full-view-back-button', SWAP_BUTTON: 'cash-tokens-full-view-swap-button', BUY_BUTTON: 'cash-tokens-full-view-buy-button', + HEADING: 'cash-tokens-full-view-heading', }; diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx index ef7dcccea715..0dfc8742f8a6 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx @@ -24,7 +24,10 @@ import { HeaderBase, ButtonIcon, ButtonIconSize, + FontWeight, IconName, + Text, + TextVariant, } from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import Tokens from '../../UI/Tokens'; @@ -44,15 +47,14 @@ import { SwapBridgeNavigationLocation, } from '../../UI/Bridge/hooks/useSwapBridgeNavigation'; import MoneyConvertStablecoins from '../../UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins'; +import MoneyMusdEmptyBalanceRow from '../../UI/Money/components/MoneyMusdEmptyBalanceRow'; import AssetOverviewClaimBonus from '../../UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from '../Homepage/Sections/Cash/CashGetMusdEmptyState.constants'; import CashGetMusdEmptyState from '../Homepage/Sections/Cash/CashGetMusdEmptyState'; import SectionRow from '../Homepage/components/SectionRow/SectionRow'; import CashTokensFullViewSkeleton from './CashTokensFullViewSkeleton'; import { useCashTokensRefresh } from './useCashTokensRefresh'; -import { AssetType } from '../confirmations/types/token'; import Logger from '../../../util/Logger'; -import AppConstants from '../../../core/AppConstants'; import { selectMoneyHubEnabledFlag } from '../../UI/Money/selectors/featureFlags'; import { useSelector } from 'react-redux'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -73,6 +75,12 @@ const CashTokensFullView = () => { const numChainsWithMusdBalance = Object.keys(tokenBalanceByChain).length; + const handleEmptyMusdRowPress = useCallback(() => { + navigation.navigate('Asset', { + ...MUSD_MAINNET_ASSET_FOR_DETAILS, + }); + }, [navigation]); + const { tokens: conversionTokens } = useMusdConversionTokens(); const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag); @@ -133,8 +141,7 @@ const CashTokensFullView = () => { }, []); const { refreshing, onRefresh } = useCashTokensRefresh(merklRefetchRef); - const { initiateMaxConversion, initiateCustomConversion } = - useMusdConversion(); + const { initiateCustomConversion } = useMusdConversion(); const { goToBuy } = useRampNavigation(); const { goToSwaps } = useSwapBridgeNavigation({ location: SwapBridgeNavigationLocation.MainView, @@ -145,75 +152,6 @@ const CashTokensFullView = () => { navigation.goBack(); }, [navigation]); - const handleConvertMaxPress = useCallback( - async (token: AssetType) => { - try { - trackEvent( - createEventBuilder( - MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, - ) - .addProperties({ - location: MONEY_EVENT_LOCATIONS.MONEY_HUB, - button_type: 'text_button', - button_action: 'max', - button_text: strings('earn.musd_conversion.max'), - redirects_to: - MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: token.chainId - ? getNetworkName(token.chainId as Hex) - : 'unknown', - }) - .build(), - ); - await initiateMaxConversion(token); - } catch (error) { - Logger.error(error as Error, { - message: '[CashTokensFullView] Failed to initiate max conversion', - }); - } - }, - [createEventBuilder, initiateMaxConversion, trackEvent], - ); - - const handleConvertEditPress = useCallback( - async (token: AssetType) => { - try { - trackEvent( - createEventBuilder( - MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, - ) - .addProperties({ - location: MONEY_EVENT_LOCATIONS.MONEY_HUB, - button_type: 'icon_button', - icon: IconName.Edit, - button_action: 'custom', - redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: token.chainId - ? getNetworkName(token.chainId as Hex) - : 'unknown', - }) - .build(), - ); - - await initiateCustomConversion({ - preferredPaymentToken: { - address: token.address as Hex, - chainId: token.chainId as Hex, - }, - }); - } catch (error) { - Logger.error(error as Error, { - message: '[CashTokensFullView] Failed to initiate custom conversion', - }); - } - }, - [createEventBuilder, initiateCustomConversion, trackEvent], - ); - const handleConvertPress = useCallback(async () => { const topToken = conversionTokens[0]; if (!topToken) return; @@ -278,19 +216,6 @@ const CashTokensFullView = () => { }); }, [createEventBuilder, goToBuy, trackEvent]); - const handleLearnMorePress = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.MONEY_HUB_LEARN_MORE_PRESSED) - .addProperties({ - location: MONEY_EVENT_LOCATIONS.MONEY_HUB, - url: AppConstants.URLS.MUSD_LEARN_MORE, - }) - .build(), - ); - - Linking.openURL(AppConstants.URLS.MUSD_LEARN_MORE); - }, [createEventBuilder, trackEvent]); - const bonusAndConvertSections = useMemo( () => ( <> @@ -299,21 +224,10 @@ const CashTokensFullView = () => { onRefetchReady={handleRefetchReady} location={MONEY_EVENT_LOCATIONS.MONEY_HUB} /> - + ), - [ - conversionTokens, - handleConvertMaxPress, - handleConvertEditPress, - handleLearnMorePress, - handleRefetchReady, - ], + [handleRefetchReady], ); return ( @@ -330,8 +244,19 @@ const CashTokensFullView = () => { style={tw`p-4`} twClassName="h-auto" > - {strings('homepage.sections.cash')} + {strings('money.title')} + {isMoneyHubEnabled && ( + + + {strings('money.your_balance')} + + + )} {hasMusdBalanceOnAnyChain ? ( isTokenListReady ? ( { showOnlyMusd hideLoadingSkeleton hasMusdBalanceOnAnyChain={hasMusdBalanceOnAnyChain} + // MUSD-729: hide the "3% bonus" / price-rail secondary row on + // mUSD entries inside Money Hub so the row reads as a balance + // entry under the new "Your balance" heading. + hideSecondaryPriceRow={isMoneyHubEnabled} listFooterComponent={ isMoneyHubEnabled ? bonusAndConvertSections : undefined } @@ -361,12 +290,19 @@ const CashTokensFullView = () => { } > - - - + {isMoneyHubEnabled ? ( + // MUSD-729 empty state: mirror the "Your balance" funded layout + // (mUSD avatar + network badge + $0.00 / 0 mUSD). The standard + // list does not render a row for tokens with zero + // balance, and there is no shared design-system component that + // matches this presentation, so we fall back to a small bespoke + // row to keep the empty/funded structures visually consistent. + + ) : ( + + + + )} {isMoneyHubEnabled ? bonusAndConvertSections : undefined} )} diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx index d36cd07e78a6..becd6eeec25d 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx @@ -86,7 +86,7 @@ const CashSection = forwardRef( return null; } - const title = strings('homepage.sections.cash'); + const title = strings('homepage.sections.money'); return ( diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index b2d02a781cb2..0248727b4d22 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -699,7 +699,6 @@ enum EVENT_NAME { MONEY_HUB_SCREEN_VIEWED = 'Money Hub Screen Viewed', MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED = 'Money Hub Token Row Convert Clicked', MONEY_HUB_CONVERT_BUTTON_CLICKED = 'Money Hub Convert Button Clicked', - MONEY_HUB_LEARN_MORE_PRESSED = 'Money Hub Learn More Pressed', MONEY_HUB_SWAP_BUTTON_CLICKED = 'Money Hub Swap Button Clicked', MONEY_HUB_BUY_BUTTON_CLICKED = 'Money Hub Buy Button Clicked', @@ -1828,9 +1827,6 @@ const events = { MONEY_HUB_CONVERT_BUTTON_CLICKED: generateOpt( EVENT_NAME.MONEY_HUB_CONVERT_BUTTON_CLICKED, ), - MONEY_HUB_LEARN_MORE_PRESSED: generateOpt( - EVENT_NAME.MONEY_HUB_LEARN_MORE_PRESSED, - ), MONEY_HUB_SWAP_BUTTON_CLICKED: generateOpt( EVENT_NAME.MONEY_HUB_SWAP_BUTTON_CLICKED, ), diff --git a/locales/languages/en.json b/locales/languages/en.json index 5d16d9730ae2..ed11d39bc8e3 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6476,10 +6476,17 @@ }, "education": { "heading": "GET {{percentage}}% ON\nSTABLECOINS", - "description": "Convert your stablecoins to mUSD and earn up to a {{percentage}}% annualized bonus that you can claim daily.", + "description": "Convert your stablecoins to mUSD and earn a {{percentage}}% annualized bonus.", "terms_apply": "Terms apply.", "primary_button": "Get Started", - "secondary_button": "Not now" + "secondary_button": "Not now", + "checklist": { + "dollar_backed": "Dollar-backed", + "no_lockups": "No lockups", + "daily_bonus": "Daily bonus", + "metamask_stablecoins": "MetaMask stablecoins", + "no_metamask_fee": "No MetaMask fee" + } }, "buy_musd": "Buy mUSD", "get_musd": "Get mUSD", @@ -6523,6 +6530,7 @@ }, "money": { "title": "Money", + "your_balance": "Your balance", "apy_label": "{{percentage}}% APY", "apy_info_label": "APY info", "onboarding": { @@ -9005,14 +9013,14 @@ }, "homepage": { "sections": { - "cash": "Money", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", - "cash_empty_state": { + "money": "Money", + "money_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", + "money_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "money_empty_state": { "get_started": "Get started", "earn_apy": "Earn {{percentage}}% APY" }, - "cash_filled_state": { + "money_filled_state": { "add": "Add", "apy": "{{percentage}}% APY" }, From 428bdda30d2c2d4e0e80739b84dfe00d43866e13 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Wed, 6 May 2026 15:40:45 -0700 Subject: [PATCH 28/28] refactor(earn): use MMDS HeaderStandard (#29702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This change replaces the temporary `HeaderCompactStandard` component with `HeaderStandard` from `@metamask/design-system-react-native` on Earn-related surfaces. **Reason:** Align Earn UI with the MetaMask design system and reduce reliance on `component-library/components-temp` for standard headers. **What changed:** `HeaderStandard` is used for the Lending “How it works” bottom sheet, the Earn input screen header (back button and end actions), and the Earn token list bottom sheet. Behavior is intended to match the previous header (title, back/close, analytics-related tests unchanged aside from naming). Unit test comments and a `describe` block name were updated to reference `HeaderStandard`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-699 ## **Manual testing steps** ```gherkin Feature: Earn headers use design system HeaderStandard Scenario: Earn input screen header matches prior behavior Given the user is on the Earn flow and opens the amount/input screen (e.g. stake or supply) When the user views the screen header and uses the back control Then the header shows the expected title and navigation behaves as before Scenario: Lending “How it works” modal Given the user opens the Lending learn-more / “How it works” bottom sheet from Earn When the user views the header and taps close Then the sheet dismisses as before Scenario: Earn token selection bottom sheet Given the user opens the token list bottom sheet (e.g. select token to supply or withdraw) When the user views the header title and uses the close control Then the sheet closes as before and titles match the prior copy for the flow ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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] > **Low Risk** > Low risk refactor that swaps a temporary header component for the design-system header on a few Earn screens; main regression risk is minor UI/interaction differences in back/close and end-icon rendering. > > **Overview** > Updates Earn surfaces to use the design-system `HeaderStandard` instead of the temporary `HeaderCompactStandard`, including the Lending “How it works” bottom sheet, the `EarnInputView` screen header (back + optional info icon), and the Earn token list bottom sheet (close button + title). > > Adjusts imports accordingly (including `IconName` usage from the design system) and updates unit test descriptions/comments to reference `HeaderStandard` while keeping behavioral assertions the same. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c517a2efa7336eee764aeb611537c866441895fd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). Co-authored-by: Cursor --- app/components/UI/Earn/LendingLearnMoreModal/index.tsx | 4 ++-- .../UI/Earn/Views/EarnInputView/EarnInputView.test.tsx | 6 +++--- .../UI/Earn/Views/EarnInputView/EarnInputView.tsx | 5 ++--- .../UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx | 2 +- app/components/UI/Earn/components/EarnTokenList/index.tsx | 4 ++-- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/components/UI/Earn/LendingLearnMoreModal/index.tsx b/app/components/UI/Earn/LendingLearnMoreModal/index.tsx index 3ae740b0811a..7c1721a23952 100644 --- a/app/components/UI/Earn/LendingLearnMoreModal/index.tsx +++ b/app/components/UI/Earn/LendingLearnMoreModal/index.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import styleSheet from './LendingLearnMoreModal.styles'; import { useStyles } from '../../../hooks/useStyles'; import BottomSheet, { BottomSheetRef, } from '../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import Text, { TextColor, TextVariant, @@ -288,7 +288,7 @@ export const LendingLearnMoreModal = () => { return ( - diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index ec43515a31da..915f564c71aa 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -527,7 +527,7 @@ describe('EarnInputView', () => { name: 'params', }); - // Verify the title is rendered in the HeaderCompactStandard component + // Verify the title is rendered in the HeaderStandard component expect(getByText('Supply USDC')).toBeOnTheScreen(); // "0" in the input display and on the keypad @@ -1202,7 +1202,7 @@ describe('EarnInputView', () => { // Default mock returns ETH with POOLED_STAKING experience const { getByText } = renderComponent(); - // Verify the title is rendered in the HeaderCompactStandard component + // Verify the title is rendered in the HeaderStandard component expect(getByText('Stake ETH')).toBeOnTheScreen(); }); }); @@ -1845,7 +1845,7 @@ describe('EarnInputView', () => { }); }); - describe('HeaderCompactStandard interactions', () => { + describe('HeaderStandard interactions', () => { it('tracks STAKE_CANCEL_CLICKED event with token property when back button is pressed for staking', async () => { selectStablecoinLendingEnabledFlagMock.mockReturnValue(false); diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 0329aefbb389..230950da9736 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -45,8 +45,7 @@ import Keypad from '../../../../Base/Keypad'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useStyles } from '../../../../hooks/useStyles'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; -import { IconName } from '@metamask/design-system-react-native'; +import { HeaderStandard, IconName } from '@metamask/design-system-react-native'; import ScreenLayout from '../../../Ramp/Aggregator/components/ScreenLayout'; import QuickAmounts from '../../../Stake/components/QuickAmounts'; import { EVENT_PROVIDERS } from '../../../Stake/constants/events'; @@ -1005,7 +1004,7 @@ const EarnInputView = () => { return ( - { }); }); - describe('HeaderCompactStandard close button', () => { + describe('HeaderStandard close button', () => { it('invokes handleClose when close button is pressed', async () => { const { getByTestId } = renderWithProvider( diff --git a/app/components/UI/Earn/components/EarnTokenList/index.tsx b/app/components/UI/Earn/components/EarnTokenList/index.tsx index be1181e5aa23..8eafa4e89090 100644 --- a/app/components/UI/Earn/components/EarnTokenList/index.tsx +++ b/app/components/UI/Earn/components/EarnTokenList/index.tsx @@ -5,11 +5,11 @@ import React, { useReducer, useMemo, } from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { TextColor } from '../../../../../component-library/components/Texts/Text'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { View } from 'react-native'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './EarnTokenList.styles'; @@ -380,7 +380,7 @@ const EarnTokenList = () => { return ( -