Skip to content

Commit 3751d9a

Browse files
authored
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 <!-- CURSOR_SUMMARY --> --- > [!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. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c11a3c6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ba70f7b commit 3751d9a

7 files changed

Lines changed: 922 additions & 8 deletions

File tree

app/components/UI/Predict/controllers/PredictController.test.ts

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ import {
4444
import type { PredictFeatureFlags } from '../types/flags';
4545

4646
import { PREDICT_ERROR_CODES } from '../constants/errors';
47-
import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants';
47+
import {
48+
MATIC_CONTRACTS_V2,
49+
POLYMARKET_PROVIDER_ID,
50+
} from '../providers/polymarket/constants';
4851
// Mock the PolymarketProvider and its dependencies
4952
jest.mock('../providers/polymarket/PolymarketProvider');
5053

@@ -280,6 +283,8 @@ describe('PredictController', () => {
280283
getCryptoTargetPrice: jest.fn(),
281284
invalidateAccountState: jest.fn(),
282285
beforePublishDepositWalletDeposit: jest.fn(),
286+
beforeSignClaim: jest.fn(),
287+
publishClaim: jest.fn(),
283288
syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(),
284289
} as unknown as jest.Mocked<PolymarketProvider>;
285290

@@ -289,6 +294,9 @@ describe('PredictController', () => {
289294
mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction.mockResolvedValue(
290295
undefined,
291296
);
297+
mockPolymarketProvider.publishClaim?.mockResolvedValue({
298+
transactionHash: undefined,
299+
});
292300

293301
// Default safe mocks for async fire-and-forget methods
294302
// (prevents unhandled rejections when payWithAnyTokenConfirmation is
@@ -2732,7 +2740,14 @@ describe('PredictController', () => {
27322740
address: '0x1234567890123456789012345678901234567890',
27332741
}),
27342742
});
2735-
expect(addTransactionBatch).toHaveBeenCalled();
2743+
expect(addTransactionBatch).toHaveBeenCalledWith(
2744+
expect.objectContaining({
2745+
disableHook: true,
2746+
disableSequential: true,
2747+
gasFeeToken: MATIC_CONTRACTS_V2.collateral,
2748+
skipInitialGasEstimate: true,
2749+
}),
2750+
);
27362751
});
27372752
});
27382753

@@ -6206,6 +6221,24 @@ describe('PredictController', () => {
62066221
});
62076222

62086223
describe('publish', () => {
6224+
const claimTransactionMeta = {
6225+
id: 'tx-claim',
6226+
batchId: 'batch-claim',
6227+
txParams: {
6228+
from: MOCK_ADDRESS,
6229+
to: '0xTarget',
6230+
data: '0xdata',
6231+
value: '0x0',
6232+
},
6233+
nestedTransactions: [
6234+
{
6235+
id: 'nested-claim',
6236+
type: TransactionType.predictClaim,
6237+
data: '0xclaim' as `0x${string}`,
6238+
},
6239+
],
6240+
} as unknown as TransactionMeta;
6241+
62096242
it('passes through by default', async () => {
62106243
await withController(async ({ controller }) => {
62116244
const result = await controller.publish({
@@ -6218,6 +6251,58 @@ describe('PredictController', () => {
62186251
});
62196252

62206253
expect(result).toEqual({ transactionHash: undefined });
6254+
expect(mockPolymarketProvider.publishClaim).not.toHaveBeenCalled();
6255+
});
6256+
});
6257+
6258+
it('delegates pending claims to provider.publishClaim', async () => {
6259+
mockPolymarketProvider.publishClaim?.mockResolvedValue({
6260+
transactionHash:
6261+
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
6262+
});
6263+
6264+
await withController(async ({ controller }) => {
6265+
const claimablePositions = [createMockPosition({ claimable: true })];
6266+
controller.updateStateForTesting((state) => {
6267+
state.pendingClaims[MOCK_ADDRESS.toUpperCase()] = 'batch-claim';
6268+
state.claimablePositions[MOCK_ADDRESS.toUpperCase()] =
6269+
claimablePositions;
6270+
});
6271+
6272+
const result = await controller.publish({
6273+
transactionMeta: claimTransactionMeta,
6274+
});
6275+
6276+
expect(result).toEqual({
6277+
transactionHash:
6278+
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
6279+
});
6280+
expect(mockPolymarketProvider.publishClaim).toHaveBeenCalledWith({
6281+
transactionMeta: claimTransactionMeta,
6282+
signer: expect.objectContaining({ address: MOCK_ADDRESS }),
6283+
positions: claimablePositions,
6284+
});
6285+
expect(
6286+
(mockPolymarketProvider.publishClaim as jest.Mock).mock.calls[0][0]
6287+
.positions,
6288+
).not.toBe(claimablePositions);
6289+
});
6290+
});
6291+
6292+
it('throws on pending claim batch mismatch', async () => {
6293+
await withController(async ({ controller }) => {
6294+
controller.updateStateForTesting((state) => {
6295+
state.pendingClaims[MOCK_ADDRESS] = 'different-batch';
6296+
state.claimablePositions[MOCK_ADDRESS] = [
6297+
createMockPosition({ claimable: true }),
6298+
];
6299+
});
6300+
6301+
await expect(
6302+
controller.publish({ transactionMeta: claimTransactionMeta }),
6303+
).rejects.toThrow(
6304+
'Pending claim batch does not match transaction batch',
6305+
);
62216306
});
62226307
});
62236308
});
@@ -6540,6 +6625,70 @@ describe('PredictController', () => {
65406625
expect(result).toBeUndefined();
65416626
});
65426627
});
6628+
6629+
it('delegates pending claim beforeSign even when no withdraw state exists', async () => {
6630+
const updateTransaction = jest.fn();
6631+
mockPolymarketProvider.beforeSignClaim?.mockResolvedValue({
6632+
updateTransaction,
6633+
});
6634+
6635+
await withController(async ({ controller }) => {
6636+
const claimablePositions = [createMockPosition({ claimable: true })];
6637+
controller.updateStateForTesting((state) => {
6638+
state.pendingClaims[MOCK_ADDRESS] = 'batch-claim';
6639+
state.claimablePositions[MOCK_ADDRESS] = claimablePositions;
6640+
});
6641+
6642+
const transactionMeta = {
6643+
...mockTransactionMeta,
6644+
batchId: 'batch-claim',
6645+
nestedTransactions: [
6646+
{
6647+
id: 'nested-claim',
6648+
type: TransactionType.predictClaim,
6649+
data: '0xclaim' as `0x${string}`,
6650+
},
6651+
],
6652+
} as unknown as TransactionMeta;
6653+
6654+
const result = await controller.beforeSign({ transactionMeta });
6655+
6656+
expect(result).toEqual({ updateTransaction });
6657+
expect(mockPolymarketProvider.beforeSignClaim).toHaveBeenCalledWith({
6658+
transactionMeta,
6659+
signer: expect.objectContaining({ address: MOCK_ADDRESS }),
6660+
positions: claimablePositions,
6661+
});
6662+
expect(
6663+
(mockPolymarketProvider.beforeSignClaim as jest.Mock).mock.calls[0][0]
6664+
.positions,
6665+
).not.toBe(claimablePositions);
6666+
});
6667+
});
6668+
6669+
it('throws when pending claim has no claimable positions', async () => {
6670+
await withController(async ({ controller }) => {
6671+
controller.updateStateForTesting((state) => {
6672+
state.pendingClaims[MOCK_ADDRESS] = 'batch-claim';
6673+
state.claimablePositions[MOCK_ADDRESS] = [];
6674+
});
6675+
6676+
await expect(
6677+
controller.beforeSign({
6678+
transactionMeta: {
6679+
...mockTransactionMeta,
6680+
batchId: 'batch-claim',
6681+
nestedTransactions: [
6682+
{
6683+
type: TransactionType.predictClaim,
6684+
data: '0xclaim' as `0x${string}`,
6685+
},
6686+
],
6687+
} as unknown as TransactionMeta,
6688+
}),
6689+
).rejects.toThrow('No claimable positions found for pending claim');
6690+
});
6691+
});
65436692
});
65446693

65456694
describe('clearWithdrawTransaction', () => {

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,6 +1454,7 @@ export class PredictController extends BaseController<
14541454
networkClientId,
14551455
disableHook: true,
14561456
disableSequential: true,
1457+
skipInitialGasEstimate: true,
14571458
// Temporarily breaking abstraction, can instead be abstracted via provider.
14581459
gasFeeToken: MATIC_CONTRACTS_V2.collateral as Hex,
14591460
transactions,
@@ -2680,7 +2681,62 @@ export class PredictController extends BaseController<
26802681
});
26812682
}
26822683

2683-
public async beforeSign(request: {
2684+
private getPendingClaimContext(transactionMeta: TransactionMeta):
2685+
| {
2686+
senderAddress: string;
2687+
matchedAddress: string;
2688+
pendingValue: string;
2689+
positions: PredictPosition[];
2690+
signer: Signer;
2691+
}
2692+
| undefined {
2693+
const isClaim = transactionMeta.nestedTransactions?.some(
2694+
(tx) => tx.type === TransactionType.predictClaim,
2695+
);
2696+
2697+
if (!isClaim) {
2698+
return undefined;
2699+
}
2700+
2701+
const senderAddress = transactionMeta.txParams.from as string | undefined;
2702+
if (!senderAddress) {
2703+
return undefined;
2704+
}
2705+
2706+
const normalizedAddress = senderAddress.toLowerCase();
2707+
const matchedAddress = Object.keys(this.state.pendingClaims).find(
2708+
(addressKey) => addressKey.toLowerCase() === normalizedAddress,
2709+
);
2710+
2711+
if (!matchedAddress) {
2712+
return undefined;
2713+
}
2714+
2715+
const pendingValue = this.state.pendingClaims[matchedAddress];
2716+
2717+
if (
2718+
pendingValue !== 'pending' &&
2719+
transactionMeta.batchId &&
2720+
pendingValue !== transactionMeta.batchId
2721+
) {
2722+
throw new Error('Pending claim batch does not match transaction batch');
2723+
}
2724+
2725+
const claimablePositions = this.state.claimablePositions[matchedAddress];
2726+
if (!claimablePositions || claimablePositions.length === 0) {
2727+
throw new Error('No claimable positions found for pending claim');
2728+
}
2729+
2730+
return {
2731+
senderAddress,
2732+
matchedAddress,
2733+
pendingValue,
2734+
positions: [...claimablePositions],
2735+
signer: this.getSigner(senderAddress),
2736+
};
2737+
}
2738+
2739+
private async beforeSignWithdrawIfNeeded(request: {
26842740
transactionMeta: TransactionMeta;
26852741
}): Promise<
26862742
| {
@@ -2783,10 +2839,49 @@ export class PredictController extends BaseController<
27832839
};
27842840
}
27852841

2786-
public async publish(_request: {
2842+
public async beforeSign(request: {
2843+
transactionMeta: TransactionMeta;
2844+
}): Promise<
2845+
| {
2846+
updateTransaction?: (transaction: TransactionMeta) => void;
2847+
}
2848+
| undefined
2849+
> {
2850+
const withdrawResult = await this.beforeSignWithdrawIfNeeded(request);
2851+
if (withdrawResult) {
2852+
return withdrawResult;
2853+
}
2854+
2855+
const claimContext = this.getPendingClaimContext(request.transactionMeta);
2856+
if (!claimContext) {
2857+
return undefined;
2858+
}
2859+
2860+
return this.provider.beforeSignClaim?.({
2861+
transactionMeta: request.transactionMeta,
2862+
signer: claimContext.signer,
2863+
positions: claimContext.positions,
2864+
});
2865+
}
2866+
2867+
public async publish(request: {
27872868
transactionMeta: TransactionMeta;
27882869
}): Promise<{ transactionHash?: string }> {
2789-
return { transactionHash: undefined };
2870+
const claimContext = this.getPendingClaimContext(request.transactionMeta);
2871+
2872+
if (!claimContext) {
2873+
return { transactionHash: undefined };
2874+
}
2875+
2876+
if (!this.provider.publishClaim) {
2877+
return { transactionHash: undefined };
2878+
}
2879+
2880+
return this.provider.publishClaim({
2881+
transactionMeta: request.transactionMeta,
2882+
signer: claimContext.signer,
2883+
positions: claimContext.positions,
2884+
});
27902885
}
27912886

27922887
public clearWithdrawTransaction(): void {

0 commit comments

Comments
 (0)