diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b706839d37..dde33862d3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.73.2] + +### Added + +- Added Polymarket CLOB v2 support (#29076) + +### Fixed + +- Fixed Perps $0 balance display for accounts funded via HyperLiquid spot USDC (#29110) +- Fixed Perps balance not refreshing after trades, funding, or transfers for HyperLiquid users, and corrected total balance inflation on Unified-mode accounts (#29226) + ## [7.73.1] ### Fixed @@ -11222,7 +11233,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.73.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.73.2...HEAD +[7.73.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.73.1...v7.73.2 [7.73.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.73.0...v7.73.1 [7.73.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.72.1...v7.73.0 [7.72.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.72.0...v7.72.1 diff --git a/android/app/build.gradle b/android/app/build.gradle index 77bd198df023..5248d8144b0e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.73.0" - versionCode 4594 + versionName "7.73.2" + versionCode 4647 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 4444d388c7f8..17eaded61b5c 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -323,6 +323,12 @@ export const PerpsWithdrawViewSelectorsIDs = { DEST_TOKEN_AREA: 'dest-token-area', CONTINUE_BUTTON: 'continue-button', BOTTOM_SHEET_TOOLTIP: 'withdraw-bottom-sheet-tooltip', + RECEIVE_VALUE: 'perps-withdraw-receive-value', + FEE_VALUE: 'perps-withdraw-fee-value', + TIME_VALUE: 'perps-withdraw-time-value', + // Must render availableBalance only (not availableToTradeBalance): + // withdraw does not offer spot collateral. + AVAILABLE_BALANCE_TEXT: 'perps-withdraw-available-balance-text', }; // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 777baee6950e..eebf4a0804d8 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -1056,6 +1056,47 @@ describe('PerpsMarketDetailsView', () => { ).toBeNull(); }); + it('shows add funds CTA when total balance is funded but spendable balance has no direct order path', () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '100.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '100', + }, + isInitialLoading: false, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + expect( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON), + ).toBeOnTheScreen(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON), + ).toBeNull(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON), + ).toBeNull(); + }); + it('calls navigateToConfirmation and depositWithConfirmation when add funds is pressed', async () => { mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); mockUsePerpsAccount.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 77ad982aca17..48a23bbf3202 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -429,17 +429,15 @@ const PerpsMarketDetailsView: React.FC = () => { useDefaultPayWithTokenWhenNoPerpsBalance(); const { depositWithConfirmation } = usePerpsTrading(); const { navigateToConfirmation } = useConfirmNavigation(); - const availableBalance = Number.parseFloat( - account?.availableBalance?.toString() ?? '0', + const tradeableBalance = Number.parseFloat( + account?.availableToTradeBalance?.toString() ?? + account?.availableBalance?.toString() ?? + '0', ); - const showAddFundsCTA = - isEligible && - !isLoadingPosition && - !existingPosition && - !isAtOICap && + const hasDirectOrderFundingPath = !isLoadingAccount && - availableBalance < PERPS_MIN_BALANCE_THRESHOLD && - defaultPayTokenWhenNoPerpsBalance === null; + (tradeableBalance >= PERPS_MIN_BALANCE_THRESHOLD || + defaultPayTokenWhenNoPerpsBalance !== null); const handleAddFunds = useCallback(async () => { if (!isEligible) { @@ -1151,9 +1149,13 @@ const PerpsMarketDetailsView: React.FC = () => { const shouldShowNewPositionActions = hasLongShortButtons && !existingPosition && !isAtOICap; const shouldShowAddFundsCTASection = - shouldShowNewPositionActions && showAddFundsCTA; + shouldShowNewPositionActions && + isEligible && + !isLoadingAccount && + !isLoadingPosition && + !hasDirectOrderFundingPath; const shouldShowLongShortButtonsOnly = - shouldShowNewPositionActions && !showAddFundsCTA; + shouldShowNewPositionActions && !shouldShowAddFundsCTASection; const shouldShowPerpsMarketInsights = isPerpsInsightsEnabled && diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx index 950b55970fb1..c0f5d7f4ccf6 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx @@ -403,7 +403,11 @@ const PerpsWithdrawView: React.FC = () => { ]} /> - + {strings('perps.withdrawal.available_balance', { amount: formattedBalance, })} diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index 1b7267072081..0c6eba98cf18 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -182,7 +182,13 @@ const PerpsMarketBalanceActions: React.FC = ({ [stopBalanceAnimation], ); - const availableBalance = perpsAccount?.availableBalance || '0'; + // Order-entry surface reads availableToTradeBalance (withdrawable + + // unreserved spot collateral). Withdraw surfaces keep reading + // availableBalance directly. + const availableBalance = + perpsAccount?.availableToTradeBalance ?? + perpsAccount?.availableBalance ?? + '0'; // Show skeleton while loading initial account data if (isInitialLoading) { diff --git a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts index ba1bc3765069..77896bb1e0fb 100644 --- a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts +++ b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts @@ -39,11 +39,13 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment if (!featureEnabled) { return null; } - const availableBalance = Number.parseFloat( - perpsAccount?.availableBalance?.toString() ?? '0', + const tradeableBalance = Number.parseFloat( + perpsAccount?.availableToTradeBalance?.toString() ?? + perpsAccount?.availableBalance?.toString() ?? + '0', ); - if (availableBalance > PERPS_MIN_BALANCE_THRESHOLD) { + if (tradeableBalance > PERPS_MIN_BALANCE_THRESHOLD) { return null; } if (!allowlistAssets?.length) { @@ -93,6 +95,7 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment }, [ featureEnabled, perpsAccount?.availableBalance, + perpsAccount?.availableToTradeBalance, allowlistAssets, activeProvider, currentNetwork, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index 05f021c00aaa..af7d842319ce 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -92,7 +92,9 @@ export function usePerpsOrderForm( const availableBalance = Number.parseFloat( effectiveAvailableBalanceParam != null ? effectiveAvailableBalanceParam.toString() - : (account?.availableBalance?.toString() ?? '0'), + : (account?.availableToTradeBalance?.toString() ?? + account?.availableBalance?.toString() ?? + '0'), ); // When paying with a custom token, use selected token amount in USD (including 0); otherwise use Perps balance diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index 834336119bbb..d7d86766d07d 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -1077,6 +1077,7 @@ describe('hyperLiquidAdapter', () => { expect(result).toEqual({ availableBalance: '700.25', + availableToTradeBalance: '700.25', // withdrawable + free spot (no spot provided) marginUsed: '300.25', unrealizedPnl: '24.5', // 50.0 + (-25.5) returnOnEquity: '7.991673605328893', // Calculated from weighted return and margin @@ -1120,6 +1121,7 @@ describe('hyperLiquidAdapter', () => { expect(result).toEqual({ availableBalance: '350.0', + availableToTradeBalance: '850.5', // withdrawable 350 + free spot 500.5 (hold = 0) marginUsed: '150.0', unrealizedPnl: '100', returnOnEquity: '0', @@ -1158,6 +1160,7 @@ describe('hyperLiquidAdapter', () => { expect(result).toEqual({ availableBalance: '800.0', + availableToTradeBalance: '800', // withdrawable + 0 (no spot totals) marginUsed: '200.0', unrealizedPnl: '0', returnOnEquity: '0', diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 4da3103c950c..5b30cdb265f5 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -72,6 +72,7 @@ import { filterSupportedLeagues } from '../constants/sports'; import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { + LEGACY_V2_CLOB_BASE_URL, MATIC_CONTRACTS, POLYMARKET_PROVIDER_ID, } from '../providers/polymarket/constants'; @@ -520,12 +521,31 @@ export class PredictController extends BaseController< ), ) ?? false; + const predictClobV2Enabled = + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag(flags.predictClobV2), + ) ?? false; + + const predictClobV2UseLegacyClobHost = predictClobV2Enabled + ? (validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + flags.predictClobV2UseLegacyClobHost, + ), + ) ?? false) + : false; + + const predictClobV2ClobBaseUrl = predictClobV2UseLegacyClobHost + ? LEGACY_V2_CLOB_BASE_URL + : undefined; + return { feeCollection, liveSportsLeagues, marketHighlightsFlag, fakOrdersEnabled, predictWithAnyTokenEnabled, + predictClobV2Enabled, + predictClobV2ClobBaseUrl, }; } diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index cc2a012e31d6..d7e3e6ed1bc5 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -1,4 +1,9 @@ -import { POLYMARKET_PROVIDER_ID } from './constants'; +import { + DEFAULT_CLOB_BASE_URL, + LEGACY_V2_CLOB_BASE_URL, + POLYMARKET_PROVIDER_ID, + USDC_E_ADDRESS, +} from './constants'; // Mock external dependencies jest.mock('../../../../../core/Engine', () => ({ context: { @@ -41,6 +46,7 @@ import { 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, isLiveSportsEvent, @@ -54,6 +60,7 @@ import { getClaimTransaction, getDeployProxyWalletTransaction, getProxyWalletAllowancesTransaction, + getWithdrawTransactionCallData, hasAllowances, } from './safe/utils'; import { PERMIT2_ADDRESS } from './safe/constants'; @@ -61,6 +68,7 @@ import { createApiKey, encodeClaim, getBalance, + getRawBalance, getContractConfig, getFeeRateBps, fetchEventsFromPolymarketApi, @@ -107,6 +115,8 @@ jest.mock('./utils', () => { 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(), @@ -119,11 +129,16 @@ jest.mock('./utils', () => { submitClobOrder: jest.fn(), getMarketPositions: jest.fn(), getBalance: jest.fn(), + getRawBalance: jest.fn(), previewOrder: jest.fn(), POLYGON_MAINNET_CHAIN_ID: 137, }; }); +jest.mock('./protocol/transport', () => ({ + submitProtocolClobOrder: jest.fn(), +})); + jest.mock('./safe/utils', () => ({ computeProxyAddress: jest.fn(), createPermit2FeeAuthorization: jest.fn(), @@ -132,10 +147,13 @@ jest.mock('./safe/utils', () => ({ 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(1000000), + getSafeUsdcAmount: jest.fn().mockReturnValue(1), + getSafeUsdcAmountRaw: jest.fn().mockReturnValue(1000000n), })); const mockGameCacheInstance = { @@ -240,11 +258,29 @@ 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 mockExtractNeededTeamsFromEvents = extractNeededTeamsFromEvents as jest.Mock; describe('PolymarketProvider', () => { + const originalBuilderCode = process.env.MM_PREDICT_BUILDER_CODE; + + beforeAll(() => { + process.env.MM_PREDICT_BUILDER_CODE = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + }); + + afterAll(() => { + if (originalBuilderCode === undefined) { + delete process.env.MM_PREDICT_BUILDER_CODE; + return; + } + + process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; + }); + const defaultFeatureFlags: PredictFeatureFlags = { feeCollection: DEFAULT_FEE_COLLECTION_FLAG, liveSportsLeagues: [], @@ -255,6 +291,7 @@ describe('PolymarketProvider', () => { }, fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, + predictClobV2Enabled: false, }; const createProvider = ( featureFlagsOverride?: Partial, @@ -985,6 +1022,19 @@ describe('PolymarketProvider', () => { }, 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'); @@ -1108,8 +1158,8 @@ describe('PolymarketProvider', () => { expect(result).toMatchObject({ success: true, response: expect.any(Object), - error: undefined, }); + expect(result).not.toHaveProperty('error'); }); it('successfully places a sell order and returns correct result', async () => { @@ -1129,8 +1179,8 @@ describe('PolymarketProvider', () => { expect(result).toMatchObject({ success: true, response: expect.any(Object), - error: undefined, }); + expect(result).not.toHaveProperty('error'); }); it('handles order submission failure', async () => { @@ -1233,6 +1283,110 @@ describe('PolymarketProvider', () => { 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(); @@ -1406,6 +1560,7 @@ describe('PolymarketProvider', () => { describe('previewOrder', () => { beforeEach(() => { jest.clearAllMocks(); + mockPreviewOrder.mockResolvedValue({}); }); const createPreviewSigner = () => ({ @@ -1457,6 +1612,7 @@ describe('PolymarketProvider', () => { expect(mockPreviewOrder).toHaveBeenCalledWith({ ...mockParams, feeCollection: DEFAULT_FEE_COLLECTION_FLAG, + isV2: false, }); }); it('returns FOK orderType by default', async () => { @@ -1466,6 +1622,22 @@ describe('PolymarketProvider', () => { 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' }, @@ -1582,6 +1754,7 @@ describe('PolymarketProvider', () => { expect(mockCreateApiKey).toHaveBeenCalledTimes(1); expect(mockCreateApiKey).toHaveBeenCalledWith({ address: mockSigner1.address, + clobVersion: 'v1', }); }); @@ -1611,9 +1784,53 @@ describe('PolymarketProvider', () => { 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, }); }); }); @@ -2938,6 +3155,69 @@ describe('PolymarketProvider', () => { }), ); }); + + 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', () => { @@ -4356,6 +4636,38 @@ describe('PolymarketProvider', () => { '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', () => { @@ -4767,6 +5079,27 @@ describe('PolymarketProvider', () => { 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', () => { @@ -4831,6 +5164,26 @@ describe('PolymarketProvider', () => { 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', () => { @@ -4868,6 +5221,60 @@ describe('PolymarketProvider', () => { }), ).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', () => { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 8d79584eac20..14f6bae0962f 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -4,7 +4,7 @@ import { } from '@metamask/keyring-controller'; import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; import { Hex, numberToHex } from '@metamask/utils'; -import { parseUnits } from 'ethers/lib/utils'; +import { getAddress, parseUnits } from 'ethers/lib/utils'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../../util/Logger'; import { analytics } from '../../../../../util/analytics/analytics'; @@ -56,12 +56,10 @@ import { SignWithdrawResponse, } from '../types'; import { - MATIC_CONTRACTS, MIN_COLLATERAL_BALANCE_FOR_CLAIM, ORDER_RATE_LIMIT_MS, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, - ROUNDING_CONFIG, SAFE_EXEC_GAS_LIMIT, } from './constants'; import { PERMIT2_ADDRESS } from './safe/constants'; @@ -73,26 +71,22 @@ import { getDeployProxyWalletTransaction, getProxyWalletAllowancesTransaction, getSafeUsdcAmount, + getSafeUsdcAmountRaw, getWithdrawTransactionCallData, hasAllowances, } from './safe/utils'; import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { ApiKeyCreds, - OrderData, OrderType, PolymarketApiActivity, PolymarketApiTeam, PolymarketPosition, - SignatureType, - TickSize, - UtilsSide, } from './types'; import { createApiKey, encodeErc20Transfer, fetchEventsFromPolymarketApi, - generateSalt, getBalance, getContractConfig, getL2Headers, @@ -103,7 +97,6 @@ import { parsePolymarketEvents, parsePolymarketPositions, previewOrder, - roundOrderAmount, submitClobOrder, } from './utils'; import { PredictFeatureFlags } from '../../types/flags'; @@ -114,6 +107,24 @@ import { import { GameCache } from './GameCache'; import { TeamsCache } from './TeamsCache'; import { WebSocketManager } from './WebSocketManager'; +import { + getProtocolDepositTokenAddress, + getProtocolWithdrawTokenAddress, + resolvePolymarketProtocol, + type PolymarketProtocolDefinition, +} from './protocol/definitions'; +import { + buildProtocolUnsignedOrder, + getPreviewFeeRateBpsForProtocol, + getProtocolOrderTypedData, + getProtocolVerifyingContract, + serializeProtocolRelayerOrder, +} from './protocol/orderCodec'; +import { submitProtocolClobOrder } from './protocol/transport'; +import { buildClaimTransaction } from './preflight/claim'; +import { buildDepositMaintenanceTransaction } from './preflight/deposit'; +import { buildTradeAllowancesTx } from './preflight/trade'; +import { buildWithdrawTransaction } from './preflight/withdraw'; export type SignTypedMessageFn = ( params: TypedMessageParams, @@ -146,7 +157,7 @@ export class PolymarketProvider implements PredictProvider { readonly chainId = POLYGON_MAINNET_CHAIN_ID; readonly #getFeatureFlags: () => PredictFeatureFlags; - #apiKeysByAddress: Map = new Map(); + #apiKeysByProtocolAddress: Map = new Map(); #accountStateByAddress: Map = new Map(); #lastBuyOrderTimestampByAddress: Map = new Map(); #buyOrderInProgressByAddress: Map = new Map(); @@ -237,6 +248,395 @@ export class PolymarketProvider implements PredictProvider { ); } + #getProtocol(): PolymarketProtocolDefinition { + return resolvePolymarketProtocol(this.#getFeatureFlags()); + } + + #pickExecutor(executors: string[]): string { + const randomIndex = new Uint32Array(1); + global.crypto.getRandomValues(randomIndex); + + return executors[randomIndex[0] % executors.length]; + } + + #getPlaceOrderType({ + preview, + feeCollection, + fakOrdersEnabled, + permit2FeeReady, + permit2AllowanceReady, + }: { + preview: OrderPreview; + feeCollection: PredictFeatureFlags['feeCollection']; + fakOrdersEnabled: boolean; + permit2FeeReady: boolean; + permit2AllowanceReady: boolean; + }): OrderType { + if ( + !this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + return OrderType.FOK; + } + + const hasFees = preview.fees !== undefined && preview.fees.totalFee > 0; + + if (!hasFees || (permit2FeeReady && permit2AllowanceReady)) { + return OrderType.FAK; + } + + return OrderType.FOK; + } + + #throwPlaceOrderError({ + error, + side, + }: { + error?: string; + side: Side; + }): never { + if (error?.includes(`order couldn't be fully filled`)) { + throw new Error( + side === Side.BUY + ? PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED + : PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED, + ); + } + + if ( + error?.includes(`not available in your region`) || + error?.includes(`unable to access this provider`) + ) { + throw new Error(PREDICT_ERROR_CODES.NOT_ELIGIBLE); + } + + throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); + } + + async #submitOrderV1({ + 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; + }) { + const safeAddress = + this.#accountStateByAddress.get(signer.address)?.address ?? + computeProxyAddress(signer.address); + + const order = buildProtocolUnsignedOrder({ + protocol, + preview: { + ...preview, + feeRateBps: getPreviewFeeRateBpsForProtocol({ protocol, preview }), + }, + makerAddress: safeAddress, + signerAddress: getAddress(signer.address), + }); + + const typedData = getProtocolOrderTypedData({ + protocol, + order, + verifyingContract: getProtocolVerifyingContract({ + protocol, + negRisk: preview.negRisk, + }), + }); + + 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: Permit2FeeAuthorization | undefined; + let executor: string | undefined; + let permit2FeeReady = false; + + if ( + preview.fees !== undefined && + preview.fees.totalFee > 0 && + shouldUsePermit2 + ) { + const feeAmount = BigInt( + parseUnits(preview.fees.totalFee.toString(), 6).toString(), + ); + executor = this.#pickExecutor(preview.fees.executors ?? []); + feeAuthorization = await createPermit2FeeAuthorization({ + safeAddress, + signer, + amount: feeAmount, + spender: executor, + tokenAddress: protocol.collateral.feeAuthorizationToken, + }); + permit2FeeReady = true; + } + + let allowancesTx: { to: string; data: string } | undefined; + let permit2AllowanceReady = false; + + try { + allowancesTx = await buildTradeAllowancesTx({ + signer, + safeAddress, + protocol, + }); + permit2AllowanceReady = true; + } catch (allowanceError) { + DevLogger.log( + 'PolymarketProvider: Failed to generate v2 allowances transaction', + { error: allowanceError }, + ); + Logger.error( + allowanceError instanceof Error + ? allowanceError + : new Error(String(allowanceError)), + this.getErrorContext('placeOrder:v2AllowancesTx', { + operation: 'generate_allowances_tx_v2', + }), + ); + throw new Error('Failed to prepare v2 trade preflight'); + } + + 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 submitProtocolClobOrder({ + protocol, + headers, + clobOrder, + feeAuthorization, + executor, + allowancesTx, + }); + + if (!orderResult.success) { + DevLogger.log('PolymarketProvider: Place order V2 failed', { + error: orderResult.error, + errorDetails: undefined, + side: preview.side, + outcomeTokenId: preview.outcomeTokenId, + }); + this.#throwPlaceOrderError({ + error: orderResult.error, + side: preview.side, + }); + } + + return orderResult.response; + } + public async getMarketDetails({ marketId, }: { @@ -335,16 +735,24 @@ export class PolymarketProvider implements PredictProvider { private async getApiKey({ address, + protocol, }: { address: string; + protocol: Pick; }): Promise { - const cachedApiKey = this.#apiKeysByAddress.get(address); + const cacheKey = `${protocol.key}:${protocol.transport.clobBaseUrl}:${address}`; + const cachedApiKey = this.#apiKeysByProtocolAddress.get(cacheKey); if (cachedApiKey) { return cachedApiKey; } - const apiKeyCreds = await createApiKey({ address }); - this.#apiKeysByAddress.set(address, apiKeyCreds); + const apiKeyCreds = await createApiKey({ + address, + clobVersion: protocol.key, + clobBaseUrl: + protocol.key === 'v2' ? protocol.transport.clobBaseUrl : undefined, + }); + this.#apiKeysByProtocolAddress.set(cacheKey, apiKeyCreds); return apiKeyCreds; } @@ -1109,12 +1517,22 @@ export class PolymarketProvider implements PredictProvider { }, ): Promise { const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const basePreview = await previewOrder({ ...params, feeCollection }); + 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, + }), + }; - // Determine intended order type from feature flags. - // FAK is used when Permit2 config is active and FAK orders are enabled. - // placeOrder() guarantees the Permit2 allowance is set before submission, - // so we can always use FAK when the config allows it. let orderType = OrderType.FOK; if ( @@ -1127,35 +1545,22 @@ export class PolymarketProvider implements PredictProvider { orderType = OrderType.FAK; } - if (params.signer) { - if (this.isRateLimited(params.signer.address)) { - return { - ...basePreview, - orderType, - rateLimited: true, - }; - } + if (params.signer && this.isRateLimited(params.signer.address)) { + return { + ...normalizedPreview, + orderType, + rateLimited: true, + }; } - return { ...basePreview, orderType }; + return { ...normalizedPreview, orderType }; } public async placeOrder( params: PlaceOrderParams & { signer: Signer }, ): Promise { const { signer, preview } = params; - const { - outcomeTokenId, - side, - maxAmountSpent, - minAmountReceived, - negRisk, - feeRateBps, - fees, - slippage, - tickSize, - positionId, - } = preview; + const { outcomeTokenId, side, positionId } = preview; // Check existing position for both BUY and SELL to validate claimable status let existingPosition: PredictPosition | undefined; @@ -1206,261 +1611,27 @@ export class PolymarketProvider implements PredictProvider { } try { - 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'); - } - - /* - * Introduce slippage into minAmountReceived to reduce failure rate. - */ - const roundConfig = ROUNDING_CONFIG[tickSize.toString() as TickSize]; - const decimals = roundConfig.amount ?? 4; - - let _minWithSlippage = minAmountReceived * (1 - slippage); - /* - * For BUY orders, the minAmountWithSlippage needs to be capped at - * maxAmountSpent + tickSize, otherwise, the order will fail due to - * sharePrice being >= 1 (which is impossible). - */ - if (side === Side.BUY) { - _minWithSlippage = Math.max( - _minWithSlippage, - maxAmountSpent + tickSize, - ); - } - - const minAmountWithSlippage = roundOrderAmount({ - amount: _minWithSlippage, - decimals, - }); - - const makerAmount = parseUnits(maxAmountSpent.toString(), 6).toString(); - const takerAmount = parseUnits( - minAmountWithSlippage.toString(), - 6, - ).toString(); - - /** - * Do NOT change the order below. - * This order needs to match the order on the relayer. - */ - const order: OrderData & { salt: string } = { - salt: generateSalt(), - maker: makerAddress, - signer: signer.address, - taker: '0x0000000000000000000000000000000000000000', - tokenId: outcomeTokenId, - makerAmount, - takerAmount, - expiration: '0', - nonce: '0', - feeRateBps: feeRateBps ?? '0', - side: side === Side.BUY ? UtilsSide.BUY : UtilsSide.SELL, - signatureType: SignatureType.POLY_GNOSIS_SAFE, - }; - - const contractConfig = getContractConfig(chainId); - - const exchangeContract = negRisk - ? contractConfig.negRiskExchange - : contractConfig.exchange; - - const verifyingContract = exchangeContract; - - const typedData = getOrderTypedData({ - order, - chainId, - verifyingContract, - }); - - const signature = await signer.signTypedMessage( - { data: typedData, from: signer.address }, - SignTypedDataVersion.V4, - ); - - const signedOrder = { - ...order, - signature, - }; - - const signerApiKey = await this.getApiKey({ address: signer.address }); - - // Determine fees, permit2, and order type BEFORE building clobOrder - // so the HMAC signature covers the correct orderType. - const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); - const shouldUsePermit2 = this.#hasPermit2Config({ - permit2Enabled: fees?.permit2Enabled, - executors: fees?.executors, - }); - - let feeAuthorization: - | SafeFeeAuthorization - | Permit2FeeAuthorization - | undefined; - let executor: string | undefined; - let orderType: OrderType = OrderType.FOK; - let permit2FeeReady = false; - - if (fees !== undefined && fees.totalFee > 0) { - const safeAddress = computeProxyAddress(signer.address); - const feeAmountInUsdc = BigInt( - parseUnits(fees.totalFee.toString(), 6).toString(), - ); - - if (shouldUsePermit2) { - // Always use Permit2 fee authorization when permit2 is enabled. - // The relay will submit the allowancesTx on-chain first (if - // attached) before redeeming the Permit2 authorization. - permit2FeeReady = true; - const executors = fees.executors ?? []; - const randomIndex = new Uint32Array(1); - global.crypto.getRandomValues(randomIndex); - executor = executors[randomIndex[0] % executors.length]; - feeAuthorization = await createPermit2FeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - spender: executor, - }); - } else { - feeAuthorization = await createSafeFeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - to: fees.collector, - }); - } - } - - let allowancesTx: { to: string; data: string } | undefined; - let permit2AllowanceReady = false; - - // When Permit2 is enabled via feature flags, ensure the proxy wallet - // has the required allowances. If not, generate a signed Safe TX that - // the relay will submit on-chain before processing the order. - // This uses the feature flag (not per-order fees) so it works for - // both BUY orders (with fees) and SELL orders (no fees). - // - // IMPORTANT: Skip when a Safe fee authorization was already signed, - // because both transactions read the same on-chain Safe nonce and - // the relay would invalidate one when executing the other. - 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({ + const protocol = this.#getProtocol(); + const orderResponse = + protocol.key === 'v2' + ? await this.#submitOrderV2({ + signer, + preview, + protocol, + }) + : await this.#submitOrderV1({ signer, - extraUsdcSpenders: [PERMIT2_ADDRESS], + preview, + protocol, }); - allowancesTx = allowanceTx.params; - permit2AllowanceReady = true; - } - } catch (allowanceError) { - // Log but don't block the order — the relay will fall back - // to FOK if FAK isn't viable without allowances. - 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', - }), - ); - } - } - - // Determine order type: FAK when Permit2 config is active and - // FAK orders are enabled. For orders with fees, the relay also - // needs Permit2 allowances (either already on-chain or via - // the attached allowancesTx). - if ( - this.#shouldUseFakOrderType({ - permit2Enabled: feeCollection.permit2Enabled, - executors: feeCollection.executors, - fakOrdersEnabled, - }) - ) { - const hasFees = fees !== undefined && fees.totalFee > 0; - if (!hasFees || (permit2FeeReady && permit2AllowanceReady)) { - orderType = OrderType.FAK; - } - } - - const clobOrder = { - order: { ...signedOrder, side, salt: parseInt(signedOrder.salt) }, - owner: signerApiKey.apiKey, - orderType, - }; - - const body = JSON.stringify(clobOrder); - - const headers = await getL2Headers({ - l2HeaderArgs: { - method: 'POST', - requestPath: `/order`, - body, - }, - address: clobOrder.order.signer ?? '', - apiKey: signerApiKey, - }); - - const { success, response, error } = await submitClobOrder({ - headers, - clobOrder, - feeAuthorization, - executor, - allowancesTx, - }); - - if (!success) { - DevLogger.log('PolymarketProvider: Place order failed', { - error, - errorDetails: undefined, - side, - outcomeTokenId, - }); - if (error.includes(`order couldn't be fully filled`)) { - throw new Error( - side === Side.BUY - ? PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED - : PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED, - ); - } - if ( - error.includes(`not available in your region`) || - error.includes(`unable to access this provider`) - ) { - throw new Error(PREDICT_ERROR_CODES.NOT_ELIGIBLE); - } - throw new Error(error ?? PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); - } - if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); - // Create optimistic position update - if (response.makingAmount && response.takingAmount) { + if (orderResponse.makingAmount && orderResponse.takingAmount) { try { - const spentAmount = parseFloat(response.makingAmount); - const receivedAmount = parseFloat(response.takingAmount); + const spentAmount = parseFloat(orderResponse.makingAmount); + const receivedAmount = parseFloat(orderResponse.takingAmount); await this.createOrUpdateOptimisticPosition({ address: signer.address, @@ -1476,7 +1647,6 @@ export class PolymarketProvider implements PredictProvider { preview, }); } catch (optimisticError) { - // Log but don't fail the order DevLogger.log( 'PolymarketProvider: Failed to create optimistic position update', optimisticError, @@ -1494,7 +1664,6 @@ export class PolymarketProvider implements PredictProvider { } } } else if (positionId) { - // SELL order - mark position for optimistic removal this.removeOptimisticPosition({ address: signer.address, positionId, @@ -1504,14 +1673,13 @@ export class PolymarketProvider implements PredictProvider { } return { - success, + success: true, response: { - id: response.orderID, - spentAmount: response.makingAmount, - receivedAmount: response.takingAmount, - txHashes: response.transactionsHashes, + id: orderResponse.orderID ?? '', + spentAmount: orderResponse.makingAmount ?? '0', + receivedAmount: orderResponse.takingAmount ?? '0', + txHashes: orderResponse.transactionsHashes, }, - error, } as OrderResult; } catch (error) { // Catch all errors and return them in consistent format @@ -1539,6 +1707,7 @@ export class PolymarketProvider implements PredictProvider { ): Promise { try { const { positions, signer } = params; + const protocol = this.#getProtocol(); if (!positions || positions.length === 0) { throw new Error('No positions provided for claim'); @@ -1548,18 +1717,12 @@ export class PolymarketProvider implements PredictProvider { throw new Error('Signer address is required for claim'); } - const signerBalance = await getBalance({ address: signer.address }); - - let includeTransferTransaction = false; - - if (signerBalance < MIN_COLLATERAL_BALANCE_FOR_CLAIM) { - includeTransferTransaction = true; - } - // Get safe address from cache or fetch it let safeAddress: string | undefined; try { - safeAddress = computeProxyAddress(signer.address); + safeAddress = + this.#accountStateByAddress.get(signer.address)?.address ?? + computeProxyAddress(signer.address); } catch (error) { throw new Error( `Failed to retrieve account state: ${ @@ -1572,6 +1735,24 @@ 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 { @@ -1675,16 +1856,17 @@ export class PolymarketProvider implements PredictProvider { public async prepareDeposit( params: PrepareDepositParams, ): Promise { - const transactions = []; + const transactions: PrepareDepositResponse['transactions'] = []; const { signer } = params; + const protocol = this.#getProtocol(); if (!signer?.address) { throw new Error('Signer address is required for deposit preparation'); } - const { collateral } = MATIC_CONTRACTS; + const depositTokenAddress = getProtocolDepositTokenAddress(protocol); - if (!collateral) { + if (!depositTokenAddress) { throw new Error('Collateral contract address not configured'); } @@ -1719,6 +1901,44 @@ export class PolymarketProvider implements PredictProvider { this.setPolymarketAccountCreatedTrait(); } + const depositTransactionCallData = generateTransferData('transfer', { + toAddress: accountState.address, + amount: '0x0', + }); + + if (!depositTransactionCallData) { + throw new Error( + 'Failed to generate transfer data for deposit transaction', + ); + } + + const depositTransaction = { + params: { + to: depositTokenAddress as Hex, + data: depositTransactionCallData as Hex, + }, + 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 @@ -1743,24 +1963,7 @@ export class PolymarketProvider implements PredictProvider { transactions.push(allowanceTransaction); } - const depositTransactionCallData = generateTransferData('transfer', { - toAddress: accountState.address, - amount: '0x0', - }); - - if (!depositTransactionCallData) { - throw new Error( - 'Failed to generate transfer data for deposit transaction', - ); - } - - transactions.push({ - params: { - to: collateral as Hex, - data: depositTransactionCallData as Hex, - }, - type: TransactionType.predictDeposit, - }); + transactions.push(depositTransaction); return { chainId: CHAIN_IDS.POLYGON, @@ -1841,17 +2044,33 @@ export class PolymarketProvider implements PredictProvider { if (!address) { throw new Error('address is required'); } - const cachedAddress = this.#accountStateByAddress.get(address); + const predictAddress = - cachedAddress?.address ?? computeProxyAddress(address); - const balance = await getBalance({ address: predictAddress }); - return balance; + this.#accountStateByAddress.get(address)?.address ?? + computeProxyAddress(address); + const protocol = this.#getProtocol(); + + if (protocol.key !== 'v2') { + return await getBalance({ address: predictAddress }); + } + + const balances = await Promise.all( + protocol.collateral.balanceTokens.map((tokenAddress) => + getBalance({ + address: predictAddress, + tokenAddress, + }), + ), + ); + + return balances.reduce((sum, balance) => sum + balance, 0); } public async prepareWithdraw( params: PrepareWithdrawParams, ): Promise { const { signer } = params; + const protocol = this.#getProtocol(); if (!signer.address) { throw new Error('Signer address is required'); @@ -1861,6 +2080,8 @@ export class PolymarketProvider implements PredictProvider { this.#accountStateByAddress.get(signer.address)?.address ?? (await this.getAccountState({ ownerAddress: signer.address })).address; + const withdrawTokenAddress = getProtocolWithdrawTokenAddress(protocol); + const callData = encodeErc20Transfer({ to: signer.address, value: '0x0', @@ -1870,7 +2091,7 @@ export class PolymarketProvider implements PredictProvider { chainId: CHAIN_IDS.POLYGON, transaction: { params: { - to: MATIC_CONTRACTS.collateral as Hex, + to: withdrawTokenAddress as Hex, data: callData, gas: numberToHex(SAFE_EXEC_GAS_LIMIT) as Hex, }, @@ -1884,6 +2105,7 @@ export class PolymarketProvider implements PredictProvider { params: SignWithdrawParams, ): Promise { const { callData, signer } = params; + const protocol = this.#getProtocol(); if (!signer.address) { throw new Error('Signer address is required'); @@ -1893,14 +2115,30 @@ export class PolymarketProvider implements PredictProvider { this.#accountStateByAddress.get(signer.address)?.address ?? computeProxyAddress(signer.address); + const amount = getSafeUsdcAmount(callData); + const requestedAmountRaw = getSafeUsdcAmountRaw(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, signer, safeAddress, }); - const amount = getSafeUsdcAmount(callData); - return { callData: signedCallData, amount, diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 7ee5a875088d..8ccdb5c04a71 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -4,6 +4,9 @@ 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. */ @@ -81,5 +84,27 @@ export const MATIC_CONTRACTS: ContractConfig = { conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', }; +export const MATIC_CONTRACTS_V2: ContractConfig = { + exchange: '0xE111180000d2663C0091e4f400237545B87B996B', + negRiskAdapter: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', + negRiskExchange: '0xe2222d279d744050d28e00520010520000310F59', + collateral: '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', + conditionalTokens: '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', +}; + +export const USDC_E_ADDRESS = MATIC_CONTRACTS.collateral; + +export const COLLATERAL_ONRAMP_ADDRESS = + '0x93070a847efEf7F70739046A929D47a521F5B8ee'; + +export const COLLATERAL_OFFRAMP_ADDRESS = + '0x2957922Eb93258b93368531d39fAcCA3B4dC5854'; + +export const CTF_COLLATERAL_ADAPTER_ADDRESS = + '0xADa100874d00e3331D00F2007a9c336a65009718'; + +export const NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS = + '0xAdA200001000ef00D07553cEE7006808F895c6F1'; + export const POLYGON_USDC_CAIP_ASSET_ID = `${POLYGON_MAINNET_CAIP_CHAIN_ID}/erc20:${MATIC_CONTRACTS.collateral}` as const; diff --git a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts new file mode 100644 index 000000000000..3061f11eb26f --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts @@ -0,0 +1,220 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { parseUnits } from 'ethers/lib/utils'; +import type { PredictPosition } from '../../../types'; +import type { Signer } from '../../types'; +import { + HASH_ZERO_BYTES32, + MIN_COLLATERAL_BALANCE_FOR_CLAIM, +} from '../constants'; +import { + POLYMARKET_V2_PROTOCOL, + type PolymarketProtocolDefinition, +} from '../protocol/definitions'; +import { OperationType, type SafeTransaction } from '../safe/types'; +import { encodeRedeemPositions } from '../utils'; +import { + buildSignedSafeExecution, + buildUnwrapTransaction, + compileAllowanceMaintenanceTransactions, + getRawTokenBalance, +} from './core'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import { + getCanonicalV2AllowanceRequirements, + type V2AllowanceRequirement, +} from './v2AllowanceRequirements'; + +const MIN_GAS_STATION_USDCE_BALANCE_RAW = BigInt( + parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), +); + +type PolymarketV2ProtocolDefinition = Extract< + PolymarketProtocolDefinition, + { key: 'v2' } +>; + +function buildClaimSubtransactions({ + positions, + protocol, +}: { + positions: PredictPosition[]; + protocol: PolymarketV2ProtocolDefinition; +}): SafeTransaction[] { + return positions.map((position) => ({ + to: position.negRisk + ? protocol.claim.negRiskTarget + : protocol.claim.standardTarget, + data: encodeRedeemPositions({ + collateralToken: protocol.collateral.claimToken, + parentCollectionId: HASH_ZERO_BYTES32, + conditionId: position.outcomeId, + indexSets: [1, 2], + }), + operation: OperationType.Call, + value: '0', + })); +} + +export function getClaimRequirements({ + positions, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + positions: PredictPosition[]; + protocol?: PolymarketV2ProtocolDefinition; +}): V2AllowanceRequirement[] { + const requiresStandardAdapter = positions.some( + (position) => !position.negRisk, + ); + const requiresNegRiskAdapter = positions.some((position) => position.negRisk); + + return [ + ...getCanonicalV2AllowanceRequirements(protocol), + ...(requiresStandardAdapter + ? [ + { + type: 'erc1155-operator' as const, + tokenAddress: protocol.contracts.conditionalTokens, + operator: protocol.claim.standardTarget, + }, + ] + : []), + ...(requiresNegRiskAdapter + ? [ + { + type: 'erc1155-operator' as const, + tokenAddress: protocol.contracts.conditionalTokens, + operator: protocol.claim.negRiskTarget, + }, + ] + : []), + ]; +} + +export interface ClaimPlan { + gasStationDeficit: bigint; + safeUsdceBalance: bigint; + eoaUsdceBalance: bigint; + missingRequirements: V2AllowanceRequirement[]; + transactions: SafeTransaction[]; +} + +export async function planClaim({ + signer, + positions, + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + positions: PredictPosition[]; + safeAddress: string; + protocol?: PolymarketV2ProtocolDefinition; +}): 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 gasStationDeficit = + eoaUsdceBalance >= MIN_GAS_STATION_USDCE_BALANCE_RAW + ? 0n + : MIN_GAS_STATION_USDCE_BALANCE_RAW - eoaUsdceBalance; + + const transactions = compileClaimTransactions({ + protocol, + signer, + positions, + safeAddress, + missingRequirements, + safeUsdceBalance, + gasStationDeficit, + }); + + return { + gasStationDeficit, + safeUsdceBalance, + eoaUsdceBalance, + missingRequirements, + transactions, + }; +} + +function compileClaimTransactions({ + protocol = POLYMARKET_V2_PROTOCOL, + signer, + positions, + safeAddress, + missingRequirements, + safeUsdceBalance, + gasStationDeficit, +}: { + protocol?: PolymarketV2ProtocolDefinition; + signer: Signer; + positions: PredictPosition[]; + safeAddress: string; + missingRequirements: V2AllowanceRequirement[]; + safeUsdceBalance: bigint; + gasStationDeficit: bigint; +}): SafeTransaction[] { + const transactions = compileAllowanceMaintenanceTransactions({ + protocol, + safeAddress, + missingRequirements, + usdceBalance: safeUsdceBalance, + }); + + transactions.push( + ...buildClaimSubtransactions({ + positions, + protocol, + }), + ); + + const unwrapTransaction = buildUnwrapTransaction({ + recipientAddress: signer.address, + amount: gasStationDeficit, + protocol, + }); + + if (unwrapTransaction) { + transactions.push(unwrapTransaction); + } + + return transactions; +} + +export async function buildClaimTransaction({ + signer, + positions, + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + positions: PredictPosition[]; + safeAddress: string; + protocol?: PolymarketV2ProtocolDefinition; +}) { + const plan = await planClaim({ + signer, + positions, + safeAddress, + protocol, + }); + + return buildSignedSafeExecution({ + signer, + safeAddress, + transactions: plan.transactions, + type: TransactionType.predictClaim, + }); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.test.ts new file mode 100644 index 000000000000..556068568bce --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.test.ts @@ -0,0 +1,39 @@ +jest.mock('../utils', () => ({ + encodeApprove: jest.fn(({ spender }) => `approve:${spender}`), + encodeErc1155Approve: jest.fn(({ spender }) => `setApproval:${spender}`), +})); + +import { compileRequirementTransactions } from './compileRequirementTransactions'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +describe('compileRequirementTransactions', () => { + it('compiles erc20 approvals and erc1155 operator approvals in order', () => { + const requirements: V2AllowanceRequirement[] = [ + { + type: 'erc20-allowance', + tokenAddress: '0x1000000000000000000000000000000000000000', + spender: '0x2000000000000000000000000000000000000000', + }, + { + type: 'erc1155-operator', + tokenAddress: '0x3000000000000000000000000000000000000000', + operator: '0x4000000000000000000000000000000000000000', + }, + ]; + + expect(compileRequirementTransactions(requirements)).toEqual([ + { + to: '0x1000000000000000000000000000000000000000', + data: 'approve:0x2000000000000000000000000000000000000000', + operation: 0, + value: '0', + }, + { + to: '0x3000000000000000000000000000000000000000', + data: 'setApproval:0x4000000000000000000000000000000000000000', + operation: 0, + value: '0', + }, + ]); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.ts b/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.ts new file mode 100644 index 000000000000..d18c047b6fed --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/compileRequirementTransactions.ts @@ -0,0 +1,32 @@ +import { ethers } from 'ethers'; +import { OperationType, type SafeTransaction } from '../safe/types'; +import { encodeApprove, encodeErc1155Approve } from '../utils'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +export function compileRequirementTransactions( + requirements: V2AllowanceRequirement[], +): SafeTransaction[] { + return requirements.map((requirement) => { + if (requirement.type === 'erc20-allowance') { + return { + to: requirement.tokenAddress, + data: encodeApprove({ + spender: requirement.spender, + amount: ethers.constants.MaxUint256.toBigInt(), + }), + operation: OperationType.Call, + value: '0', + }; + } + + return { + to: requirement.tokenAddress, + data: encodeErc1155Approve({ + spender: requirement.operator, + approved: true, + }), + operation: OperationType.Call, + value: '0', + }; + }); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/core.ts b/app/components/UI/Predict/providers/polymarket/preflight/core.ts new file mode 100644 index 000000000000..4c1718a6613e --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/core.ts @@ -0,0 +1,177 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import type { Signer } from '../../types'; +import { + POLYMARKET_V2_PROTOCOL, + type PolymarketProtocolDefinition, +} from '../protocol/definitions'; +import { encodeUnwrap, encodeWrap } from '../protocol/orderCodec'; +import { OperationType, type SafeTransaction } from '../safe/types'; +import { + aggregateTransaction, + getSafeTransactionCallData, +} from '../safe/utils'; +import { getRawBalance } from '../utils'; +import { compileRequirementTransactions } from './compileRequirementTransactions'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +export interface SignedSafeExecution { + params: { to: Hex; data: Hex }; + type: TransactionType; +} + +export async function getRawTokenBalance({ + address, + tokenAddress, +}: { + address: string; + tokenAddress: string; +}): Promise { + return getRawBalance({ address, tokenAddress }); +} + +export function aggregateSafeTransactions( + transactions: SafeTransaction[], +): SafeTransaction { + return aggregateTransaction(transactions); +} + +export async function signSafeTransactions({ + signer, + safeAddress, + transactions, +}: { + signer: Signer; + safeAddress: string; + transactions: SafeTransaction[]; +}): Promise { + return (await getSafeTransactionCallData({ + signer, + safeAddress, + txn: aggregateSafeTransactions(transactions), + })) as Hex; +} + +export function buildWrapTransaction({ + safeAddress, + amount, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + safeAddress: string; + amount: bigint; + protocol?: PolymarketProtocolDefinition; +}): SafeTransaction | undefined { + if (amount <= 0n || protocol.collateral.onrampAddress === undefined) { + return undefined; + } + + return { + to: protocol.collateral.onrampAddress, + data: encodeWrap({ + asset: protocol.collateral.legacyUsdceToken, + to: safeAddress, + amount, + }), + operation: OperationType.Call, + value: '0', + }; +} + +export function buildUnwrapTransaction({ + recipientAddress, + amount, + protocol = POLYMARKET_V2_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', + }; +} + +export function compileAllowanceMaintenanceTransactions({ + safeAddress, + missingRequirements, + usdceBalance, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + safeAddress: string; + missingRequirements: V2AllowanceRequirement[]; + usdceBalance: bigint; + protocol?: PolymarketProtocolDefinition; +}): SafeTransaction[] { + const transactions = compileRequirementTransactions(missingRequirements); + const wrapTransaction = buildWrapTransaction({ + safeAddress, + amount: usdceBalance, + protocol, + }); + + if (wrapTransaction) { + transactions.push(wrapTransaction); + } + + return transactions; +} + +export async function buildSignedSafeExecution({ + signer, + safeAddress, + transactions, + type, +}: { + signer: Signer; + safeAddress: string; + transactions: SafeTransaction[]; + type: TransactionType; +}): Promise { + const callData = await signSafeTransactions({ + signer, + safeAddress, + transactions, + }); + + return { + params: { + to: safeAddress as Hex, + data: callData, + }, + type, + }; +} + +export async function buildSignedSafeExecutionIfNeeded({ + signer, + safeAddress, + transactions, + type, +}: { + signer: Signer; + safeAddress: string; + transactions: SafeTransaction[]; + type: TransactionType; +}): Promise { + if (transactions.length === 0) { + return undefined; + } + + return buildSignedSafeExecution({ + signer, + safeAddress, + transactions, + type, + }); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts new file mode 100644 index 000000000000..b053b1cfb3b2 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/deposit.ts @@ -0,0 +1,88 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { Signer } from '../../types'; +import { + POLYMARKET_V2_PROTOCOL, + type PolymarketProtocolDefinition, +} from '../protocol/definitions'; +import type { SafeTransaction } from '../safe/types'; +import { + buildSignedSafeExecutionIfNeeded, + compileAllowanceMaintenanceTransactions, + getRawTokenBalance, +} from './core'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; + +export interface DepositMaintenancePlan { + missingRequirements: ReturnType; + preExistingSafeUsdceBalance: bigint; + transactions: SafeTransaction[]; +} + +export async function planDepositMaintenance({ + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + safeAddress: string; + protocol?: PolymarketProtocolDefinition; +}): Promise { + const [missingRequirements, preExistingSafeUsdceBalance] = await Promise.all([ + inspectMissingRequirements({ + address: safeAddress, + requirements: getCanonicalV2AllowanceRequirements(protocol), + }), + getRawTokenBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + }), + ]); + + return { + missingRequirements, + preExistingSafeUsdceBalance, + transactions: compileDepositMaintenanceTransactions({ + protocol, + safeAddress, + missingRequirements, + preExistingSafeUsdceBalance, + }), + }; +} + +function compileDepositMaintenanceTransactions({ + protocol = POLYMARKET_V2_PROTOCOL, + safeAddress, + missingRequirements, + preExistingSafeUsdceBalance, +}: { + protocol?: PolymarketProtocolDefinition; + safeAddress: string; + missingRequirements: ReturnType; + preExistingSafeUsdceBalance: bigint; +}): SafeTransaction[] { + return compileAllowanceMaintenanceTransactions({ + protocol, + safeAddress, + missingRequirements, + usdceBalance: preExistingSafeUsdceBalance, + }); +} + +export async function buildDepositMaintenanceTransaction({ + signer, + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + safeAddress: string; + protocol?: PolymarketProtocolDefinition; +}) { + const plan = await planDepositMaintenance({ safeAddress, protocol }); + + return buildSignedSafeExecutionIfNeeded({ + signer, + safeAddress, + transactions: plan.transactions, + type: TransactionType.contractInteraction, + }); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.test.ts new file mode 100644 index 000000000000..a47edcd96c0a --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.test.ts @@ -0,0 +1,83 @@ +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +const requirements: V2AllowanceRequirement[] = [ + { + type: 'erc20-allowance', + tokenAddress: '0x1000000000000000000000000000000000000000', + spender: '0x2000000000000000000000000000000000000000', + }, + { + type: 'erc20-allowance', + tokenAddress: '0x1000000000000000000000000000000000000000', + spender: '0x3000000000000000000000000000000000000000', + }, + { + type: 'erc1155-operator', + tokenAddress: '0x4000000000000000000000000000000000000000', + operator: '0x5000000000000000000000000000000000000000', + }, +]; + +describe('inspectMissingRequirements', () => { + it('returns an empty array when nothing is missing', async () => { + const missing = await inspectMissingRequirements({ + address: '0x1111111111111111111111111111111111111111', + requirements, + readAllowance: jest.fn().mockResolvedValue(1n), + readIsApprovedForAll: jest.fn().mockResolvedValue(true), + }); + + expect(missing).toEqual([]); + }); + + it('returns missing erc20 requirements in input order', async () => { + const readAllowance = jest + .fn() + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(1n); + + const missing = await inspectMissingRequirements({ + address: '0x1111111111111111111111111111111111111111', + requirements, + readAllowance, + readIsApprovedForAll: jest.fn().mockResolvedValue(true), + }); + + expect(missing).toEqual([requirements[0]]); + }); + + it('returns missing erc1155 requirements', async () => { + const readIsApprovedForAll = jest.fn().mockResolvedValue(false); + + const missing = await inspectMissingRequirements({ + address: '0x1111111111111111111111111111111111111111', + requirements, + readAllowance: jest.fn().mockResolvedValue(1n), + readIsApprovedForAll, + }); + + expect(missing).toEqual([requirements[2]]); + expect(readIsApprovedForAll).toHaveBeenCalledWith({ + tokenAddress: '0x4000000000000000000000000000000000000000', + owner: '0x1111111111111111111111111111111111111111', + operator: '0x5000000000000000000000000000000000000000', + }); + }); + + it('returns mixed missing requirements without reordering', async () => { + const readAllowance = jest + .fn() + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(0n); + + const missing = await inspectMissingRequirements({ + address: '0x1111111111111111111111111111111111111111', + requirements, + readAllowance, + readIsApprovedForAll: jest.fn().mockResolvedValue(false), + }); + + expect(missing).toEqual(requirements); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.ts b/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.ts new file mode 100644 index 000000000000..b4e7ad8bbf65 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/inspectMissingRequirements.ts @@ -0,0 +1,41 @@ +import { getAllowance, getIsApprovedForAll } from '../utils'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +export async function inspectMissingRequirements({ + address, + requirements, + readAllowance = getAllowance, + readIsApprovedForAll = getIsApprovedForAll, +}: { + address: string; + requirements: V2AllowanceRequirement[]; + readAllowance?: typeof getAllowance; + readIsApprovedForAll?: typeof getIsApprovedForAll; +}): Promise { + const results = await Promise.all( + requirements.map(async (requirement) => { + if (requirement.type === 'erc20-allowance') { + const allowance = await readAllowance({ + tokenAddress: requirement.tokenAddress, + owner: address, + spender: requirement.spender, + }); + + return allowance > 0n ? undefined : requirement; + } + + const approved = await readIsApprovedForAll({ + tokenAddress: requirement.tokenAddress, + owner: address, + operator: requirement.operator, + }); + + return approved ? undefined : requirement; + }), + ); + + return results.filter( + (requirement): requirement is V2AllowanceRequirement => + requirement !== undefined, + ); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/trade.ts b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts new file mode 100644 index 000000000000..b3e8358b5809 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/trade.ts @@ -0,0 +1,93 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { Signer } from '../../types'; +import { + POLYMARKET_V2_PROTOCOL, + type PolymarketProtocolDefinition, +} from '../protocol/definitions'; +import type { SafeTransaction } from '../safe/types'; +import { + buildSignedSafeExecutionIfNeeded, + compileAllowanceMaintenanceTransactions, + getRawTokenBalance, +} from './core'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; + +export interface TradePreflightPlan { + missingRequirements: ReturnType; + safeUsdceBalance: bigint; + transactions: SafeTransaction[]; +} + +export async function planTradePreflight({ + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + safeAddress: string; + protocol?: PolymarketProtocolDefinition; +}): Promise { + const [missingRequirements, safeUsdceBalance] = await Promise.all([ + inspectMissingRequirements({ + address: safeAddress, + requirements: getCanonicalV2AllowanceRequirements(protocol), + }), + getRawTokenBalance({ + address: safeAddress, + tokenAddress: protocol.collateral.legacyUsdceToken, + }), + ]); + + return { + missingRequirements, + safeUsdceBalance, + transactions: compileTradePreflightTransactions({ + protocol, + safeAddress, + missingRequirements, + safeUsdceBalance, + }), + }; +} + +export function compileTradePreflightTransactions({ + protocol = POLYMARKET_V2_PROTOCOL, + safeAddress, + missingRequirements, + safeUsdceBalance, +}: { + protocol?: PolymarketProtocolDefinition; + safeAddress: string; + missingRequirements: ReturnType; + safeUsdceBalance: bigint; +}): SafeTransaction[] { + return compileAllowanceMaintenanceTransactions({ + protocol, + safeAddress, + missingRequirements, + usdceBalance: safeUsdceBalance, + }); +} + +export async function buildTradeAllowancesTx({ + signer, + safeAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + safeAddress: string; + protocol?: PolymarketProtocolDefinition; +}): Promise<{ to: string; data: string } | undefined> { + const plan = await planTradePreflight({ + safeAddress, + protocol, + }); + + const signedExecution = await buildSignedSafeExecutionIfNeeded({ + signer, + safeAddress, + transactions: plan.transactions, + type: TransactionType.contractInteraction, + }); + + return signedExecution?.params; +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts new file mode 100644 index 000000000000..08e53ad3f6c1 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts @@ -0,0 +1,32 @@ +import { PERMIT2_ADDRESS } from '../safe/constants'; +import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; +import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; + +describe('v2 allowance requirements', () => { + it('returns the canonical requirement list in deterministic order', () => { + const requirements = getCanonicalV2AllowanceRequirements(); + + expect(requirements).toHaveLength(10); + expect(requirements[0]).toEqual({ + type: 'erc20-allowance', + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + spender: POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + }); + expect(requirements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + 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 new file mode 100644 index 000000000000..9989616e6357 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.ts @@ -0,0 +1,88 @@ +import { + POLYMARKET_V2_PROTOCOL, + type PolymarketProtocolDefinition, +} from '../protocol/definitions'; +import { PERMIT2_ADDRESS } from '../safe/constants'; + +export interface Erc20AllowanceRequirement { + type: 'erc20-allowance'; + tokenAddress: string; + spender: string; +} + +export interface Erc1155OperatorRequirement { + type: 'erc1155-operator'; + tokenAddress: string; + operator: string; +} + +export type V2AllowanceRequirement = + | Erc20AllowanceRequirement + | Erc1155OperatorRequirement; + +function buildErc20AllowanceRequirements({ + tokenAddress, + spenders, +}: { + tokenAddress: string; + spenders: string[]; +}): Erc20AllowanceRequirement[] { + return spenders.map((spender) => ({ + type: 'erc20-allowance', + tokenAddress, + spender, + })); +} + +function buildErc1155OperatorRequirements({ + tokenAddress, + operators, +}: { + tokenAddress: string; + operators: string[]; +}): Erc1155OperatorRequirement[] { + return operators.map((operator) => ({ + type: 'erc1155-operator', + tokenAddress, + operator, + })); +} + +export function getCanonicalV2AllowanceRequirements( + 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', + ); + } + + return [ + { + type: 'erc20-allowance', + tokenAddress: collateral.legacyUsdceToken, + spender: collateral.onrampAddress, + }, + ...buildErc20AllowanceRequirements({ + tokenAddress: collateral.tradingToken, + spenders: [ + contracts.conditionalTokens, + contracts.exchange, + contracts.negRiskExchange, + contracts.negRiskAdapter, + PERMIT2_ADDRESS, + collateral.offrampAddress, + ], + }), + ...buildErc1155OperatorRequirements({ + tokenAddress: contracts.conditionalTokens, + operators: [ + contracts.exchange, + contracts.negRiskExchange, + contracts.negRiskAdapter, + ], + }), + ]; +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts new file mode 100644 index 000000000000..82c2635e5add --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.test.ts @@ -0,0 +1,119 @@ +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('./inspectMissingRequirements', () => ({ + inspectMissingRequirements: jest.fn().mockResolvedValue([]), +})); + +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 { planWithdraw } from './withdraw'; + +const mockGetRawTokenBalance = getRawTokenBalance as jest.MockedFunction< + typeof getRawTokenBalance +>; + +const signer = { + address: '0x1111111111111111111111111111111111111111', + signPersonalMessage: jest.fn(), + signTypedMessage: jest.fn(), +}; + +describe('planWithdraw', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not read Safe pUSD when the Safe already has enough USDC.e', 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(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({ + address: '0x9999999999999999999999999999999999999999', + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + }); + expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ + POLYMARKET_V2_PROTOCOL.collateral.offrampAddress, + POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + ]); + }); + + 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 new file mode 100644 index 000000000000..fff3cc1be12f --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/withdraw.ts @@ -0,0 +1,167 @@ +import { TransactionType } from '@metamask/transaction-controller'; +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, + getRawTokenBalance, +} from './core'; +import { compileRequirementTransactions } from './compileRequirementTransactions'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import { getCanonicalV2AllowanceRequirements } from './v2AllowanceRequirements'; + +export interface WithdrawPlan { + requestedAmountRaw: bigint; + safeUsdceBalance: bigint; + deficit: bigint; + missingRequirements: ReturnType; + transactions: SafeTransaction[]; +} + +export async function planWithdraw({ + signer, + safeAddress, + requestedAmountRaw, + mode, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + safeAddress: string; + requestedAmountRaw: bigint; + mode: WithdrawExecutionMode; + protocol?: PolymarketProtocolDefinition; +}): Promise { + const [missingRequirements, safeUsdceBalance] = await Promise.all([ + inspectMissingRequirements({ + address: safeAddress, + requirements: getCanonicalV2AllowanceRequirements(protocol), + }), + 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'); + } + } + + return { + requestedAmountRaw, + safeUsdceBalance, + deficit, + missingRequirements, + transactions: compileWithdrawTransactions({ + signer, + safeAddress, + requestedAmountRaw, + deficit, + missingRequirements, + mode, + protocol, + }), + }; +} + +function compileWithdrawTransactions({ + signer, + safeAddress, + requestedAmountRaw, + deficit, + missingRequirements, + mode, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + safeAddress: string; + requestedAmountRaw: bigint; + deficit: bigint; + missingRequirements: ReturnType; + mode: WithdrawExecutionMode; + 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, + protocol, + }); + + if (unwrapTransaction) { + transactions.push(unwrapTransaction); + } + + transactions.push({ + to: protocol.collateral.legacyUsdceToken, + data: encodeErc20Transfer({ + to: signer.address, + value: requestedAmountRaw, + }), + operation: OperationType.Call, + value: '0', + }); + + return transactions; +} + +export async function buildWithdrawTransaction({ + signer, + safeAddress, + requestedAmountRaw, + mode, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + signer: Signer; + safeAddress: string; + requestedAmountRaw: bigint; + mode: WithdrawExecutionMode; + protocol?: PolymarketProtocolDefinition; +}) { + const plan = await planWithdraw({ + signer, + safeAddress, + requestedAmountRaw, + mode, + protocol, + }); + + return buildSignedSafeExecution({ + signer, + safeAddress, + transactions: plan.transactions, + type: TransactionType.predictWithdraw, + }); +} diff --git a/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts new file mode 100644 index 000000000000..be681f4c8d54 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/workflows.test.ts @@ -0,0 +1,220 @@ +import { parseUnits } from 'ethers/lib/utils'; +import { PredictPositionStatus, type PredictPosition } from '../../../types'; +import { MIN_COLLATERAL_BALANCE_FOR_CLAIM } from '../constants'; +import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; +import { planClaim, getClaimRequirements } from './claim'; +import { getRawTokenBalance } from './core'; +import { planDepositMaintenance } from './deposit'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; +import { planTradePreflight } from './trade'; +import { planWithdraw } from './withdraw'; +import type { V2AllowanceRequirement } from './v2AllowanceRequirements'; + +jest.mock('./core', () => { + const actual = jest.requireActual('./core'); + + return { + ...actual, + getRawTokenBalance: jest.fn(), + }; +}); + +jest.mock('./inspectMissingRequirements', () => ({ + inspectMissingRequirements: jest.fn(), +})); + +const mockGetRawTokenBalance = jest.mocked(getRawTokenBalance); +const mockInspectMissingRequirements = jest.mocked(inspectMissingRequirements); + +const missingRequirements: V2AllowanceRequirement[] = [ + { + type: 'erc20-allowance', + tokenAddress: '0x1000000000000000000000000000000000000000', + spender: '0x2000000000000000000000000000000000000000', + }, +]; + +const claimPosition: PredictPosition = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + title: 'Question?', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcomeTokenId: '123', + outcomeIndex: 0, + outcome: 'Yes', + icon: 'https://example.com/icon.png', + amount: 2, + status: PredictPositionStatus.WON, + endDate: '2026-01-01T00:00:00.000Z', + size: 2, + price: 0.5, + currentValue: 2, + cashPnl: 1, + percentPnl: 100, + claimable: true, + avgPrice: 0.5, + initialValue: 1, + realizedPnl: 1, + negRisk: false, +}; + +const signer = { + address: '0x1111111111111111111111111111111111111111', + signPersonalMessage: jest.fn(), + signTypedMessage: jest.fn(), +}; + +const gasStationThresholdRaw = BigInt( + parseUnits(MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), 6).toString(), +); + +describe('preflight workflow planners', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInspectMissingRequirements.mockResolvedValue(missingRequirements); + }); + + it('builds trade transactions as repairs first and wrap-all second', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(10n); + + const plan = await planTradePreflight({ + protocol: POLYMARKET_V2_PROTOCOL, + safeAddress: '0x1111111111111111111111111111111111111111', + }); + + expect(plan.transactions[0]?.to).toBe( + '0x1000000000000000000000000000000000000000', + ); + expect(plan.transactions[1]?.to).toBe( + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + ); + }); + + it('builds deposit maintenance with the same repair-then-wrap ordering', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(10n); + + const plan = await planDepositMaintenance({ + protocol: POLYMARKET_V2_PROTOCOL, + safeAddress: '0x1111111111111111111111111111111111111111', + }); + + expect(plan.transactions).toHaveLength(2); + expect(plan.transactions[0]?.to).toBe( + '0x1000000000000000000000000000000000000000', + ); + expect(plan.transactions[1]?.to).toBe( + POLYMARKET_V2_PROTOCOL.collateral.onrampAddress, + ); + }); + + it('builds claim transactions as repairs, wrap, adapter claim, then exact-deficit unwrap', async () => { + mockGetRawTokenBalance.mockResolvedValueOnce(10n).mockResolvedValueOnce(0n); + + const plan = await planClaim({ + protocol: POLYMARKET_V2_PROTOCOL, + signer, + positions: [claimPosition], + safeAddress: '0x9999999999999999999999999999999999999999', + }); + + expect(plan.gasStationDeficit).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, + ]); + }); + + it('builds negRisk claim transactions through the negRisk collateral adapter', async () => { + mockGetRawTokenBalance + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(gasStationThresholdRaw); + + const plan = await planClaim({ + protocol: POLYMARKET_V2_PROTOCOL, + signer, + positions: [{ ...claimPosition, negRisk: true }], + safeAddress: '0x9999999999999999999999999999999999999999', + }); + + expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ + '0x1000000000000000000000000000000000000000', + POLYMARKET_V2_PROTOCOL.claim.negRiskTarget, + ]); + }); + + it('adds claim-only adapter approvals for the v2 claim targets needed by the positions', () => { + const requirements = getClaimRequirements({ + protocol: POLYMARKET_V2_PROTOCOL, + positions: [ + claimPosition, + { ...claimPosition, id: 'position-2', negRisk: true }, + ], + }); + + const adapterOperators = requirements + .flatMap((requirement) => { + if (requirement.type !== 'erc1155-operator') { + return []; + } + + if ( + requirement.operator !== + POLYMARKET_V2_PROTOCOL.claim.standardTarget && + requirement.operator !== POLYMARKET_V2_PROTOCOL.claim.negRiskTarget + ) { + return []; + } + + return [requirement.operator]; + }) + .sort(); + + expect(adapterOperators).toEqual( + [ + POLYMARKET_V2_PROTOCOL.claim.negRiskTarget, + POLYMARKET_V2_PROTOCOL.claim.standardTarget, + ].sort(), + ); + }); + + it('builds withdraw fallback as repairs, optional unwrap, then usdce transfer', async () => { + mockGetRawTokenBalance + .mockResolvedValueOnce(1_000_000n) + .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, + ]); + }); + + it('builds withdraw preferred mode as repairs followed by 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: 'pusd-transfer', + }); + + expect(plan.transactions.map((transaction) => transaction.to)).toEqual([ + '0x1000000000000000000000000000000000000000', + POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + ]); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts new file mode 100644 index 000000000000..7e820cc1571f --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts @@ -0,0 +1,127 @@ +import { + CTF_COLLATERAL_ADAPTER_ADDRESS, + DEFAULT_CLOB_BASE_URL, + HASH_ZERO_BYTES32, + LEGACY_V2_CLOB_BASE_URL, + NEG_RISK_CTF_COLLATERAL_ADAPTER_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', () => { + const originalBuilderCode = process.env.MM_PREDICT_BUILDER_CODE; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + delete process.env.MM_PREDICT_BUILDER_CODE; + logSpy = jest.spyOn(Logger, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + afterAll(() => { + if (originalBuilderCode === undefined) { + delete process.env.MM_PREDICT_BUILDER_CODE; + return; + } + + process.env.MM_PREDICT_BUILDER_CODE = originalBuilderCode; + }); + + it('resolves v1 when predictClobV2 is disabled', () => { + expect(resolvePolymarketProtocol({ predictClobV2Enabled: false })).toBe( + POLYMARKET_V1_PROTOCOL, + ); + }); + + it('resolves v2 when predictClobV2 is enabled', () => { + expect(resolvePolymarketProtocol({ predictClobV2Enabled: true })).toBe( + POLYMARKET_V2_PROTOCOL, + ); + }); + + it('defaults the v2 protocol to the canonical CLOB host', () => { + expect(POLYMARKET_V2_PROTOCOL.transport.clobBaseUrl).toBe( + DEFAULT_CLOB_BASE_URL, + ); + }); + + 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', + }), + }), + ); + }); + + it('reads the builder code from MM_PREDICT_BUILDER_CODE', () => { + process.env.MM_PREDICT_BUILDER_CODE = + '0x1111111111111111111111111111111111111111111111111111111111111111'; + + expect(getClobV2BuilderCode()).toBe( + '0x1111111111111111111111111111111111111111111111111111111111111111', + ); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('falls back to zero builder code when MM_PREDICT_BUILDER_CODE is missing', () => { + process.env.MM_PREDICT_BUILDER_CODE = ''; + + expect(getClobV2BuilderCode()).toBe(HASH_ZERO_BYTES32); + expect(logSpy).toHaveBeenCalledWith( + 'Polymarket CLOB v2 builder code missing in MM_PREDICT_BUILDER_CODE; falling back to zero bytes32 value', + ); + }); + + it('falls back to zero builder code when MM_PREDICT_BUILDER_CODE is invalid', () => { + process.env.MM_PREDICT_BUILDER_CODE = 'invalid'; + + expect(getClobV2BuilderCode()).toBe(HASH_ZERO_BYTES32); + expect(logSpy).toHaveBeenCalledWith( + 'Polymarket CLOB v2 builder code invalid in MM_PREDICT_BUILDER_CODE; falling back to zero bytes32 value', + ); + }); + + it('routes v2 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, + ); + 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, + ); + expect(getProtocolWithdrawTokenAddress(POLYMARKET_V2_PROTOCOL)).toBe( + POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + ); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts new file mode 100644 index 000000000000..c653350e3e82 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/definitions.ts @@ -0,0 +1,193 @@ +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, + USDC_E_ADDRESS, +} 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'; + +interface BasePolymarketProtocolDefinition { + key: PolymarketProtocolKey; + contracts: ContractConfig; + collateral: { + legacyUsdceToken: string; + tradingToken: string; + claimToken: string; + feeAuthorizationToken: string; + balanceTokens: string[]; + onrampAddress?: string; + offrampAddress?: string; + }; + order: { + domainVersion: '1' | '2'; + metadata: string; + getBuilderCode?: () => string; + }; + transport: { + clobVersionHeader?: '2'; + clobBaseUrl: string; + }; + workflow: { + depositMode: DepositExecutionMode; + withdrawMode: WithdrawExecutionMode; + }; + claim: { + standardTarget: string; + negRiskTarget: string; + }; +} + +function isBytes32Hex(value: string | undefined): value is string { + return Boolean(value?.match(/^0x[a-fA-F0-9]{64}$/u)); +} + +export function getClobV2BuilderCode(): string { + const value = process.env.MM_PREDICT_BUILDER_CODE; + + if (isBytes32Hex(value)) { + return value; + } + + const reason = value ? 'invalid' : 'missing'; + + Logger.log( + `Polymarket CLOB v2 builder code ${reason} in MM_PREDICT_BUILDER_CODE; falling back to zero bytes32 value`, + ); + + 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, + collateral: { + legacyUsdceToken: USDC_E_ADDRESS, + 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', + metadata: HASH_ZERO_BYTES32, + getBuilderCode: getClobV2BuilderCode, + }, + transport: { + clobVersionHeader: '2', + clobBaseUrl: DEFAULT_CLOB_BASE_URL, + }, + workflow: { + depositMode: 'usdce-transfer', + withdrawMode: 'usdce-deficit-unwrap', + }, + claim: { + standardTarget: CTF_COLLATERAL_ADAPTER_ADDRESS, + negRiskTarget: NEG_RISK_CTF_COLLATERAL_ADAPTER_ADDRESS, + }, +} satisfies BasePolymarketProtocolDefinition; + +export type PolymarketProtocolDefinition = + | typeof POLYMARKET_V1_PROTOCOL + | 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; + } +} + +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, + }, + }; +} diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts new file mode 100644 index 000000000000..6fb226b1ef33 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.test.ts @@ -0,0 +1,188 @@ +import { Side, type OrderPreview } from '../../../types'; +import { OrderType } from '../types'; +import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; +import { + buildProtocolUnsignedOrder, + encodeUnwrap, + encodeWrap, + getPreviewFeeRateBpsForProtocol, + getProtocolOrderTypedData, + getProtocolVerifyingContract, + serializeProtocolRelayerOrder, +} from './orderCodec'; + +const preview: OrderPreview = { + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: '1234', + timestamp: 1, + side: Side.BUY, + sharePrice: 0.51, + maxAmountSpent: 10, + minAmountReceived: 19.45, + slippage: 0.03, + tickSize: 0.01, + minOrderSize: 0.01, + negRisk: false, + feeRateBps: '77', +}; + +describe('polymarket protocol order codec', () => { + const protocolV2 = { + ...POLYMARKET_V2_PROTOCOL, + order: { + ...POLYMARKET_V2_PROTOCOL.order, + getBuilderCode: () => + '0x3333333333333333333333333333333333333333333333333333333333333333', + }, + }; + + 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, + preview, + makerAddress: '0x1111111111111111111111111111111111111111', + signerAddress: '0x2222222222222222222222222222222222222222', + nowInSeconds: 456, + }); + + expect(order).toHaveProperty('timestamp', '456'); + expect(order).toHaveProperty( + 'metadata', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ); + expect(order).toHaveProperty( + 'builder', + '0x3333333333333333333333333333333333333333333333333333333333333333', + ); + expect(order).not.toHaveProperty('taker'); + expect(order).not.toHaveProperty('nonce'); + expect(order).not.toHaveProperty('feeRateBps'); + expect(Object.keys(order)).toEqual([ + 'salt', + 'maker', + 'signer', + 'tokenId', + 'makerAmount', + 'takerAmount', + 'expiration', + 'timestamp', + 'metadata', + 'builder', + 'side', + 'signatureType', + ]); + }); + + it('builds v2 typed data with domain version 2 and bytes32 fields', () => { + const order = buildProtocolUnsignedOrder({ + protocol: protocolV2, + preview, + makerAddress: '0x1111111111111111111111111111111111111111', + signerAddress: '0x2222222222222222222222222222222222222222', + nowInSeconds: 456, + }); + + const typedData = getProtocolOrderTypedData({ + protocol: protocolV2, + order, + verifyingContract: getProtocolVerifyingContract({ + protocol: protocolV2, + negRisk: true, + }), + }); + + expect(typedData.domain.version).toBe('2'); + expect(typedData.domain.verifyingContract).toBe( + protocolV2.contracts.negRiskExchange, + ); + expect(typedData.types.Order).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'metadata', type: 'bytes32' }), + expect.objectContaining({ name: 'builder', type: 'bytes32' }), + ]), + ); + }); + + it('serializes signed orders into the relayer body shape', () => { + const order = buildProtocolUnsignedOrder({ + protocol: protocolV2, + preview, + makerAddress: '0x1111111111111111111111111111111111111111', + signerAddress: '0x2222222222222222222222222222222222222222', + nowInSeconds: 456, + }); + + const serialized = serializeProtocolRelayerOrder({ + signedOrder: { + ...order, + signature: '0xsig', + }, + owner: 'owner-key', + orderType: OrderType.FAK, + side: Side.BUY, + }); + + expect(serialized).toEqual( + expect.objectContaining({ + owner: 'owner-key', + orderType: OrderType.FAK, + order: expect.objectContaining({ + side: Side.BUY, + signature: '0xsig', + salt: expect.any(Number), + }), + }), + ); + }); + + 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('encodes wrap and unwrap calls', () => { + expect( + encodeWrap({ + asset: protocolV2.collateral.legacyUsdceToken, + to: '0x1111111111111111111111111111111111111111', + amount: 42n, + }), + ).toMatch(/^0x[0-9a-f]+$/u); + + expect( + encodeUnwrap({ + asset: protocolV2.collateral.legacyUsdceToken, + to: '0x1111111111111111111111111111111111111111', + amount: 42n, + }), + ).toMatch(/^0x[0-9a-f]+$/u); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts new file mode 100644 index 000000000000..aad09dba6b53 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/orderCodec.ts @@ -0,0 +1,378 @@ +import { Hex } from '@metamask/utils'; +import { Interface, parseUnits } from 'ethers/lib/utils'; +import { Side, type OrderPreview } from '../../../types'; +import { + EIP712Domain, + POLYGON_MAINNET_CHAIN_ID, + ROUNDING_CONFIG, +} from '../constants'; +import { + type ClobOrderObject, + type OrderData, + OrderType, + SignatureType, + type TickSize, + UtilsSide, +} from '../types'; +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 interface OrderDataV2 { + maker: string; + signer?: string; + tokenId: string; + makerAmount: string; + takerAmount: string; + side: UtilsSide; + expiration?: string; + signatureType?: SignatureType; + timestamp: string; + metadata: string; + builder: string; +} + +export interface SignedOrderV2 extends OrderDataV2 { + salt: string; + signature: string; +} + +export interface ClobOrderObjectV2 { + order: Omit & { + side: Side; + salt: number; + }; + owner: string; + 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; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const ORDER_PRIMARY_TYPE = 'Order'; +const ORDER_DOMAIN_NAME = 'Polymarket CTF Exchange'; +const ORDER_DOMAIN_TYPES = [ + ...EIP712Domain, + { name: 'verifyingContract', type: 'address' }, +]; + +function buildProtocolOrderDomain({ + protocol, + verifyingContract, + chainId, +}: { + protocol: PolymarketProtocolDefinition; + verifyingContract: string; + chainId: number; +}) { + return { + name: ORDER_DOMAIN_NAME, + version: protocol.order.domainVersion, + chainId, + verifyingContract, + }; +} + +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' }, + ], + }; + } + + 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' }, + ], + }; +} + +function getTakerAmountWithSlippage(preview: OrderPreview): string { + const roundConfig = ROUNDING_CONFIG[preview.tickSize.toString() as TickSize]; + const decimals = roundConfig.amount ?? 4; + + let minAmountWithSlippage = + preview.minAmountReceived * (1 - preview.slippage); + + if (preview.side === Side.BUY) { + minAmountWithSlippage = Math.max( + minAmountWithSlippage, + preview.maxAmountSpent + preview.tickSize, + ); + } + + return parseUnits( + roundOrderAmount({ + amount: minAmountWithSlippage, + decimals, + }).toString(), + 6, + ).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, + makerAddress, + signerAddress, + nowInSeconds = Math.floor(Date.now() / 1000), +}: { + protocol: PolymarketProtocolDefinition; + preview: OrderPreview; + makerAddress: string; + signerAddress: string; + 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. + const salt = generateSalt(); + const maker = makerAddress; + const signer = signerAddress; + const tokenId = preview.outcomeTokenId; + const makerAmount = parseUnits( + preview.maxAmountSpent.toString(), + 6, + ).toString(); + const takerAmount = getTakerAmountWithSlippage(preview); + const side = preview.side === Side.BUY ? UtilsSide.BUY : UtilsSide.SELL; + const signatureType = SignatureType.POLY_GNOSIS_SAFE; + + 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, + }; + } + + return { + salt, + maker, + signer, + taker: ZERO_ADDRESS, + tokenId, + makerAmount, + takerAmount, + expiration: '0', + nonce: '0', + feeRateBps: preview.feeRateBps ?? '0', + side, + signatureType, + }; +} + +export function getProtocolVerifyingContract({ + protocol, + negRisk, +}: { + protocol: PolymarketProtocolDefinition; + negRisk: boolean; +}): string { + return negRisk + ? protocol.contracts.negRiskExchange + : protocol.contracts.exchange; +} + +export function getProtocolOrderTypedData({ + protocol, + order, + verifyingContract, + chainId = POLYGON_MAINNET_CHAIN_ID, +}: { + protocol: PolymarketProtocolDefinition; + order: ProtocolUnsignedOrder; + verifyingContract: string; + chainId?: number; +}) { + return { + primaryType: ORDER_PRIMARY_TYPE, + domain: buildProtocolOrderDomain({ + protocol, + verifyingContract, + chainId, + }), + types: getProtocolOrderTypes(protocol), + 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, + orderType, + side, +}: { + signedOrder: ProtocolSignedOrder; + owner: string; + 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'], + owner, + orderType, + }; +} + +export function getPreviewFeeRateBpsForProtocol({ + protocol, + preview, +}: { + protocol: PolymarketProtocolDefinition; + preview: OrderPreview; +}): string { + if (protocol.key === 'v2') { + return '0'; + } + + return preview.feeRateBps ?? '0'; +} + +export function encodeWrap({ + asset, + to, + amount, +}: { + asset: string; + to: string; + amount: bigint | string; +}): Hex { + return new Interface([ + '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 new file mode 100644 index 000000000000..8da8799707c2 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.test.ts @@ -0,0 +1,97 @@ +import type { ClobHeaders } from '../types'; +import type { ProtocolRelayerOrder } from './orderCodec'; +import { submitProtocolClobOrder } from './transport'; +import { POLYMARKET_V1_PROTOCOL, POLYMARKET_V2_PROTOCOL } from './definitions'; + +jest.mock('../utils', () => ({ + getPolymarketEndpoints: jest.fn(() => ({ + CLOB_RELAYER: 'https://predict.api.cx.metamask.io', + })), +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const headers: ClobHeaders = { + POLY_ADDRESS: '0x1111111111111111111111111111111111111111', + POLY_SIGNATURE: 'sig', + POLY_TIMESTAMP: '1', + POLY_API_KEY: 'key', + POLY_PASSPHRASE: 'pass', +}; + +const clobOrder = { + owner: 'owner', + orderType: 'FOK', + order: { + salt: 1, + side: 'BUY', + }, +} as ProtocolRelayerOrder; + +describe('polymarket protocol transport', () => { + beforeEach(() => { + 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 () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ + success: true, + }), + }); + + await submitProtocolClobOrder({ + protocol: POLYMARKET_V2_PROTOCOL, + headers, + clobOrder, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://predict.api.cx.metamask.io/order', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Clob-Version': '2' }), + }), + ); + }); + + it('normalizes relay errors into the existing result shape', async () => { + mockFetch.mockRejectedValue(new Error('boom')); + + await expect( + submitProtocolClobOrder({ + protocol: POLYMARKET_V2_PROTOCOL, + headers, + clobOrder, + }), + ).resolves.toEqual({ + success: false, + error: 'Failed to submit CLOB order: boom', + }); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/protocol/transport.ts b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts new file mode 100644 index 000000000000..4e8df360b477 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/protocol/transport.ts @@ -0,0 +1,92 @@ +import type { Result } from '../../../types'; +import type { ClobHeaders, OrderResponse } from '../types'; +import { getPolymarketEndpoints } from '../utils'; +import type { + Permit2FeeAuthorization, + SafeFeeAuthorization, +} from '../safe/types'; +import type { PolymarketProtocolDefinition } from './definitions'; +import type { ProtocolRelayerOrder } from './orderCodec'; + +function normalizeRelayerHeaders(headers: ClobHeaders): Record { + const normalizedHeaders: Record = { ...headers }; + + for (const [key, value] of Object.entries(headers)) { + normalizedHeaders[key.replace(/_/gu, '-')] = value; + } + + return normalizedHeaders; +} + +export async function submitProtocolClobOrder({ + protocol, + headers, + clobOrder, + feeAuthorization, + executor, + allowancesTx, +}: { + protocol: Pick; + headers: ClobHeaders; + clobOrder: ProtocolRelayerOrder; + feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; + executor?: string; + allowancesTx?: { to: string; data: string }; +}): Promise> { + const { CLOB_RELAYER } = getPolymarketEndpoints(); + const url = `${CLOB_RELAYER}/order`; + const requestHeaders = normalizeRelayerHeaders(headers); + + if (protocol.transport.clobVersionHeader) { + requestHeaders['X-Clob-Version'] = protocol.transport.clobVersionHeader; + } + + const body = { + ...clobOrder, + ...(feeAuthorization ? { feeAuthorization } : {}), + ...(executor ? { executor } : {}), + ...(allowancesTx ? { allowancesTx } : {}), + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(body), + }); + + if (response.status === 403) { + return { + success: false, + error: 'You are unable to access this provider.', + }; + } + + let responseData: OrderResponse | undefined; + + try { + responseData = (await response.json()) as OrderResponse; + } catch { + responseData = undefined; + } + + if (!response.ok || !responseData || responseData.success === false) { + return { + success: false, + error: responseData?.errorMsg ?? response.statusText, + }; + } + + return { + success: true, + response: responseData, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + return { + success: false, + error: `Failed to submit CLOB order: ${message}`, + }; + } +} 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 b9c8f65eedb8..60945e53d64c 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -32,6 +32,7 @@ import { getClaimTransaction, getWithdrawTransactionCallData, getSafeUsdcAmount, + getSafeUsdcAmountRaw, } from './utils'; import { OperationType } from './types'; import { Signer } from '../../types'; @@ -757,7 +758,11 @@ describe('safe utils', () => { expect(result).toBe(true); expect(mockGetAllowance).toHaveBeenCalled(); - expect(mockGetIsApprovedForAll).toHaveBeenCalled(); + expect(mockGetIsApprovedForAll).toHaveBeenCalledWith( + expect.objectContaining({ + tokenAddress: MATIC_CONTRACTS.conditionalTokens, + }), + ); }); it('returns false when some allowances are zero', async () => { @@ -1451,6 +1456,17 @@ describe('safe utils', () => { }); }); + describe('getSafeUsdcAmountRaw', () => { + it('decodes the raw ERC20 amount without a float round-trip', () => { + const data = + '0xa9059cbb000000000000000000000000100c7b833bbd604a77890783439bbb9d65e31de70000000000000000000000000000000000000000000000000000000000186a00'; + + const amount = getSafeUsdcAmountRaw(data); + + expect(amount).toBe(1600000n); + }); + }); + describe('getSafeUsdcAmount', () => { it('decodes USDC amount from ERC20 transfer data', () => { const data = diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index 6e121b739016..9fc9639a1d0e 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -333,15 +333,17 @@ export const createPermit2FeeAuthorization = async ({ signer, amount, spender, + tokenAddress = MATIC_CONTRACTS.collateral, }: { safeAddress: Hex; signer: Signer; amount: bigint; spender: string; + tokenAddress?: string; }): Promise => { const nonce = await getPermit2Nonce(); const deadline = (Math.floor(Date.now() / 1000) + 3600).toString(); - const token = MATIC_CONTRACTS.collateral; + const token = tokenAddress; const domain = { name: 'Permit2', @@ -754,6 +756,7 @@ export const hasAllowances = async ({ for (const spender of outcomeTokenSpenders) { isApprovedForAllCalls.push( getIsApprovedForAll({ + tokenAddress: MATIC_CONTRACTS.conditionalTokens, owner: address, operator: spender, }), @@ -910,7 +913,7 @@ export function computeProxyAddress(userAddress: string): Hex { * @param data ERC20 transfer calldata (0xa9059cbb...) * @returns USDC amount in decimal format (e.g., 1.5 for 1.5 USDC) */ -export function getSafeUsdcAmount(data: string): number { +export function getSafeUsdcAmountRaw(data: string): bigint { if (!data.startsWith('0xa9059cbb')) { throw new Error('Not an ERC20 transfer call'); } @@ -936,23 +939,25 @@ export function getSafeUsdcAmount(data: string): number { throw new Error('Invalid encoded amount in calldata'); } - // Convert to USDC float - const usdcValue = parseFloat(ethers.utils.formatUnits(amount, 6)); + const rawAmount = amount.toBigInt(); + const maxReasonableRawAmount = parseUnits('100000000000', 6).toBigInt(); - // Check for unreasonably large values (likely corrupted data) - // USDC total supply is ~35 billion, so anything above 100 billion is invalid - const MAX_REASONABLE_USDC = 1e11; // 100 billion USDC - if (usdcValue > MAX_REASONABLE_USDC || !isFinite(usdcValue)) { + if (rawAmount > maxReasonableRawAmount) { throw new Error( - `Decoded USDC amount is invalid or too large: ${usdcValue}`, + `Decoded USDC amount is invalid or too large: ${rawAmount.toString()}`, ); } - // Validate non-negative - if (usdcValue < 0) { - throw new Error(`Decoded USDC amount is negative: ${usdcValue}`); + if (rawAmount < 0n) { + throw new Error(`Decoded USDC amount is negative: ${rawAmount.toString()}`); } - // Round to 6 decimals to match USDC + return rawAmount; +} + +export function getSafeUsdcAmount(data: string): number { + const rawAmount = getSafeUsdcAmountRaw(data); + const usdcValue = parseFloat(ethers.utils.formatUnits(rawAmount, 6)); + return Math.round(usdcValue * 1e6) / 1e6; } diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index beb3b9bec0bb..7202bd3db504 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -13,8 +13,10 @@ import { import { PREDICT_ERROR_CODES } from '../../constants/errors'; import { ClobAuthDomain, + DEFAULT_CLOB_BASE_URL, EIP712Domain, HASH_ZERO_BYTES32, + LEGACY_V2_CLOB_BASE_URL, MATIC_CONTRACTS, MSG_TO_SIGN, POLYGON_MAINNET_CHAIN_ID, @@ -134,7 +136,7 @@ describe('polymarket utils', () => { const endpoints = getPolymarketEndpoints(); expect(endpoints).toEqual({ GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', - CLOB_ENDPOINT: 'https://clob.polymarket.com', + CLOB_ENDPOINT: DEFAULT_CLOB_BASE_URL, DATA_API_ENDPOINT: 'https://data-api.polymarket.com', GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', CLOB_RELAYER: 'https://predict.api.cx.metamask.io', @@ -393,6 +395,44 @@ describe('polymarket utils', () => { ); }); + 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('handle fetch errors', async () => { const error = new Error('Network error'); mockFetch.mockRejectedValue(error); @@ -427,6 +467,48 @@ describe('polymarket utils', () => { ); }); + 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, @@ -448,6 +530,40 @@ describe('polymarket utils', () => { 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 = { + 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, + }); + + 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); @@ -518,6 +634,48 @@ describe('polymarket utils', () => { ); }); + 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); @@ -3580,6 +3738,72 @@ describe('polymarket utils', () => { 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, diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index feb995e356b6..ad33d61be5ca 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -39,6 +39,7 @@ import type { } from '../types'; import { ClobAuthDomain, + DEFAULT_CLOB_BASE_URL, EIP712Domain, HASH_ZERO_BYTES32, MATIC_CONTRACTS, @@ -73,7 +74,7 @@ import { PredictFeeCollection } from '../../types/flags'; export const getPolymarketEndpoints = () => ({ GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', - CLOB_ENDPOINT: 'https://clob.polymarket.com', + CLOB_ENDPOINT: DEFAULT_CLOB_BASE_URL, DATA_API_ENDPOINT: 'https://data-api.polymarket.com', GEOBLOCK_API_ENDPOINT: 'https://polymarket.com/api/geoblock', CLOB_RELAYER: @@ -191,13 +192,39 @@ export const getL2Headers = async ({ return headers; }; -export const deriveApiKey = async ({ address }: { address: string }) => { +function getClobEndpoint({ + clobVersion = 'v1', + clobBaseUrl, +}: { + clobVersion?: 'v1' | 'v2'; + clobBaseUrl?: string; +}): 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; +}) => { const headers = await getL1Headers({ address }); - const response = await fetch(`${CLOB_ENDPOINT}/auth/derive-api-key`, { - method: 'GET', - headers, - }); + const response = await fetch( + `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/derive-api-key`, + { + method: 'GET', + headers, + }, + ); if (!response.ok) { throw new Error('Failed to derive API key'); } @@ -205,16 +232,26 @@ export const deriveApiKey = async ({ address }: { address: string }) => { return apiKeyRaw as ApiKeyCreds; }; -export const createApiKey = async ({ address }: { address: string }) => { - const { CLOB_ENDPOINT } = getPolymarketEndpoints(); +export const createApiKey = async ({ + address, + clobVersion = 'v1', + clobBaseUrl, +}: { + address: string; + clobVersion?: 'v1' | 'v2'; + clobBaseUrl?: string; +}) => { const headers = await getL1Headers({ address }); - const response = await fetch(`${CLOB_ENDPOINT}/auth/api-key`, { - method: 'POST', - headers, - body: '', - }); + const response = await fetch( + `${getClobEndpoint({ clobVersion, clobBaseUrl })}/auth/api-key`, + { + method: 'POST', + headers, + body: '', + }, + ); if (response.status === 400) { - return await deriveApiKey({ address }); + return await deriveApiKey({ address, clobVersion, clobBaseUrl }); } const apiKeyRaw = await response.json(); return apiKeyRaw as ApiKeyCreds; @@ -223,12 +260,21 @@ export const createApiKey = async ({ address }: { address: string }) => { export const priceValid = (price: number, tickSize: TickSize): boolean => price >= parseFloat(tickSize) && price <= 1 - parseFloat(tickSize); -export const getOrderBook = async ({ tokenId }: { tokenId: string }) => { - const { CLOB_ENDPOINT } = getPolymarketEndpoints(); - - const response = await fetch(`${CLOB_ENDPOINT}/book?token_id=${tokenId}`, { - method: 'GET', - }); +export const getOrderBook = async ({ + tokenId, + clobVersion = 'v1', + clobBaseUrl, +}: { + tokenId: string; + clobVersion?: 'v1' | 'v2'; + clobBaseUrl?: string; +}) => { + const response = await fetch( + `${getClobEndpoint({ clobVersion, clobBaseUrl })}/book?token_id=${tokenId}`, + { + method: 'GET', + }, + ); if (!response.ok) { const responseData = (await response.json()) as { error: string }; if ( @@ -1331,9 +1377,11 @@ export const getAllowance = async ({ }; export const getIsApprovedForAll = async ({ + tokenAddress, owner, operator, }: { + tokenAddress: string; owner: string; operator: string; }): Promise => { @@ -1345,9 +1393,6 @@ export const getIsApprovedForAll = async ({ NetworkController.getNetworkClientById(networkClientId).provider, ); - // Get the conditional tokens contract address - const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - // Encode the isApprovedForAll function call const data = new Interface([ 'function isApprovedForAll(address owner, address operator) external view returns (bool)', @@ -1356,7 +1401,7 @@ export const getIsApprovedForAll = async ({ // Make the contract call const res = await query(ethQuery, 'call', [ { - to: contractConfig.conditionalTokens, + to: tokenAddress, data, }, ]); @@ -1387,11 +1432,13 @@ export const getMarketPositions = async ({ return parsedPositions; }; -export const getBalance = async ({ +export const getRawBalance = async ({ address, + tokenAddress, }: { address: string; -}): Promise => { + tokenAddress: string; +}): Promise => { const { NetworkController } = Engine.context; const networkClientId = NetworkController.findNetworkClientIdByChainId( numberToHex(POLYGON_MAINNET_CHAIN_ID), @@ -1400,25 +1447,34 @@ export const getBalance = async ({ NetworkController.getNetworkClientById(networkClientId).provider, ); - // Get the collateral token contract address - const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); - - // Encode the balanceOf function call const data = new Interface([ 'function balanceOf(address account) external view returns (uint256)', ]).encodeFunctionData('balanceOf', [address]); - // Make the contract call const res = await query(ethQuery, 'call', [ { - to: contractConfig.collateral, + to: tokenAddress, data, }, ]); - // Decode the result and convert to USDC (6 decimals) - const balance = Number(BigInt(res)) / 10 ** COLLATERAL_TOKEN_DECIMALS; - return balance; + return BigInt(res); +}; + +export const getBalance = async ({ + address, + tokenAddress, +}: { + address: string; + tokenAddress?: string; +}): Promise => { + const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); + const balance = await getRawBalance({ + address, + tokenAddress: tokenAddress ?? contractConfig.collateral, + }); + + return Number(balance) / 10 ** COLLATERAL_TOKEN_DECIMALS; }; const matchBuyOrder = ({ @@ -1558,13 +1614,27 @@ export const roundOrderAmount = ({ export const previewOrder = async ( params: Omit & { feeCollection?: PredictFeeCollection; + isV2?: boolean; + clobBaseUrl?: string; }, ): Promise => { - const { marketId, outcomeId, outcomeTokenId, side, size, feeCollection } = - params; + const { + marketId, + outcomeId, + outcomeTokenId, + side, + size, + feeCollection, + isV2, + clobBaseUrl, + } = params; const [book, feeRateBps] = await Promise.all([ - getOrderBook({ tokenId: outcomeTokenId }), - getFeeRateBps({ tokenId: outcomeTokenId }), + getOrderBook({ + tokenId: outcomeTokenId, + clobVersion: isV2 ? 'v2' : 'v1', + clobBaseUrl: isV2 ? clobBaseUrl : undefined, + }), + isV2 ? Promise.resolve('0') : getFeeRateBps({ tokenId: outcomeTokenId }), ]); 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 f0b174b5ec38..1acb63030a50 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,4 +1,6 @@ import { + selectPredictClobV2EnabledFlag, + selectPredictClobV2ClobBaseUrlFlag, selectPredictEnabledFlag, selectPredictFakOrdersEnabledFlag, selectPredictFeeCollectionFlag, @@ -7,6 +9,7 @@ import { selectPredictHotTabFlag, selectPredictWithAnyTokenEnabledFlag, } from '.'; +import { LEGACY_V2_CLOB_BASE_URL } from '../../providers/polymarket/constants'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import { mockedState, @@ -1074,4 +1077,170 @@ describe('Predict Feature Flag Selectors', () => { expect(result).toBe('carousel'); }); }); + + 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('selectPredictClobV2ClobBaseUrlFlag', () => { + it('returns undefined when predictClobV2 is disabled even if legacy host flag is enabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictClobV2: { + enabled: false, + minimumVersion: '1.0.0', + }, + predictClobV2UseLegacyClobHost: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictClobV2ClobBaseUrlFlag(state); + + expect(result).toBeUndefined(); + }); + + it('returns legacy host URL when both predictClobV2 and legacy host flag are enabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictClobV2: { + enabled: true, + minimumVersion: '1.0.0', + }, + predictClobV2UseLegacyClobHost: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictClobV2ClobBaseUrlFlag(state); + + expect(result).toBe(LEGACY_V2_CLOB_BASE_URL); + }); + + it('returns undefined when predictClobV2 is enabled but legacy host flag is disabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictClobV2: { + enabled: true, + minimumVersion: '1.0.0', + }, + predictClobV2UseLegacyClobHost: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictClobV2ClobBaseUrlFlag(state); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when remote feature flags are empty', () => { + const result = selectPredictClobV2ClobBaseUrlFlag(mockedEmptyFlagsState); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index 9e8231e7ac86..8b292fe2e27b 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -10,6 +10,7 @@ import { DEFAULT_HOT_TAB_FLAG, } from '../../constants/flags'; import { unwrapRemoteFeatureFlag } from '../../utils/flags'; +import { LEGACY_V2_CLOB_BASE_URL } from '../../providers/polymarket/constants'; /** * Selector for Predict trading feature enablement @@ -166,3 +167,49 @@ export const selectPredictWithAnyTokenEnabledFlag = createSelector( ), ) ?? false, ); + +/** + * Selector for Predict CLOB v2 enablement + * + * Uses version-gated feature flag `predictClobV2` from remote config. + * Falls back to `false` if remote flag is unavailable or invalid. + * + * @returns {boolean} True if CLOB v2 is enabled and version requirement is met + */ +export const selectPredictClobV2EnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + remoteFeatureFlags?.predictClobV2, + ), + ) ?? false, +); + +/** + * Selector for Predict CLOB v2 legacy host override. + * + * When `predictClobV2` is enabled and `predictClobV2UseLegacyClobHost` is also enabled, + * returns the legacy v2 CLOB host URL for internal RC testing during the migration window. + * Otherwise returns `undefined` so the protocol uses the canonical host. + * + * @returns {string | undefined} The legacy v2 CLOB host URL, or undefined. + */ +export const selectPredictClobV2ClobBaseUrlFlag = createSelector( + selectPredictClobV2EnabledFlag, + selectRemoteFeatureFlags, + (predictClobV2Enabled, remoteFeatureFlags) => { + if (!predictClobV2Enabled) { + return undefined; + } + + const useLegacy = + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + remoteFeatureFlags?.predictClobV2UseLegacyClobHost, + ), + ) ?? false; + + return useLegacy ? LEGACY_V2_CLOB_BASE_URL : undefined; + }, +); diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 3d43cfff0d57..6fe60c31e2c1 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -18,12 +18,18 @@ export interface PredictMarketHighlightsFlag extends VersionGatedFeatureFlag { highlights: PredictMarketHighlight[]; } +export type PredictClobV2Flag = VersionGatedFeatureFlag; + +export type PredictClobV2UseLegacyClobHostFlag = VersionGatedFeatureFlag; + export interface PredictFeatureFlags { feeCollection: PredictFeeCollection; liveSportsLeagues: string[]; marketHighlightsFlag: PredictMarketHighlightsFlag; fakOrdersEnabled: boolean; predictWithAnyTokenEnabled: boolean; + predictClobV2Enabled: boolean; + predictClobV2ClobBaseUrl?: string; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/constants/ota.ts b/app/constants/ota.ts index f2fdecf69790..0e7a83d4f989 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -10,7 +10,7 @@ import otaConfig from '../../ota.config.js'; * Reset when releasing a new native build as appropriate for that line. * Kept here (not only in ota.config.js) so changes there do not alter the Expo fingerprint and break CI. */ -export const OTA_VERSION: string = 'v7.73.1'; +export const OTA_VERSION: string = 'vX.XX.X'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 444c5b48df42..bcfbdc5d1670 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -2145,7 +2145,7 @@ describe('HyperLiquidProvider', () => { const accountState = await provider.getAccountState(); expect(accountState).toBeDefined(); - expect(accountState.totalBalance).toBe('20500'); // 10000 (spot) + 10500 (perps marginSummary) + expect(accountState.totalBalance).toBe('19500'); // 10500 (perps) + 10000 (spot.total) - 1000 (spot.hold, double-counted in accountValue) expect( mockClientService.getInfoClient().clearinghouseState, ).toHaveBeenCalled(); @@ -2154,6 +2154,24 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalled(); }); + it('does not count USDH-only spot balance in funded-state totals', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDH', hold: '1000', total: '10000' }], + }), + }), + ); + + const accountState = await provider.getAccountState(); + + expect(accountState).toBeDefined(); + expect(accountState.totalBalance).toBe('10500'); + expect( + mockClientService.getInfoClient().spotClearinghouseState, + ).toHaveBeenCalled(); + }); + it('gets markets successfully', async () => { const markets = await provider.getMarkets(); @@ -3633,7 +3651,7 @@ describe('HyperLiquidProvider', () => { const accountState = await hip3Provider.getAccountState(); - expect(parseFloat(accountState.totalBalance)).toBe(20500); + expect(parseFloat(accountState.totalBalance)).toBe(19500); // perps 10500 + spot.total 10000 - spot.hold 1000 expect(parseFloat(accountState.marginUsed)).toBe(500); expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ user: '0x123', @@ -8575,6 +8593,7 @@ describe('HyperLiquidProvider', () => { clearinghouseState: jest.fn(), frontendOpenOrders: jest.fn(), perpDexs: jest.fn().mockResolvedValue([null]), + spotClearinghouseState: jest.fn().mockResolvedValue({ balances: [] }), }; }); @@ -9340,4 +9359,19 @@ describe('HyperLiquidProvider', () => { expect(Array.isArray(markets)).toBe(true); }); }); + + describe('getExchangeClient escape hatch', () => { + it('delegates to the client service and resolves with the underlying ExchangeClient', async () => { + const sentinel = mockClientService.getExchangeClient(); + await expect(provider.getExchangeClient()).resolves.toBe(sentinel); + }); + + it('propagates errors thrown by the client service', async () => { + const bomb = new Error('client not initialized'); + mockClientService.getExchangeClient = jest.fn(() => { + throw bomb; + }); + await expect(provider.getExchangeClient()).rejects.toBe(bomb); + }); + }); }); diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index cb0d1bbc3a80..8514931a7a83 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -1,5 +1,6 @@ import { CaipAccountId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import type { ExchangeClient } from '@nktkas/hyperliquid'; import { v4 as uuidv4 } from 'uuid'; import type { CandlePeriod } from '../constants/chartConfig'; @@ -110,7 +111,10 @@ import type { } from '../types/hyperliquid-types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types'; -import { aggregateAccountStates } from '../utils/accountUtils'; +import { + addSpotBalanceToAccountState, + aggregateAccountStates, +} from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptAccountStateFromSDK, @@ -5632,17 +5636,38 @@ export class HyperLiquidProvider implements PerpsProvider { isTestnet: this.#clientService.isTestnetMode(), }); const dexs = await this.#getStandaloneValidatedDexs(); - const results = await queryStandaloneClearinghouseStates( - standaloneInfoClient, - userAddress, - dexs, - ); + 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, + ), + ]); - // Aggregate account states across all DEXs - const dexAccountStates = results.map((perpsState) => + // Aggregate account states across all DEXs, then apply spot-backed + // adjustments so streamed/standalone/full paths report the same totals. + const dexAccountStates = standalonePerpsResults.map((perpsState) => adaptAccountStateFromSDK(perpsState), ); - const aggregatedAccountState = aggregateAccountStates(dexAccountStates); + const aggregatedAccountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + standaloneSpotStateResult, + ); this.#deps.debugLogger.log( 'HyperLiquidProvider: standalone account state fetched', @@ -5757,19 +5782,10 @@ export class HyperLiquidProvider implements PerpsProvider { ); return dexAccountState; }); - const aggregatedAccountState = aggregateAccountStates(dexAccountStates); - - // Add spot balance to totalBalance (spot is global, not per-DEX) - let spotBalance = 0; - if (spotState?.balances && Array.isArray(spotState.balances)) { - spotBalance = spotState.balances.reduce( - (sum, balance) => sum + parseFloat(balance.total || '0'), - 0, - ); - } - aggregatedAccountState.totalBalance = ( - parseFloat(aggregatedAccountState.totalBalance) + spotBalance - ).toString(); + const aggregatedAccountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + spotState, + ); // Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts) const subAccountBreakdown: Record< @@ -7836,6 +7852,19 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Escape hatch for agentic validation flows and test harnesses that drive + * HL mutations directly. NOT part of the PerpsProvider interface. + * Production code paths must go through the provider's own methods. + * + * @returns A promise resolving to the underlying HyperLiquid SDK + * ExchangeClient. Promise shape matches the existing agentic flows + * (hl-provision-fixture) that chain `.then` on the result. + */ + public async getExchangeClient(): Promise { + return this.#clientService.getExchangeClient(); + } + /** * Disconnect provider * diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts index 7dc6d8b937c3..1d4725e2983e 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts @@ -104,6 +104,7 @@ describe('HyperLiquidSubscriptionService', () => { let mockSubscriptionClient: any; let mockWalletAdapter: any; let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; beforeEach(() => { jest.useFakeTimers(); @@ -278,6 +279,12 @@ describe('HyperLiquidSubscriptionService', () => { }, 0); return Promise.resolve(mockSubscription); }), + spotState: jest.fn((_params: any, _callback: any) => + // Default: subscribe resolves but never emits. Tests that need the + // push-driven path call mockSubscriptionClient.spotState.mock.calls[0][1] + // manually to drive the handler. + Promise.resolve(mockSubscription), + ), l2Book: jest.fn((_params: any, callback: any) => { // Simulate l2Book data setTimeout(() => { @@ -371,9 +378,16 @@ describe('HyperLiquidSubscriptionService', () => { }; // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + mockClientService = { ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + })), isTestnetMode: jest.fn(() => false), ensureTransportReady: jest.fn().mockResolvedValue(undefined), getConnectionState: jest.fn(() => 'connected'), @@ -3672,6 +3686,270 @@ describe('HyperLiquidSubscriptionService', () => { }); }); + describe('spotState WebSocket Subscription', () => { + it('establishes a spotState subscription on subscribeToAccount', async () => { + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.stringMatching(/^0x/) }), + expect.any(Function), + ); + + unsubscribe(); + }); + + it('does not re-subscribe spotState for the same user', async () => { + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + const unsubscribe2 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledTimes(1); + + unsubscribe1(); + unsubscribe2(); + }); + + it('re-notifies account subscribers when a spotState push arrives', async () => { + // Seed aggregation with a perps tick so #dexAccountCache is non-empty, + // which is the guard the handler uses before calling + // #aggregateAndNotifySubscribers. + const firstCallback = jest.fn(); + const firstUnsubscribe = service.subscribeToAccount({ + callback: firstCallback, + }); + await jest.runAllTimersAsync(); + + const notifyCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: notifyCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = notifyCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + + expect(notifyCallback.mock.calls.length).toBeGreaterThan(callsBefore); + + firstUnsubscribe(); + 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({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + const observerCallback = jest.fn(); + const observerUnsubscribe = service.subscribeToAccount({ + callback: observerCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = observerCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + spotState: { balances: [] }, + }); + + expect(observerCallback.mock.calls.length).toBe(callsBefore); + + observerUnsubscribe(); + unsubscribe(); + }); + + it('unsubscribes spotState when the last account subscriber leaves', async () => { + const unsubSpot = jest.fn().mockResolvedValue(undefined); + mockSubscriptionClient.spotState.mockResolvedValueOnce({ + unsubscribe: unsubSpot, + }); + + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + unsubscribe(); + await jest.runAllTimersAsync(); + + expect(unsubSpot).toHaveBeenCalled(); + }); + }); + + describe('spot-adjusted account balance parity', () => { + it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + 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(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls.at(-1)[0]; + expect(accountState.totalBalance).toBe('100.76531791'); + expect(accountState.availableBalance).toBe('0'); + expect(accountState.subAccountBreakdown).toEqual({ + main: { availableBalance: '0', totalBalance: '0' }, + xyz: { availableBalance: '0', totalBalance: '0' }, + }); + expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('includes spot balance in webData2 (single-DEX) account updates without flickering', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + availableBalance: '50', + totalBalance: '200', + marginUsed: '10', + unrealizedPnl: '5', + returnOnEquity: '0.05', + })); + + let webData2Callback: ((data: any) => void) | undefined; + mockSubscriptionClient.webData2.mockImplementation( + (_params: any, callback: any) => { + webData2Callback = callback; + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const singleDexService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, + ); + + const mockCallback = jest.fn(); + const unsubscribe = singleDexService.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const firstUpdate = mockCallback.mock.calls.at(-1)[0]; + expect(firstUpdate.totalBalance).toBe('300.76531791'); + expect(firstUpdate.availableBalance).toBe('50'); + + // Simulate a second WebSocket tick — should still include spot balance, + // not revert to perps-only 200. + mockCallback.mockClear(); + expect(webData2Callback).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + webData2Callback!({ + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }); + + await jest.runAllTimersAsync(); + + if (mockCallback.mock.calls.length > 0) { + const secondUpdate = mockCallback.mock.calls.at(-1)[0]; + expect(secondUpdate.totalBalance).toBe('300.76531791'); + } + + unsubscribe(); + }); + }); + describe('aggregateAccountStates - returnOnEquity calculation', () => { it('calculates positive ROE when unrealizedPnl is positive', async () => { // Override the adapter mock diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index 90bb73c37174..7e8e9a8614be 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -13,6 +13,7 @@ import type { FrontendOpenOrdersResponse, ClearinghouseStateWsEvent, OpenOrdersWsEvent, + SpotStateWsEvent, } from '@nktkas/hyperliquid'; import type { HyperLiquidClientService } from './HyperLiquidClientService'; @@ -37,7 +38,11 @@ import type { PerpsPlatformDependencies, PerpsLogger, } from '../types'; -import { calculateWeightedReturnOnEquity } from '../utils/accountUtils'; +import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; +import { + addSpotBalanceToAccountState, + calculateWeightedReturnOnEquity, +} from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { adaptPositionFromSDK, @@ -132,6 +137,15 @@ export class HyperLiquidSubscriptionService { // Order fill subscriptions keyed by accountId (normalized: undefined -> 'default') readonly #orderFillSubscriptions = new Map(); + readonly #spotStateSubscriptions = new Map(); + + readonly #spotStateSubscriptionPromises = new Map>(); + + // Bumped on cleanup so in-flight #ensureSpotStateSubscription + // continuations discard their subscription instead of rehydrating + // #spotStateSubscriptions after clearAll/cleanupSharedWebData3. + #spotStateSubscriptionGeneration = 0; + readonly #symbolSubscriberCounts = new Map(); readonly #dexSubscriberCounts = new Map(); // Track subscribers per DEX for assetCtxs @@ -156,6 +170,20 @@ export class HyperLiquidSubscriptionService { readonly #dexAccountCache = new Map(); // Per-DEX account state + #cachedSpotState: SpotClearinghouseStateResponse | null = null; + + #cachedSpotStateUserAddress: string | null = null; + + #spotStatePromise?: Promise; + + #spotStatePromiseUserAddress?: string; + + // Monotonic token bumped on cleanUp/clearAll and on each new fetch. + // Any in-flight #refreshSpotState that resolves with a stale token + // discards its result, preventing cross-account cache contamination + // when accounts are switched mid-fetch. + #spotStateGeneration = 0; + #cachedPositions: Position[] | null = null; // Aggregated positions #cachedOrders: Order[] | null = null; // Aggregated orders @@ -971,15 +999,177 @@ export class HyperLiquidSubscriptionService { // Calculate weighted returnOnEquity across all DEXs const returnOnEquity = calculateWeightedReturnOnEquity(accountStatesForROE); - return { - ...firstDexAccount, - availableBalance: totalAvailableBalance.toString(), - totalBalance: totalBalance.toString(), - marginUsed: totalMarginUsed.toString(), - unrealizedPnl: totalUnrealizedPnl.toString(), - subAccountBreakdown, - returnOnEquity, - }; + return addSpotBalanceToAccountState( + { + ...firstDexAccount, + availableBalance: totalAvailableBalance.toString(), + totalBalance: totalBalance.toString(), + marginUsed: totalMarginUsed.toString(), + unrealizedPnl: totalUnrealizedPnl.toString(), + subAccountBreakdown, + returnOnEquity, + }, + this.#cachedSpotState, + ); + } + + async #ensureSpotState(accountId?: CaipAccountId): Promise { + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + + if ( + this.#cachedSpotState && + this.#cachedSpotStateUserAddress === userAddress + ) { + return; + } + + // Share an in-flight fetch only if it targets the same user. + // A pending fetch for a different user is stale after an account switch — + // start a fresh fetch; the stale one will self-discard via generation check. + if ( + this.#spotStatePromise && + this.#spotStatePromiseUserAddress === userAddress + ) { + await this.#spotStatePromise; + return; + } + + this.#spotStateGeneration += 1; + const generation = this.#spotStateGeneration; + const promise = this.#refreshSpotState(userAddress, generation); + this.#spotStatePromise = promise; + this.#spotStatePromiseUserAddress = userAddress; + + try { + await promise; + } finally { + // Only clear tracker if we're still the latest in-flight fetch. + // A newer fetch may have already replaced us. + if (this.#spotStatePromise === promise) { + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; + } + } + } + + async #refreshSpotState( + userAddress: string, + generation: number, + ): Promise { + try { + // Cold-start safety: getInfoClient() throws until the SDK has been + // initialized via ensureSubscriptionClient. On a fresh service + // instance subscribeToAccount can race ahead of the webData3 path, + // so initialize here first — subsequent calls are no-ops. + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + + if (generation !== this.#spotStateGeneration) { + return; + } + + const infoClient = this.#clientService.getInfoClient(); + const result = await infoClient.spotClearinghouseState({ + user: userAddress, + }); + + // 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) { + return; + } + + this.#cachedSpotState = result; + this.#cachedSpotStateUserAddress = userAddress; + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } catch (error) { + if (generation !== this.#spotStateGeneration) { + return; + } + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.refreshSpotState'), + this.#getErrorContext('refreshSpotState'), + ); + } + } + + async #ensureSpotStateSubscription(accountId?: CaipAccountId): Promise { + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + + if (this.#spotStateSubscriptions.has(userAddress)) { + return; + } + + const inFlight = this.#spotStateSubscriptionPromises.get(userAddress); + if (inFlight) { + await inFlight; + return; + } + + const startGeneration = this.#spotStateSubscriptionGeneration; + + const promise = (async (): Promise => { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('SubscriptionClient not available'); + } + + const subscription = await subscriptionClient.spotState( + { user: userAddress as `0x${string}` }, + (event: SpotStateWsEvent) => { + try { + if (event.user.toLowerCase() !== userAddress.toLowerCase()) { + return; + } + // Invalidate any in-flight REST refreshSpotState so it drops + // its result instead of overwriting this fresher WS snapshot. + this.#spotStateGeneration += 1; + this.#cachedSpotState = event.spotState; + this.#cachedSpotStateUserAddress = event.user; + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureSpotStateSubscription', + ), + this.#getErrorContext('spotState callback error', { + user: userAddress, + }), + ); + } + }, + ); + + // Discard if cleanup ran while we were awaiting the subscription + // handshake; rehydrating #spotStateSubscriptions here would leave + // a stale entry that short-circuits future resubscribe attempts. + if (startGeneration !== this.#spotStateSubscriptionGeneration) { + await subscription.unsubscribe().catch(() => undefined); + return; + } + + this.#spotStateSubscriptions.set(userAddress, subscription); + })(); + + this.#spotStateSubscriptionPromises.set(userAddress, promise); + try { + await promise; + } finally { + this.#spotStateSubscriptionPromises.delete(userAddress); + } } /** @@ -1415,10 +1605,17 @@ export class HyperLiquidSubscriptionService { this.#oiCapSubscribers.forEach((callback) => callback(oiCaps)); } - // Notify subscribers (no aggregation needed - only main DEX) + // Notify subscribers (no aggregation needed - only main DEX). + // Apply spot balance so single-DEX accounts see the same + // spot-inclusive totalBalance as the HIP-3 aggregation path. + const spotAdjustedAccount = addSpotBalanceToAccountState( + accountState, + this.#cachedSpotState, + ); + const positionsHash = this.#hashPositions(positionsWithTPSL); const ordersHash = this.#hashOrders(orders); - const accountHash = this.#hashAccountState(accountState); + const accountHash = this.#hashAccountState(spotAdjustedAccount); if (positionsHash !== this.#cachedPositionsHash) { this.#cachedPositions = positionsWithTPSL; @@ -1437,10 +1634,10 @@ export class HyperLiquidSubscriptionService { } if (accountHash !== this.#cachedAccountHash) { - this.#cachedAccount = accountState; + this.#cachedAccount = spotAdjustedAccount; this.#cachedAccountHash = accountHash; this.#accountSubscribers.forEach((callback) => - callback(accountState), + callback(spotAdjustedAccount), ); } } catch (error) { @@ -1867,6 +2064,30 @@ export class HyperLiquidSubscriptionService { this.#webData3SubscriptionPromise = undefined; } + // Cleanup spotState subscriptions (per-user). Bump generation + + // drop in-flight promises so a racing #ensureSpotStateSubscription + // continuation discards its subscription rather than rehydrating + // #spotStateSubscriptions after this clear. + this.#spotStateSubscriptionGeneration += 1; + this.#spotStateSubscriptionPromises.clear(); + if (this.#spotStateSubscriptions.size > 0) { + this.#spotStateSubscriptions.forEach((subscription, user) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupSharedWebData3ISubscription', + ), + this.#getErrorContext( + 'cleanupSharedWebData3ISubscription.spotState', + { user }, + ), + ); + }); + }); + this.#spotStateSubscriptions.clear(); + } + // Cleanup individual subscriptions (clearinghouseState + openOrders) if (this.#clearinghouseStateSubscriptions.size > 0) { this.#clearinghouseStateSubscriptions.forEach( @@ -1933,6 +2154,13 @@ export class HyperLiquidSubscriptionService { this.#cachedPositions = null; this.#cachedOrders = null; this.#cachedAccount = null; + this.#cachedSpotState = null; + this.#cachedSpotStateUserAddress = null; + // 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; + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; this.#ordersCacheInitialized = false; // Reset cache initialization flag this.#positionsCacheInitialized = false; // Reset cache initialization flag @@ -2242,11 +2470,29 @@ export class HyperLiquidSubscriptionService { // Increment account subscriber count this.#accountSubscriberCount += 1; - // Immediately provide cached data if available + // Immediately provide cached data if available. May be spot-less if the + // spot fetch has not resolved yet (or permanently failed) — subscribers + // prefer stale-but-present data over silent starvation; the next + // aggregation after #ensureSpotState / next WebSocket update pushes the + // spot-inclusive value. if (this.#cachedAccount) { callback(this.#cachedAccount); } + this.#ensureSpotState(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount.ensureSpotState'), + ); + }); + + this.#ensureSpotStateSubscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount.ensureSpotStateSubscription'), + ); + }); + // Ensure shared subscription is active (reuses existing connection) this.#ensureSharedWebData3Subscription(accountId).catch((error) => { this.#logErrorUnlessClearing( @@ -3638,6 +3884,18 @@ export class HyperLiquidSubscriptionService { }); this.#orderFillSubscriptions.clear(); + // Clear spotState subscriptions. Bump generation + drop in-flight + // promises so any racing #ensureSpotStateSubscription continuation + // unsubscribes its fresh sub instead of rehydrating the cleared map. + this.#spotStateSubscriptionGeneration += 1; + this.#spotStateSubscriptionPromises.clear(); + this.#spotStateSubscriptions.forEach((subscription) => { + subscription.unsubscribe().catch(() => { + // Ignore errors during cleanup + }); + }); + this.#spotStateSubscriptions.clear(); + // Clear cached data this.#cachedPriceData = null; this.#allMidsSnapshots.clear(); @@ -3674,6 +3932,11 @@ export class HyperLiquidSubscriptionService { this.#dexPositionsCache.clear(); this.#dexOrdersCache.clear(); this.#dexAccountCache.clear(); + this.#cachedSpotState = null; + this.#cachedSpotStateUserAddress = null; + this.#spotStateGeneration += 1; + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; this.#dexAssetCtxsCache.clear(); // Clear subscription references (actual cleanup handled by client service) diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index 9dcfb312a78a..26e9973c7e9c 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -212,6 +212,7 @@ export type Position = { // Using 'type' instead of 'interface' for BaseController Json compatibility export type AccountState = { availableBalance: string; // Based on HyperLiquid: withdrawable + availableToTradeBalance?: string; // withdrawable + unreserved spot collateral (order-entry path) totalBalance: string; // Based on HyperLiquid: accountValue marginUsed: string; // Based on HyperLiquid: marginUsed unrealizedPnl: string; // Based on HyperLiquid: unrealizedPnl diff --git a/app/controllers/perps/utils/accountUtils.test.ts b/app/controllers/perps/utils/accountUtils.test.ts index 4004f42b76bd..20d78c997d61 100644 --- a/app/controllers/perps/utils/accountUtils.test.ts +++ b/app/controllers/perps/utils/accountUtils.test.ts @@ -2,8 +2,10 @@ import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState } from '../types'; import { + addSpotBalanceToAccountState, aggregateAccountStates, calculateWeightedReturnOnEquity, + getSpotBalance, } from './accountUtils'; describe('aggregateAccountStates', () => { @@ -167,6 +169,115 @@ describe('aggregateAccountStates', () => { }); }); +describe('spot balance helpers', () => { + it('returns zero spot balance when no spot state is provided', () => { + expect(getSpotBalance()).toBe(0); + }); + + it('adds spot balance to totalBalance without mutating the input state', () => { + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'USDC', total: '25.5' }, + { coin: 'HYPE', total: '0.5' }, + ], + } as never); + + // 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(accountState.totalBalance).toBe('100'); + }); + + it('ignores non-collateral spot balances entirely', () => { + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '50', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'HYPE', total: '1000' }, + { coin: 'PURR', total: '5000' }, + ], + } as never); + + expect(result.totalBalance).toBe(accountState.totalBalance); + expect(result.availableBalance).toBe(accountState.availableBalance); + expect(result.availableToTradeBalance).toBe(accountState.availableBalance); + }); + + it('excludes USDH-only spot balance from funded-state totals', () => { + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'USDH', total: '75.25' }, + { coin: 'HYPE', total: '999' }, + ], + } as never); + + expect(result.totalBalance).toBe(accountState.totalBalance); + expect(result.availableBalance).toBe(accountState.availableBalance); + expect(result.availableToTradeBalance).toBe(accountState.availableBalance); + }); + + it('adds only the USDC portion when USDC and USDH are both present', () => { + const accountState: AccountState = { + availableBalance: '0', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'USDC', total: '20' }, + { coin: 'USDH', total: '30' }, + { coin: 'HYPE', total: '9999' }, + ], + } as never); + + expect(result.totalBalance).toBe('30'); + }); + + it('preserves numeric fields and defaults availableToTradeBalance when spot balance is zero', () => { + const accountState: AccountState = { + availableBalance: '1', + totalBalance: '2', + marginUsed: '3', + unrealizedPnl: '4', + returnOnEquity: '5', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [], + } as never); + + expect(result.totalBalance).toBe(accountState.totalBalance); + expect(result.availableBalance).toBe(accountState.availableBalance); + expect(result.marginUsed).toBe(accountState.marginUsed); + expect(result.availableToTradeBalance).toBe(accountState.availableBalance); + }); +}); + describe('calculateWeightedReturnOnEquity', () => { it('returns 0 for empty array', () => { expect(calculateWeightedReturnOnEquity([])).toBe('0'); diff --git a/app/controllers/perps/utils/accountUtils.ts b/app/controllers/perps/utils/accountUtils.ts index 377cdde6f1d8..bf8efa35cff7 100644 --- a/app/controllers/perps/utils/accountUtils.ts +++ b/app/controllers/perps/utils/accountUtils.ts @@ -6,6 +6,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState, PerpsInternalAccount } from '../types'; +import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); @@ -89,6 +90,92 @@ 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. +const SPOT_COLLATERAL_COINS = new Set(['USDC']); + +export function getSpotBalance( + spotState?: SpotClearinghouseStateResponse | null, +): number { + if (!spotState?.balances || !Array.isArray(spotState.balances)) { + return 0; + } + + return spotState.balances.reduce( + (sum: number, balance: { coin?: string; total?: string }) => { + if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { + return sum; + } + const value = parseFloat(balance.total ?? '0'); + return Number.isFinite(value) ? sum + value : sum; + }, + 0, + ); +} + +export function getSpotHold( + spotState?: SpotClearinghouseStateResponse | null, +): number { + if (!spotState?.balances || !Array.isArray(spotState.balances)) { + return 0; + } + + return spotState.balances.reduce( + (sum: number, balance: { coin?: string; hold?: string }) => { + if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { + return sum; + } + const value = parseFloat(balance.hold ?? '0'); + return Number.isFinite(value) ? sum + value : sum; + }, + 0, + ); +} + +export function addSpotBalanceToAccountState( + accountState: AccountState, + spotState?: SpotClearinghouseStateResponse | null, +): AccountState { + const spotBalance = getSpotBalance(spotState); + const spotHold = getSpotHold(spotState); + const freeSpot = Math.max(0, spotBalance - spotHold); + + const currentTotal = parseFloat(accountState.totalBalance); + const currentAvailable = parseFloat(accountState.availableBalance); + + // Preserve sentinel totals (e.g. PERPS_CONSTANTS.FallbackDataDisplay '--') + // rather than coercing them to NaN. + if (!Number.isFinite(currentTotal)) { + return accountState; + } + + if (spotBalance === 0) { + return { + ...accountState, + availableToTradeBalance: Number.isFinite(currentAvailable) + ? currentAvailable.toString() + : accountState.availableBalance, + }; + } + + const 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 + // surfaces via spot.hold. Standard mode has spotHold = 0, no-op. + const nextTotal = currentTotal + spotBalance - spotHold; + + return { + ...accountState, + totalBalance: nextTotal.toString(), + availableToTradeBalance: availableToTrade, + }; +} + /** * Aggregate multiple per-DEX AccountState objects into one by summing numeric fields. * ROE is recalculated as (totalUnrealizedPnl / totalMarginUsed) * 100. @@ -113,10 +200,25 @@ export function aggregateAccountStates(states: AccountState[]): AccountState { if (index === 0) { return { ...state }; } + const accAvailableToTrade = parseFloat( + acc.availableToTradeBalance ?? acc.availableBalance, + ); + const stateAvailableToTrade = parseFloat( + state.availableToTradeBalance ?? state.availableBalance, + ); + const availableToTradeSum = + Number.isFinite(accAvailableToTrade) && + Number.isFinite(stateAvailableToTrade) + ? (accAvailableToTrade + stateAvailableToTrade).toString() + : undefined; + return { availableBalance: ( parseFloat(acc.availableBalance) + parseFloat(state.availableBalance) ).toString(), + ...(availableToTradeSum !== undefined && { + availableToTradeBalance: availableToTradeSum, + }), totalBalance: ( parseFloat(acc.totalBalance) + parseFloat(state.totalBalance) ).toString(), diff --git a/app/controllers/perps/utils/hyperLiquidAdapter.ts b/app/controllers/perps/utils/hyperLiquidAdapter.ts index 8cbe2a2614f5..171919560564 100644 --- a/app/controllers/perps/utils/hyperLiquidAdapter.ts +++ b/app/controllers/perps/utils/hyperLiquidAdapter.ts @@ -289,18 +289,28 @@ export function adaptAccountStateFromSDK( const perpsBalance = parseFloat(perpsState.marginSummary.accountValue); let spotBalance = 0; + let spotHold = 0; if (spotState?.balances && Array.isArray(spotState.balances)) { - spotBalance = spotState.balances.reduce( - (sum: number, balance: { total?: string }) => - sum + parseFloat(balance.total ?? '0'), - 0, - ); + for (const balance of spotState.balances) { + spotBalance += parseFloat(balance.total ?? '0'); + spotHold += parseFloat((balance as { hold?: string }).hold ?? '0'); + } } - const totalBalance = (spotBalance + perpsBalance).toString(); + // Subtract spotHold to avoid double-counting margin on Unified/PM: + // perpsBalance already includes the margin HL surfaces as spot.hold. + // Standard mode has spotHold = 0. See addSpotBalanceToAccountState. + const totalBalance = (spotBalance + perpsBalance - spotHold).toString(); + + const withdrawable = parseFloat(perpsState.withdrawable || '0'); + const freeSpot = Math.max(0, spotBalance - spotHold); + const availableToTradeBalance = ( + (Number.isFinite(withdrawable) ? withdrawable : 0) + freeSpot + ).toString(); const accountState: AccountState = { availableBalance: perpsState.withdrawable || '0', + availableToTradeBalance, totalBalance: totalBalance || '0', marginUsed: perpsState.marginSummary.totalMarginUsed || '0', unrealizedPnl: totalUnrealizedPnl.toString() || '0', diff --git a/babel.config.tests.js b/babel.config.tests.js index edd4867c569e..0b880ffe1ef5 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -51,6 +51,8 @@ const newOverrides = [ 'app/components/UI/Card/util/mapBaanxApiUrl.test.ts', 'app/core/Engine/controllers/card-controller/services/baanx-config.ts', 'app/core/Engine/controllers/card-controller/services/baanx-config.test.ts', + 'app/components/UI/Predict/providers/polymarket/protocol/definitions.ts', + 'app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts', 'app/store/migrations/**', 'app/util/networks/customNetworks.tsx', ], diff --git a/bitrise.yml b/bitrise.yml index a5c2cca1722f..47207ee1615f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3552,16 +3552,16 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.73.0 + VERSION_NAME: 7.73.2 - opts: is_expand: false - VERSION_NUMBER: 4594 + VERSION_NUMBER: 4647 - opts: is_expand: false - FLASK_VERSION_NAME: 7.73.0 + FLASK_VERSION_NAME: 7.73.2 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4594 + FLASK_VERSION_NUMBER: 4647 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/builds.yml b/builds.yml index f3d665042ada..448699f3d60e 100644 --- a/builds.yml +++ b/builds.yml @@ -46,6 +46,7 @@ _public_envs: &public_envs # Servers (production) # Temporary flag to enable builds with GitHub Actions, remove it when deprecating bitrise BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY: 'true' MM_CHARTING_LIBRARY_URL: 'https://charting-assets.static.metamask.io/tradingview/advanced-charts/v30.1.0/' + MM_PREDICT_BUILDER_CODE: '0x11a22276beb720e66a072cba8b8e74cded60afda510af535b947b81a1b81a883' # Common secrets (shared across ALL builds - same names, GitHub Environment determines values) _secrets: &secrets # Infrastructure @@ -218,6 +219,7 @@ builds: METAMASK_ENVIRONMENT: 'test' METAMASK_BUILD_TYPE: 'main' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -240,6 +242,7 @@ builds: METAMASK_ENVIRONMENT: 'e2e' METAMASK_BUILD_TYPE: 'main' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -262,6 +265,7 @@ builds: METAMASK_ENVIRONMENT: 'exp' METAMASK_BUILD_TYPE: 'main' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -282,6 +286,7 @@ builds: METAMASK_ENVIRONMENT: 'dev' METAMASK_BUILD_TYPE: 'main' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -321,6 +326,7 @@ builds: METAMASK_ENVIRONMENT: 'test' METAMASK_BUILD_TYPE: 'flask' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -343,6 +349,7 @@ builds: METAMASK_ENVIRONMENT: 'e2e' METAMASK_BUILD_TYPE: 'flask' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -364,6 +371,7 @@ builds: METAMASK_ENVIRONMENT: 'dev' METAMASK_BUILD_TYPE: 'flask' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' @@ -415,6 +423,7 @@ builds: METAMASK_ENVIRONMENT: 'dev' METAMASK_BUILD_TYPE: 'qa' MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' + MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' diff --git a/docs/perps/hyperliquid/account-abstraction-modes.md b/docs/perps/hyperliquid/account-abstraction-modes.md new file mode 100644 index 000000000000..7121b81bba23 --- /dev/null +++ b/docs/perps/hyperliquid/account-abstraction-modes.md @@ -0,0 +1,78 @@ +# Account abstraction modes + +A user's _account abstraction mode_ determines how spot and perps balances interact, and whether various assets are used as collateral for perps trading. + +The supported modes are: + +1. Standard (recommended for market makers, high volume automated users, and deployers/builders): separate perp and spot balances, separate DEX balances. Cross margin applies to each DEX separately. +2. Unified account (recommended for most users): single balance for each asset. This balance collateralizes all cross margin positions in that asset and is unified with spot balance in that asset. For example, USDC balance is the single source for validator-operated perps, XYZ perps, and spot trading against USDC as a quote asset. USDH spot balance is the single source for KM perps, FLX perps, VNTL perps, and spot trading against USDH as a quote asset. +3. Portfolio margin (most capital efficient, currently in pre-alpha): single portfolio unifying all eligible assets, which are currently HYPE, BTC, USDH, USDC. See [Portfolio margin](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/portfolio-margin) for more details. + +There is one more mode that is not relevant to most users, included here for completeness: + +4. DEX abstraction (to be discontinued): USDC balances default to perps balance, all other collateral defaults to spot balance. IMPORTANT: Cross margin on HIP-3 DEXs does not behave intuitively for DEX abstraction users. Interfaces should deprecate DEX abstraction support going forward. + +Important details: + +1. Builder code addresses must be in Standard mode to accrue builder fees +2. Portfolio margin and unified account are limited to 50k user actions per day. Standard mode has no such restrictions. +3. For API users, unified account and portfolio margin shows all balances and holds in the spot clearinghouse state. Individual perp dex user states are not meaningful. + +See Python SDK and [API docs](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api) for examples on the agent- and user-signed actions for changing account abstraction modes. Automated traders can also use the Settings dropdown on app.hyperliquid.xyz to change their account abstraction modes. app.hyperliquid.xyz defaults to unified account, and Classic in "Account Unification Mode" refers to standard, DEX abstraction (which will be discontinued), or unified account. + +### Unified Account Ratio + +To compute the precise unified account ratio for monitoring liquidation risk: + +```typescript +function computeUnifiedAccountRatio( + multiverse: Record, + perpDexStates: Array<{ + clearinghouseState: { + crossMaintenanceMarginUsed: number; + assetPositions: Array<{ + position: { leverage: { type: string }; marginUsed: number }; + }>; + }; + }>, + spotBalances: Array<{ token: number; total: number }>, +): number { + const indexToCollateralToken: Record = {}; + for (const meta of Object.values(multiverse)) { + indexToCollateralToken[meta.index] = meta.collateralToken; + } + + const crossMarginByToken: Record = {}; + const isolatedMarginByToken: Record = {}; + + for (let index = 0; index < perpDexStates.length; index++) { + const dex = perpDexStates[index]; + const token = indexToCollateralToken[index]; + if (dex === undefined || token === undefined) continue; + + crossMarginByToken[token] = + (crossMarginByToken[token] ?? 0) + + dex.clearinghouseState.crossMaintenanceMarginUsed; + + for (const ap of dex.clearinghouseState.assetPositions) { + if (ap.position.leverage.type === 'isolated') { + isolatedMarginByToken[token] = + (isolatedMarginByToken[token] ?? 0) + ap.position.marginUsed; + } + } + } + + let maxRatio = 0; + for (const [tokenStr, crossMargin] of Object.entries(crossMarginByToken)) { + const token = Number(tokenStr); + const spotTotal = spotBalances.find((b) => b.token === token)?.total ?? 0; + const isolatedMargin = isolatedMarginByToken[token] ?? 0; + const available = spotTotal - isolatedMargin; + if (available > 0) { + maxRatio = Math.max(maxRatio, crossMargin / available); + } + } + + return maxRatio; +} +``` diff --git a/docs/perps/hyperliquid/margin-tiers.md b/docs/perps/hyperliquid/margin-tiers.md new file mode 100644 index 000000000000..d9d517037d53 --- /dev/null +++ b/docs/perps/hyperliquid/margin-tiers.md @@ -0,0 +1,103 @@ +# Margin tiers + +Like most centralized exchanges, the tiered leverage formula on Hyperliquid is as follows: + +`maintenance_margin = notional_position_value * maintenance_margin_rate - maintenance_deduction` + +On Hyperliquid, `maintenance_margin_rate` and `maintenance_deduction` depend only on the margin tiers, not the asset. + +`maintenance_margin_rate(tier = n) = (Initial Margin Rate at Maximum leverage at tier n) / 2` . For example, at 20x max leverage, `maintenance_margin_rate = 2.5%`. + +Maintenance deduction is defined at each tier to account for the different maintenance margin rates used at previous tiers: + +`maintenance_deduction(tier = 0) = 0` + +`maintenance_deduction(tier = n) = maintenance_deduction(tier = n - 1) + notional_position_lower_bound(tier = n) * (maintenance_margin_rate(tier = n) - maintenance_margin_rate(tier = n - 1))` for `n > 0` + +In other words, maintenance deduction is defined so that new positions opened at each tier increase maintenance margin at `maintenance_margin_rate` , while having the total maintenance margin be a continuous function of position size. + +Margin tables have unique IDs and the tiers can be found in the `meta` Info response. For IDs less than 50, there is a single tier with max leverage equal to the ID. + +### Mainnet Margin Tiers + +Mainnet margin tiers are enabled for the assets below: + +#### BTC + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-150M | 40 | +| >150M | 20 | + +#### ETH + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-100M | 25 | +| >100M | 15 | + +#### SOL + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-70M | 20 | +| >70M | 10 | + +#### XRP + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-40M | 20 | +| >40M | 10 | + +#### DOGE, kPEPE, SUI, WLD, TRUMP, LTC, ENA, POPCAT, WIF, AAVE, kBONK, LINK, CRV, AVAX, ADA, UNI, NEAR, TIA, APT, BCH, HYPE, FARTCOIN + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-20M | 10 | +| >20M | 5 | + +#### OP, ARB, LDO, TON, MKR, ONDO, JUP, INJ, kSHIB, SEI, TRX, BNB, DOT + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-3M | 10 | +| >3M | 5 | + +### Testnet Margin Tiers + +The tiers on testnet are lower than mainnet would feature, for ease of testing. + +#### LDO, ARB, MKR, ATOM, PAXG, TAO, ICP, AVAX, FARTCOIN - testnet only + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-10k | 10 | +| >10k | 5 | + +#### DOGE, TIA, SUI, kSHIB, AAVE, TON - testnet only + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-20k | 10 | +| 20-100k | 5 | +| >100k | 3 | + +#### ETH - testnet only + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-20k | 25 | +| 20-50k | 10 | +| 50-200k | 5 | +| >200k | 3 | + +#### BTC - testnet only + +| Notional Position Value (USDC) | Max Leverage | +| ------------------------------ | ------------ | +| 0-10k | 40 | +| 10-50k | 25 | +| 50-100k | 10 | +| 100k-300k | 5 | +| >300k | 3 | diff --git a/docs/perps/hyperliquid/margining.md b/docs/perps/hyperliquid/margining.md index ccc61c410ff5..b501aa25fa9f 100644 --- a/docs/perps/hyperliquid/margining.md +++ b/docs/perps/hyperliquid/margining.md @@ -6,7 +6,13 @@ Margin computations follow similar formulas to major centralized derivatives exc When opening a position, a margin mode is selected. _Cross margin_ is the default, which allows for maximal capital efficiency by sharing collateral between all other cross margin positions. _Isolated margin_ is also supported, which allows an asset's collateral to be constrained to that asset. Liquidations in that asset do not affect other isolated positions or cross positions. Similarly, cross liquidations or other isolated liquidations do not affect the original isolated position. -Some assets are _isolated-only_, which functions the same as isolated margin with the additional constraint that margin cannot be removed. Margin is proportionally removed as the position is closed. +Some assets are _strict isolated_, which functions the same as isolated margin with the additional constraint that margin cannot be removed. Margin is proportionally removed as the position is closed. + +### HIP-3 Margin Modes + +When users have perp positions across multiple DEXs, cross margin behaves different depending on the user's account abstraction. For unified account and portfolio margin, the user's cross margin positions in DEXs with the same collateral all share margin. For standard abstraction, cross margin only applies to the assets within the same DEX. See [here](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/account-abstraction-modes) for more details. + +HIP-3 DEXs also support "no cross" margin mode, which allows isolated margin with margin removal enabled, but does not allow cross margin. ### Initial Margin and Leverage diff --git a/docs/perps/hyperliquid/portfolio-margin.md b/docs/perps/hyperliquid/portfolio-margin.md new file mode 100644 index 000000000000..6f10829d1a22 --- /dev/null +++ b/docs/perps/hyperliquid/portfolio-margin.md @@ -0,0 +1,63 @@ +# Portfolio margin + +Under portfolio margin, a user’s spot and perps trading are unified for greater capital efficiency. Furthermore, portfolio margin accounts automatically earn yield on all borrowable assets not actively used for trading. + +Portfolio margin unlocks functionality such as the carry trade where a spot balance is offset by a short perps position, collateralized by the spot balance. Spot and perp pnl offset each other, protecting against liquidation on the perp position. More generally, spot and perps trading can be performed from a single unified balance. For example, a user could also hold HYPE and immediately buy BTC on the BTC/USDH book. All HIP-3 DEXs are included in portfolio margin, though not all HIP-3 DEX collateral assets are borrowable. Future HyperCore asset classes and primitives will support portfolio margin as well. + +Users can supply eligible quote assets to earn yield. This synergizes and composes with HyperEVM lending protocols. In a future upgrade, CoreWriter will expose the same supply action for smart contracts. Portfolio margin intentionally does not bring a full-fledged lending market to HyperCore, as that is best built by independent teams on the EVM. For example, HyperCore lending is not tokenized, but an EVM protocol could do so by launching a fully onchain yield-bearing ERC20 token contract through CoreWriter and precompiles. Portfolio margin introduces organic demand to borrow and should expand the value proposition of teams building on the HyperEVM. + +IMPORTANT: Portfolio margin is a complex technical upgrade and requires bootstrapping the supply side for borrowable assets. Portfolio margin accounts will fall back to non-portfolio margin behavior when caps are hit. During alpha mode, the following requirements apply: + +- Master account >$5M in weighted volume +- USDH: 500M USDH global supply cap, 100M USDH global borrow cap, 5M USDH user supply cap, 1M USDH user borrow cap +- USDC: 500M USDC global supply cap, 100M USDC global borrow cap, 5M USDC user supply cap, 1M USDC user borrow cap +- HYPE: 1M HYPE global supply cap, 50k HYPE user supply cap +- BTC: 400 BTC global supply cap, 20 BTC user supply cap + +### LTV and borrowing + +Under portfolio margin, eligible collateral assets have an LTV (loan-to-value) ratio between 0 and 1. During pre-alpha, HYPE will have an LTV of 0.5. When placing spot and perp orders under portfolio margin, insufficient balance will automatically borrowed against eligible collateral up to `token_balance * borrow_oracle_price * ltv` , where price is denominated in the asset being borrowed. + +Borrowed assets accrue interest continuously, and are indexed hourly to match the perp funding interval. Portfolio margin users pay interest on borrowed assets and earn interest on idle assets according to the same rate. During pre-alpha, the borrow interest rate for stablecoins is set at `0.05 + 4.75 * max(0, utilization - 0.8)` APY, compounded continuously depending on the instantaneous value of `utilization = total_borrowed_value / total_supplied_value` . Earned interest is accrued proportionally to all suppliers. The protocol retains 10% of borrowed interest as a buffer for future liquidations. + +### Example: Carry trade + +The carry trade becomes significantly more capital efficient with portfolio margin, as there is no trading cost to rebalance over signifcant price ranges. A portfolio margin account's spot borrow and perps pnl offset each other for accounting. The trader still needs to account for external factors such as funding, interest, and drift between spot and perp prices. + +For example, a user holds 1 BTC in spot and shorts 1 BTC-USDC perp at 10x leverage. If BTC's price is 100k, the user only pays interest on the 1/10 initial margin but earns funding on the full 100k position. If BTC's price moves down to 50k, the trader has unrealized pnl in USDC. The trader can choose to maintain the notional value of the trade by buying more spot BTC and increasing the perp short. If BTC's price moves up to 150k, portfolio margin automatically borrows 50k USDC against the spot BTC, now worth 150k. The user can sell BTC and close the perp position to maintain the notional exposure of the funding trade. If BTC's price moves up to 200k, the trader must reduce the notional exposure of the funding trade to avoid a borrow-lend liquidation. + +Note that the hedged price range increased dramatically compared to the same trade without portfolio margin, where the perp leg is collateralized by USDC. + +### Liquidations + +Portfolio margin is a generalization of cross margin. Instead of margining all perp positions within one DEX together, all cross margin perp positions and spot balances are collectively margined together within one account. Sub-accounts are still treated separately under portfolio margin. + +Liquidations are triggered when the entire portfolio margin account is below its portfolio maintenance margin requirement. Users can monitor this requirement via the _portfolio margin ratio,_ defined as + +{% code overflow="wrap" %} + +```latex +portfolio_margin_ratio = max_{borrowable_token} (portfolio_maintenance_requirement(borrowable_token) / portfolio_liquidation_value(borrowable_token)) + +where + +portfolio_maintenance_requirement(token) = min_borrow_offset + sum_{dex} cross_maintenance_margin(dex) + borrowed_size_for_maintenance(token) * borrow_oracle_price(token) + +portfolio_liquidation_value(token) = portfolio_balance(token) + min(borrow_cap(token), min(portfolio_balance(token), supply_cap(token)) * borrow_oracle_price(token) * liquidation_threshold(token)) + +liquidation_threshold(token) = 0.5 + 0.5 * LTV(token) + +borrow_oracle_price(token) = median(HL_spot_USDC_price, HL_perp_mark_price * USDT_USDC_oracle, HL_perp_oracle_price * USDT_USDC_oracle) + +USDT_USDC_oracle = 1 / HL_spot_oracle_price(USDC) + +min_borrow_offset = 20 USDC +``` + +{% endcode %} + +The account becomes liquidatable when portfolio_margin_ratio > 0.95. All notional values in the above definition are converted to USDC using `borrow_oracle_price(token)` . + +During mainnet pre-alpha, the caps per user will begin at `borrow_cap(USDC) = 1000` and `supply_cap(HYPE) = 200`. After borrow caps are hit, additional margin used must be supplied by the user using the settlement asset regardless of whether portfolio margin is active. Therefore, the best way to test the full portfolio margin behavior is to use small test accounts. + +Depending on the order of oracle price updates, either perp positions or spot borrows may be liquidated first. In other words, once portfolio margin ratio is liquidatable, users should not expect a deterministic liquidation sequence. diff --git a/docs/perps/hyperliquid/subscriptions.md b/docs/perps/hyperliquid/subscriptions.md index 884416bd53e1..6e1ba14f88e4 100644 --- a/docs/perps/hyperliquid/subscriptions.md +++ b/docs/perps/hyperliquid/subscriptions.md @@ -27,13 +27,13 @@ The subscription object contains the details of the specific feed you want to su - Subscription message: `{ "type": "webData3", "user": "
" }` - Data format: `WebData3` 4. `twapStates` : - - Subscription message: `{ "type": "twapStates", "user": "
" }` + - Subscription message: `{ "type": "twapStates", "user": "
", "dex": "" }` - Data format: `TwapStates` 5. `clearinghouseState:` - - Subscription message: `{ "type": "clearinghouseState", "user": "
" }` + - Subscription message: `{ "type": "clearinghouseState", "user": "
", "dex": "" }` - Data format: `ClearinghouseState` 6. `openOrders`: - - Subscription message: `{ "type": "openOrders", "user": "
" }` + - Subscription message: `{ "type": "openOrders", "user": "
", "dex": "" }` - Data format: `OpenOrders` 7. `candle`: - Subscription message: `{ "type": "candle", "coin": "", "interval": "" }` @@ -77,6 +77,16 @@ The subscription object contains the details of the specific feed you want to su 19. `bbo` : - Subscription message: `{ "type": "bbo", "coin": "" }` - Data format: `WsBbo` +20. `spotState` + - Subscription message: `{ "type": "spotState", "user": "
", "isPortfolioMargin": bool }` + - Data format: `WsSpotState` + - `isPortfolioMargin` is an optional argument +21. `allDexsClearinghouseState` + 1. Subscription message: `{ "type": "allDexsClearinghouseState", "user": "
" }` + 2. Data format: `WsAllDexsClearinghouseState` +22. `allDexsAssetCtxs` + 1. Subscription message: `{ "type": "allDexsAssetCtxs" }` + 2. Data format: `WsAllDexsAssetCtxs` ### Data formats @@ -102,6 +112,9 @@ The `data` field format depends on the subscription type: - `WsUserFundings` : Funding payments snapshot followed by funding payments on the hour - `WsUserNonFundingLedgerUpdates`: Ledger updates not including funding payments: withdrawals, deposits, transfers, and liquidations - `WsBbo` : Bbo updates that are sent only if the bbo changes on a block +- `WsSpotState` : Spot state update. +- `WsAllDexsClearinghouseState` : Clearinghouse states across all dexs for specific user +- `WsAllDexsAssetCtxs` : Asset contexts across all dexs For the streaming user endpoints such as `WsUserFills`,`WsUserFundings` the first message has `isSnapshot: true` and the following streaming updates have `isSnapshot: false`. @@ -370,6 +383,32 @@ interface TwapStates { user: string; states: Array<[number, TwapState]>; } + +interface WsSpotState { + user: string; + spotState: SpotState; +} + +interface SpotState { + balances: Array; +} + +interface UserBalance { + coin: string; + token: number; + hold: string; + total: string; + entryNtl: string; +} + +interface WsAllDexsClearinghouseState { + user: string; + clearinghouseStates: Record; +} + +interface WsAllDexsAssetCtxs { + ctxs: Record>; +} ```
diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 952566592251..3e4508320613 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4594; + CURRENT_PROJECT_VERSION = 4647; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.73.0; + MARKETING_VERSION = 7.73.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 6b9e33a0ac42..eb7a23e50ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.73.0", + "version": "7.73.2", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", diff --git a/tests/page-objects/Perps/PerpsMarketListView.ts b/tests/page-objects/Perps/PerpsMarketListView.ts index 7faf962dcf91..2b6aa603d48e 100644 --- a/tests/page-objects/Perps/PerpsMarketListView.ts +++ b/tests/page-objects/Perps/PerpsMarketListView.ts @@ -137,7 +137,9 @@ class PerpsMarketListView { async selectMarket(marketName: string) { await encapsulatedAction({ detox: async () => { - const marketElement = Matchers.getElementByText(marketName); + const marketElement = Matchers.getElementByID( + `${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${marketName}`, + ); await Gestures.waitAndTap(marketElement); }, appium: async () => {