Skip to content

Commit 11349b0

Browse files
Cherry-picking commits from main to release/7.76.3-ota for PR #29917 (#30019)
- feat(predict): add deposit wallet deposit foundation (#29917) ## **Description** This is PR 2 for the Predict Deposit Wallet migration and is temporarily opened against `predict/dw-confirmation-hooks` to accelerate review while PR 1 is reviewed. Once PR 1 merges, this PR should be rebased/retargeted to `main`. Polymarket migrated new API users from the legacy Safe/proxy wallet model to deterministic Deposit Wallets. This PR adds the shared Predict Deposit Wallet foundation and enables the deposit flow while preserving legacy Safe behavior for users with existing Polymarket activity. Included changes: - Adds `walletType` to Predict account state and routes users by legacy Safe activity: - legacy Safe with raw Polymarket activity stays on Safe - legacy Safe without activity routes to Deposit Wallet - deployed legacy Safe + Activity API failure fails closed - Adds Deposit Wallet helper utilities for deterministic address derivation, relayer proxy calls, polling, EIP-712 batch execution, and CLOB balance-allowance sync. - Adds Deposit Wallet deposit preflight to deploy/setup allowances in `beforePublish`. - Adds legacy Safe migration sweep planning for zero-activity users with stranded pUSD/USDC.e. - Updates deposit preparation so Deposit Wallet users receive pUSD transfers to their Deposit Wallet. - Updates balance/position lookup to use the active Predict wallet. - Adds post-confirm best-effort CLOB balance-allowance sync for Deposit Wallet deposits and deposit-and-order deposits. - Keeps order and claim execution support out of scope for this PR; those follow in separate PRs. Validation performed: - `yarn jest app/components/UI/Predict/providers/polymarket/depositWallet.test.ts app/components/UI/Predict/providers/polymarket/preflight/legacySafeMigration.test.ts app/components/UI/Predict/providers/polymarket/preflight/depositWallet.test.ts app/components/UI/Predict/providers/polymarket/preflight/v2AllowanceRequirements.test.ts app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts app/components/UI/Predict/controllers/PredictController.test.ts app/components/UI/Predict/hooks/usePredictAccountState.test.ts --runInBand --forceExit` - `yarn lint:tsc` ## **Changelog** CHANGELOG entry: Added support for Polymarket Deposit Wallet deposits in Predict ## **Related issues** Fixes: [PRED-858](https://consensyssoftware.atlassian.net/browse/PRED-858) ## **Manual testing steps** ```gherkin Feature: Predict Deposit Wallet deposits Scenario: new Predict user deposits to a Polymarket Deposit Wallet Given the selected wallet has no legacy Safe Polymarket activity And the user opens Predict deposit When user confirms a Predict deposit Then the Deposit Wallet setup preflight runs through the relayer And the pUSD deposit transaction transfers funds to the derived Deposit Wallet And the deposit completes without changing legacy Safe users' behavior ``` ```gherkin Feature: Legacy Safe compatibility Scenario: existing Predict user with legacy Safe activity deposits Given the selected wallet has a deployed legacy Safe with Polymarket activity When user prepares a Predict deposit Then Predict keeps using the legacy Safe path And no Deposit Wallet relayer preflight is run ``` ## **Screenshots/Recordings** N/A — core/provider flow changes covered by tests and manual wallet-flow validation. ### **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 - [ ] 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-858]: https://consensyssoftware.atlassian.net/browse/PRED-858?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > High risk because it changes how Predict chooses deposit addresses and adds new pre-publish relayer/batch execution plus post-confirm sync, which directly affects deposit and deposit+order transaction flows and fund routing. > > **Overview** > Adds first-class support for Polymarket **Deposit Wallet** accounts in Predict by extending `AccountState` with `walletType` and routing users to either legacy Safe or a deterministic deposit wallet based on legacy Safe deployment and Polymarket Activity API results (failing closed on API errors). > > Implements deposit-wallet infrastructure in the Polymarket provider: deterministic address derivation, relayer proxy calls/polling, EIP-712 batch execution, allowance-preflight planning (excluding Permit2), and a legacy Safe “sweep” preflight to migrate stranded USDC.e/pUSD to the deposit wallet. > > Updates the deposit pipeline to (1) build deposits targeting the deposit wallet (with optional sweep), (2) run a new `beforePublish` deposit-wallet preflight to ensure wallet deployment and required allowances, and (3) on confirmed deposits/deposit+order, invalidate cached account state and best-effort sync CLOB balance/allowance—waiting for that sync before placing the follow-on order. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c5f61e2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [36204c5](36204c5) [PRED-858]: https://consensyssoftware.atlassian.net/browse/PRED-858?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 0207b3c commit 11349b0

15 files changed

Lines changed: 2557 additions & 128 deletions

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

Lines changed: 148 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,18 @@ describe('PredictController', () => {
278278
createOptimisticPositionFromPreview: jest.fn(),
279279
clearOptimisticPosition: jest.fn(),
280280
getCryptoTargetPrice: jest.fn(),
281+
invalidateAccountState: jest.fn(),
282+
beforePublishDepositWalletDeposit: jest.fn(),
283+
syncDepositWalletBalanceAllowanceForDepositTransaction: jest.fn(),
281284
} as unknown as jest.Mocked<PolymarketProvider>;
282285

286+
mockPolymarketProvider.beforePublishDepositWalletDeposit.mockResolvedValue(
287+
true,
288+
);
289+
mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction.mockResolvedValue(
290+
undefined,
291+
);
292+
283293
// Default safe mocks for async fire-and-forget methods
284294
// (prevents unhandled rejections when payWithAnyTokenConfirmation is
285295
// triggered by onBuyPaymentTokenChange but the async chain completes
@@ -4885,6 +4895,38 @@ describe('PredictController', () => {
48854895
});
48864896
});
48874897

4898+
it('invalidates account state and syncs deposit-wallet balance allowance after confirmed deposits', async () => {
4899+
await withController(async ({ controller, messenger }) => {
4900+
const transactionMeta = createPredictTransactionMeta({
4901+
nestedType: TransactionType.predictDeposit,
4902+
status: TransactionStatus.confirmed,
4903+
from: accountAddress,
4904+
});
4905+
4906+
controller.updateStateForTesting((state) => {
4907+
state.pendingDeposits = {
4908+
[accountAddress]: 'pending',
4909+
};
4910+
});
4911+
4912+
messenger.publish('TransactionController:transactionStatusUpdated', {
4913+
transactionMeta,
4914+
} as { transactionMeta: TransactionMeta });
4915+
4916+
await Promise.resolve();
4917+
4918+
expect(
4919+
mockPolymarketProvider.invalidateAccountState,
4920+
).toHaveBeenCalledWith(accountAddress);
4921+
expect(
4922+
mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction,
4923+
).toHaveBeenCalledWith({
4924+
transactionMeta,
4925+
signerAddress: accountAddress,
4926+
});
4927+
});
4928+
});
4929+
48884930
it('clears only sender pending deposit when selected account differs', () => {
48894931
withController(({ controller, messenger }) => {
48904932
const selectedAddress = accountAddress;
@@ -5721,6 +5763,7 @@ describe('PredictController', () => {
57215763
const mockAccountState = {
57225764
address: '0xProxyAddress' as `0x${string}`,
57235765
isDeployed: true,
5766+
walletType: 'safe' as const,
57245767
balance: 100.5,
57255768
};
57265769

@@ -6140,18 +6183,24 @@ describe('PredictController', () => {
61406183
});
61416184

61426185
describe('beforePublish', () => {
6143-
it('passes through by default', async () => {
6186+
it('delegates to provider deposit-wallet preflight', async () => {
61446187
await withController(async ({ controller }) => {
6145-
const result = await controller.beforePublish({
6146-
transactionMeta: {
6147-
id: 'tx-1',
6148-
txParams: {
6149-
from: MOCK_ADDRESS,
6150-
},
6151-
} as TransactionMeta,
6152-
});
6188+
const transactionMeta = {
6189+
id: 'tx-before-publish',
6190+
txParams: {
6191+
from: '0x1234567890123456789012345678901234567890',
6192+
},
6193+
} as TransactionMeta;
6194+
6195+
const result = await controller.beforePublish({ transactionMeta });
61536196

61546197
expect(result).toBe(true);
6198+
expect(
6199+
mockPolymarketProvider.beforePublishDepositWalletDeposit,
6200+
).toHaveBeenCalledWith({
6201+
transactionMeta,
6202+
getSigner: expect.any(Function),
6203+
});
61556204
});
61566205
});
61576206
});
@@ -9077,8 +9126,8 @@ describe('PredictController', () => {
90779126
],
90789127
}) as any;
90799128

9080-
it('places the order when depositAndOrder transaction is confirmed and preview exists', () => {
9081-
withController(({ controller, messenger }) => {
9129+
it('places the order when depositAndOrder transaction is confirmed and preview exists', async () => {
9130+
await withController(async ({ controller, messenger }) => {
90829131
const preview = createMockOrderPreview();
90839132
const placeOrderSpy = jest
90849133
.spyOn(controller, 'placeOrder')
@@ -9126,6 +9175,9 @@ describe('PredictController', () => {
91269175
},
91279176
} as { transactionMeta: TransactionMeta });
91289177

9178+
await Promise.resolve();
9179+
await Promise.resolve();
9180+
91299181
expect(placeOrderSpy).toHaveBeenCalledWith({
91309182
analyticsProperties: { marketId: 'market-1' },
91319183
preview,
@@ -9135,8 +9187,78 @@ describe('PredictController', () => {
91359187
});
91369188
});
91379189

9138-
it('forwards activeAbTests to placeOrder when depositAndOrder is confirmed', () => {
9139-
withController(({ controller, messenger }) => {
9190+
it('waits for deposit-wallet balance allowance sync before placing depositAndOrder', async () => {
9191+
await withController(async ({ controller, messenger }) => {
9192+
const preview = createMockOrderPreview();
9193+
const placeOrderSpy = jest
9194+
.spyOn(controller, 'placeOrder')
9195+
.mockResolvedValue({
9196+
success: true,
9197+
response: {
9198+
id: 'order-123',
9199+
spentAmount: '100',
9200+
receivedAmount: '200',
9201+
},
9202+
} as any);
9203+
let resolveSync: () => void = jest.fn();
9204+
const syncPromise = new Promise<void>((resolve) => {
9205+
resolveSync = resolve;
9206+
});
9207+
mockPolymarketProvider.syncDepositWalletBalanceAllowanceForDepositTransaction.mockReturnValueOnce(
9208+
syncPromise,
9209+
);
9210+
9211+
setActiveOrderForTest(controller, {
9212+
state: ActiveOrderState.DEPOSITING,
9213+
transactionId: 'tx-1',
9214+
});
9215+
(
9216+
controller as unknown as {
9217+
pendingOrderPreviews: {
9218+
[transactionId: string]: {
9219+
preview: OrderPreview;
9220+
signerAddress: string;
9221+
};
9222+
};
9223+
}
9224+
).pendingOrderPreviews['tx-1'] = {
9225+
preview,
9226+
signerAddress: accountAddress,
9227+
};
9228+
9229+
messenger.publish('TransactionController:transactionStatusUpdated', {
9230+
transactionMeta: {
9231+
...createPredictTransactionMeta({
9232+
nestedType: TransactionType.predictDeposit,
9233+
status: TransactionStatus.confirmed,
9234+
}),
9235+
type: TransactionType.predictDepositAndOrder,
9236+
nestedTransactions: [
9237+
{ type: TransactionType.predictDepositAndOrder },
9238+
],
9239+
},
9240+
} as { transactionMeta: TransactionMeta });
9241+
9242+
await Promise.resolve();
9243+
9244+
expect(placeOrderSpy).not.toHaveBeenCalled();
9245+
9246+
resolveSync();
9247+
await Promise.resolve();
9248+
await Promise.resolve();
9249+
9250+
expect(placeOrderSpy).toHaveBeenCalledWith(
9251+
expect.objectContaining({
9252+
preview,
9253+
address: accountAddress,
9254+
transactionId: 'tx-1',
9255+
}),
9256+
);
9257+
});
9258+
});
9259+
9260+
it('forwards activeAbTests to placeOrder when depositAndOrder is confirmed', async () => {
9261+
await withController(async ({ controller, messenger }) => {
91409262
const preview = createMockOrderPreview();
91419263
const abTests = [
91429264
{ key: 'predict-pwat-experiment', value: 'treatment' },
@@ -9184,6 +9306,9 @@ describe('PredictController', () => {
91849306
},
91859307
} as { transactionMeta: TransactionMeta });
91869308

9309+
await Promise.resolve();
9310+
await Promise.resolve();
9311+
91879312
expect(placeOrderSpy).toHaveBeenCalledWith(
91889313
expect.objectContaining({ activeAbTests: abTests }),
91899314
);
@@ -9645,8 +9770,8 @@ describe('PredictController', () => {
96459770
});
96469771
});
96479772

9648-
it('does not update activeBuyOrder when deposit confirms for a different active order', () => {
9649-
withController(({ controller, messenger }) => {
9773+
it('does not update activeBuyOrder when deposit confirms for a different active order', async () => {
9774+
await withController(async ({ controller, messenger }) => {
96509775
setActiveOrderForTest(controller, {
96519776
state: ActiveOrderState.PREVIEW,
96529777
});
@@ -9694,6 +9819,9 @@ describe('PredictController', () => {
96949819
},
96959820
} as { transactionMeta: TransactionMeta });
96969821

9822+
await Promise.resolve();
9823+
await Promise.resolve();
9824+
96979825
expect(placeOrderSpy).toHaveBeenCalledWith({
96989826
analyticsProperties: { marketId: 'market-1' },
96999827
preview,
@@ -9805,8 +9933,8 @@ describe('PredictController', () => {
98059933
],
98069934
}) as any;
98079935

9808-
it('forwards the transaction address to placeOrder when depositAndOrder confirms after account switch', () => {
9809-
withController(({ controller, messenger }) => {
9936+
it('forwards the transaction address to placeOrder when depositAndOrder confirms after account switch', async () => {
9937+
await withController(async ({ controller, messenger }) => {
98109938
const preview = createMockOrderPreview();
98119939
const placeOrderSpy = jest
98129940
.spyOn(controller, 'placeOrder')
@@ -9854,6 +9982,9 @@ describe('PredictController', () => {
98549982
},
98559983
} as { transactionMeta: TransactionMeta });
98569984

9985+
await Promise.resolve();
9986+
await Promise.resolve();
9987+
98579988
expect(placeOrderSpy).toHaveBeenCalledWith({
98589989
analyticsProperties: { marketId: 'market-2' },
98599990
preview,

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

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2144,6 +2144,38 @@ export class PredictController extends BaseController<
21442144
});
21452145
}
21462146

2147+
private async syncDepositWalletBalanceAllowanceIfNeeded({
2148+
transactionMeta,
2149+
address,
2150+
}: {
2151+
transactionMeta: TransactionMeta;
2152+
address: string;
2153+
}): Promise<void> {
2154+
try {
2155+
await this.provider.syncDepositWalletBalanceAllowanceForDepositTransaction(
2156+
{
2157+
transactionMeta,
2158+
signerAddress: address,
2159+
},
2160+
);
2161+
} catch (error) {
2162+
DevLogger.log(
2163+
'PredictController: Deposit wallet balance-allowance sync failed',
2164+
{
2165+
error: error instanceof Error ? error.message : 'Unknown error',
2166+
transactionId: transactionMeta.id,
2167+
},
2168+
);
2169+
Logger.error(
2170+
ensureError(error),
2171+
this.getErrorContext('syncDepositWalletBalanceAllowanceIfNeeded', {
2172+
operation: 'deposit_wallet_balance_allowance_sync',
2173+
transactionId: transactionMeta.id,
2174+
}),
2175+
);
2176+
}
2177+
}
2178+
21472179
private handleTransactionSideEffects(
21482180
type: PredictTransactionEventType,
21492181
status: PredictTransactionEventStatus,
@@ -2157,6 +2189,20 @@ export class PredictController extends BaseController<
21572189
this.clearPendingDepositForAddress({ address });
21582190
}
21592191

2192+
let depositWalletSyncPromise: Promise<void> | undefined;
2193+
if (
2194+
(type === 'deposit' || type === 'depositAndOrder') &&
2195+
status === 'confirmed'
2196+
) {
2197+
this.provider.invalidateAccountState(address);
2198+
depositWalletSyncPromise = this.syncDepositWalletBalanceAllowanceIfNeeded(
2199+
{
2200+
transactionMeta,
2201+
address,
2202+
},
2203+
);
2204+
}
2205+
21602206
if (type === 'depositAndOrder' && status === 'confirmed') {
21612207
const transactionId = transactionMeta.id;
21622208
const pendingOrder = transactionId
@@ -2186,20 +2232,24 @@ export class PredictController extends BaseController<
21862232
activeAbTests: pendingActiveAbTests,
21872233
} = pendingOrder;
21882234

2189-
this.placeOrder({
2190-
analyticsProperties: pendingAnalytics,
2191-
activeAbTests: pendingActiveAbTests,
2192-
preview,
2193-
address: signerAddress,
2194-
transactionId,
2195-
}).catch((error) => {
2196-
Logger.error(
2197-
ensureError(error),
2198-
this.getErrorContext('handleTransactionSideEffects', {
2199-
operation: 'placeOrder',
2235+
(depositWalletSyncPromise ?? Promise.resolve())
2236+
.then(() =>
2237+
this.placeOrder({
2238+
analyticsProperties: pendingAnalytics,
2239+
activeAbTests: pendingActiveAbTests,
2240+
preview,
2241+
address: signerAddress,
2242+
transactionId,
22002243
}),
2201-
);
2202-
});
2244+
)
2245+
.catch((error) => {
2246+
Logger.error(
2247+
ensureError(error),
2248+
this.getErrorContext('handleTransactionSideEffects', {
2249+
operation: 'placeOrder',
2250+
}),
2251+
);
2252+
});
22032253
}
22042254

22052255
if (type === 'depositAndOrder' && status === 'failed') {
@@ -2621,10 +2671,13 @@ export class PredictController extends BaseController<
26212671
}
26222672
}
26232673

2624-
public async beforePublish(_request: {
2674+
public async beforePublish(request: {
26252675
transactionMeta: TransactionMeta;
26262676
}): Promise<boolean> {
2627-
return true;
2677+
return this.provider.beforePublishDepositWalletDeposit({
2678+
transactionMeta: request.transactionMeta,
2679+
getSigner: (address?: string) => this.getSigner(address),
2680+
});
26282681
}
26292682

26302683
public async beforeSign(request: {

app/components/UI/Predict/hooks/usePredictAccountState.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe('usePredictAccountState', () => {
6363
const mockAccountState = {
6464
address: '0x1234567890abcdef1234567890abcdef12345678',
6565
isDeployed: true,
66+
walletType: 'safe' as const,
6667
};
6768

6869
beforeEach(() => {

0 commit comments

Comments
 (0)