Skip to content

Commit 0bb907e

Browse files
authored
feat(deposit): implement user limits fetch before proceeding with an order (#21174)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Implement user deposit limits validation (daily/monthly/yearly) in the authenticated deposit flow. Checks limits after KYC approval using the user's actual KYC type, and displays clear error messages when deposit amount including fees would exceed remaining limits. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added Deposit limits ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2669?atlOrigin=eyJpIjoiNWVhOWFiMzA1Yzg4NDAxY2IzZjBkZWMzYThjMzRhZDYiLCJwIjoiaiJ9 ## **Manual testing steps** ```gherkin Feature: Deposit Limits Validation Scenario: User exceeds daily/monthly/yearly deposit limit Given user is authenticated and KYC approved And user has $100 daily/monthly/yearly limit remaining And user has selected USD as currency When user enters $200 deposit amount And user clicks Continue Then error message should display "This deposit would exceed your daily/monthly/yearly limit. Your deposit including fees must be $100 USD or less." And user should remain on build quote screen Scenario: User successfully deposits within daily limit Given user is authenticated and KYC approved And user has $300 daily limit remaining And user has selected USD as currency When user enters $100 deposit amount And user clicks Continue Then deposit should proceed to payment screen And no error message should be displayed ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> No limits check. ### **After** <!-- [screenshots/recordings] --> <img width="301" height="655" alt="Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 15 03 28" src="https://github.com/user-attachments/assets/3070cf38-8a01-48ae-8d7b-e9f121b20c46" /> <img width="301" height="655" alt="Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 14 54 53" src="https://github.com/user-attachments/assets/88c33ae1-714d-444d-b0c7-0dbb22bc6059" /> ## **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] > Integrates `getUserLimits` into the authenticated deposit flow to block deposits exceeding daily/monthly/yearly limits, with new i18n messages and tests; bumps native ramps SDK. > > - **Deposit flow**: > - Add `getUserLimits` via `useDepositSdkMethod` and new `checkUserLimits(quote, kycType)` to enforce daily/monthly/yearly remaining limits before creating orders or generating payment URLs. > - Pass actual KYC type to limits check; use region currency in messages. > - **UX/i18n**: > - Add strings `deposit.buildQuote.limitExceeded` and `deposit.buildQuote.limitError` for clear limit errors. > - **Tests**: > - Extend `useDepositRouting.test.ts` with cases for within-limits, daily/monthly/yearly exceed, null/undefined limits, and correct KYC type propagation. > - **Dependencies**: > - Bump `@consensys/native-ramps-sdk` to `2.1.5`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e56b064. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent bb2ff00 commit 0bb907e

File tree

5 files changed

+287
-8
lines changed

5 files changed

+287
-8
lines changed

app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ let mockGetOrder = jest.fn().mockResolvedValue({
4444
paymentMethod: 'credit_debit_card',
4545
fiatCurrency: 'USD',
4646
});
47+
let mockGetUserLimits = jest.fn().mockResolvedValue({
48+
exceeded: { 1: false, 30: false, 365: false },
49+
limits: { 1: 300, 30: 1000, 365: 3000 },
50+
remaining: { 1: 300, 30: 1000, 365: 3000 },
51+
shortage: {},
52+
spent: { 1: 0, 30: 0, 365: 0 },
53+
});
4754

4855
const mockNavigate = jest.fn();
4956
const mockDispatch = jest.fn();
@@ -137,12 +144,17 @@ jest.mock('./useDepositSdkMethod', () => ({
137144
mockGetOrder(...wrapParams(customParams, params));
138145
return [mockUseDepositSdkMethodInitialState, wrappedGetOrder];
139146
}
147+
if (config?.method === 'getUserLimits') {
148+
const wrappedGetUserLimits = (...customParams: unknown[]) =>
149+
mockGetUserLimits(...wrapParams(customParams, params));
150+
return [mockUseDepositSdkMethodInitialState, wrappedGetUserLimits];
151+
}
140152
return [mockUseDepositSdkMethodInitialState, jest.fn()];
141153
}),
142154
}));
143155

144156
const mockLogoutFromProvider = jest.fn();
145-
const mockSelectedRegion = { isoCode: 'US' };
157+
const mockSelectedRegion = { isoCode: 'US', currency: 'USD' };
146158
let mockSelectedPaymentMethod = {
147159
isManualBankTransfer: false,
148160
id: 'credit_debit_card',
@@ -232,6 +244,13 @@ describe('useDepositRouting', () => {
232244
networkFees: '5.99',
233245
partnerFees: '5.99',
234246
});
247+
mockGetUserLimits = jest.fn().mockResolvedValue({
248+
exceeded: { 1: false, 30: false, 365: false },
249+
limits: { 1: 300, 30: 1000, 365: 3000 },
250+
remaining: { 1: 300, 30: 1000, 365: 3000 },
251+
shortage: {},
252+
spent: { 1: 0, 30: 0, 365: 0 },
253+
});
235254

236255
mockUseHandleNewOrder.mockReturnValue(
237256
jest.fn().mockResolvedValue(undefined),
@@ -1088,4 +1107,195 @@ describe('useDepositRouting', () => {
10881107
expect(mockTrackEvent).not.toHaveBeenCalled();
10891108
});
10901109
});
1110+
1111+
describe('User limits checking', () => {
1112+
it('should check user limits and proceed when within limits', async () => {
1113+
const mockQuote = {
1114+
quoteId: 'test-quote-id',
1115+
fiatAmount: 100,
1116+
} as BuyQuote;
1117+
1118+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1119+
status: 'APPROVED',
1120+
kycType: 'SIMPLE',
1121+
});
1122+
1123+
const { result } = renderHook(() => useDepositRouting());
1124+
1125+
await expect(
1126+
result.current.routeAfterAuthentication(mockQuote),
1127+
).resolves.not.toThrow();
1128+
1129+
expect(mockGetUserLimits).toHaveBeenCalledWith(
1130+
'USD',
1131+
'credit_debit_card',
1132+
'SIMPLE',
1133+
);
1134+
expect(mockRequestOtt).toHaveBeenCalled();
1135+
expect(mockGeneratePaymentUrl).toHaveBeenCalled();
1136+
});
1137+
1138+
it('should pass real kycType from requirements to getUserLimits', async () => {
1139+
const mockQuote = {
1140+
quoteId: 'test-quote-id',
1141+
fiatAmount: 100,
1142+
} as BuyQuote;
1143+
1144+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1145+
status: 'APPROVED',
1146+
kycType: 'STANDARD',
1147+
});
1148+
1149+
const { result } = renderHook(() => useDepositRouting());
1150+
1151+
await expect(
1152+
result.current.routeAfterAuthentication(mockQuote),
1153+
).resolves.not.toThrow();
1154+
1155+
expect(mockGetUserLimits).toHaveBeenCalledWith(
1156+
'USD',
1157+
'credit_debit_card',
1158+
'STANDARD',
1159+
);
1160+
});
1161+
1162+
it('should throw error when deposit exceeds daily limit', async () => {
1163+
const mockQuote = {
1164+
quoteId: 'test-quote-id',
1165+
fiatAmount: 150,
1166+
} as BuyQuote;
1167+
1168+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1169+
status: 'APPROVED',
1170+
kycType: 'SIMPLE',
1171+
});
1172+
1173+
mockGetUserLimits = jest.fn().mockResolvedValue({
1174+
exceeded: { 1: false, 30: false, 365: false },
1175+
limits: { 1: 100, 30: 1000, 365: 3000 },
1176+
remaining: { 1: 100, 30: 1000, 365: 3000 },
1177+
shortage: {},
1178+
spent: { 1: 0, 30: 0, 365: 0 },
1179+
});
1180+
1181+
const { result } = renderHook(() => useDepositRouting());
1182+
1183+
await expect(
1184+
result.current.routeAfterAuthentication(mockQuote),
1185+
).rejects.toThrow(/daily.*100.*USD/);
1186+
1187+
expect(mockCreateOrder).not.toHaveBeenCalled();
1188+
expect(mockRequestOtt).not.toHaveBeenCalled();
1189+
});
1190+
1191+
it('should throw error when deposit exceeds monthly limit', async () => {
1192+
const mockQuote = {
1193+
quoteId: 'test-quote-id',
1194+
fiatAmount: 600,
1195+
} as BuyQuote;
1196+
1197+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1198+
status: 'APPROVED',
1199+
kycType: 'SIMPLE',
1200+
});
1201+
1202+
mockGetUserLimits = jest.fn().mockResolvedValue({
1203+
exceeded: { 1: false, 30: false, 365: false },
1204+
limits: { 1: 1000, 30: 500, 365: 3000 },
1205+
remaining: { 1: 1000, 30: 500, 365: 3000 },
1206+
shortage: {},
1207+
spent: { 1: 0, 30: 0, 365: 0 },
1208+
});
1209+
1210+
const { result } = renderHook(() => useDepositRouting());
1211+
1212+
await expect(
1213+
result.current.routeAfterAuthentication(mockQuote),
1214+
).rejects.toThrow(/monthly.*500.*USD/);
1215+
1216+
expect(mockCreateOrder).not.toHaveBeenCalled();
1217+
expect(mockRequestOtt).not.toHaveBeenCalled();
1218+
});
1219+
1220+
it('should throw error when deposit exceeds yearly limit', async () => {
1221+
const mockQuote = {
1222+
quoteId: 'test-quote-id',
1223+
fiatAmount: 1100,
1224+
} as BuyQuote;
1225+
1226+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1227+
status: 'APPROVED',
1228+
kycType: 'SIMPLE',
1229+
});
1230+
1231+
mockGetUserLimits = jest.fn().mockResolvedValue({
1232+
exceeded: { 1: false, 30: false, 365: false },
1233+
limits: { 1: 2000, 30: 5000, 365: 1000 },
1234+
remaining: { 1: 2000, 30: 5000, 365: 1000 },
1235+
shortage: {},
1236+
spent: { 1: 0, 30: 0, 365: 0 },
1237+
});
1238+
1239+
const { result } = renderHook(() => useDepositRouting());
1240+
1241+
await expect(
1242+
result.current.routeAfterAuthentication(mockQuote),
1243+
).rejects.toThrow(/yearly.*1000.*USD/);
1244+
1245+
expect(mockCreateOrder).not.toHaveBeenCalled();
1246+
expect(mockRequestOtt).not.toHaveBeenCalled();
1247+
});
1248+
1249+
it('should throw error when getUserLimits returns null', async () => {
1250+
const mockQuote = {
1251+
quoteId: 'test-quote-id',
1252+
fiatAmount: 100,
1253+
} as BuyQuote;
1254+
1255+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1256+
status: 'APPROVED',
1257+
kycType: 'SIMPLE',
1258+
});
1259+
1260+
mockGetUserLimits = jest.fn().mockResolvedValue(null);
1261+
1262+
const { result } = renderHook(() => useDepositRouting());
1263+
1264+
await expect(
1265+
result.current.routeAfterAuthentication(mockQuote),
1266+
).rejects.toThrow('Failed to check your deposit limits');
1267+
1268+
expect(mockCreateOrder).not.toHaveBeenCalled();
1269+
expect(mockRequestOtt).not.toHaveBeenCalled();
1270+
});
1271+
1272+
it('should throw error when any limit value is undefined', async () => {
1273+
const mockQuote = {
1274+
quoteId: 'test-quote-id',
1275+
fiatAmount: 100,
1276+
} as BuyQuote;
1277+
1278+
mockGetKycRequirement = jest.fn().mockResolvedValue({
1279+
status: 'APPROVED',
1280+
kycType: 'SIMPLE',
1281+
});
1282+
1283+
mockGetUserLimits = jest.fn().mockResolvedValue({
1284+
exceeded: { 1: false, 30: false, 365: false },
1285+
limits: { 1: 300, 365: 3000 },
1286+
remaining: { 1: 300, 365: 3000 },
1287+
shortage: {},
1288+
spent: { 1: 0, 30: 0, 365: 0 },
1289+
});
1290+
1291+
const { result } = renderHook(() => useDepositRouting());
1292+
1293+
await expect(
1294+
result.current.routeAfterAuthentication(mockQuote),
1295+
).rejects.toThrow('Failed to check your deposit limits');
1296+
1297+
expect(mockCreateOrder).not.toHaveBeenCalled();
1298+
expect(mockRequestOtt).not.toHaveBeenCalled();
1299+
});
1300+
});
10911301
});

app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ export const useDepositRouting = () => {
9090
throws: true,
9191
});
9292

93+
const [, getUserLimits] = useDepositSdkMethod({
94+
method: 'getUserLimits',
95+
onMount: false,
96+
throws: true,
97+
});
98+
9399
const popToBuildQuote = useCallback(() => {
94100
navigation.dispatch((state) => {
95101
const buildQuoteIndex = state.routes.findIndex(
@@ -108,6 +114,64 @@ export const useDepositRouting = () => {
108114
});
109115
}, [navigation]);
110116

117+
const checkUserLimits = useCallback(
118+
async (quote: BuyQuote, kycType: string) => {
119+
const userLimits = await getUserLimits(
120+
selectedRegion?.currency || '',
121+
selectedPaymentMethod?.id || '',
122+
kycType,
123+
);
124+
125+
if (!userLimits?.remaining) {
126+
throw new Error(strings('deposit.buildQuote.limitError'));
127+
}
128+
129+
const { remaining } = userLimits;
130+
const dailyLimit = remaining['1'];
131+
const monthlyLimit = remaining['30'];
132+
const yearlyLimit = remaining['365'];
133+
134+
if (
135+
dailyLimit === undefined ||
136+
monthlyLimit === undefined ||
137+
yearlyLimit === undefined
138+
) {
139+
throw new Error(strings('deposit.buildQuote.limitError'));
140+
}
141+
142+
const depositAmount = quote.fiatAmount;
143+
const currency = selectedRegion?.currency || '';
144+
145+
if (depositAmount > dailyLimit) {
146+
throw new Error(
147+
strings('deposit.buildQuote.limitExceeded', {
148+
period: 'daily',
149+
remaining: `${dailyLimit} ${currency}`,
150+
}),
151+
);
152+
}
153+
154+
if (depositAmount > monthlyLimit) {
155+
throw new Error(
156+
strings('deposit.buildQuote.limitExceeded', {
157+
period: 'monthly',
158+
remaining: `${monthlyLimit} ${currency}`,
159+
}),
160+
);
161+
}
162+
163+
if (depositAmount > yearlyLimit) {
164+
throw new Error(
165+
strings('deposit.buildQuote.limitExceeded', {
166+
period: 'yearly',
167+
remaining: `${yearlyLimit} ${currency}`,
168+
}),
169+
);
170+
}
171+
},
172+
[getUserLimits, selectedRegion?.currency, selectedPaymentMethod?.id],
173+
);
174+
111175
const navigateToVerifyIdentityCallback = useCallback(
112176
({ quote }: { quote: BuyQuote }) => {
113177
popToBuildQuote();
@@ -349,6 +413,8 @@ export const useDepositRouting = () => {
349413
throw new Error('Missing user details');
350414
}
351415

416+
await checkUserLimits(quote, requirements.kycType);
417+
352418
if (selectedPaymentMethod?.isManualBankTransfer) {
353419
const order = await createOrder(
354420
quote,
@@ -489,6 +555,7 @@ export const useDepositRouting = () => {
489555
createOrder,
490556
requestOtt,
491557
generatePaymentUrl,
558+
checkUserLimits,
492559
selectedWalletAddress,
493560
themeAppearance,
494561
colors,

locales/languages/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,9 @@
594594
"unexpectedError": "An unexpected error occurred.",
595595
"quoteFetchError": "Failed to fetch quote.",
596596
"kycFormsFetchError": "Failed to fetch KYC forms.",
597-
"title": "Deposit"
597+
"title": "Deposit",
598+
"limitExceeded": "This deposit would exceed your {{period}} limit. Your deposit including fees must be {{remaining}} or less.",
599+
"limitError": "Failed to check your deposit limits. Please try again later."
598600
},
599601
"token_modal": {
600602
"select_a_token": "Select a Token",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
},
192192
"dependencies": {
193193
"@config-plugins/detox": "^9.0.0",
194-
"@consensys/native-ramps-sdk": "^2.1.2",
194+
"@consensys/native-ramps-sdk": "2.1.5",
195195
"@consensys/on-ramp-sdk": "2.1.11",
196196
"@craftzdog/react-native-buffer": "^6.1.0",
197197
"@deeeed/hyperliquid-node20": "^0.23.1-node20.1",

yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,9 +2046,9 @@ __metadata:
20462046
languageName: node
20472047
linkType: hard
20482048

2049-
"@consensys/native-ramps-sdk@npm:^2.1.2":
2050-
version: 2.1.2
2051-
resolution: "@consensys/native-ramps-sdk@npm:2.1.2"
2049+
"@consensys/native-ramps-sdk@npm:2.1.5":
2050+
version: 2.1.5
2051+
resolution: "@consensys/native-ramps-sdk@npm:2.1.5"
20522052
dependencies:
20532053
"@metamask/utils": ^11.5.0
20542054
async: ^3.2.3
@@ -2057,7 +2057,7 @@ __metadata:
20572057
crypto-js: ^4.2.0
20582058
reflect-metadata: ^0.1.13
20592059
uuid: ^9.0.0
2060-
checksum: 6db885cebb8ccc6c859c55f22bf686d842b0975f97d98c09eec48b52e7601fdb338c9f201182ec9f32cac07e84a98dc3e981a675e6a71a4ea8dbc298bc5a0be0
2060+
checksum: 4e2253858a221845579dc289400fd871b259dd1062537042cafa0c9417924ae9b2ca09187facabfce9eeb96f22113df7fad0801bee4164f9069003a4851f75d3
20612061
languageName: node
20622062
linkType: hard
20632063

@@ -34049,7 +34049,7 @@ __metadata:
3404934049
"@babel/register": ^7.24.6
3405034050
"@babel/runtime": ^7.25.0
3405134051
"@config-plugins/detox": ^9.0.0
34052-
"@consensys/native-ramps-sdk": ^2.1.2
34052+
"@consensys/native-ramps-sdk": 2.1.5
3405334053
"@consensys/on-ramp-sdk": 2.1.11
3405434054
"@craftzdog/react-native-buffer": ^6.1.0
3405534055
"@cucumber/message-streams": ^4.0.1

0 commit comments

Comments
 (0)