diff --git a/.github/workflows/build-and-upload-to-testflight.yml b/.github/workflows/build-and-upload-to-testflight.yml index 4d9a14695009..5650a1f7641b 100644 --- a/.github/workflows/build-and-upload-to-testflight.yml +++ b/.github/workflows/build-and-upload-to-testflight.yml @@ -19,6 +19,11 @@ on: required: false type: string default: 'MetaMask BETA & Release Candidates' + distribute_external: + description: 'Whether to distribute to external testers. Defaults to false; nightly-build.yml relies on the script default (true) so it always distributes externally.' + required: false + type: boolean + default: false workflow_dispatch: inputs: source_branch: @@ -44,6 +49,11 @@ on: - 'MetaMask BETA & Release Candidates' - 'MM Card Team' - 'Ramp Provider Testing' + distribute_external: + description: 'Whether to distribute to external testers' + required: false + type: boolean + default: false permissions: contents: write @@ -79,6 +89,7 @@ jobs: build_commit_sha: ${{ needs.build.outputs.built_commit_sha }} build_version: ${{ needs.build.outputs.semantic_version }} build_number: ${{ needs.build.outputs.ios_version_code }} + distribute_external: ${{ inputs.distribute_external }} secrets: inherit cleanup-build-branch: diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 26b1fbac7863..09791369f563 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -31,6 +31,7 @@ jobs: source_branch: main environment: exp testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit # ── iOS rc: build + TestFlight upload (after exp for sequential versions) ─ @@ -42,6 +43,7 @@ jobs: source_branch: main environment: rc testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit # ── Android exp: ephemeral branch + build ────────────────────────────── diff --git a/.github/workflows/upload-to-testflight.yml b/.github/workflows/upload-to-testflight.yml index 2755272d892f..c310effe2b63 100644 --- a/.github/workflows/upload-to-testflight.yml +++ b/.github/workflows/upload-to-testflight.yml @@ -39,6 +39,11 @@ on: description: 'The build number of the app (eg. 4134)' required: true type: string + distribute_external: + description: 'Whether to distribute to external testers. Set false for nightly/internal builds to avoid expiring previous TestFlight builds.' + required: false + type: boolean + default: true permissions: contents: read @@ -157,7 +162,8 @@ jobs: "github_actions_main-${{ inputs.environment }}" \ "${{ inputs.source_branch }}" \ "${{ steps.ipa.outputs.path }}" \ - "${{ inputs.testflight_group }}" + "${{ inputs.testflight_group }}" \ + "${{ inputs.distribute_external }}" - name: Cleanup API Key if: always() diff --git a/app/actions/multiSrp/index.test.ts b/app/actions/multiSrp/index.test.ts index 02dbf7e45184..1c8673b8e505 100644 --- a/app/actions/multiSrp/index.test.ts +++ b/app/actions/multiSrp/index.test.ts @@ -1,11 +1,7 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import ExtendedKeyringTypes from '../../constants/keyringTypes'; import Engine from '../../core/Engine'; -import { - importNewSecretRecoveryPhrase, - createNewSecretRecoveryPhrase, - addNewHdAccount, -} from './'; +import { importNewSecretRecoveryPhrase, addNewHdAccount } from './'; import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils'; import { TraceName, TraceOperation } from '../../util/trace'; import ReduxService from '../../core/redux/ReduxService'; @@ -57,6 +53,13 @@ const hdKeyring = { }, }; +const hdKeyringV2 = { + getAccounts: () => { + mockGetAccounts(); + return [{ address: mockAddress }]; + }, +}; + jest.mock('../../selectors/seedlessOnboardingController', () => ({ selectSeedlessOnboardingLoginFlow: (state: unknown) => mockSelectSeedlessOnboardingLoginFlow(state), @@ -103,6 +106,8 @@ jest.mock('../../core/Engine', () => ({ getKeyringsByType: () => mockGetKeyringsByType(), withKeyring: (_selector: unknown, operation: (args: unknown) => void) => operation({ keyring: hdKeyring, metadata: { id: '1234' } }), + withKeyringV2: (_selector: unknown, operation: (args: unknown) => void) => + operation({ keyring: hdKeyringV2, metadata: { id: '1234' } }), }, AccountsController: { getNextAvailableAccountName: jest.fn().mockReturnValue('Snap Account 1'), @@ -474,33 +479,6 @@ describe('MultiSRP Actions', () => { }); }); - describe('createNewSecretRecoveryPhrase', () => { - it('creates new SRP', async () => { - mockAddNewKeyring.mockResolvedValue({ - getAccounts: () => Promise.resolve([mockAddress]), - }); - - await createNewSecretRecoveryPhrase(); - - expect(mockAddNewKeyring).toHaveBeenCalledWith( - KeyringTypes.hd, - undefined, - ); - expect(mockSetSelectedAddress).toHaveBeenCalledWith(mockAddress); - }); - - it('Does not set selected address or gets accounts on errors', async () => { - mockAddNewKeyring.mockRejectedValue(new Error('Test error')); - - await expect( - async () => await createNewSecretRecoveryPhrase(), - ).rejects.toThrow('Test error'); - - expect(mockGetAccounts).not.toHaveBeenCalled(); - expect(mockSetSelectedAddress).not.toHaveBeenCalled(); - }); - }); - describe('addNewHdAccount', () => { it('adds a new HD account, sets the selected address and returns the account', async () => { mockAddAccounts.mockReturnValue([mockAddress]); diff --git a/app/actions/multiSrp/index.ts b/app/actions/multiSrp/index.ts index 7ef1836140df..bc83e29f437b 100644 --- a/app/actions/multiSrp/index.ts +++ b/app/actions/multiSrp/index.ts @@ -50,7 +50,7 @@ export async function importNewSecretRecoveryPhrase( }); const entropySource = wallet.entropySource; - const [newAccountAddress] = await KeyringController.withKeyring( + const [newAccount] = await KeyringController.withKeyringV2( { id: entropySource, }, @@ -80,7 +80,7 @@ export async function importNewSecretRecoveryPhrase( } catch (error) { await MultichainAccountService.removeMultichainAccountWallet( entropySource, - newAccountAddress, + newAccount.address, ); const errorMessage = @@ -129,7 +129,7 @@ export async function importNewSecretRecoveryPhrase( } finally { // We trigger the callback with the results, even in case of error (0 discovered accounts) await callback?.({ - address: newAccountAddress, + address: newAccount.address, discoveredAccountsCount, error: capturedError, }); @@ -137,26 +137,10 @@ export async function importNewSecretRecoveryPhrase( })(); if (shouldSelectAccount) { - Engine.setSelectedAddress(newAccountAddress); + Engine.setSelectedAddress(newAccount.address); } - return { address: newAccountAddress, discoveredAccountsCount }; -} - -export async function createNewSecretRecoveryPhrase() { - const { KeyringController } = Engine.context; - const newHdkeyring = await KeyringController.addNewKeyring( - ExtendedKeyringTypes.hd, - ); - - const [newAccountAddress] = await KeyringController.withKeyring( - { - id: newHdkeyring.id, - }, - async ({ keyring }) => keyring.getAccounts(), - ); - - return Engine.setSelectedAddress(newAccountAddress); + return { address: newAccount.address, discoveredAccountsCount }; } export async function addNewHdAccount( diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index dafc316251a1..e26ec86de636 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -44,7 +44,10 @@ import { import type { PredictFeatureFlags } from '../types/flags'; import { PREDICT_ERROR_CODES } from '../constants/errors'; -import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; +import { + MATIC_CONTRACTS_V2, + POLYMARKET_PROVIDER_ID, +} from '../providers/polymarket/constants'; // Mock the PolymarketProvider and its dependencies jest.mock('../providers/polymarket/PolymarketProvider'); @@ -280,6 +283,8 @@ describe('PredictController', () => { getCryptoTargetPrice: jest.fn(), invalidateAccountState: jest.fn(), beforePublishDepositWalletDeposit: jest.fn(), + beforeSignClaim: jest.fn(), + publishClaim: jest.fn(), syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(), } as unknown as jest.Mocked; @@ -289,6 +294,9 @@ describe('PredictController', () => { mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction.mockResolvedValue( undefined, ); + mockPolymarketProvider.publishClaim?.mockResolvedValue({ + transactionHash: undefined, + }); // Default safe mocks for async fire-and-forget methods // (prevents unhandled rejections when payWithAnyTokenConfirmation is @@ -2732,7 +2740,14 @@ describe('PredictController', () => { address: '0x1234567890123456789012345678901234567890', }), }); - expect(addTransactionBatch).toHaveBeenCalled(); + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + disableHook: true, + disableSequential: true, + gasFeeToken: MATIC_CONTRACTS_V2.collateral, + skipInitialGasEstimate: true, + }), + ); }); }); @@ -6206,6 +6221,24 @@ describe('PredictController', () => { }); describe('publish', () => { + const claimTransactionMeta = { + id: 'tx-claim', + batchId: 'batch-claim', + txParams: { + from: MOCK_ADDRESS, + to: '0xTarget', + data: '0xdata', + value: '0x0', + }, + nestedTransactions: [ + { + id: 'nested-claim', + type: TransactionType.predictClaim, + data: '0xclaim' as `0x${string}`, + }, + ], + } as unknown as TransactionMeta; + it('passes through by default', async () => { await withController(async ({ controller }) => { const result = await controller.publish({ @@ -6218,6 +6251,58 @@ describe('PredictController', () => { }); expect(result).toEqual({ transactionHash: undefined }); + expect(mockPolymarketProvider.publishClaim).not.toHaveBeenCalled(); + }); + }); + + it('delegates pending claims to provider.publishClaim', async () => { + mockPolymarketProvider.publishClaim?.mockResolvedValue({ + transactionHash: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + + await withController(async ({ controller }) => { + const claimablePositions = [createMockPosition({ claimable: true })]; + controller.updateStateForTesting((state) => { + state.pendingClaims[MOCK_ADDRESS.toUpperCase()] = 'batch-claim'; + state.claimablePositions[MOCK_ADDRESS.toUpperCase()] = + claimablePositions; + }); + + const result = await controller.publish({ + transactionMeta: claimTransactionMeta, + }); + + expect(result).toEqual({ + transactionHash: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + expect(mockPolymarketProvider.publishClaim).toHaveBeenCalledWith({ + transactionMeta: claimTransactionMeta, + signer: expect.objectContaining({ address: MOCK_ADDRESS }), + positions: claimablePositions, + }); + expect( + (mockPolymarketProvider.publishClaim as jest.Mock).mock.calls[0][0] + .positions, + ).not.toBe(claimablePositions); + }); + }); + + it('throws on pending claim batch mismatch', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.pendingClaims[MOCK_ADDRESS] = 'different-batch'; + state.claimablePositions[MOCK_ADDRESS] = [ + createMockPosition({ claimable: true }), + ]; + }); + + await expect( + controller.publish({ transactionMeta: claimTransactionMeta }), + ).rejects.toThrow( + 'Pending claim batch does not match transaction batch', + ); }); }); }); @@ -6540,6 +6625,70 @@ describe('PredictController', () => { expect(result).toBeUndefined(); }); }); + + it('delegates pending claim beforeSign even when no withdraw state exists', async () => { + const updateTransaction = jest.fn(); + mockPolymarketProvider.beforeSignClaim?.mockResolvedValue({ + updateTransaction, + }); + + await withController(async ({ controller }) => { + const claimablePositions = [createMockPosition({ claimable: true })]; + controller.updateStateForTesting((state) => { + state.pendingClaims[MOCK_ADDRESS] = 'batch-claim'; + state.claimablePositions[MOCK_ADDRESS] = claimablePositions; + }); + + const transactionMeta = { + ...mockTransactionMeta, + batchId: 'batch-claim', + nestedTransactions: [ + { + id: 'nested-claim', + type: TransactionType.predictClaim, + data: '0xclaim' as `0x${string}`, + }, + ], + } as unknown as TransactionMeta; + + const result = await controller.beforeSign({ transactionMeta }); + + expect(result).toEqual({ updateTransaction }); + expect(mockPolymarketProvider.beforeSignClaim).toHaveBeenCalledWith({ + transactionMeta, + signer: expect.objectContaining({ address: MOCK_ADDRESS }), + positions: claimablePositions, + }); + expect( + (mockPolymarketProvider.beforeSignClaim as jest.Mock).mock.calls[0][0] + .positions, + ).not.toBe(claimablePositions); + }); + }); + + it('throws when pending claim has no claimable positions', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.pendingClaims[MOCK_ADDRESS] = 'batch-claim'; + state.claimablePositions[MOCK_ADDRESS] = []; + }); + + await expect( + controller.beforeSign({ + transactionMeta: { + ...mockTransactionMeta, + batchId: 'batch-claim', + nestedTransactions: [ + { + type: TransactionType.predictClaim, + data: '0xclaim' as `0x${string}`, + }, + ], + } as unknown as TransactionMeta, + }), + ).rejects.toThrow('No claimable positions found for pending claim'); + }); + }); }); describe('clearWithdrawTransaction', () => { diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index c85e227de6a9..dff17733a080 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1454,6 +1454,7 @@ export class PredictController extends BaseController< networkClientId, disableHook: true, disableSequential: true, + skipInitialGasEstimate: true, // Temporarily breaking abstraction, can instead be abstracted via provider. gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex, transactions, @@ -2680,7 +2681,62 @@ export class PredictController extends BaseController< }); } - public async beforeSign(request: { + private getPendingClaimContext(transactionMeta: TransactionMeta): + | { + senderAddress: string; + matchedAddress: string; + pendingValue: string; + positions: PredictPosition[]; + signer: Signer; + } + | undefined { + const isClaim = transactionMeta.nestedTransactions?.some( + (tx) => tx.type === TransactionType.predictClaim, + ); + + if (!isClaim) { + return undefined; + } + + const senderAddress = transactionMeta.txParams.from as string | undefined; + if (!senderAddress) { + return undefined; + } + + const normalizedAddress = senderAddress.toLowerCase(); + const matchedAddress = Object.keys(this.state.pendingClaims).find( + (addressKey) => addressKey.toLowerCase() === normalizedAddress, + ); + + if (!matchedAddress) { + return undefined; + } + + const pendingValue = this.state.pendingClaims[matchedAddress]; + + if ( + pendingValue !== 'pending' && + transactionMeta.batchId && + pendingValue !== transactionMeta.batchId + ) { + throw new Error('Pending claim batch does not match transaction batch'); + } + + const claimablePositions = this.state.claimablePositions[matchedAddress]; + if (!claimablePositions || claimablePositions.length === 0) { + throw new Error('No claimable positions found for pending claim'); + } + + return { + senderAddress, + matchedAddress, + pendingValue, + positions: [...claimablePositions], + signer: this.getSigner(senderAddress), + }; + } + + private async beforeSignWithdrawIfNeeded(request: { transactionMeta: TransactionMeta; }): Promise< | { @@ -2783,10 +2839,49 @@ export class PredictController extends BaseController< }; } - public async publish(_request: { + public async beforeSign(request: { + transactionMeta: TransactionMeta; + }): Promise< + | { + updateTransaction?: (transaction: TransactionMeta) => void; + } + | undefined + > { + const withdrawResult = await this.beforeSignWithdrawIfNeeded(request); + if (withdrawResult) { + return withdrawResult; + } + + const claimContext = this.getPendingClaimContext(request.transactionMeta); + if (!claimContext) { + return undefined; + } + + return this.provider.beforeSignClaim?.({ + transactionMeta: request.transactionMeta, + signer: claimContext.signer, + positions: claimContext.positions, + }); + } + + public async publish(request: { transactionMeta: TransactionMeta; }): Promise<{ transactionHash?: string }> { - return { transactionHash: undefined }; + const claimContext = this.getPendingClaimContext(request.transactionMeta); + + if (!claimContext) { + return { transactionHash: undefined }; + } + + if (!this.provider.publishClaim) { + return { transactionHash: undefined }; + } + + return this.provider.publishClaim({ + transactionMeta: request.transactionMeta, + signer: claimContext.signer, + positions: claimContext.positions, + }); } public clearWithdrawTransaction(): void { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index f286c71639c5..61b02dbdf00b 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -9,7 +9,7 @@ import { analytics } from '../../../../../util/analytics/analytics'; import { UserProfileProperty } from '../../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; import type { OrderPreview } from '../types'; -import { Side } from '../../types'; +import { Side, type PredictPosition } from '../../types'; import type { PredictFeatureFlags } from '../../types/flags'; import { PolymarketProvider } from './PolymarketProvider'; import { OrderType, SignatureType } from './types'; @@ -47,6 +47,10 @@ import { previewOrder, } from './utils'; import { submitProtocolClobOrder } from './protocol/transport'; +import { + buildClaimTransaction, + planDepositWalletClaim, +} from './preflight/claim'; import { buildDepositMaintenanceTransaction } from './preflight/deposit'; import type { SignedSafeExecution } from './preflight/core'; import { planDepositWalletPreflight } from './preflight/depositWallet'; @@ -126,6 +130,11 @@ jest.mock('./depositWallet', () => ({ waitForDepositWalletTransaction: jest.fn(), })); +jest.mock('./preflight/claim', () => ({ + buildClaimTransaction: jest.fn(), + planDepositWalletClaim: jest.fn(), +})); + jest.mock('./preflight/deposit', () => ({ buildDepositMaintenanceTransaction: jest.fn(), })); @@ -171,6 +180,8 @@ const mockIsSmartContractAddress = jest.mocked(isSmartContractAddress); const mockParsePolymarketPositions = jest.mocked(parsePolymarketPositions); const mockPreviewOrder = jest.mocked(previewOrder); const mockSubmitProtocolClobOrder = jest.mocked(submitProtocolClobOrder); +const mockBuildClaimTransaction = jest.mocked(buildClaimTransaction); +const mockPlanDepositWalletClaim = jest.mocked(planDepositWalletClaim); const mockBuildDepositMaintenanceTransaction = jest.mocked( buildDepositMaintenanceTransaction, ); @@ -231,6 +242,40 @@ function createDepositTransactionMeta({ } as TransactionMeta; } +function createClaimPosition( + overrides: Partial = {}, +): PredictPosition { + return { + id: 'position-1', + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcome: 'Yes', + outcomeTokenId: 'token-1', + currentValue: 1, + title: 'Market', + icon: '', + amount: 1, + price: 1, + status: 'open', + size: 1, + outcomeIndex: 0, + percentPnl: 0, + cashPnl: 0, + claimable: true, + initialValue: 1, + avgPrice: 1, + endDate: new Date(0).toISOString(), + negRisk: false, + ...overrides, + } as PredictPosition; +} + +async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + const basePreview: OrderPreview = { marketId: 'market-1', outcomeId: @@ -322,6 +367,20 @@ describe('PolymarketProvider', () => { params: { to: '0xFactory', data: '0xdeploy' }, type: TransactionType.contractInteraction, }); + mockBuildClaimTransaction.mockResolvedValue({ + params: { + to: legacySafeAddress as `0x${string}`, + data: '0xsignedClaim' as `0x${string}`, + }, + type: TransactionType.predictClaim, + }); + mockPlanDepositWalletClaim.mockResolvedValue([ + { + target: MATIC_CONTRACTS_V2.collateral, + value: '0', + data: '0xclaim', + }, + ]); mockBuildDepositMaintenanceTransaction.mockResolvedValue(undefined); mockBuildLegacySafeMigrationSweepTransaction.mockResolvedValue(undefined); mockGetDepositWalletRelayerTransactionId.mockImplementation( @@ -959,6 +1018,178 @@ describe('PolymarketProvider', () => { expect(mockPlanDepositWalletPreflight).not.toHaveBeenCalled(); }); + it('marks deposit-wallet claim transactions as externally signed before signing', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + mockIsSmartContractAddress + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + const provider = createProvider(); + const transactionMeta = { + id: 'claim-tx', + txParams: { + from: signer.address, + nonce: '0x1', + }, + } as TransactionMeta; + + const result = await provider.beforeSignClaim({ + transactionMeta, + signer, + positions: [createClaimPosition()], + }); + + expect(result?.updateTransaction).toBeDefined(); + + result?.updateTransaction?.(transactionMeta); + expect(transactionMeta.isExternalSign).toBe(true); + expect(transactionMeta.isGasFeeTokenIgnoredIfBalance).toBe(false); + expect(transactionMeta.selectedGasFeeToken).toBeUndefined(); + expect(transactionMeta.txParams.nonce).toBeUndefined(); + }); + + it('passes through Safe claims before signing', async () => { + const result = await createProvider().beforeSignClaim({ + transactionMeta: { + id: 'claim-tx', + txParams: { from: signer.address }, + } as TransactionMeta, + signer, + positions: [createClaimPosition()], + }); + + expect(result).toBeUndefined(); + }); + + it('passes through Safe claim publishing', async () => { + const result = await createProvider().publishClaim({ + transactionMeta: { + id: 'claim-tx', + isExternalSign: true, + txParams: { from: signer.address }, + } as TransactionMeta, + signer, + positions: [createClaimPosition()], + }); + + expect(result).toEqual({ transactionHash: undefined }); + expect(mockPlanDepositWalletClaim).not.toHaveBeenCalled(); + expect(mockExecuteDepositWalletBatch).not.toHaveBeenCalled(); + }); + + it('publishes deposit-wallet claims through the relayer batch and returns once a hash is available', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + mockIsSmartContractAddress + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockWaitForDepositWalletTransaction.mockResolvedValue( + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + ); + + const positions = [createClaimPosition()]; + const result = await createProvider().publishClaim({ + transactionMeta: { + id: 'claim-tx', + isExternalSign: true, + txParams: { from: signer.address }, + } as TransactionMeta, + signer, + positions, + }); + + expect(mockPlanDepositWalletClaim).toHaveBeenCalledWith({ + positions, + walletAddress: depositWalletAddress, + protocol: expect.objectContaining({ key: 'v2' }), + }); + expect(mockExecuteDepositWalletBatch).toHaveBeenCalledWith({ + signer, + walletAddress: depositWalletAddress, + calls: [ + { + target: MATIC_CONTRACTS_V2.collateral, + value: '0', + data: '0xclaim', + }, + ], + }); + expect(mockWaitForDepositWalletTransaction).toHaveBeenCalledWith({ + transactionID: 'batch-1', + }); + expect(result).toEqual({ + transactionHash: + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + }); + }); + + it('requires external-sign metadata before publishing deposit-wallet claims', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + mockIsSmartContractAddress + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + await expect( + createProvider().publishClaim({ + transactionMeta: { + id: 'claim-tx', + txParams: { from: signer.address }, + } as TransactionMeta, + signer, + positions: [createClaimPosition()], + }), + ).rejects.toThrow( + 'Deposit wallet claim publish requires external-sign transaction', + ); + }); + + it('syncs deposit-wallet CLOB balance allowance after confirmed claims', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + mockIsSmartContractAddress + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + createProvider().confirmClaim({ + positions: [createClaimPosition()], + signer, + }); + await flushPromises(); + + expect( + mockSyncDepositWalletCollateralBalanceAllowance, + ).toHaveBeenCalledWith({ + protocol: expect.objectContaining({ key: 'v2' }), + signerAddress: signer.address, + apiKey: { + apiKey: 'api-key', + secret: 'secret', + passphrase: 'passphrase', + }, + }); + }); + + it('does not sync claim balance allowance for Safe users', async () => { + createProvider().confirmClaim({ + positions: [createClaimPosition()], + signer, + }); + await flushPromises(); + + expect( + mockSyncDepositWalletCollateralBalanceAllowance, + ).not.toHaveBeenCalled(); + }); + it('syncs deposit-wallet CLOB balance allowance after matching deposits', async () => { await createProvider().syncDepositWalletBalanceAllowanceForDepositTransaction( { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 422e3712053f..e31a503483d2 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -39,6 +39,8 @@ import { } from '../../types'; import { AccountState, + BeforeSignClaimParams, + BeforeSignClaimResult, ClaimOrderParams, ClaimOrderResponse, ConnectionStatus, @@ -59,6 +61,8 @@ import { PrepareWithdrawResponse, PreviewOrderParams, PriceUpdateCallback, + PublishClaimParams, + PublishClaimResult, Signer, SignWithdrawParams, SignWithdrawResponse, @@ -125,7 +129,10 @@ import { signProtocolOrder, } from './protocol/orderCodec'; import { submitProtocolClobOrder } from './protocol/transport'; -import { buildClaimTransaction } from './preflight/claim'; +import { + buildClaimTransaction, + planDepositWalletClaim, +} from './preflight/claim'; import { buildDepositMaintenanceTransaction } from './preflight/deposit'; import { planDepositWalletPreflight } from './preflight/depositWallet'; import { buildLegacySafeMigrationSweepTransaction } from './preflight/legacySafeMigration'; @@ -1930,6 +1937,165 @@ export class PolymarketProvider implements PredictProvider { } } + public async beforeSignClaim({ + transactionMeta, + signer, + positions, + }: BeforeSignClaimParams): Promise { + if (!positions || positions.length === 0) { + throw new Error('No claimable positions found for claim signing'); + } + + const accountState = await this.getAccountState({ + ownerAddress: signer.address, + }); + + if (accountState.walletType !== 'deposit-wallet') { + return undefined; + } + + DevLogger.log('PolymarketProvider: Deposit wallet claim beforeSign', { + operation: 'deposit_wallet_claim_before_sign', + walletType: 'deposit-wallet', + signerAddress: signer.address, + depositWalletAddress: accountState.address, + transactionId: transactionMeta.id, + positionCount: positions.length, + }); + + return { + updateTransaction: (transaction: TransactionMeta) => { + transaction.isExternalSign = true; + transaction.selectedGasFeeToken = undefined; + transaction.isGasFeeTokenIgnoredIfBalance = false; + delete transaction.txParams.nonce; + }, + }; + } + + public async publishClaim({ + transactionMeta, + signer, + positions, + }: PublishClaimParams): Promise { + if (!positions || positions.length === 0) { + throw new Error('No claimable positions found for claim publish'); + } + + const protocol = this.#getProtocol(); + const accountState = await this.getAccountState({ + ownerAddress: signer.address, + }); + + if (accountState.walletType !== 'deposit-wallet') { + return { transactionHash: undefined }; + } + + if (transactionMeta.isExternalSign !== true) { + throw new Error( + 'Deposit wallet claim publish requires external-sign transaction', + ); + } + + try { + const calls = await planDepositWalletClaim({ + positions, + walletAddress: accountState.address, + protocol, + }); + + DevLogger.log( + 'PolymarketProvider: Deposit wallet claim publish started', + { + operation: 'deposit_wallet_claim_publish', + walletType: 'deposit-wallet', + signerAddress: signer.address, + depositWalletAddress: accountState.address, + transactionId: transactionMeta.id, + positionCount: positions.length, + callCount: calls.length, + }, + ); + + const executeResponse = await executeDepositWalletBatch({ + signer, + walletAddress: accountState.address, + calls, + }); + const transactionID = + getDepositWalletRelayerTransactionId(executeResponse); + + if (!transactionID) { + throw new Error( + 'Polymarket deposit wallet claim response missing transactionID', + ); + } + + const transactionHash = await waitForDepositWalletTransaction({ + transactionID, + }); + + DevLogger.log( + 'PolymarketProvider: Deposit wallet claim publish submitted', + { + operation: 'deposit_wallet_claim_publish', + walletType: 'deposit-wallet', + signerAddress: signer.address, + depositWalletAddress: accountState.address, + transactionId: transactionMeta.id, + relayerTransactionID: transactionID, + positionCount: positions.length, + callCount: calls.length, + transactionHash, + }, + ); + + return { transactionHash }; + } catch (error) { + DevLogger.log('PolymarketProvider: Deposit wallet claim publish failed', { + operation: 'deposit_wallet_claim_publish', + walletType: 'deposit-wallet', + signerAddress: signer.address, + depositWalletAddress: accountState.address, + transactionId: transactionMeta.id, + positionCount: positions.length, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + Logger.error( + error instanceof Error ? error : new Error(String(error)), + this.getErrorContext('publishClaim', { + operation: 'deposit_wallet_claim_publish', + walletType: 'deposit-wallet', + positionCount: positions.length, + }), + ); + + throw error; + } + } + + private async syncDepositWalletBalanceAllowanceForSignerIfNeeded({ + signerAddress, + }: { + signerAddress: string; + }): Promise { + const accountState = await this.getAccountState({ + ownerAddress: signerAddress, + }); + + if (accountState.walletType !== 'deposit-wallet') { + return; + } + + const apiKey = await this.getApiKey({ address: signerAddress }); + await syncDepositWalletCollateralBalanceAllowance({ + protocol: this.#getProtocol(), + signerAddress, + apiKey, + }); + } + public confirmClaim({ positions, signer, @@ -1946,6 +2112,26 @@ export class PolymarketProvider implements PredictProvider { marketId: position.marketId, }); }); + + this.syncDepositWalletBalanceAllowanceForSignerIfNeeded({ + signerAddress: signer.address, + }).catch((error) => { + DevLogger.log( + 'PolymarketProvider: Deposit wallet claim balance-allowance sync failed', + { + operation: 'deposit_wallet_claim_balance_allowance_sync', + error: error instanceof Error ? error.message : 'Unknown error', + }, + ); + + Logger.error( + error instanceof Error ? error : new Error(String(error)), + this.getErrorContext('confirmClaim', { + operation: 'deposit_wallet_claim_balance_allowance_sync', + walletType: 'deposit-wallet', + }), + ); + }); } public async isEligible(): Promise { diff --git a/app/components/UI/Predict/providers/polymarket/preflight/claim.test.ts b/app/components/UI/Predict/providers/polymarket/preflight/claim.test.ts new file mode 100644 index 000000000000..3a39b19ff596 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/preflight/claim.test.ts @@ -0,0 +1,185 @@ +import type { PredictPosition } from '../../../types'; +import { POLYMARKET_V2_PROTOCOL } from '../protocol/definitions'; +import { PERMIT2_ADDRESS } from '../safe/constants'; +import { planDepositWalletClaim } from './claim'; +import { compileRequirementTransactions } from './compileRequirementTransactions'; +import { inspectMissingRequirements } from './inspectMissingRequirements'; + +jest.mock('./inspectMissingRequirements', () => ({ + inspectMissingRequirements: jest.fn(), +})); + +jest.mock('./compileRequirementTransactions', () => ({ + compileRequirementTransactions: jest.fn((requirements) => + requirements.map( + (requirement: { tokenAddress: string }, index: number) => ({ + to: requirement.tokenAddress, + data: `0x${String(index + 1).padStart(64, '0')}`, + operation: 0, + value: '0', + }), + ), + ), +})); + +const mockInspectMissingRequirements = jest.mocked(inspectMissingRequirements); +const mockCompileRequirementTransactions = jest.mocked( + compileRequirementTransactions, +); + +const walletAddress = '0x1111111111111111111111111111111111111111'; + +function createPosition( + overrides: Partial = {}, +): PredictPosition { + return { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outcome: 'Yes', + outcomeTokenId: 'token-1', + currentValue: 1, + title: 'Market', + icon: '', + amount: 1, + price: 1, + status: 'open', + size: 1, + outcomeIndex: 0, + percentPnl: 0, + cashPnl: 0, + claimable: true, + initialValue: 1, + avgPrice: 1, + endDate: new Date(0).toISOString(), + negRisk: false, + ...overrides, + } as PredictPosition; +} + +describe('planDepositWalletClaim', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInspectMissingRequirements.mockResolvedValue([]); + }); + + it('throws for empty positions', async () => { + await expect( + planDepositWalletClaim({ positions: [], walletAddress }), + ).rejects.toThrow('No positions provided for deposit wallet claim'); + + expect(mockInspectMissingRequirements).not.toHaveBeenCalled(); + }); + + it('inspects active claim requirements without legacy sweep requirements', async () => { + await planDepositWalletClaim({ + positions: [createPosition()], + walletAddress, + }); + + expect(mockInspectMissingRequirements).toHaveBeenCalledWith({ + address: walletAddress, + requirements: expect.not.arrayContaining([ + expect.objectContaining({ + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.legacyUsdceToken, + }), + ]), + }); + }); + + it('filters Permit2 while preserving allowed deposit-wallet claim requirements', async () => { + await planDepositWalletClaim({ + positions: [createPosition()], + walletAddress, + }); + + expect(mockInspectMissingRequirements).toHaveBeenCalledWith({ + address: walletAddress, + requirements: expect.not.arrayContaining([ + expect.objectContaining({ + type: 'erc20-allowance', + spender: PERMIT2_ADDRESS, + }), + ]), + }); + expect(mockInspectMissingRequirements).toHaveBeenCalledWith({ + address: walletAddress, + requirements: expect.arrayContaining([ + expect.objectContaining({ + type: 'erc20-allowance', + spender: POLYMARKET_V2_PROTOCOL.contracts.exchange, + }), + expect.objectContaining({ + type: 'erc1155-operator', + operator: POLYMARKET_V2_PROTOCOL.claim.standardTarget, + }), + ]), + }); + }); + + it('includes missing requirement calls before redeem calls', async () => { + const missingRequirement = { + type: 'erc20-allowance' as const, + tokenAddress: POLYMARKET_V2_PROTOCOL.collateral.tradingToken, + spender: POLYMARKET_V2_PROTOCOL.contracts.exchange, + }; + mockInspectMissingRequirements.mockResolvedValue([missingRequirement]); + + const calls = await planDepositWalletClaim({ + positions: [createPosition()], + walletAddress, + }); + + expect(mockCompileRequirementTransactions).toHaveBeenCalledWith([ + missingRequirement, + ]); + expect(calls[0]).toEqual({ + target: missingRequirement.tokenAddress, + value: '0', + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + expect(calls[1]).toEqual( + expect.objectContaining({ + target: POLYMARKET_V2_PROTOCOL.claim.standardTarget, + value: '0', + }), + ); + }); + + it('uses the neg-risk redeem target for neg-risk positions', async () => { + const calls = await planDepositWalletClaim({ + positions: [createPosition({ negRisk: true })], + walletAddress, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual( + expect.objectContaining({ + target: POLYMARKET_V2_PROTOCOL.claim.negRiskTarget, + value: '0', + }), + ); + }); + + it('preserves redeem call order for multiple positions', async () => { + const calls = await planDepositWalletClaim({ + positions: [ + createPosition({ id: 'standard', negRisk: false }), + createPosition({ + id: 'neg-risk', + outcomeId: + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + negRisk: true, + }), + ], + walletAddress, + }); + + expect(calls.map((call) => call.target)).toEqual([ + POLYMARKET_V2_PROTOCOL.claim.standardTarget, + POLYMARKET_V2_PROTOCOL.claim.negRiskTarget, + ]); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts index 01eb962364e4..de516a7a5115 100644 --- a/app/components/UI/Predict/providers/polymarket/preflight/claim.ts +++ b/app/components/UI/Predict/providers/polymarket/preflight/claim.ts @@ -10,6 +10,7 @@ import { POLYMARKET_V2_PROTOCOL, type PolymarketProtocolDefinition, } from '../protocol/definitions'; +import { toDepositWalletCalls, type DepositWalletCall } from '../depositWallet'; import { OperationType, type SafeTransaction } from '../safe/types'; import { encodeErc20Transfer, encodeRedeemPositions } from '../utils'; import { @@ -17,8 +18,10 @@ import { compileAllowanceMaintenanceTransactions, getRawTokenBalance, } from './core'; +import { compileRequirementTransactions } from './compileRequirementTransactions'; import { inspectMissingRequirements } from './inspectMissingRequirements'; import { + filterDepositWalletUnsupportedRequirements, getActiveV2AllowanceRequirements, getLegacySweepAllowanceRequirements, type V2AllowanceRequirement, @@ -205,6 +208,44 @@ function compileClaimTransactions({ return transactions; } +export async function planDepositWalletClaim({ + positions, + walletAddress, + protocol = POLYMARKET_V2_PROTOCOL, +}: { + positions: PredictPosition[]; + walletAddress: string; + protocol?: PolymarketV2ProtocolDefinition; +}): Promise { + if (!positions || positions.length === 0) { + throw new Error('No positions provided for deposit wallet claim'); + } + + const requirements = filterDepositWalletUnsupportedRequirements( + getClaimRequirements({ + positions, + protocol, + includeLegacySweep: false, + }), + ); + + const missingRequirements = await inspectMissingRequirements({ + address: walletAddress, + requirements, + }); + + const transactions = [ + ...compileRequirementTransactions(missingRequirements), + ...buildClaimSubtransactions({ positions, protocol }), + ]; + + if (transactions.length === 0) { + throw new Error('No deposit wallet claim calls generated'); + } + + return toDepositWalletCalls(transactions); +} + export async function buildClaimTransaction({ signer, positions, diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index 7684f45261b1..01c2ce8592f5 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -26,7 +26,10 @@ import { UnrealizedPnL, } from '../types'; import { Hex } from '@metamask/utils'; -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; import { PredictFeatureFlags } from '../types/flags'; // Re-export shared types so existing provider-layer imports continue to work @@ -99,6 +102,26 @@ export interface ClaimOrderParams { signer: Signer; } +export interface BeforeSignClaimParams { + transactionMeta: TransactionMeta; + signer: Signer; + positions: PredictPosition[]; +} + +export interface BeforeSignClaimResult { + updateTransaction?: (transaction: TransactionMeta) => void; +} + +export interface PublishClaimParams { + transactionMeta: TransactionMeta; + signer: Signer; + positions: PredictPosition[]; +} + +export interface PublishClaimResult { + transactionHash?: string; +} + export interface ClaimOrderResponse { chainId: number; transactions: { @@ -154,6 +177,10 @@ export interface PredictProvider { ): Promise; prepareClaim(params: ClaimOrderParams): Promise; + beforeSignClaim?( + params: BeforeSignClaimParams, + ): Promise; + publishClaim?(params: PublishClaimParams): Promise; confirmClaim?(params: { positions: PredictPosition[]; signer: Signer }): void; isEligible(): Promise; diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index 23b4c1749dbb..75539ae7a707 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -166,6 +166,10 @@ const mockHdKeyring = { getAccounts: jest.fn().mockResolvedValue([mockAddress]), }; +const mockHdKeyringV2 = { + getAccounts: jest.fn().mockResolvedValue([{ address: mockAddress }]), +}; + jest.mock('../Engine', () => ({ resetState: jest.fn(), controllerMessenger: { @@ -1812,6 +1816,12 @@ describe('Authentication', () => { async ({ id: _id }, callback) => await callback({ keyring: mockHdKeyring }), ), + withKeyringV2: jest + .fn() + .mockImplementation( + async ({ id: _id }, callback) => + await callback({ keyring: mockHdKeyringV2 }), + ), state: { keyrings: [createMockHdKeyringObject()], }, @@ -2917,6 +2927,12 @@ describe('Authentication', () => { async ({ id: _id }, callback) => await callback({ keyring: mockHdKeyring }), ), + withKeyringV2: jest + .fn() + .mockImplementation( + async ({ id: _id }, callback) => + await callback({ keyring: mockHdKeyringV2 }), + ), state: { keyrings: [createMockHdKeyringObject()], }, @@ -3042,7 +3058,7 @@ describe('Authentication', () => { // Arrange const mnemonic = 'test mnemonic phrase for wallet'; const error = new Error('Failed to get accounts'); - mockHdKeyring.getAccounts.mockRejectedValue(error); + mockHdKeyringV2.getAccounts.mockRejectedValue(error); Engine.context.MultichainAccountService.createMultichainAccountWallet.mockResolvedValue( mockMultichainAccountWallet, ); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index 8a872fc52284..9fbc21b96a56 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -1121,7 +1121,7 @@ class AuthenticationService { ); const entropySource = wallet.entropySource; - const [newAccountAddress] = await KeyringController.withKeyring( + const [newAccount] = await KeyringController.withKeyringV2( { id: entropySource }, async ({ keyring }) => keyring.getAccounts(), ); @@ -1137,7 +1137,7 @@ class AuthenticationService { // handle seedless controller import error by reverting keyring controller mnemonic import await MultichainAccountService.removeMultichainAccountWallet( entropySource, - newAccountAddress, + newAccount.address, ); throw error; }