diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 55e4ab2e150d..1ce54674c3b5 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -23,8 +23,13 @@ jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => { }; }); +import { query } from '@metamask/controller-utils'; import Engine from '../../../../../core/Engine'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; +import { + generateTransferData, + isSmartContractAddress, +} from '../../../../../util/transactions'; import { PredictPosition, PredictPositionStatus, @@ -32,7 +37,16 @@ import { Recurrence, Side, } from '../../types'; +import { OrderPreview, PlaceOrderParams } from '../types'; import { PolymarketProvider } from './PolymarketProvider'; +import { + computeProxyAddress, + createSafeFeeAuthorization, + getClaimTransaction, + getDeployProxyWalletTransaction, + getProxyWalletAllowancesTransaction, + hasAllowances, +} from './safe/utils'; import { createApiKey, encodeClaim, @@ -49,20 +63,6 @@ import { priceValid, submitClobOrder, } from './utils'; -import { OrderPreview, PlaceOrderParams } from '../types'; -import { query } from '@metamask/controller-utils'; -import { - computeProxyAddress, - createSafeFeeAuthorization, - getClaimTransaction, - getDeployProxyWalletTransaction, - getProxyWalletAllowancesTransaction, - hasAllowances, -} from './safe/utils'; -import { - generateTransferData, - isSmartContractAddress, -} from '../../../../../util/transactions'; jest.mock('@metamask/controller-utils', () => { const actual = jest.requireActual('@metamask/controller-utils'); @@ -152,6 +152,7 @@ const mockGetClaimTransaction = getClaimTransaction as jest.Mock; const mockHasAllowances = hasAllowances as jest.Mock; const mockQuery = query as jest.Mock; const mockPreviewOrder = previewOrder as jest.Mock; +const mockGetBalance = getBalance as jest.Mock; describe('PolymarketProvider', () => { const createProvider = () => new PolymarketProvider(); @@ -1460,21 +1461,23 @@ describe('PolymarketProvider', () => { negRiskAdapter: '0xNegRiskAdapterAddress', }); mockEncodeClaim.mockReturnValue('0xencodedclaim'); - mockGetClaimTransaction.mockResolvedValue({ - to: '0xConditionalTokensAddress', - data: '0xencodedclaim', - value: '0x0', - }); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); - // Mock getAccountState to return a safe address - const mockAccountState = { - address: '0xSafeAddress123456789012345678901234567890' as const, - isDeployed: true, - hasAllowances: true, - }; - jest - .spyOn(PolymarketProvider.prototype, 'getAccountState') - .mockResolvedValue(mockAccountState); + // Mock getBalance to return a balance above the threshold by default + mockGetBalance.mockResolvedValue(1); + + // Mock computeProxyAddress to return a safe address + mockComputeProxyAddress.mockReturnValue( + '0xSafeAddress123456789012345678901234567890', + ); // Mock hasAllowances used by getAccountState mockHasAllowances.mockResolvedValue(true); @@ -1524,11 +1527,15 @@ describe('PolymarketProvider', () => { expect(result).toEqual({ chainId: 137, // POLYGON_MAINNET_CHAIN_ID - transactions: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, + transactions: [ + { + params: { + data: '0xencodedclaim', + to: '0xConditionalTokensAddress', + value: '0x0', + }, + }, + ], }); // encodeClaim is called internally by getClaimTransaction @@ -1572,11 +1579,15 @@ describe('PolymarketProvider', () => { expect(result).toEqual({ chainId: 137, - transactions: { - data: '0xencodedclaim', - to: '0xConditionalTokensAddress', - value: '0x0', - }, + transactions: [ + { + params: { + data: '0xencodedclaim', + to: '0xConditionalTokensAddress', + value: '0x0', + }, + }, + ], }); // encodeClaim is called internally by getClaimTransaction @@ -1719,6 +1730,311 @@ describe('PolymarketProvider', () => { }), ).rejects.toThrow('No claim transaction generated'); }); + + it('calls getBalance to check signer collateral balance', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(1); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetBalance).toHaveBeenCalledWith({ address: signer.address }); + }); + + it('does not include transfer when signer balance is above minimum collateral threshold', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(1); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetClaimTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + includeTransferTransaction: false, + }), + ); + }); + + it('does not include transfer when signer balance equals minimum collateral threshold', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(0.5); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetClaimTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + includeTransferTransaction: false, + }), + ); + }); + + it('includes transfer when signer balance is below minimum collateral threshold', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(0.3); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetClaimTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + includeTransferTransaction: true, + }), + ); + }); + + it('includes transfer when signer balance is zero', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(0); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetClaimTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + includeTransferTransaction: true, + }), + ); + }); + + it('includes transfer when signer balance is slightly below threshold', async () => { + const { provider, signer } = setupPrepareClaimTest(); + mockGetBalance.mockResolvedValue(0.49); + mockGetClaimTransaction.mockResolvedValue([ + { + params: { + to: '0xConditionalTokensAddress', + data: '0xencodedclaim', + value: '0x0', + }, + }, + ]); + + const position = { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeIndex: 0, + outcome: 'Yes', + outcomeTokenId: '0', + title: 'Test Market Position', + icon: 'test-icon.png', + amount: 1.5, + price: 0.5, + size: 1.5, + negRisk: false, + redeemable: true, + status: PredictPositionStatus.OPEN, + realizedPnl: 0, + curPrice: 0.5, + conditionId: 'outcome-456', + percentPnl: 0, + cashPnl: 0, + initialValue: 0.5, + avgPrice: 0.5, + currentValue: 0.5, + endDate: '2025-01-01T00:00:00Z', + claimable: false, + }; + + await provider.prepareClaim({ + positions: [position], + signer, + }); + + expect(mockGetClaimTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + includeTransferTransaction: true, + }), + ); + }); }); describe('isEligible', () => { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 09a49af9e848..3aa3919c4546 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -11,16 +11,17 @@ import { generateTransferData, isSmartContractAddress, } from '../../../../../util/transactions'; +import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../../constants/errors'; import { GetPriceHistoryParams, GetPriceParams, GetPriceResponse, - PriceResult, PredictActivity, PredictCategory, PredictMarket, PredictPosition, PredictPriceHistoryPoint, + PriceResult, Side, UnrealizedPnL, } from '../../types'; @@ -38,18 +39,18 @@ import { PredictProvider, PrepareDepositParams, PrepareDepositResponse, - SignWithdrawParams, - SignWithdrawResponse, PrepareWithdrawParams, PrepareWithdrawResponse, PreviewOrderParams, Signer, + SignWithdrawParams, + SignWithdrawResponse, } from '../types'; -import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../../constants/errors'; import { - ORDER_RATE_LIMIT_MS, FEE_COLLECTOR_ADDRESS, MATIC_CONTRACTS, + MIN_COLLATERAL_BALANCE_FOR_CLAIM, + ORDER_RATE_LIMIT_MS, POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, ROUNDING_CONFIG, @@ -771,6 +772,14 @@ 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 { @@ -794,6 +803,7 @@ export class PolymarketProvider implements PredictProvider { signer, positions, safeAddress, + includeTransferTransaction, }); } catch (error) { throw new Error( diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 22ef176007ef..51fe9030fea5 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -15,6 +15,8 @@ export const SLIPPAGE = 0.015; // 1.5% export const ORDER_RATE_LIMIT_MS = 5000; +export const MIN_COLLATERAL_BALANCE_FOR_CLAIM = 0.5; + export const POLYGON_MAINNET_CHAIN_ID = 137; export const POLYGON_MAINNET_CAIP_CHAIN_ID = `eip155:${POLYGON_MAINNET_CHAIN_ID}` as const; 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 8701c86690c4..016db9d59d3f 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -79,6 +79,7 @@ jest.mock('../../../../../../util/transactions', () => ({ jest.mock('../utils', () => ({ encodeApprove: jest.fn(() => '0x095ea7b3000000000000000000000000'), encodeErc1155Approve: jest.fn(() => '0xa22cb465000000000000000000000000'), + encodeErc20Transfer: jest.fn(() => '0xa9059cbb000000000000000000000000'), encodeClaim: jest.fn(() => '0x4e71d92d000000000000000000000000'), getAllowance: jest.fn(), getIsApprovedForAll: jest.fn(), @@ -727,6 +728,51 @@ describe('safe utils', () => { expect(safeTxn.to).toBeDefined(); expect(safeTxn.data).toBeDefined(); }); + + it('creates claim transaction without transfer when includeTransfer is not provided', () => { + const safeTxn = createClaimSafeTransaction([mockPosition]); + + expect(safeTxn).toHaveProperty('to'); + expect(safeTxn).toHaveProperty('data'); + }); + + it('includes transfer transaction when includeTransfer address is provided', () => { + const includeTransfer = { address: TEST_ADDRESS }; + + const safeTxn = createClaimSafeTransaction( + [mockPosition], + includeTransfer, + ); + + expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); + expect(safeTxn.operation).toBe(OperationType.DelegateCall); + expect(safeTxn.data).toBeDefined(); + }); + + it('creates multisend transaction with transfer for single position when includeTransfer is provided', () => { + const includeTransfer = { address: TEST_ADDRESS }; + + const safeTxn = createClaimSafeTransaction( + [mockPosition], + includeTransfer, + ); + + expect(safeTxn.to).toBe(SAFE_MULTISEND_ADDRESS); + expect(safeTxn.operation).toBe(OperationType.DelegateCall); + }); + + it('includes transfer with correct recipient address', () => { + const recipientAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const includeTransfer = { address: recipientAddress }; + + const safeTxn = createClaimSafeTransaction( + [mockPosition], + includeTransfer, + ); + + expect(safeTxn).toBeDefined(); + expect(safeTxn.data).toBeDefined(); + }); }); describe('getSafeTransactionCallData', () => { @@ -1071,6 +1117,94 @@ describe('safe utils', () => { expect(txs).toHaveLength(1); expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); }); + + it('generates claim transaction without transfer when includeTransferTransaction is false', async () => { + const signer = buildSigner(); + const positions = [mockPosition]; + + setupMocksForFeeAuth(); + + const txs = await getClaimTransaction({ + signer, + positions, + safeAddress: TEST_SAFE_ADDRESS, + includeTransferTransaction: false, + }); + + expect(Array.isArray(txs)).toBe(true); + expect(txs).toHaveLength(1); + expect(txs[0]).toHaveProperty('params'); + }); + + it('generates claim transaction without transfer when includeTransferTransaction is undefined', async () => { + const signer = buildSigner(); + const positions = [mockPosition]; + + setupMocksForFeeAuth(); + + const txs = await getClaimTransaction({ + signer, + positions, + safeAddress: TEST_SAFE_ADDRESS, + }); + + expect(Array.isArray(txs)).toBe(true); + expect(txs).toHaveLength(1); + }); + + it('includes transfer transaction when includeTransferTransaction is true', async () => { + const signer = buildSigner(); + const positions = [mockPosition]; + + setupMocksForFeeAuth(); + + const txs = await getClaimTransaction({ + signer, + positions, + safeAddress: TEST_SAFE_ADDRESS, + includeTransferTransaction: true, + }); + + expect(Array.isArray(txs)).toBe(true); + expect(txs).toHaveLength(1); + expect(txs[0]).toHaveProperty('params'); + expect(txs[0].params).toHaveProperty('data'); + }); + + it('uses signer address for transfer when includeTransferTransaction is true', async () => { + const signerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const signer = buildSigner({ address: signerAddress }); + const positions = [mockPosition]; + + setupMocksForFeeAuth(); + + const txs = await getClaimTransaction({ + signer, + positions, + safeAddress: TEST_SAFE_ADDRESS, + includeTransferTransaction: true, + }); + + expect(txs).toBeDefined(); + expect(Array.isArray(txs)).toBe(true); + expect(txs[0].params.data).toMatch(/^0x[a-f0-9]+$/); + }); + + it('signs claim transaction with transfer when includeTransferTransaction is true', async () => { + const signer = buildSigner(); + const positions = [mockPosition]; + + setupMocksForFeeAuth(); + + await getClaimTransaction({ + signer, + positions, + safeAddress: TEST_SAFE_ADDRESS, + includeTransferTransaction: true, + }); + + expect(mockSignPersonalMessage).toHaveBeenCalled(); + }); }); describe('getWithdrawTransactionCallData', () => { diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index 5ff1e82d372d..511258898e96 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -23,14 +23,17 @@ import Logger, { type LoggerErrorOptions } from '../../../../../../util/Logger'; import { isSmartContractAddress } from '../../../../../../util/transactions'; import { Signer } from '../../types'; import { + COLLATERAL_TOKEN_DECIMALS, CONDITIONAL_TOKEN_DECIMALS, MATIC_CONTRACTS, + MIN_COLLATERAL_BALANCE_FOR_CLAIM, POLYGON_MAINNET_CHAIN_ID, } from '../constants'; import { encodeApprove, encodeClaim, encodeErc1155Approve, + encodeErc20Transfer, getAllowance, getContractConfig, getIsApprovedForAll, @@ -608,7 +611,12 @@ export const hasAllowances = async ({ address }: { address: string }) => { ); }; -export const createClaimSafeTransaction = (positions: PredictPosition[]) => { +export const createClaimSafeTransaction = ( + positions: PredictPosition[], + includeTransfer?: { + address: string; + }, +) => { const safeTxns: SafeTransaction[] = []; const contractConfig = getContractConfig(POLYGON_MAINNET_CHAIN_ID); @@ -634,6 +642,21 @@ export const createClaimSafeTransaction = (positions: PredictPosition[]) => { }); } + if (includeTransfer) { + safeTxns.push({ + to: MATIC_CONTRACTS.collateral, + data: encodeErc20Transfer({ + to: includeTransfer.address, + value: parseUnits( + MIN_COLLATERAL_BALANCE_FOR_CLAIM.toString(), + COLLATERAL_TOKEN_DECIMALS, + ).toBigInt(), + }), + operation: OperationType.Call, + value: '0', + }); + } + const safeTxn = aggregateTransaction(safeTxns); return safeTxn; @@ -643,12 +666,17 @@ export const getClaimTransaction = async ({ signer, positions, safeAddress, + includeTransferTransaction, }: { signer: Signer; positions: PredictPosition[]; safeAddress: string; + includeTransferTransaction?: boolean; }) => { - const safeTxn = createClaimSafeTransaction(positions); + const includeTransfer = includeTransferTransaction + ? { address: signer.address } + : undefined; + const safeTxn = createClaimSafeTransaction(positions, includeTransfer); const callData = await getSafeTransactionCallData({ signer, safeAddress,