From 26ab4ddd60d59720d4c95166c994fbaa89bfaa3e Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Sat, 9 May 2026 01:04:07 +0100 Subject: [PATCH 1/3] ci: External distribution configuration with fastlane (#29940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Problem Nightly iOS builds for a given app version (e.g. 7.78.0) were expiring all previous TestFlight builds, leaving only the most recent one active in the external testing group. As a result, every nightly upload was calling `upload_to_testflight` with `distribute_external: true`, which causes Apple to automatically expire the previous build in the external group each time a new one is submitted. ### Solution The `distribute_external` parameter is now threaded end-to-end through the full upload chain: - **`scripts/upload-to-testflight.sh`**: Changed the default for the 5th argument from `true` to `false`, so callers must explicitly opt in to external distribution. - **`upload-to-testflight.yml`**: Added `distribute_external` as a workflow input (default `true` for backwards compatibility with direct callers) and passes it as the 5th argument to the script. - **`build-and-upload-to-testflight.yml`**: Added `distribute_external` as a workflow input (default `false` for both `workflow_call` and `workflow_dispatch`), and threads it through to `upload-to-testflight.yml`. - **`nightly-build.yml`**: Explicitly passes `distribute_external: true` for both `ios-exp` and `ios-rc` jobs, so nightly builds continue to reach the external testing group (needed since the org exceeds the 100-person internal tester limit). The intent is now explicit and self-documenting: nightly builds always go to the external group, while ad-hoc manual builds (triggered via `workflow_dispatch`) default to internal-only, avoiding accidental expiration of the active nightly build. ## **Changelog** CHANGELOG entry: null ## **Related issues** No issue: CI regression fix — the issue's origin hasn't been found yet. ## **Manual testing steps** ```gherkin Feature: TestFlight external distribution is configurable per caller Scenario: nightly build distributes to external testers Given the nightly-build.yml workflow is triggered on schedule When ios-exp and ios-rc jobs run Then distribute_external is true And the build is added to the "MetaMask BETA & Release Candidates" external group And previous builds for the same version are expired by Apple (expected Apple behaviour for external groups) Scenario: manual workflow_dispatch build does not expire the active nightly build Given a developer triggers build-and-upload-to-testflight.yml via workflow_dispatch And does not explicitly set distribute_external When the upload runs Then distribute_external defaults to false And the build is uploaded to TestFlight as internal-only And the active nightly build in the external group is not expired Scenario: explicit external distribution via workflow_dispatch Given a developer triggers build-and-upload-to-testflight.yml via workflow_dispatch And sets distribute_external to true When the upload runs Then the build is added to the selected external testing group ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/workflows/build-and-upload-to-testflight.yml | 11 +++++++++++ .github/workflows/nightly-build.yml | 2 ++ .github/workflows/upload-to-testflight.yml | 8 +++++++- 3 files changed, 20 insertions(+), 1 deletion(-) 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() From ba70f7b10c5a95f0aae8ed58483ecf732cb573e3 Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Fri, 8 May 2026 20:16:31 -0400 Subject: [PATCH 2/3] feat: update usage of `withKeyring` to `withKeyringV2` for EVM operations. (#29638) ## **Description** Updates usage of `withKeyring` to `withKeyringV2`. Note: Instances where `withKeyring` was not updated is considered to be dead code and will be removed in a follow up PR. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **Manual testing steps** 1. Build from this branch. 2. Ensure account ux is not affected (creating accounts, importing, using hardware wallets) ## **Screenshots/Recordings** N/A - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Updates the keyring access method used when importing/restoring mnemonics, which can impact account selection/address retrieval during onboarding and seedless flows. Limited scope but touches wallet import/authentication paths where regressions would be user-visible. > > **Overview** > Switches mnemonic import flows to use `KeyringController.withKeyringV2` when retrieving the first account address after creating a multichain wallet (in `actions/multiSrp.importNewSecretRecoveryPhrase` and `Authentication.importSeedlessMnemonicToVault`). > > Removes the unused `createNewSecretRecoveryPhrase` action and its associated unit tests, and updates the `multiSrp` tests to mock `withKeyringV2` accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2e9b6284c0b60b315ee18ddf2d1bd4384ace31de. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/actions/multiSrp/index.test.ts | 42 +++++-------------- app/actions/multiSrp/index.ts | 26 +++--------- .../Authentication/Authentication.test.ts | 18 +++++++- app/core/Authentication/Authentication.ts | 4 +- 4 files changed, 34 insertions(+), 56 deletions(-) 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/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; } From 3751d9a03bc509ea08e94f2df6066656f691958c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Fri, 8 May 2026 18:51:29 -0600 Subject: [PATCH 3/3] feat(predict): add deposit wallet claim flow (#29936) ## **Description** Adds Deposit Wallet support for the Predict claim flow. Polymarket Deposit Wallet users still create a normal MetaMask claim confirmation so the transaction is visible in activity, but the signed confirmation transaction is not published directly. Instead, Predict intercepts pending claim transactions in the publish hook, submits the actual claim calls as a Polymarket Deposit Wallet relayer `WALLET` batch, waits only until the relayer returns a transaction hash, and returns that hash to TransactionController. This PR preserves legacy Safe claim behavior: Safe users continue to sign and publish the existing Safe claim transaction path. Key changes: - Add Deposit Wallet claim planning that builds relayer calls from claimable positions. - Mark Deposit Wallet claim confirmation transactions as externally signed before signing. - Publish Deposit Wallet claims through the relayer batch and return as soon as a transaction hash is available. - Trigger best-effort CLOB balance-allowance sync after confirmed claims. - Wire Predict pending-claim context into `beforeSign` and `publish`. - Add `skipInitialGasEstimate` to claim confirmation batch creation. ## **Changelog** CHANGELOG entry: Fixed Predict claims for Polymarket Deposit Wallet users. ## **Related issues** Fixes: [PRED-859](https://consensyssoftware.atlassian.net/browse/PRED-859) ## **Manual testing steps** ```gherkin Feature: Predict Deposit Wallet claim flow Scenario: Deposit Wallet user claims resolved positions Given a Predict user is routed to a Polymarket Deposit Wallet And the user has claimable positions When the user starts the claim flow Then MetaMask shows the normal claim confirmation When the user approves the confirmation Then the claim is submitted through the Polymarket Deposit Wallet relayer And MetaMask activity tracks the returned transaction hash Scenario: legacy Safe user claims resolved positions Given a Predict user is routed to a legacy Safe wallet And the user has claimable positions When the user approves the claim confirmation Then the existing Safe claim publish path is used ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [PRED-859]: https://consensyssoftware.atlassian.net/browse/PRED-859?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Changes claim transaction `beforeSign`/`publish` behavior and introduces a new relayer-based submission path for deposit-wallet users, which could affect transaction lifecycle and activity tracking if metadata or batch matching is wrong. > > **Overview** > Adds **deposit-wallet support for Predict claims** by intercepting pending `predictClaim` transactions in `PredictController` and delegating `beforeSign`/`publish` to new provider hooks (`beforeSignClaim`, `publishClaim`). Deposit-wallet claims are now marked as *externally signed* before signing and are published via a Polymarket relayer `WALLET` batch (planned by new `planDepositWalletClaim`) while legacy Safe claims continue to pass through. > > Claim batch submission is tweaked to set `skipInitialGasEstimate` and include the MATIC collateral gas token, and `confirmClaim` now triggers a best-effort deposit-wallet collateral allowance sync after claim confirmation. Tests are expanded/added across `PredictController`, `PolymarketProvider`, and new `preflight/claim` coverage for requirement filtering and call ordering. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c11a3c626a9639cfed55e9627de7ec5116b37fb8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../controllers/PredictController.test.ts | 153 +++++++++++- .../Predict/controllers/PredictController.ts | 101 +++++++- .../polymarket/PolymarketProvider.test.ts | 233 +++++++++++++++++- .../polymarket/PolymarketProvider.ts | 188 +++++++++++++- .../polymarket/preflight/claim.test.ts | 185 ++++++++++++++ .../providers/polymarket/preflight/claim.ts | 41 +++ app/components/UI/Predict/providers/types.ts | 29 ++- 7 files changed, 922 insertions(+), 8 deletions(-) create mode 100644 app/components/UI/Predict/providers/polymarket/preflight/claim.test.ts 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;