@nadohq/mobile-client 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +202 -10
  2. package/dist/{MobileClient-CfVZXTcz.cjs → MobileClient-vuI5wlB_.cjs} +303 -21
  3. package/dist/MobileClient-vuI5wlB_.cjs.map +1 -0
  4. package/dist/MobileClient.cjs +1 -1
  5. package/dist/MobileClient.d.cts +175 -20
  6. package/dist/MobileClient.d.cts.map +1 -1
  7. package/dist/MobileClient.d.ts +175 -20
  8. package/dist/MobileClient.d.ts.map +1 -1
  9. package/dist/MobileClient.js +303 -21
  10. package/dist/MobileClient.js.map +1 -1
  11. package/dist/{MobileServerFailureError-BZVwlsMg.d.ts → MobileServerFailureError-BeCCNShj.d.cts} +2 -3
  12. package/dist/MobileServerFailureError-BeCCNShj.d.cts.map +1 -0
  13. package/dist/{MobileServerFailureError-CbtK8kD4.d.cts → MobileServerFailureError-hHv28weI.d.ts} +2 -3
  14. package/dist/MobileServerFailureError-hHv28weI.d.ts.map +1 -0
  15. package/dist/MobileUnsupportedNotificationRouteError-DpiNBvvY.d.cts +15 -0
  16. package/dist/MobileUnsupportedNotificationRouteError-DpiNBvvY.d.cts.map +1 -0
  17. package/dist/MobileUnsupportedNotificationRouteError-DpiNBvvY.d.ts +15 -0
  18. package/dist/MobileUnsupportedNotificationRouteError-DpiNBvvY.d.ts.map +1 -0
  19. package/dist/{buildSignedMobileRequest-C1v0flCR.d.cts → buildSignedMobileRequest-DcEMKU_4.d.cts} +2 -2
  20. package/dist/{buildSignedMobileRequest-C1v0flCR.d.cts.map → buildSignedMobileRequest-DcEMKU_4.d.cts.map} +1 -1
  21. package/dist/{buildSignedMobileRequest-UeeWdYGU.d.ts → buildSignedMobileRequest-DlTMvoXR.d.ts} +2 -2
  22. package/dist/{buildSignedMobileRequest-UeeWdYGU.d.ts.map → buildSignedMobileRequest-DlTMvoXR.d.ts.map} +1 -1
  23. package/dist/{canonicalize-5eeZrkzQ.d.ts → canonicalize-BOZlflI3.d.ts} +2 -2
  24. package/dist/canonicalize-BOZlflI3.d.ts.map +1 -0
  25. package/dist/{canonicalize-CBaZprTo.d.cts → canonicalize-BvCozzvJ.d.cts} +2 -2
  26. package/dist/canonicalize-BvCozzvJ.d.cts.map +1 -0
  27. package/dist/{clientTypes-_mEgzmNs.d.cts → clientTypes-KpHlCDGZ.d.cts} +341 -38
  28. package/dist/clientTypes-KpHlCDGZ.d.cts.map +1 -0
  29. package/dist/{clientTypes-C9dJufxk.d.ts → clientTypes-XRI41VdC.d.ts} +341 -38
  30. package/dist/clientTypes-XRI41VdC.d.ts.map +1 -0
  31. package/dist/dataMappers.cjs +257 -20
  32. package/dist/dataMappers.cjs.map +1 -1
  33. package/dist/dataMappers.d.cts +98 -15
  34. package/dist/dataMappers.d.cts.map +1 -1
  35. package/dist/dataMappers.d.ts +98 -15
  36. package/dist/dataMappers.d.ts.map +1 -1
  37. package/dist/dataMappers.js +242 -20
  38. package/dist/dataMappers.js.map +1 -1
  39. package/dist/{eip712MethodByType-D8jLoxDu.d.cts → eip712MethodByType-DyAmzH7b.d.cts} +2 -2
  40. package/dist/eip712MethodByType-DyAmzH7b.d.cts.map +1 -0
  41. package/dist/{eip712MethodByType-DcUh8h-l.d.ts → eip712MethodByType-qSSAF6i2.d.ts} +2 -2
  42. package/dist/eip712MethodByType-qSSAF6i2.d.ts.map +1 -0
  43. package/dist/feed-BrgkFqP3.d.cts +23 -0
  44. package/dist/feed-BrgkFqP3.d.cts.map +1 -0
  45. package/dist/feed-DcbhLoo1.d.ts +23 -0
  46. package/dist/feed-DcbhLoo1.d.ts.map +1 -0
  47. package/dist/index-BVVgDSdq.d.cts +1 -0
  48. package/dist/index-BVVgDSdq.d.ts +1 -0
  49. package/dist/index.cjs +8 -2
  50. package/dist/index.d.cts +14 -16
  51. package/dist/index.d.ts +14 -16
  52. package/dist/index.js +4 -3
  53. package/dist/{mobileErrorCodes-CmjYEIYm.d.ts → mobileErrorCodes-CdwX6QTO.d.cts} +8 -3
  54. package/dist/mobileErrorCodes-CdwX6QTO.d.cts.map +1 -0
  55. package/dist/{mobileErrorCodes-CmjYEIYm.d.cts → mobileErrorCodes-CdwX6QTO.d.ts} +8 -3
  56. package/dist/mobileErrorCodes-CdwX6QTO.d.ts.map +1 -0
  57. package/dist/{payloadHash-DhHmgRqL.d.cts → payloadHash-BgGSZock.d.cts} +2 -2
  58. package/dist/payloadHash-BgGSZock.d.cts.map +1 -0
  59. package/dist/{payloadHash-E1_8fpLW.d.ts → payloadHash-Bi6upCLQ.d.ts} +2 -2
  60. package/dist/payloadHash-Bi6upCLQ.d.ts.map +1 -0
  61. package/dist/{serverExecuteTypes-BwWGspOg.d.ts → serverExecuteTypes-DuCWe_H_.d.cts} +23 -14
  62. package/dist/serverExecuteTypes-DuCWe_H_.d.cts.map +1 -0
  63. package/dist/{serverExecuteTypes-B-OJok19.d.cts → serverExecuteTypes-usJ6HMJv.d.ts} +23 -14
  64. package/dist/serverExecuteTypes-usJ6HMJv.d.ts.map +1 -0
  65. package/dist/{serverModelTypes-CiA-dy1R.d.ts → serverModelTypes-CTHMNIQS.d.cts} +170 -13
  66. package/dist/serverModelTypes-CTHMNIQS.d.cts.map +1 -0
  67. package/dist/{serverModelTypes-CiA-dy1R.d.cts → serverModelTypes-CTHMNIQS.d.ts} +170 -13
  68. package/dist/serverModelTypes-CTHMNIQS.d.ts.map +1 -0
  69. package/dist/{serverResponseGuards-CShiXpHT.d.ts → serverResponseGuards-CzmldAad.d.cts} +2 -2
  70. package/dist/{serverResponseGuards-BW69PS7O.d.cts.map → serverResponseGuards-CzmldAad.d.cts.map} +1 -1
  71. package/dist/{serverResponseGuards-BW69PS7O.d.cts → serverResponseGuards-Dvkbjp2_.d.ts} +2 -2
  72. package/dist/{serverResponseGuards-CShiXpHT.d.ts.map → serverResponseGuards-Dvkbjp2_.d.ts.map} +1 -1
  73. package/dist/signing/buildSignedMobileRequest.d.cts +1 -1
  74. package/dist/signing/buildSignedMobileRequest.d.ts +1 -1
  75. package/dist/signing/canonicalize.cjs +53 -2
  76. package/dist/signing/canonicalize.cjs.map +1 -1
  77. package/dist/signing/canonicalize.d.cts +1 -1
  78. package/dist/signing/canonicalize.d.ts +1 -1
  79. package/dist/signing/canonicalize.js +53 -2
  80. package/dist/signing/canonicalize.js.map +1 -1
  81. package/dist/signing/eip712MethodByType.cjs +7 -0
  82. package/dist/signing/eip712MethodByType.cjs.map +1 -1
  83. package/dist/signing/eip712MethodByType.d.cts +1 -1
  84. package/dist/signing/eip712MethodByType.d.ts +1 -1
  85. package/dist/signing/eip712MethodByType.js +7 -0
  86. package/dist/signing/eip712MethodByType.js.map +1 -1
  87. package/dist/signing/index.d.cts +5 -5
  88. package/dist/signing/index.d.ts +5 -5
  89. package/dist/signing/payloadHash.cjs +1 -1
  90. package/dist/signing/payloadHash.d.cts +1 -1
  91. package/dist/signing/payloadHash.d.ts +1 -1
  92. package/dist/signing/payloadHash.js +1 -1
  93. package/dist/signing/types.d.cts +1 -1
  94. package/dist/signing/types.d.ts +1 -1
  95. package/dist/types/MobileServerFailureError.d.cts +1 -1
  96. package/dist/types/MobileServerFailureError.d.ts +1 -1
  97. package/dist/types/MobileUnsupportedNotificationRouteError.cjs +19 -0
  98. package/dist/types/MobileUnsupportedNotificationRouteError.cjs.map +1 -0
  99. package/dist/types/MobileUnsupportedNotificationRouteError.d.cts +2 -0
  100. package/dist/types/MobileUnsupportedNotificationRouteError.d.ts +2 -0
  101. package/dist/types/MobileUnsupportedNotificationRouteError.js +18 -0
  102. package/dist/types/MobileUnsupportedNotificationRouteError.js.map +1 -0
  103. package/dist/types/clientTypes.d.cts +2 -2
  104. package/dist/types/clientTypes.d.ts +2 -2
  105. package/dist/types/index.cjs +4 -0
  106. package/dist/types/index.d.cts +8 -9
  107. package/dist/types/index.d.ts +8 -9
  108. package/dist/types/index.js +3 -2
  109. package/dist/types/mobileErrorCodes.cjs +11 -1
  110. package/dist/types/mobileErrorCodes.cjs.map +1 -1
  111. package/dist/types/mobileErrorCodes.d.cts +1 -1
  112. package/dist/types/mobileErrorCodes.d.ts +1 -1
  113. package/dist/types/mobileErrorCodes.js +11 -1
  114. package/dist/types/mobileErrorCodes.js.map +1 -1
  115. package/dist/types/serverBaseTypes.d.cts +1 -1
  116. package/dist/types/serverBaseTypes.d.ts +1 -1
  117. package/dist/types/serverExecuteTypes.d.cts +2 -2
  118. package/dist/types/serverExecuteTypes.d.ts +2 -2
  119. package/dist/types/serverModelTypes.cjs +15 -0
  120. package/dist/types/serverModelTypes.cjs.map +1 -1
  121. package/dist/types/serverModelTypes.d.cts +2 -2
  122. package/dist/types/serverModelTypes.d.ts +2 -2
  123. package/dist/types/serverModelTypes.js +14 -1
  124. package/dist/types/serverModelTypes.js.map +1 -1
  125. package/dist/types/serverQueryTypes.d.cts +2 -2
  126. package/dist/types/serverQueryTypes.d.ts +2 -2
  127. package/dist/types/serverRequestTypes.d.cts +2 -2
  128. package/dist/types/serverRequestTypes.d.ts +2 -2
  129. package/dist/types-CByl_NyJ.d.ts +825 -0
  130. package/dist/types-CByl_NyJ.d.ts.map +1 -0
  131. package/dist/types-CR5iaKR4.d.cts +825 -0
  132. package/dist/types-CR5iaKR4.d.cts.map +1 -0
  133. package/dist/utils/buildMobileFeedImageMultipart.cjs +31 -0
  134. package/dist/utils/buildMobileFeedImageMultipart.cjs.map +1 -0
  135. package/dist/utils/buildMobileFeedImageMultipart.d.cts +15 -0
  136. package/dist/utils/buildMobileFeedImageMultipart.d.cts.map +1 -0
  137. package/dist/utils/buildMobileFeedImageMultipart.d.ts +15 -0
  138. package/dist/utils/buildMobileFeedImageMultipart.d.ts.map +1 -0
  139. package/dist/utils/buildMobileFeedImageMultipart.js +30 -0
  140. package/dist/utils/buildMobileFeedImageMultipart.js.map +1 -0
  141. package/dist/utils/feed.cjs +12 -0
  142. package/dist/utils/feed.cjs.map +1 -1
  143. package/dist/utils/feed.d.cts +2 -2
  144. package/dist/utils/feed.d.ts +2 -2
  145. package/dist/utils/feed.js +11 -1
  146. package/dist/utils/feed.js.map +1 -1
  147. package/dist/utils/index.cjs +3 -1
  148. package/dist/utils/index.d.cts +3 -3
  149. package/dist/utils/index.d.ts +3 -3
  150. package/dist/utils/index.js +2 -2
  151. package/dist/utils/serverResponseGuards.d.cts +1 -1
  152. package/dist/utils/serverResponseGuards.d.ts +1 -1
  153. package/package.json +3 -3
  154. package/src/MobileClient.ts +444 -19
  155. package/src/dataMappers.test.ts +511 -6
  156. package/src/dataMappers.ts +325 -24
  157. package/src/feedImages.test.ts +309 -0
  158. package/src/notifications.test.ts +950 -0
  159. package/src/signing/canonicalize.ts +72 -0
  160. package/src/signing/eip712MethodByType.ts +10 -0
  161. package/src/signing/signing.test.ts +67 -1
  162. package/src/signing/types.ts +55 -9
  163. package/src/socialFeed.test.ts +63 -2
  164. package/src/types/MobileUnsupportedNotificationRouteError.ts +16 -0
  165. package/src/types/clientTypes.ts +427 -36
  166. package/src/types/index.ts +1 -0
  167. package/src/types/mobileErrorCodes.ts +10 -0
  168. package/src/types/serverExecuteTypes.ts +22 -10
  169. package/src/types/serverModelTypes.ts +216 -11
  170. package/src/types/serverQueryTypes.ts +106 -9
  171. package/src/types/serverRequestTypes.ts +114 -17
  172. package/src/utils/buildMobileFeedImageMultipart.test.ts +45 -0
  173. package/src/utils/buildMobileFeedImageMultipart.ts +36 -0
  174. package/src/utils/feed.ts +16 -0
  175. package/dist/MobileClient-CfVZXTcz.cjs.map +0 -1
  176. package/dist/MobileServerFailureError-BZVwlsMg.d.ts.map +0 -1
  177. package/dist/MobileServerFailureError-CbtK8kD4.d.cts.map +0 -1
  178. package/dist/canonicalize-5eeZrkzQ.d.ts.map +0 -1
  179. package/dist/canonicalize-CBaZprTo.d.cts.map +0 -1
  180. package/dist/clientTypes-C9dJufxk.d.ts.map +0 -1
  181. package/dist/clientTypes-_mEgzmNs.d.cts.map +0 -1
  182. package/dist/eip712MethodByType-D8jLoxDu.d.cts.map +0 -1
  183. package/dist/eip712MethodByType-DcUh8h-l.d.ts.map +0 -1
  184. package/dist/feed-DTJlsg8W.d.cts +0 -14
  185. package/dist/feed-DTJlsg8W.d.cts.map +0 -1
  186. package/dist/feed-DTJlsg8W.d.ts +0 -14
  187. package/dist/feed-DTJlsg8W.d.ts.map +0 -1
  188. package/dist/mobileErrorCodes-CmjYEIYm.d.cts.map +0 -1
  189. package/dist/mobileErrorCodes-CmjYEIYm.d.ts.map +0 -1
  190. package/dist/payloadHash-DhHmgRqL.d.cts.map +0 -1
  191. package/dist/payloadHash-E1_8fpLW.d.ts.map +0 -1
  192. package/dist/serverBaseTypes-B5J1_TyC.d.cts +0 -25
  193. package/dist/serverBaseTypes-B5J1_TyC.d.cts.map +0 -1
  194. package/dist/serverBaseTypes-DnLzsdm-.d.ts +0 -25
  195. package/dist/serverBaseTypes-DnLzsdm-.d.ts.map +0 -1
  196. package/dist/serverExecuteTypes-B-OJok19.d.cts.map +0 -1
  197. package/dist/serverExecuteTypes-BwWGspOg.d.ts.map +0 -1
  198. package/dist/serverModelTypes-CiA-dy1R.d.cts.map +0 -1
  199. package/dist/serverModelTypes-CiA-dy1R.d.ts.map +0 -1
  200. package/dist/serverQueryTypes-CJcjLB7n.d.ts +0 -283
  201. package/dist/serverQueryTypes-CJcjLB7n.d.ts.map +0 -1
  202. package/dist/serverQueryTypes-V1f9yE7L.d.cts +0 -283
  203. package/dist/serverQueryTypes-V1f9yE7L.d.cts.map +0 -1
  204. package/dist/serverRequestTypes-CRlkNSu5.d.cts +0 -270
  205. package/dist/serverRequestTypes-CRlkNSu5.d.cts.map +0 -1
  206. package/dist/serverRequestTypes-DZoOhV2o.d.ts +0 -270
  207. package/dist/serverRequestTypes-DZoOhV2o.d.ts.map +0 -1
  208. package/dist/types-BiRmH5Iv.d.cts +0 -108
  209. package/dist/types-BiRmH5Iv.d.cts.map +0 -1
  210. package/dist/types-DoJuq0Ij.d.ts +0 -108
  211. package/dist/types-DoJuq0Ij.d.ts.map +0 -1
@@ -0,0 +1,950 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import {
3
+ getNadoEIP712Domain,
4
+ getNadoEIP712PrimaryType,
5
+ getNadoEIP712Types,
6
+ Subaccount,
7
+ subaccountToHex,
8
+ WalletNotProvidedError,
9
+ } from '@nadohq/shared';
10
+ import { createWalletClient, http, recoverTypedDataAddress } from 'viem';
11
+ import { privateKeyToAccount } from 'viem/accounts';
12
+ import { ink } from 'viem/chains';
13
+ import { MobileClient } from './MobileClient';
14
+ import { mapMobileNotificationRoute } from './dataMappers';
15
+ import {
16
+ canonicalizeMobileInner,
17
+ getMobilePayloadHash,
18
+ MobileSignedInner,
19
+ MobileSignedRequest,
20
+ } from './signing';
21
+ import {
22
+ MOBILE_ERROR_CODES,
23
+ MOBILE_NOTIFICATION_MIN_TRADE_SIZES_USD,
24
+ MOBILE_NOTIFICATION_POSITION_THRESHOLDS,
25
+ MobileNotificationCapabilities,
26
+ MobileNotificationRegistrationV2,
27
+ MobileNotificationRoute,
28
+ MobileNotificationRouteName,
29
+ MobileNotificationSettingsV2,
30
+ MobileNotificationSubscriptionsUpdate,
31
+ MobileProductEvent,
32
+ MobileServerFailureError,
33
+ MobileServerNotificationDeviceMetadata,
34
+ MobileServerNotificationInboxItem,
35
+ MobileServerNotificationRoute,
36
+ MobileServerNotificationSettingsV2Response,
37
+ MobileServerRegisteredWalletResponse,
38
+ MobileServerRegisterExpoTokenParams,
39
+ MobileServerWithExpoTokenParams,
40
+ MobileSignedRequestParams,
41
+ MobileUnsupportedNotificationRouteError,
42
+ } from './types';
43
+
44
+ /** Whether a fixture shape is assignable to a public contract, checked by TypeScript rather than a cast. */
45
+ type IsAssignable<Source, Target> = [Source] extends [Target] ? true : false;
46
+
47
+ const owner = privateKeyToAccount(
48
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b786907',
49
+ );
50
+ const linked = privateKeyToAccount(
51
+ '0x0123456789012345678901234567890123456789012345678901234567890123',
52
+ );
53
+ const walletClient = createWalletClient({
54
+ account: owner,
55
+ chain: ink,
56
+ transport: http(),
57
+ });
58
+ const auth: MobileSignedRequestParams = {
59
+ subaccountOwner: owner.address,
60
+ subaccountName: 'default',
61
+ chainId: ink.id,
62
+ verifyingAddr: '0x0000000000000000000000000000000000000001',
63
+ };
64
+ const expoToken = 'ExponentPushToken[notifications-unit-test]';
65
+ const notificationId = '11111111-1111-4111-8111-111111111111';
66
+ const eventId = '22222222-2222-4222-8222-222222222222';
67
+ const target =
68
+ '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd64656661756c740000000000' as const;
69
+ const targetSubaccount: Subaccount = {
70
+ subaccountOwner: '0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd',
71
+ subaccountName: 'default',
72
+ };
73
+ const capabilities: MobileNotificationCapabilities = {
74
+ routes: ['portfolio', 'profile'],
75
+ typeIds: ['order_fill', 'new_follower'],
76
+ payloadVersions: [2],
77
+ };
78
+ const settings: MobileNotificationSettingsV2 = {
79
+ schemaVersion: 2,
80
+ revision: 7,
81
+ settings: {
82
+ myPositions: true,
83
+ positionThresholds: [25],
84
+ myOrders: true,
85
+ followingTrades: false,
86
+ followingPosts: true,
87
+ minTradeSizeUsd: 0,
88
+ newFollowers: true,
89
+ announcements: false,
90
+ discovery: false,
91
+ },
92
+ };
93
+ const searchEvent: MobileProductEvent = {
94
+ query: 'BTC',
95
+ occurredAt: 1792886400000,
96
+ eventName: 'search',
97
+ clientEventId: eventId,
98
+ };
99
+
100
+ const serverSettings: MobileServerNotificationSettingsV2Response = {
101
+ schema_version: 2,
102
+ revision: 7,
103
+ settings: {
104
+ my_positions: true,
105
+ position_thresholds: [25],
106
+ my_orders: true,
107
+ following_trades: false,
108
+ following_posts: true,
109
+ min_trade_size_usd: 0,
110
+ new_followers: true,
111
+ announcements: false,
112
+ discovery: false,
113
+ },
114
+ };
115
+ function mockClient(
116
+ data: unknown = { status: 'success' },
117
+ status = 200,
118
+ signed = true,
119
+ ) {
120
+ const client = new MobileClient({
121
+ url: 'https://mobile.example/v1/mobile',
122
+ walletClient: signed ? walletClient : undefined,
123
+ });
124
+ const requests: { url?: string; body: Record<string, unknown> }[] = [];
125
+ client.axiosInstance.defaults.adapter = (config) => {
126
+ requests.push({
127
+ url: config.url,
128
+ body: JSON.parse(config.data as string) as Record<string, unknown>,
129
+ });
130
+ return Promise.resolve({
131
+ data,
132
+ status,
133
+ statusText: 'mock',
134
+ headers: {},
135
+ config,
136
+ });
137
+ };
138
+ return { client, requests };
139
+ }
140
+
141
+ const signedCases: {
142
+ name: string;
143
+ route: 'query' | 'execute';
144
+ method: string;
145
+ run: (client: MobileClient) => Promise<unknown>;
146
+ expected: MobileSignedInner;
147
+ }[] = [
148
+ {
149
+ name: 'registerExpoTokenV2',
150
+ route: 'execute',
151
+ method: 'mobile:execute_register_expo_token_v2',
152
+ run: (client) =>
153
+ client.registerExpoTokenV2({
154
+ ...auth,
155
+ expoToken,
156
+ platform: 'ios',
157
+ capabilities,
158
+ }),
159
+ expected: {
160
+ type: 'register_expo_token_v2',
161
+ expo_token: expoToken,
162
+ platform: 'ios',
163
+ locale: null,
164
+ app_version: null,
165
+ capabilities: {
166
+ payload_versions: [2],
167
+ type_ids: ['order_fill', 'new_follower'],
168
+ routes: ['portfolio', 'profile'],
169
+ },
170
+ },
171
+ },
172
+ {
173
+ name: 'getNotificationSubscriptions',
174
+ route: 'query',
175
+ method: 'mobile:query_notification_subscriptions',
176
+ run: (client) => client.getNotificationSubscriptions(auth),
177
+ expected: { type: 'notification_subscriptions' },
178
+ },
179
+ {
180
+ name: 'getNotificationInboxV2',
181
+ route: 'query',
182
+ method: 'mobile:query_notification_inbox_v2',
183
+ run: (client) => client.getNotificationInboxV2(auth),
184
+ expected: { type: 'notification_inbox_v2', cursor: null, limit: null },
185
+ },
186
+ {
187
+ name: 'updateNotificationSubscriptions',
188
+ route: 'execute',
189
+ method: 'mobile:execute_update_notification_subscriptions',
190
+ run: (client) =>
191
+ client.updateNotificationSubscriptions({
192
+ ...auth,
193
+ update: {
194
+ traderBell: {
195
+ enabled: true,
196
+ subaccount: targetSubaccount,
197
+ },
198
+ expectedRevision: 3,
199
+ },
200
+ }),
201
+ expected: {
202
+ type: 'update_notification_subscriptions',
203
+ expected_revision: 3,
204
+ trader_bell: { subaccount: target, enabled: true },
205
+ market: null,
206
+ import_markets: null,
207
+ },
208
+ },
209
+ {
210
+ name: 'recordProductEvents',
211
+ route: 'execute',
212
+ method: 'mobile:execute_record_product_events',
213
+ run: (client) =>
214
+ client.recordProductEvents({ ...auth, events: [searchEvent] }),
215
+ expected: {
216
+ type: 'record_product_events',
217
+ events: [
218
+ {
219
+ client_event_id: eventId,
220
+ event_name: 'search',
221
+ notification_id: null,
222
+ occurred_at: searchEvent.occurredAt,
223
+ product_id: null,
224
+ query: 'BTC',
225
+ },
226
+ ],
227
+ },
228
+ },
229
+ ];
230
+
231
+ describe('[mobile-client]: notification v2 signing and transport', () => {
232
+ describe.each([false, true])('linked signer: %s', (useLinkedSigner) => {
233
+ it.each(signedCases)(
234
+ '$name uses the correct route, method, sender and canonical hash',
235
+ async ({ route, method, run, expected }) => {
236
+ const { client, requests } = mockClient(
237
+ route === 'query'
238
+ ? {
239
+ status: 'success',
240
+ revision: 0,
241
+ favorites_imported: false,
242
+ market_ids: [],
243
+ trader_bells: [],
244
+ notifications: [],
245
+ next_cursor: null,
246
+ }
247
+ : undefined,
248
+ );
249
+ if (useLinkedSigner) {
250
+ client.setLinkedSigner(
251
+ createWalletClient({
252
+ account: linked,
253
+ chain: ink,
254
+ transport: http(),
255
+ }),
256
+ );
257
+ }
258
+ await run(client);
259
+ expect(requests).toHaveLength(1);
260
+ expect(requests[0].url).toBe(
261
+ `https://mobile.example/v1/mobile/${route}`,
262
+ );
263
+ const { sender, signature, nonce, ...inner } = requests[0]
264
+ .body as MobileSignedRequest;
265
+ expect(sender).toBe(subaccountToHex(auth));
266
+ expect(typeof nonce).toBe('string');
267
+ expect(inner).toEqual(expected);
268
+ expect(getMobilePayloadHash(inner)).toBe(
269
+ getMobilePayloadHash(expected),
270
+ );
271
+ const recovered = await recoverTypedDataAddress({
272
+ domain: getNadoEIP712Domain(auth.verifyingAddr, auth.chainId),
273
+ types: getNadoEIP712Types('nado_authentication'),
274
+ primaryType: getNadoEIP712PrimaryType('nado_authentication'),
275
+ message: {
276
+ method,
277
+ sender,
278
+ payloadHash: getMobilePayloadHash(expected),
279
+ nonce: BigInt(nonce),
280
+ },
281
+ signature,
282
+ });
283
+ expect(recovered.toLowerCase()).toBe(
284
+ (useLinkedSigner ? linked : owner).address.toLowerCase(),
285
+ );
286
+ },
287
+ );
288
+ });
289
+
290
+ it.each(signedCases)(
291
+ '$name requires a signer before sending a request',
292
+ async ({ run }) => {
293
+ const { client, requests } = mockClient(undefined, 200, false);
294
+ await expect(run(client)).rejects.toBeInstanceOf(WalletNotProvidedError);
295
+ expect(requests).toHaveLength(0);
296
+ },
297
+ );
298
+
299
+ it.each<MobileNotificationSubscriptionsUpdate>([
300
+ {
301
+ expectedRevision: 0,
302
+ market: { subscribed: false, productId: 2 },
303
+ },
304
+ {
305
+ expectedRevision: 1,
306
+ importMarkets: [4, 2, 4],
307
+ },
308
+ {
309
+ expectedRevision: 2,
310
+ importMarkets: [],
311
+ },
312
+ ])('preserves market/import operations: %j', async (update) => {
313
+ const original = JSON.stringify(update);
314
+ const { client, requests } = mockClient();
315
+ await client.updateNotificationSubscriptions({ ...auth, update });
316
+ expect(requests[0].body).toMatchObject({
317
+ expected_revision: update.expectedRevision,
318
+ trader_bell: null,
319
+ market: update.market
320
+ ? {
321
+ product_id: update.market.productId,
322
+ subscribed: update.market.subscribed,
323
+ }
324
+ : null,
325
+ import_markets: update.importMarkets ?? null,
326
+ });
327
+ if (requests[0].body.market) {
328
+ expect(Object.keys(requests[0].body.market)).toEqual([
329
+ 'product_id',
330
+ 'subscribed',
331
+ ]);
332
+ }
333
+ expect(JSON.stringify(update)).toBe(original);
334
+ });
335
+
336
+ it('preserves all product event kinds, stable IDs, nulls, and timestamps across retries', async () => {
337
+ const events: MobileProductEvent[] = [
338
+ {
339
+ clientEventId: eventId,
340
+ occurredAt: searchEvent.occurredAt,
341
+ eventName: 'notif_opened',
342
+ notificationId: notificationId,
343
+ },
344
+ {
345
+ clientEventId: eventId,
346
+ occurredAt: searchEvent.occurredAt,
347
+ eventName: 'market_viewed',
348
+ productId: 2,
349
+ },
350
+ {
351
+ clientEventId: eventId,
352
+ occurredAt: searchEvent.occurredAt,
353
+ eventName: 'chart_opened',
354
+ productId: 4,
355
+ },
356
+ searchEvent,
357
+ ];
358
+ const original = JSON.stringify(events);
359
+ const { client, requests } = mockClient();
360
+ await client.recordProductEvents({ ...auth, events });
361
+ await client.recordProductEvents({ ...auth, events });
362
+ const wireEvents = [
363
+ {
364
+ client_event_id: eventId,
365
+ event_name: 'notif_opened',
366
+ notification_id: notificationId,
367
+ occurred_at: searchEvent.occurredAt,
368
+ product_id: null,
369
+ query: null,
370
+ },
371
+ {
372
+ client_event_id: eventId,
373
+ event_name: 'market_viewed',
374
+ notification_id: null,
375
+ occurred_at: searchEvent.occurredAt,
376
+ product_id: 2,
377
+ query: null,
378
+ },
379
+ {
380
+ client_event_id: eventId,
381
+ event_name: 'chart_opened',
382
+ notification_id: null,
383
+ occurred_at: searchEvent.occurredAt,
384
+ product_id: 4,
385
+ query: null,
386
+ },
387
+ {
388
+ client_event_id: eventId,
389
+ event_name: 'search',
390
+ notification_id: null,
391
+ occurred_at: searchEvent.occurredAt,
392
+ product_id: null,
393
+ query: 'BTC',
394
+ },
395
+ ];
396
+ expect(requests[0].body.events).toEqual(wireEvents);
397
+ expect(requests[1].body.events).toEqual(wireEvents);
398
+ expect(requests[0].body.nonce).not.toBe(requests[1].body.nonce);
399
+ expect(JSON.stringify(events)).toBe(original);
400
+ });
401
+ });
402
+
403
+ describe('[mobile-client]: notification v2 responses and failures', () => {
404
+ it.each<{
405
+ server: MobileServerNotificationRoute;
406
+ client: MobileNotificationRoute;
407
+ }>([
408
+ {
409
+ server: { name: 'market', product_id: 2 },
410
+ client: { name: 'market', productId: 2 },
411
+ },
412
+ {
413
+ server: { name: 'profile', subaccount: target },
414
+ client: { name: 'profile', subaccount: target },
415
+ },
416
+ { server: { name: 'portfolio' }, client: { name: 'portfolio' } },
417
+ ])('maps the supported $server.name route', ({ server, client }) => {
418
+ expect(mapMobileNotificationRoute(server)).toEqual(client);
419
+ });
420
+
421
+ it('keeps v2 registration and legacy query capability versions distinct', () => {
422
+ const registrationVersions: [
423
+ IsAssignable<[1], MobileNotificationCapabilities['payloadVersions']>,
424
+ IsAssignable<[2], MobileNotificationCapabilities['payloadVersions']>,
425
+ IsAssignable<[1, 2], MobileNotificationCapabilities['payloadVersions']>,
426
+ ] = [false, true, false];
427
+ const queriedVersions: [
428
+ IsAssignable<
429
+ [1],
430
+ MobileNotificationRegistrationV2['capabilities']['payloadVersions']
431
+ >,
432
+ IsAssignable<
433
+ [2],
434
+ MobileNotificationRegistrationV2['capabilities']['payloadVersions']
435
+ >,
436
+ IsAssignable<
437
+ [1, 2],
438
+ MobileNotificationRegistrationV2['capabilities']['payloadVersions']
439
+ >,
440
+ ] = [true, true, false];
441
+ expect(registrationVersions).toEqual([false, true, false]);
442
+ expect(queriedVersions).toEqual([true, true, false]);
443
+ });
444
+
445
+ it('keeps shared credentials and nullable registration metadata required', () => {
446
+ const optionalTokenAccepted: IsAssignable<
447
+ { expo_token?: string },
448
+ MobileServerWithExpoTokenParams
449
+ > = false;
450
+ const missingMetadataAccepted: IsAssignable<
451
+ Pick<MobileServerRegisterExpoTokenParams, 'expo_token' | 'platform'>,
452
+ MobileServerRegisterExpoTokenParams
453
+ > = false;
454
+ const params: MobileServerRegisterExpoTokenParams = {
455
+ expo_token: expoToken,
456
+ platform: 'ios',
457
+ locale: null,
458
+ app_version: null,
459
+ };
460
+ expect(optionalTokenAccepted).toBe(false);
461
+ expect(missingMetadataAccepted).toBe(false);
462
+ expect(params).toMatchObject({ locale: null, app_version: null });
463
+ });
464
+
465
+ it('derives the supported route names without allowing arbitrary destinations', () => {
466
+ const routes: MobileNotificationRouteName[] = [
467
+ 'profile',
468
+ 'market',
469
+ 'portfolio',
470
+ ];
471
+ const arbitraryRouteAccepted: IsAssignable<
472
+ 'url',
473
+ MobileNotificationRouteName
474
+ > = false;
475
+ expect(routes).toEqual(['profile', 'market', 'portfolio']);
476
+ expect(arbitraryRouteAccepted).toBe(false);
477
+ });
478
+
479
+ it('maps shared device metadata without changing the legacy wallet response', async () => {
480
+ const metadata: MobileServerNotificationDeviceMetadata = {
481
+ platform: 'android',
482
+ locale: 'en-US',
483
+ app_version: '1.2.3',
484
+ };
485
+ const response: MobileServerRegisteredWalletResponse = {
486
+ wallet: owner.address,
487
+ ...metadata,
488
+ token_fingerprint_prefix: 'a1b2c3d4',
489
+ last_seen_at: 1792886400000,
490
+ };
491
+ const { client, requests } = mockClient(
492
+ { status: 'success', ...response },
493
+ 200,
494
+ false,
495
+ );
496
+ expect(await client.getRegisteredWallet({ expoToken })).toEqual({
497
+ wallet: owner.address,
498
+ platform: metadata.platform,
499
+ locale: metadata.locale,
500
+ appVersion: metadata.app_version,
501
+ tokenFingerprintPrefix: response.token_fingerprint_prefix,
502
+ lastSeenAt: response.last_seen_at,
503
+ });
504
+ expect(requests[0].body).toEqual({
505
+ type: 'registered_wallet',
506
+ expo_token: expoToken,
507
+ });
508
+ });
509
+
510
+ it.each([1, 2] as const)(
511
+ 'reads version %i capabilities without a wallet and preserves generation precision',
512
+ async (version) => {
513
+ const registration: MobileNotificationRegistrationV2 = {
514
+ registrationGeneration: '9007199254740993',
515
+ capabilities:
516
+ version === 1
517
+ ? { payloadVersions: [1], typeIds: [], routes: [] }
518
+ : capabilities,
519
+ };
520
+ const { client, requests } = mockClient(
521
+ {
522
+ status: 'success',
523
+ registration_generation: registration.registrationGeneration,
524
+ capabilities: {
525
+ payload_versions: registration.capabilities.payloadVersions,
526
+ type_ids: registration.capabilities.typeIds,
527
+ routes: registration.capabilities.routes,
528
+ },
529
+ },
530
+ 200,
531
+ false,
532
+ );
533
+ expect(await client.getNotificationRegistrationV2({ expoToken })).toEqual(
534
+ registration,
535
+ );
536
+ expect(requests[0]).toEqual({
537
+ url: 'https://mobile.example/v1/mobile/public_query',
538
+ body: { type: 'notification_registration_v2', expo_token: expoToken },
539
+ });
540
+ },
541
+ );
542
+
543
+ it('reads and replaces the full settings document without a signer', async () => {
544
+ const { client, requests } = mockClient(
545
+ { status: 'success', ...serverSettings },
546
+ 200,
547
+ false,
548
+ );
549
+ expect(await client.getNotificationSettingsV2({ expoToken })).toEqual({
550
+ ...settings,
551
+ });
552
+ expect(requests[0]).toEqual({
553
+ url: 'https://mobile.example/v1/mobile/public_query',
554
+ body: { type: 'notification_settings_v2', expo_token: expoToken },
555
+ });
556
+ expect(
557
+ await client.updateNotificationSettingsV2({
558
+ expoToken,
559
+ expectedRevision: 6,
560
+ settings: settings.settings,
561
+ }),
562
+ ).toMatchObject({ ...settings });
563
+ expect(requests[1]).toEqual({
564
+ url: 'https://mobile.example/v1/mobile/public_execute',
565
+ body: {
566
+ type: 'update_notification_settings_v2',
567
+ expo_token: expoToken,
568
+ expected_revision: 6,
569
+ settings: serverSettings.settings,
570
+ },
571
+ });
572
+ });
573
+
574
+ it('returns subscription state without the response envelope', async () => {
575
+ const response = {
576
+ status: 'success',
577
+ revision: 4,
578
+ favorites_imported: true,
579
+ market_ids: [2, 4],
580
+ trader_bells: [target],
581
+ };
582
+ const { client } = mockClient(response);
583
+ expect(await client.getNotificationSubscriptions(auth)).toEqual({
584
+ revision: 4,
585
+ favoritesImported: true,
586
+ marketIds: [2, 4],
587
+ traderBells: [target],
588
+ });
589
+ });
590
+
591
+ it.each([true, false])(
592
+ 'preserves inbox navigation and boolean attribution: %s',
593
+ async (tradeWithin24h) => {
594
+ const response = {
595
+ status: 'success',
596
+ notifications: [
597
+ {
598
+ notification_id: notificationId,
599
+ type_id: 'new_follower',
600
+ created_at: 1792886400000,
601
+ data: {
602
+ payload_version: 2,
603
+ notification_id: notificationId,
604
+ recipient_wallet: owner.address,
605
+ type_id: 'new_follower',
606
+ template_version: 1,
607
+ route: { name: 'profile', subaccount: target },
608
+ },
609
+ trade_within_24h: tradeWithin24h,
610
+ },
611
+ ],
612
+ next_cursor: notificationId,
613
+ };
614
+ const { client, requests } = mockClient(response);
615
+ expect(
616
+ await client.getNotificationInboxV2({
617
+ ...auth,
618
+ cursor: notificationId,
619
+ limit: 50,
620
+ }),
621
+ ).toEqual({
622
+ notifications: [
623
+ {
624
+ notificationId,
625
+ typeId: 'new_follower',
626
+ createdAt: 1792886400000,
627
+ tradeWithin24h,
628
+ data: {
629
+ payloadVersion: 2,
630
+ notificationId,
631
+ recipientWallet: owner.address,
632
+ typeId: 'new_follower',
633
+ templateVersion: 1,
634
+ route: { name: 'profile', subaccount: target },
635
+ },
636
+ },
637
+ ],
638
+ nextCursor: notificationId,
639
+ });
640
+ expect(requests[0].body).toMatchObject({
641
+ type: 'notification_inbox_v2',
642
+ cursor: notificationId,
643
+ limit: 50,
644
+ });
645
+ },
646
+ );
647
+
648
+ it('rejects an inbox containing an unsupported notification route', async () => {
649
+ const { client } = mockClient({
650
+ status: 'success',
651
+ notifications: [
652
+ {
653
+ notification_id: notificationId,
654
+ type_id: 'new_follower',
655
+ created_at: 1792886400000,
656
+ trade_within_24h: false,
657
+ data: {
658
+ payload_version: 2,
659
+ notification_id: notificationId,
660
+ recipient_wallet: owner.address,
661
+ type_id: 'new_follower',
662
+ template_version: 1,
663
+ route: { name: 'future_destination' },
664
+ },
665
+ },
666
+ ],
667
+ next_cursor: null,
668
+ });
669
+ const result = client.getNotificationInboxV2(auth);
670
+ await expect(result).rejects.toBeInstanceOf(
671
+ MobileUnsupportedNotificationRouteError,
672
+ );
673
+ await expect(result).rejects.toMatchObject({
674
+ name: 'MobileUnsupportedNotificationRouteError',
675
+ routeName: 'future_destination',
676
+ });
677
+ });
678
+
679
+ it('preserves empty inbox pages', async () => {
680
+ const response = {
681
+ status: 'success',
682
+ notifications: [],
683
+ next_cursor: null,
684
+ };
685
+ const { client } = mockClient(response);
686
+ expect(await client.getNotificationInboxV2(auth)).toEqual({
687
+ notifications: [],
688
+ nextCursor: null,
689
+ });
690
+ });
691
+
692
+ it.each([
693
+ {
694
+ status: 400,
695
+ errorCode: MOBILE_ERROR_CODES.NOTIFICATION_DEVICE_LIMIT,
696
+ requestType: 'execute_register_expo_token_v2',
697
+ run: (client: MobileClient) =>
698
+ client.registerExpoTokenV2({
699
+ ...auth,
700
+ expoToken,
701
+ platform: 'ios',
702
+ capabilities,
703
+ }),
704
+ },
705
+ {
706
+ status: 400,
707
+ errorCode: MOBILE_ERROR_CODES.INVALID_EXPO_TOKEN,
708
+ requestType: 'public_query_notification_registration_v2',
709
+ run: (client: MobileClient) =>
710
+ client.getNotificationRegistrationV2({ expoToken }),
711
+ },
712
+ {
713
+ status: 409,
714
+ errorCode: MOBILE_ERROR_CODES.NOTIFICATION_REVISION_CONFLICT,
715
+ requestType: 'public_execute_update_notification_settings_v2',
716
+ run: (client: MobileClient) =>
717
+ client.updateNotificationSettingsV2({
718
+ expoToken,
719
+ expectedRevision: 0,
720
+ settings: settings.settings,
721
+ }),
722
+ },
723
+ {
724
+ status: 409,
725
+ errorCode: MOBILE_ERROR_CODES.NOTIFICATION_REVISION_CONFLICT,
726
+ requestType: 'execute_update_notification_subscriptions',
727
+ run: (client: MobileClient) =>
728
+ client.updateNotificationSubscriptions({
729
+ ...auth,
730
+ update: {
731
+ expectedRevision: 0,
732
+ importMarkets: [],
733
+ },
734
+ }),
735
+ },
736
+ {
737
+ status: 400,
738
+ errorCode: MOBILE_ERROR_CODES.INVALID_NOTIFICATION_REQUEST,
739
+ requestType: 'query_notification_inbox_v2',
740
+ run: (client: MobileClient) =>
741
+ client.getNotificationInboxV2({ ...auth, limit: 0 }),
742
+ },
743
+ {
744
+ status: 400,
745
+ errorCode: MOBILE_ERROR_CODES.INVALID_NOTIFICATION_REQUEST,
746
+ requestType: 'execute_record_product_events',
747
+ run: (client: MobileClient) =>
748
+ client.recordProductEvents({ ...auth, events: [] }),
749
+ },
750
+ {
751
+ status: 401,
752
+ errorCode: 2028,
753
+ requestType: 'query_notification_subscriptions',
754
+ run: (client: MobileClient) => client.getNotificationSubscriptions(auth),
755
+ },
756
+ ])(
757
+ 'propagates $requestType failures without retrying writes',
758
+ async ({ status, errorCode, requestType, run }) => {
759
+ const failure = {
760
+ status: 'failure',
761
+ error: 'Rejected',
762
+ error_code: errorCode,
763
+ request_type: requestType,
764
+ };
765
+ const { client, requests } = mockClient(failure, status);
766
+ const result = run(client);
767
+ await expect(result).rejects.toBeInstanceOf(MobileServerFailureError);
768
+ await expect(result).rejects.toMatchObject({
769
+ errorCode,
770
+ requestType,
771
+ httpStatus: status,
772
+ responseData: failure,
773
+ });
774
+ expect(requests).toHaveLength(1);
775
+ },
776
+ );
777
+
778
+ it('keeps non-envelope query failures as transport errors', async () => {
779
+ const { client } = mockClient('<html>Bad gateway</html>', 502);
780
+ await expect(client.getNotificationSubscriptions(auth)).rejects.toThrow(
781
+ 'Unexpected response from mobile service: 502',
782
+ );
783
+ });
784
+
785
+ it('rejects malformed successful query envelopes', async () => {
786
+ const { client } = mockClient({ notifications: [] });
787
+ await expect(client.getNotificationInboxV2(auth)).rejects.toThrow(
788
+ 'missing success envelope',
789
+ );
790
+ });
791
+
792
+ it('keeps legacy device registration on the old route without capabilities', async () => {
793
+ const { client, requests } = mockClient();
794
+ await client.registerExpoToken({ ...auth, expoToken, platform: 'ios' });
795
+ expect(requests[0].body).toMatchObject({
796
+ type: 'register_expo_token',
797
+ locale: null,
798
+ app_version: null,
799
+ });
800
+ expect(requests[0].body).not.toHaveProperty('capabilities');
801
+ });
802
+
803
+ it('keeps legacy preference writes unchanged', async () => {
804
+ const { client, requests } = mockClient(undefined, 200, false);
805
+ const result = await client.updateNotificationPreferences({
806
+ expoToken,
807
+ preferences: {
808
+ schemaVersion: 1,
809
+ categories: [{ category: 'order_fill', enabled: true, scopes: [] }],
810
+ },
811
+ });
812
+ expect(result).toEqual({ status: 'success' });
813
+ expect(requests[0].body).toEqual({
814
+ type: 'update_preferences',
815
+ expo_token: expoToken,
816
+ preferences: {
817
+ schema_version: 1,
818
+ categories: [{ category: 'order_fill', enabled: true, scopes: [] }],
819
+ },
820
+ });
821
+ });
822
+ });
823
+
824
+ describe('[mobile-client]: notification v2 contracts', () => {
825
+ it('exposes the supported notification setting options', () => {
826
+ expect(MOBILE_NOTIFICATION_POSITION_THRESHOLDS).toEqual([10, 25, 50]);
827
+ expect(MOBILE_NOTIFICATION_MIN_TRADE_SIZES_USD).toEqual([
828
+ 0, 100, 1000, 10000,
829
+ ]);
830
+ });
831
+
832
+ it('requires exactly one subscription operation at compile time', () => {
833
+ const acceptsNoOperation: IsAssignable<
834
+ { expectedRevision: number },
835
+ MobileNotificationSubscriptionsUpdate
836
+ > = false;
837
+ const acceptsMultipleOperations: IsAssignable<
838
+ {
839
+ expectedRevision: number;
840
+ market: { productId: number; subscribed: boolean };
841
+ importMarkets: number[];
842
+ },
843
+ MobileNotificationSubscriptionsUpdate
844
+ > = false;
845
+ const acceptsAllOperations: IsAssignable<
846
+ {
847
+ expectedRevision: number;
848
+ traderBell: { subaccount: Subaccount; enabled: boolean };
849
+ market: { productId: number; subscribed: boolean };
850
+ importMarkets: number[];
851
+ },
852
+ MobileNotificationSubscriptionsUpdate
853
+ > = false;
854
+ expect([
855
+ acceptsNoOperation,
856
+ acceptsMultipleOperations,
857
+ acceptsAllOperations,
858
+ ]).toEqual([false, false, false]);
859
+ });
860
+
861
+ it('requires event-specific fields and rejects unrelated fields at compile time', () => {
862
+ type EventBase = { clientEventId: string; occurredAt: number };
863
+ const acceptsMissingFields: [
864
+ IsAssignable<EventBase & { eventName: 'search' }, MobileProductEvent>,
865
+ IsAssignable<
866
+ EventBase & { eventName: 'market_viewed' },
867
+ MobileProductEvent
868
+ >,
869
+ IsAssignable<
870
+ EventBase & { eventName: 'chart_opened' },
871
+ MobileProductEvent
872
+ >,
873
+ IsAssignable<
874
+ EventBase & { eventName: 'notif_opened' },
875
+ MobileProductEvent
876
+ >,
877
+ ] = [false, false, false, false];
878
+ const acceptsUnrelatedField: IsAssignable<
879
+ EventBase & { eventName: 'search'; query: string; productId: number },
880
+ MobileProductEvent
881
+ > = false;
882
+ const acceptsMissingAttribution: IsAssignable<
883
+ Omit<MobileServerNotificationInboxItem, 'trade_within_24h'>,
884
+ MobileServerNotificationInboxItem
885
+ > = false;
886
+ expect(acceptsMissingFields).toEqual([false, false, false, false]);
887
+ expect(acceptsUnrelatedField).toBe(false);
888
+ expect(acceptsMissingAttribution).toBe(false);
889
+ });
890
+
891
+ it('canonicalizes bell key order without rewriting caller-provided bytes32', () => {
892
+ const subaccount = `0x${target.slice(2).toUpperCase()}` as const;
893
+ const inner: MobileSignedInner = {
894
+ type: 'update_notification_subscriptions',
895
+ expected_revision: 1,
896
+ trader_bell: { enabled: true, subaccount },
897
+ market: null,
898
+ import_markets: null,
899
+ };
900
+ expect(canonicalizeMobileInner(inner)).toEqual(inner);
901
+ expect(Object.keys(canonicalizeMobileInner(inner))).toEqual([
902
+ 'type',
903
+ 'expected_revision',
904
+ 'trader_bell',
905
+ 'market',
906
+ 'import_markets',
907
+ ]);
908
+ });
909
+
910
+ it.each<[MobileServerNotificationRoute, MobileNotificationRoute]>([
911
+ [
912
+ { name: 'profile', subaccount: target },
913
+ { name: 'profile', subaccount: target },
914
+ ],
915
+ [
916
+ { name: 'market', product_id: 2 },
917
+ { name: 'market', productId: 2 },
918
+ ],
919
+ [{ name: 'portfolio' }, { name: 'portfolio' }],
920
+ ])(
921
+ 'maps inbox destination %j and strips the envelope',
922
+ async (wireRoute, clientRoute) => {
923
+ const { client } = mockClient({
924
+ status: 'success',
925
+ notifications: [
926
+ {
927
+ notification_id: notificationId,
928
+ type_id: 'new_follower',
929
+ created_at: 123,
930
+ trade_within_24h: true,
931
+ data: {
932
+ payload_version: 2,
933
+ notification_id: notificationId,
934
+ recipient_wallet: owner.address,
935
+ type_id: 'new_follower',
936
+ template_version: 1,
937
+ route: wireRoute,
938
+ },
939
+ },
940
+ ],
941
+ next_cursor: null,
942
+ });
943
+ const result = await client.getNotificationInboxV2(auth);
944
+ expect(result).not.toHaveProperty('status');
945
+ expect(result.notifications[0].data.route).toEqual(clientRoute);
946
+ expect(result.notifications[0].tradeWithin24h).toBe(true);
947
+ expect(result.notifications[0]).not.toHaveProperty('trade_within_24h');
948
+ },
949
+ );
950
+ });