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;