Skip to content

Commit e4a5637

Browse files
chore(runway): cherry-pick feat: Clear stuck pending perps withdraws from app state cp-7.62.0 (#24596)
- feat: Clear stuck pending perps withdraws from app state cp-7.62.0 (#24449) ## **Description** This PR addresses multiple scenarios where Perps withdrawal requests could get stuck in a "pending" or "bridging" state, causing stuck loading indicators in the UI. **Problem** 1. Users upgrading to a new app version may have stuck pending withdrawals from before the withdrawal flow fixes 2. The updatedWithdrawalIdsRef in useWithdrawalRequests was not cleared on account switch, potentially causing stale updates if withdrawal IDs overlap across accounts 3. Users had no way to manually clear stuck pending transactions **Solution** 1. Migration 110: Automatically clears pending/bridging withdrawal requests during app upgrade, preserving completed/failed ones for transaction history 2. Account switch cleanup: Added effect to clear the updatedWithdrawalIdsRef when selectedAddress changes 3. Reset Account integration: Added clearPendingTransactionRequests() method to PerpsController, exposed via usePerpsFirstTimeUser hook, and integrated into the Reset Account flow in Advanced Settings ## **Changelog** CHANGELOG entry: Fixed stuck pending withdrawal indicators in Perps trading by clearing stale transaction requests on app upgrade and account switch ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2365 ## **Manual testing steps** ```gherkin Feature: Clear stuck pending Perps withdrawals Scenario: User upgrades app with stuck pending withdrawals Given user has pending withdrawal requests stuck from previous app version When user upgrades to new app version Then pending/bridging withdrawal requests are cleared And completed/failed withdrawal requests are preserved Scenario: User switches accounts with pending withdrawals Given user has account A with pending withdrawals And user switches to account B When user views Perps withdrawal status Then account B withdrawals are processed correctly And account A withdrawal IDs don't interfere with account B Scenario: User manually resets account Given user has stuck pending Perps transactions And user navigates to Settings > Advanced When user taps "Reset Account" and confirms Then pending/bridging Perps transactions are cleared And Perps first-time user state is reset ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Resolve stuck Perps transaction indicators and improve state handling** > > - **Adds `clearPendingTransactionRequests` to `PerpsController`**: filters out `pending`/`bridging` entries from `withdrawalRequests` and `depositRequests`, and resets `withdrawalProgress`; exposes as `PerpsController:clearPendingTransactionRequests` action. Includes comprehensive unit tests. > - **Migration 112**: on upgrade, cleans `PerpsController.withdrawalRequests` by removing `pending`/`bridging` (and invalid) entries and resets `withdrawalProgress`; preserves `completed`/`failed`. Registered in `migrations/index.ts` with extensive tests. > - **UI integration**: `usePerpsFirstTimeUser` now exposes `clearPendingTransactionRequests`; `ResetAccountModal` invokes it during account reset to clear stuck Perps transactions. Hook tests updated to cover new method and undefined controller safety. > - **Withdrawal UX/logging**: `useWithdrawalRequests` refactored to use `useStableArray`, track previous states, log only meaningful changes (initialized/status updates), and clear `updatedWithdrawalIdsRef` on account switch. Tests updated accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9016f92. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [2b417ee](2b417ee) Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com>
1 parent 477115a commit e4a5637

10 files changed

Lines changed: 990 additions & 57 deletions

File tree

app/components/UI/Perps/controllers/PerpsController.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2909,6 +2909,131 @@ describe('PerpsController', () => {
29092909
});
29102910
});
29112911

2912+
describe('clearPendingTransactionRequests', () => {
2913+
it('removes pending and bridging withdrawal requests', () => {
2914+
// Arrange: Add withdrawal requests with different statuses
2915+
controller.testUpdate((state) => {
2916+
state.withdrawalRequests = [
2917+
{
2918+
id: 'withdrawal-1',
2919+
amount: '100',
2920+
asset: 'USDC',
2921+
accountAddress: '0x123',
2922+
timestamp: Date.now(),
2923+
success: false,
2924+
status: 'pending',
2925+
},
2926+
{
2927+
id: 'withdrawal-2',
2928+
amount: '200',
2929+
asset: 'USDC',
2930+
accountAddress: '0x123',
2931+
timestamp: Date.now(),
2932+
success: false,
2933+
status: 'bridging',
2934+
},
2935+
{
2936+
id: 'withdrawal-3',
2937+
amount: '300',
2938+
asset: 'USDC',
2939+
accountAddress: '0x123',
2940+
timestamp: Date.now(),
2941+
success: true,
2942+
status: 'completed',
2943+
txHash: '0xabc',
2944+
},
2945+
{
2946+
id: 'withdrawal-4',
2947+
amount: '50',
2948+
asset: 'USDC',
2949+
accountAddress: '0x123',
2950+
timestamp: Date.now(),
2951+
success: false,
2952+
status: 'failed',
2953+
},
2954+
];
2955+
});
2956+
2957+
controller.clearPendingTransactionRequests();
2958+
2959+
expect(controller.state.withdrawalRequests).toHaveLength(2);
2960+
expect(controller.state.withdrawalRequests.map((w) => w.id)).toEqual([
2961+
'withdrawal-3',
2962+
'withdrawal-4',
2963+
]);
2964+
});
2965+
2966+
it('removes pending and bridging deposit requests', () => {
2967+
// Arrange: Add deposit requests with different statuses
2968+
controller.testUpdate((state) => {
2969+
state.depositRequests = [
2970+
{
2971+
id: 'deposit-1',
2972+
amount: '100',
2973+
asset: 'USDC',
2974+
accountAddress: '0x123',
2975+
timestamp: Date.now(),
2976+
success: false,
2977+
status: 'pending',
2978+
},
2979+
{
2980+
id: 'deposit-2',
2981+
amount: '200',
2982+
asset: 'USDC',
2983+
accountAddress: '0x123',
2984+
timestamp: Date.now(),
2985+
success: false,
2986+
status: 'bridging',
2987+
},
2988+
{
2989+
id: 'deposit-3',
2990+
amount: '300',
2991+
asset: 'USDC',
2992+
accountAddress: '0x123',
2993+
timestamp: Date.now(),
2994+
success: true,
2995+
status: 'completed',
2996+
txHash: '0xdef',
2997+
},
2998+
];
2999+
});
3000+
3001+
controller.clearPendingTransactionRequests();
3002+
3003+
expect(controller.state.depositRequests).toHaveLength(1);
3004+
expect(controller.state.depositRequests[0].id).toBe('deposit-3');
3005+
});
3006+
3007+
it('resets withdrawal progress', () => {
3008+
// Arrange: Set some withdrawal progress
3009+
controller.testUpdate((state) => {
3010+
state.withdrawalProgress = {
3011+
progress: 50,
3012+
lastUpdated: Date.now() - 10000,
3013+
activeWithdrawalId: 'withdrawal-1',
3014+
};
3015+
});
3016+
3017+
controller.clearPendingTransactionRequests();
3018+
3019+
expect(controller.state.withdrawalProgress.progress).toBe(0);
3020+
expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull();
3021+
});
3022+
3023+
it('handles empty arrays gracefully', () => {
3024+
// Arrange: Ensure arrays are empty
3025+
controller.testUpdate((state) => {
3026+
state.withdrawalRequests = [];
3027+
state.depositRequests = [];
3028+
});
3029+
3030+
controller.clearPendingTransactionRequests();
3031+
3032+
expect(controller.state.withdrawalRequests).toHaveLength(0);
3033+
expect(controller.state.depositRequests).toHaveLength(0);
3034+
});
3035+
});
3036+
29123037
describe('trade configuration', () => {
29133038
it('returns undefined for unsaved configuration', () => {
29143039
const result = controller.getTradeConfiguration('ETH');

app/components/UI/Perps/controllers/PerpsController.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,10 @@ export type PerpsControllerActions =
572572
type: 'PerpsController:resetFirstTimeUserState';
573573
handler: PerpsController['resetFirstTimeUserState'];
574574
}
575+
| {
576+
type: 'PerpsController:clearPendingTransactionRequests';
577+
handler: PerpsController['clearPendingTransactionRequests'];
578+
}
575579
| {
576580
type: 'PerpsController:saveTradeConfiguration';
577581
handler: PerpsController['saveTradeConfiguration'];
@@ -2330,6 +2334,36 @@ export class PerpsController extends BaseController<
23302334
});
23312335
}
23322336

2337+
/**
2338+
* Clear pending/bridging withdrawal and deposit requests
2339+
* This is useful when users want to clear stuck pending indicators
2340+
* Called by Reset Account feature in settings
2341+
*/
2342+
clearPendingTransactionRequests(): void {
2343+
DevLogger.log('PerpsController: Clearing pending transaction requests', {
2344+
timestamp: new Date().toISOString(),
2345+
});
2346+
2347+
this.update((state) => {
2348+
// Filter out pending/bridging withdrawals, keep completed/failed for history
2349+
state.withdrawalRequests = state.withdrawalRequests.filter(
2350+
(req) => req.status !== 'pending' && req.status !== 'bridging',
2351+
);
2352+
2353+
// Filter out pending deposits, keep completed/failed for history
2354+
state.depositRequests = state.depositRequests.filter(
2355+
(req) => req.status !== 'pending' && req.status !== 'bridging',
2356+
);
2357+
2358+
// Reset withdrawal progress
2359+
state.withdrawalProgress = {
2360+
progress: 0,
2361+
lastUpdated: Date.now(),
2362+
activeWithdrawalId: null,
2363+
};
2364+
});
2365+
}
2366+
23332367
/**
23342368
* Get saved trade configuration for a market
23352369
*/

app/components/UI/Perps/hooks/usePerpsFirstTimeUser.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock('../../../../core/Engine', () => ({
1616
PerpsController: {
1717
markTutorialCompleted: jest.fn(),
1818
resetFirstTimeUserState: jest.fn(),
19+
clearPendingTransactionRequests: jest.fn(),
1920
},
2021
},
2122
}));
@@ -47,6 +48,7 @@ describe('usePerpsFirstTimeUser', () => {
4748
isFirstTimeUser: true,
4849
markTutorialCompleted: expect.any(Function),
4950
resetFirstTimeUserState: expect.any(Function),
51+
clearPendingTransactionRequests: expect.any(Function),
5052
});
5153
});
5254

@@ -68,6 +70,7 @@ describe('usePerpsFirstTimeUser', () => {
6870
isFirstTimeUser: false,
6971
markTutorialCompleted: expect.any(Function),
7072
resetFirstTimeUserState: expect.any(Function),
73+
clearPendingTransactionRequests: expect.any(Function),
7174
});
7275
});
7376

@@ -90,6 +93,7 @@ describe('usePerpsFirstTimeUser', () => {
9093
isFirstTimeUser: true,
9194
markTutorialCompleted: expect.any(Function),
9295
resetFirstTimeUserState: expect.any(Function),
96+
clearPendingTransactionRequests: expect.any(Function),
9397
});
9498
});
9599

@@ -166,4 +170,50 @@ describe('usePerpsFirstTimeUser', () => {
166170
// Should not throw when resetFirstTimeUserState is called
167171
expect(() => result.current.resetFirstTimeUserState()).not.toThrow();
168172
});
173+
174+
it('calls PerpsController.clearPendingTransactionRequests when invoked', () => {
175+
// Arrange
176+
mockUsePerpsSelector.mockImplementation(
177+
<T>(selector: (state: PerpsControllerState) => T) => {
178+
expect(selector).toBe(selectIsFirstTimeUser);
179+
return true as T;
180+
},
181+
);
182+
// Restore mock for this test
183+
// @ts-expect-error - Partial mock for testing
184+
Engine.context.PerpsController = {
185+
markTutorialCompleted: jest.fn(),
186+
resetFirstTimeUserState: jest.fn(),
187+
clearPendingTransactionRequests: jest.fn(),
188+
};
189+
const mockClearPendingTransactionRequests = Engine.context.PerpsController
190+
.clearPendingTransactionRequests as jest.Mock;
191+
192+
// Act
193+
const { result } = renderHook(() => usePerpsFirstTimeUser());
194+
result.current.clearPendingTransactionRequests();
195+
196+
// Assert
197+
expect(mockClearPendingTransactionRequests).toHaveBeenCalledWith();
198+
});
199+
200+
it('handles PerpsController being undefined gracefully for clearPendingTransactionRequests', () => {
201+
// Arrange
202+
mockUsePerpsSelector.mockImplementation(
203+
<T>(selector: (state: PerpsControllerState) => T) => {
204+
expect(selector).toBe(selectIsFirstTimeUser);
205+
return true as T;
206+
},
207+
);
208+
// @ts-expect-error - Testing undefined case
209+
Engine.context.PerpsController = undefined;
210+
211+
// Act
212+
const { result } = renderHook(() => usePerpsFirstTimeUser());
213+
214+
// Should not throw when clearPendingTransactionRequests is called
215+
expect(() =>
216+
result.current.clearPendingTransactionRequests(),
217+
).not.toThrow();
218+
});
169219
});

app/components/UI/Perps/hooks/usePerpsFirstTimeUser.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { usePerpsSelector } from './usePerpsSelector';
44

55
/**
66
* Hook to check if the user is a first-time user of perps trading
7-
* @returns Object with isFirstTimeUser flag, markTutorialCompleted function, and resetFirstTimeUserState function
7+
* @returns Object with isFirstTimeUser flag, markTutorialCompleted function, resetFirstTimeUserState function, and clearPendingTransactionRequests function
88
*/
99
export function usePerpsFirstTimeUser(): {
1010
isFirstTimeUser: boolean;
1111
markTutorialCompleted: () => void;
1212
resetFirstTimeUserState: () => void;
13+
clearPendingTransactionRequests: () => void;
1314
} {
1415
const isFirstTimeUser = usePerpsSelector(selectIsFirstTimeUser);
1516

@@ -21,9 +22,14 @@ export function usePerpsFirstTimeUser(): {
2122
Engine.context.PerpsController?.resetFirstTimeUserState();
2223
};
2324

25+
const clearPendingTransactionRequests = () => {
26+
Engine.context.PerpsController?.clearPendingTransactionRequests();
27+
};
28+
2429
return {
2530
isFirstTimeUser,
2631
markTutorialCompleted,
2732
resetFirstTimeUserState,
33+
clearPendingTransactionRequests,
2834
};
2935
}

app/components/UI/Perps/hooks/useWithdrawalRequests.test.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,24 +1097,19 @@ describe('useWithdrawalRequests', () => {
10971097
});
10981098

10991099
describe('logging', () => {
1100-
it('logs pending withdrawals from controller state', () => {
1100+
it('logs when a new withdrawal is initialized', () => {
11011101
renderHookWithProvider(() => useWithdrawalRequests(), {
11021102
state: createMockState(),
11031103
});
11041104

11051105
expect(mockDevLogger.log).toHaveBeenCalledWith(
1106-
'Pending withdrawals from controller state:',
1106+
'Withdrawal initialized:',
11071107
expect.objectContaining({
1108-
count: 2,
1109-
withdrawals: expect.arrayContaining([
1110-
expect.objectContaining({
1111-
id: 'withdrawal-1',
1112-
timestamp: expect.any(String),
1113-
amount: '100',
1114-
asset: 'USDC',
1115-
status: 'pending',
1116-
}),
1117-
]),
1108+
id: 'withdrawal-1',
1109+
timestamp: expect.any(String),
1110+
amount: '100',
1111+
asset: 'USDC',
1112+
status: 'pending',
11181113
}),
11191114
);
11201115
});

0 commit comments

Comments
 (0)